web-scanner 2.2.0__tar.gz → 2.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {web_scanner-2.2.0 → web_scanner-2.2.1}/PKG-INFO +1 -1
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/__init__.py +1 -1
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/app.py +157 -15
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/app.tcss +36 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/tables.py +62 -13
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/widgets.py +88 -33
- {web_scanner-2.2.0 → web_scanner-2.2.1}/.gitignore +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/LICENSE +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/README.md +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/app.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/pyproject.toml +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/requirements.txt +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/__main__.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/cli.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/colors.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/core/__init__.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/core/context.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/core/models.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/core/module.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/core/scanner.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/helpers.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/__init__.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/dns.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/headers.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/links.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/security.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/seo.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/sitemap.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/ssl.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/subdomains.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/tech.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/whois.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/__init__.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/agents.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/data/public_suffix_list.dat +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/http.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/psl.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/version_check.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/__init__.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/activity.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/data/countries.json +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/export.py +0 -0
- {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/worldmap.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: web-scanner
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: An async Textual TUI for website reconnaissance — DNS, WHOIS, TLS, security, tech-stack and SEO, with no paid APIs.
|
|
5
5
|
Project-URL: Homepage, https://github.com/iamramizk/web-scanner
|
|
6
6
|
Project-URL: Repository, https://github.com/iamramizk/web-scanner
|
|
@@ -17,6 +17,7 @@ import re
|
|
|
17
17
|
import time
|
|
18
18
|
|
|
19
19
|
from bs4 import BeautifulSoup
|
|
20
|
+
from rich.padding import Padding
|
|
20
21
|
from textual.app import App, ComposeResult
|
|
21
22
|
from textual.binding import Binding
|
|
22
23
|
from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
|
|
@@ -30,13 +31,24 @@ from ..modules import all_modules
|
|
|
30
31
|
from ..net.version_check import check_for_update
|
|
31
32
|
from . import activity
|
|
32
33
|
from .export import export_csvs
|
|
33
|
-
from .tables import UNSET, render_result
|
|
34
|
+
from .tables import UNSET, render_result, render_status
|
|
34
35
|
from .widgets import ActivityLog, MapPanel, SitemapTree, StatusPanel, TabBar, Tab
|
|
35
36
|
|
|
36
37
|
#: width (cells) of the footer progress bar and its dim (incomplete) colour
|
|
37
38
|
_BAR_WIDTH = 22
|
|
38
39
|
_BAR_DIM = "grey30"
|
|
39
40
|
|
|
41
|
+
#: below this terminal width the layout collapses to a single column (the fixed map
|
|
42
|
+
#: + Server panels become tabs, activity becomes a tab) — see _apply_narrow.
|
|
43
|
+
_NARROW_MAX = 90
|
|
44
|
+
|
|
45
|
+
#: pseudo-tabs shown only in the narrow layout, before the real module tabs: the
|
|
46
|
+
#: Activity log and the Server panel, which are fixed side/bottom panels on wide
|
|
47
|
+
#: terminals. (name, label) — names must not collide with any module name.
|
|
48
|
+
_PSEUDO_TABS: tuple[tuple[str, str], ...] = (("activity", "Activity"), ("server", "Server"))
|
|
49
|
+
_PSEUDO_NAMES = frozenset(name for name, _ in _PSEUDO_TABS)
|
|
50
|
+
_PSEUDO_LABELS = {name: label for name, label in _PSEUDO_TABS}
|
|
51
|
+
|
|
40
52
|
|
|
41
53
|
def _cms_from_tech(data: object) -> tuple[str, str | None] | None:
|
|
42
54
|
"""The CMS (name, version) from the Tech result, or ``None`` if none detected.
|
|
@@ -166,6 +178,10 @@ class WebScannerApp(App):
|
|
|
166
178
|
Binding("minus,underscore", "zoom_out", "Zoom map out", show=False),
|
|
167
179
|
Binding("pageup", "scroll_main_up", "Scroll up", show=False),
|
|
168
180
|
Binding("pagedown", "scroll_main_down", "Scroll down", show=False),
|
|
181
|
+
# Number keys jump to the Nth visible tab: 1 = first tab (DNS when wide,
|
|
182
|
+
# Activity when narrow) … 9 = ninth, 0 = tenth. Not priority, so a digit typed
|
|
183
|
+
# into the focused domain input is entered as text rather than switching tabs.
|
|
184
|
+
*[Binding(str(d), f"select_tab({d})", show=False) for d in range(10)],
|
|
169
185
|
]
|
|
170
186
|
|
|
171
187
|
def __init__(self, target: str | None = None) -> None:
|
|
@@ -184,6 +200,9 @@ class WebScannerApp(App):
|
|
|
184
200
|
# value handed to StatusPanel so the export matches what's on screen.
|
|
185
201
|
self._cms: object = UNSET
|
|
186
202
|
self.selected = self.modules[0].name
|
|
203
|
+
# Narrow (phone-width) layout: right column + activity strip become tabs.
|
|
204
|
+
# Applied for real in on_mount/on_resize once the terminal size is known.
|
|
205
|
+
self._narrow = False
|
|
187
206
|
self.completed = 0
|
|
188
207
|
self.failed = 0
|
|
189
208
|
self._scanning = False
|
|
@@ -204,7 +223,7 @@ class WebScannerApp(App):
|
|
|
204
223
|
def compose(self) -> ComposeResult:
|
|
205
224
|
with Vertical(id="topbar"):
|
|
206
225
|
yield Input(value=self._target or "", placeholder="domain… (press enter to scan)", id="domain")
|
|
207
|
-
yield TabBar(self.modules, id="tabs")
|
|
226
|
+
yield TabBar(self.modules, pseudo=_PSEUDO_TABS, id="tabs")
|
|
208
227
|
with Grid(id="grid"):
|
|
209
228
|
with Vertical(id="left"):
|
|
210
229
|
with VerticalScroll(id="main"):
|
|
@@ -225,8 +244,8 @@ class WebScannerApp(App):
|
|
|
225
244
|
self.query_one("#status", VerticalScroll).border_title = "Server"
|
|
226
245
|
self.query_one("#activity", ActivityLog).border_title = "Activity Log"
|
|
227
246
|
self.query_one("#tabs", TabBar).set_selected(self.selected)
|
|
228
|
-
|
|
229
|
-
self.
|
|
247
|
+
# Establish the layout (wide vs narrow) before the first paint / scan.
|
|
248
|
+
self._apply_narrow(self.size.width < _NARROW_MAX)
|
|
230
249
|
# own group: the scan worker below is exclusive=True, which cancels every
|
|
231
250
|
# other worker in its group — a shared/default group would kill this one.
|
|
232
251
|
self.run_worker(
|
|
@@ -237,6 +256,37 @@ class WebScannerApp(App):
|
|
|
237
256
|
else:
|
|
238
257
|
self.action_toggle_edit()
|
|
239
258
|
|
|
259
|
+
def on_resize(self, event) -> None:
|
|
260
|
+
"""Switch between the wide and narrow layouts as the terminal is resized."""
|
|
261
|
+
narrow = event.size.width < _NARROW_MAX
|
|
262
|
+
if narrow != self._narrow:
|
|
263
|
+
self._apply_narrow(narrow)
|
|
264
|
+
|
|
265
|
+
def _apply_narrow(self, narrow: bool) -> None:
|
|
266
|
+
"""Toggle the single-column phone layout.
|
|
267
|
+
|
|
268
|
+
Adds/removes the ``-narrow`` class on the Screen (which drives the CSS: the
|
|
269
|
+
map + Server panels hide, the grid collapses to one column) and re-derives the
|
|
270
|
+
Python-side state that CSS can't: the visible tab set (Activity/Server become
|
|
271
|
+
real tabs), the selected tab (a pseudo tab can't stay selected once it vanishes
|
|
272
|
+
on the way back to wide), and which of #main / #activity is shown.
|
|
273
|
+
"""
|
|
274
|
+
self._narrow = narrow
|
|
275
|
+
if self.screen is not None:
|
|
276
|
+
self.screen.set_class(narrow, "-narrow")
|
|
277
|
+
if narrow:
|
|
278
|
+
# Entering narrow: select the first tab (Activity), mirroring how DNS is
|
|
279
|
+
# the default first tab on wide terminals.
|
|
280
|
+
self.selected = _PSEUDO_TABS[0][0]
|
|
281
|
+
elif self.selected in _PSEUDO_NAMES:
|
|
282
|
+
# The pseudo tabs don't exist on wide terminals — fall back to the first
|
|
283
|
+
# real module tab so the selection stays valid.
|
|
284
|
+
self.selected = self.modules[0].name
|
|
285
|
+
self.query_one("#tabs", TabBar).set_selected(self.selected)
|
|
286
|
+
self._refresh_main()
|
|
287
|
+
editing = self.focused is not None and self.focused.id == "domain"
|
|
288
|
+
self._set_keybar(editing=editing)
|
|
289
|
+
|
|
240
290
|
async def _check_version(self) -> None:
|
|
241
291
|
latest = await asyncio.to_thread(check_for_update, __version__)
|
|
242
292
|
self.post_message(VersionChecked(latest))
|
|
@@ -314,12 +364,14 @@ class WebScannerApp(App):
|
|
|
314
364
|
if event.status is ModuleStatus.DONE and self.ctx is not None:
|
|
315
365
|
self.query_one("#map", MapPanel).set_geo(self.ctx.geo)
|
|
316
366
|
self.query_one("#status-content", StatusPanel).set_ctx(self.ctx)
|
|
367
|
+
self._refresh_server_tab()
|
|
317
368
|
return
|
|
318
369
|
if event.name == SHARED_IP:
|
|
319
370
|
# Not a module — a late panel-only refresh once the shared-IP lookup lands.
|
|
320
371
|
# Re-pass the tracked CMS so a completed Tech row isn't dropped back to UNSET.
|
|
321
372
|
if self.ctx is not None:
|
|
322
373
|
self.query_one("#status-content", StatusPanel).set_ctx(self.ctx, cms=self._cms)
|
|
374
|
+
self._refresh_server_tab()
|
|
323
375
|
return
|
|
324
376
|
|
|
325
377
|
self.query_one("#tabs", TabBar).set_status(event.name, event.status)
|
|
@@ -351,6 +403,7 @@ class WebScannerApp(App):
|
|
|
351
403
|
detected = _detect_cms(data, self.ctx.html)
|
|
352
404
|
self._cms = detected
|
|
353
405
|
self.query_one("#status-content", StatusPanel).set_ctx(self.ctx, cms=detected)
|
|
406
|
+
self._refresh_server_tab()
|
|
354
407
|
self.query_one("#activity", ActivityLog).add(activity.cms(detected))
|
|
355
408
|
self._update_progress()
|
|
356
409
|
|
|
@@ -386,17 +439,30 @@ class WebScannerApp(App):
|
|
|
386
439
|
def _module_names(self) -> list[str]:
|
|
387
440
|
return [m.name for m in self.modules]
|
|
388
441
|
|
|
442
|
+
def _nav_names(self) -> list[str]:
|
|
443
|
+
"""Tab names in cycle order — the pseudo tabs (Activity/Server) lead the list
|
|
444
|
+
in the narrow layout, and are absent (fixed panels) when wide."""
|
|
445
|
+
names = self._module_names()
|
|
446
|
+
if self._narrow:
|
|
447
|
+
return [name for name, _ in _PSEUDO_TABS] + names
|
|
448
|
+
return names
|
|
449
|
+
|
|
389
450
|
def _select(self, name: str) -> None:
|
|
390
451
|
self.selected = name
|
|
391
452
|
self.query_one("#tabs", TabBar).set_selected(name)
|
|
392
453
|
self._refresh_main()
|
|
454
|
+
# Reset the shared main-panel scroll so a tab left scrolled-down doesn't carry
|
|
455
|
+
# its offset onto the next tab (they all share the one #main VerticalScroll).
|
|
456
|
+
self.query_one("#main", VerticalScroll).scroll_home(animate=False)
|
|
393
457
|
# keybar depends on the selected tab (Sitemap shows tree-nav hints); keep
|
|
394
458
|
# the editing state if the domain input still has focus.
|
|
395
459
|
editing = self.focused is not None and self.focused.id == "domain"
|
|
396
460
|
self._set_keybar(editing=editing)
|
|
397
461
|
|
|
398
462
|
def _update_main_title(self) -> None:
|
|
399
|
-
label =
|
|
463
|
+
label = _PSEUDO_LABELS.get(self.selected) or next(
|
|
464
|
+
m.label for m in self.modules if m.name == self.selected
|
|
465
|
+
)
|
|
400
466
|
main = self.query_one("#main")
|
|
401
467
|
main.border_title = label
|
|
402
468
|
main.border_subtitle = "" # only the Sitemap tab sets one (URL total)
|
|
@@ -412,15 +478,54 @@ class WebScannerApp(App):
|
|
|
412
478
|
self.query_one("#main-loading", LoadingIndicator).display = loading
|
|
413
479
|
self.query_one("#main-content", Static).display = not loading
|
|
414
480
|
|
|
481
|
+
def _main_avail(self) -> int | None:
|
|
482
|
+
"""Character width available to the main-panel table (inside border + padding),
|
|
483
|
+
or None before the panel is laid out — the table renderer then skips the ⅓ cap."""
|
|
484
|
+
width = self.query_one("#main", VerticalScroll).content_size.width
|
|
485
|
+
return width or None
|
|
486
|
+
|
|
415
487
|
def _refresh_main(self) -> None:
|
|
416
488
|
self._update_main_title()
|
|
417
|
-
|
|
489
|
+
main = self.query_one("#main", VerticalScroll)
|
|
490
|
+
activity = self.query_one("#activity", ActivityLog)
|
|
491
|
+
|
|
492
|
+
# Activity pseudo-tab (narrow only): show the live log widget full-height in
|
|
493
|
+
# place of the main panel. In the wide layout it's the fixed bottom strip.
|
|
494
|
+
if self._narrow and self.selected == "activity":
|
|
495
|
+
main.display = False
|
|
496
|
+
activity.display = True
|
|
497
|
+
# Drop tree visibility so _sync_focus doesn't park focus on a widget
|
|
498
|
+
# whose parent (#main) is now hidden.
|
|
499
|
+
self.query_one("#main-tree", SitemapTree).display = False
|
|
500
|
+
self._sync_focus()
|
|
501
|
+
return
|
|
502
|
+
main.display = True
|
|
503
|
+
activity.display = not self._narrow
|
|
504
|
+
|
|
418
505
|
tree = self.query_one("#main-tree", SitemapTree)
|
|
419
506
|
content = self.query_one("#main-content", Static)
|
|
507
|
+
loading_widget = self.query_one("#main-loading", LoadingIndicator)
|
|
508
|
+
|
|
509
|
+
# Server pseudo-tab (narrow only): render the same status table the fixed
|
|
510
|
+
# Server panel shows, into the main content area.
|
|
511
|
+
if self.selected == "server":
|
|
512
|
+
main.border_subtitle = ""
|
|
513
|
+
tree.display = False
|
|
514
|
+
has_ctx = self.ctx is not None
|
|
515
|
+
loading_widget.display = not has_ctx
|
|
516
|
+
content.display = has_ctx
|
|
517
|
+
if has_ctx:
|
|
518
|
+
# 1ch top + left pad so the status table sits clear of the border,
|
|
519
|
+
# consistent with where the other tabs' content begins.
|
|
520
|
+
content.update(Padding(render_status(self.ctx, self._cms), (1, 0, 0, 1)))
|
|
521
|
+
self._sync_focus()
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
result = self.results.get(self.selected)
|
|
420
525
|
loading = result is None
|
|
421
526
|
# Spinner inside the panel until this tab's module completes; swap to the
|
|
422
527
|
# Tree (Sitemap tab) or the Static content once there's a result to render.
|
|
423
|
-
|
|
528
|
+
loading_widget.display = loading
|
|
424
529
|
|
|
425
530
|
want_tree = (
|
|
426
531
|
self.selected == "sitemap"
|
|
@@ -446,9 +551,28 @@ class WebScannerApp(App):
|
|
|
446
551
|
msg = "no sitemap found" if self.selected == "sitemap" else "no data found"
|
|
447
552
|
self._set_main(f"[dim]{msg}[/]")
|
|
448
553
|
else:
|
|
449
|
-
|
|
554
|
+
avail = self._main_avail()
|
|
555
|
+
content.update(
|
|
556
|
+
render_result(self.selected, result.data, narrow=self._narrow, avail=avail)
|
|
557
|
+
)
|
|
558
|
+
# When switching from a tab that had #main hidden (Activity), the panel's
|
|
559
|
+
# content_size isn't known this tick, so the ⅓ column cap was skipped.
|
|
560
|
+
# Re-render once layout settles so the cap applies.
|
|
561
|
+
if avail is None:
|
|
562
|
+
self.call_after_refresh(self._reflow)
|
|
450
563
|
self._sync_focus()
|
|
451
564
|
|
|
565
|
+
def _reflow(self) -> None:
|
|
566
|
+
"""Re-render the current tab once the main panel has a known width (used after
|
|
567
|
+
it was un-hidden, when the first render ran before layout gave it a size)."""
|
|
568
|
+
if self._main_avail() is not None:
|
|
569
|
+
self._refresh_main()
|
|
570
|
+
|
|
571
|
+
def _refresh_server_tab(self) -> None:
|
|
572
|
+
"""Re-render the Server pseudo-tab as its status data lands (narrow only)."""
|
|
573
|
+
if self._narrow and self.selected == "server":
|
|
574
|
+
self._refresh_main()
|
|
575
|
+
|
|
452
576
|
def _sync_focus(self) -> None:
|
|
453
577
|
"""Focus the Sitemap Tree while that tab is up (so ↑/↓/space reach it); drop
|
|
454
578
|
focus otherwise. Never steals focus from the domain input while editing."""
|
|
@@ -469,17 +593,28 @@ class WebScannerApp(App):
|
|
|
469
593
|
def action_prev_tab(self) -> None:
|
|
470
594
|
if self.focused and self.focused.id == "domain":
|
|
471
595
|
return
|
|
472
|
-
names = self.
|
|
596
|
+
names = self._nav_names()
|
|
473
597
|
idx = (names.index(self.selected) - 1) % len(names)
|
|
474
598
|
self._select(names[idx])
|
|
475
599
|
|
|
476
600
|
def action_next_tab(self) -> None:
|
|
477
601
|
if self.focused and self.focused.id == "domain":
|
|
478
602
|
return
|
|
479
|
-
names = self.
|
|
603
|
+
names = self._nav_names()
|
|
480
604
|
idx = (names.index(self.selected) + 1) % len(names)
|
|
481
605
|
self._select(names[idx])
|
|
482
606
|
|
|
607
|
+
def action_select_tab(self, digit: int) -> None:
|
|
608
|
+
"""Jump to the Nth visible tab. ``digit`` is the key pressed: 1..9 select the
|
|
609
|
+
1st..9th tab, 0 selects the 10th. Ignored while editing the domain input (the
|
|
610
|
+
digit is typed there instead) or when there's no such tab."""
|
|
611
|
+
if self.focused and self.focused.id == "domain":
|
|
612
|
+
return
|
|
613
|
+
position = 10 if digit == 0 else digit
|
|
614
|
+
names = self._nav_names()
|
|
615
|
+
if position <= len(names):
|
|
616
|
+
self._select(names[position - 1])
|
|
617
|
+
|
|
483
618
|
def action_rescan(self) -> None:
|
|
484
619
|
if self.ctx is not None:
|
|
485
620
|
self.start_scan(self.ctx.domain)
|
|
@@ -537,11 +672,18 @@ class WebScannerApp(App):
|
|
|
537
672
|
if editing:
|
|
538
673
|
pairs = [("enter", "Scan"), ("esc", "Cancel")]
|
|
539
674
|
elif self.selected == "sitemap":
|
|
540
|
-
# tree-navigation hints, shown only while the Sitemap tab is up
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
("
|
|
544
|
-
|
|
675
|
+
# tree-navigation hints, shown only while the Sitemap tab is up. The
|
|
676
|
+
# narrow layout drops Rescan/Edit to fit the phone-width footer.
|
|
677
|
+
if self._narrow:
|
|
678
|
+
pairs = [("↑/↓", "Move"), ("enter", "Toggle"), ("space", "All"), ("←/→", "Tab")]
|
|
679
|
+
else:
|
|
680
|
+
pairs = [
|
|
681
|
+
("←/→", "Tab"), ("↑/↓", "Move"), ("enter", "Toggle"),
|
|
682
|
+
("space", "All"), ("r", "Rescan"), ("esc", "Edit"),
|
|
683
|
+
]
|
|
684
|
+
elif self._narrow:
|
|
685
|
+
# Compact set — the full labels overflow a phone-width footer.
|
|
686
|
+
pairs = [("←/→", "Tab"), ("r", "Scan"), ("s", "Save"), ("q", "Quit")]
|
|
545
687
|
else:
|
|
546
688
|
pairs = [("q", "Quit"), ("←/→", "Tab"), ("r", "Rescan"), ("s", "Save"), ("esc", "Edit domain")]
|
|
547
689
|
text = " ".join(f"[b {c}]{k}[/] {label}" for k, label in pairs)
|
|
@@ -20,9 +20,14 @@ Screen {
|
|
|
20
20
|
color: white;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/* TabBar is a HorizontalScroll so the selected tab can be scrolled into view when
|
|
24
|
+
the row overflows a narrow terminal; the scrollbar itself is hidden. */
|
|
23
25
|
#tabs {
|
|
24
26
|
height: 1;
|
|
25
27
|
margin-top: 1;
|
|
28
|
+
overflow-x: auto;
|
|
29
|
+
overflow-y: hidden;
|
|
30
|
+
scrollbar-size: 0 0;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
/* Tabs stay dim regardless of scan status; only the selected tab is highlighted. */
|
|
@@ -38,6 +43,14 @@ Tab.-selected {
|
|
|
38
43
|
color: $text;
|
|
39
44
|
text-style: bold;
|
|
40
45
|
}
|
|
46
|
+
/* Activity/Server pseudo tabs: hidden on wide terminals (fixed panels there),
|
|
47
|
+
shown only in the narrow single-column layout. */
|
|
48
|
+
Tab.-pseudo {
|
|
49
|
+
display: none;
|
|
50
|
+
}
|
|
51
|
+
.-narrow Tab.-pseudo {
|
|
52
|
+
display: block;
|
|
53
|
+
}
|
|
41
54
|
|
|
42
55
|
/* ---- bottom footer row: keybar (left) + progress (right) ---- */
|
|
43
56
|
#footer {
|
|
@@ -172,3 +185,26 @@ Tab.-selected {
|
|
|
172
185
|
padding: 1 1 0 2; /* space above the table and to the left */
|
|
173
186
|
scrollbar-size: 0 0; /* keep the bar hidden even if content overflows */
|
|
174
187
|
}
|
|
188
|
+
|
|
189
|
+
/* ---- narrow (phone-width) layout ----
|
|
190
|
+
Applied below _NARROW_MAX cols via the `-narrow` class on the Screen (see
|
|
191
|
+
_apply_narrow). The fixed map + Server panels and the activity strip become tabs,
|
|
192
|
+
so the grid collapses to a single full-width column and padding is trimmed to buy
|
|
193
|
+
back horizontal room. */
|
|
194
|
+
.-narrow #topbar {
|
|
195
|
+
padding: 1 1 0 1; /* keep the gap above the domain input; trim only below the tabs */
|
|
196
|
+
}
|
|
197
|
+
.-narrow #grid {
|
|
198
|
+
grid-size: 1 1;
|
|
199
|
+
grid-columns: 1fr;
|
|
200
|
+
grid-rows: 1fr;
|
|
201
|
+
}
|
|
202
|
+
/* One visible cell now — drop the row-span so the single column fills the height. */
|
|
203
|
+
.-narrow #left {
|
|
204
|
+
row-span: 1;
|
|
205
|
+
}
|
|
206
|
+
/* Map + Server become tabs; the activity strip is toggled by tab in Python. */
|
|
207
|
+
.-narrow #map,
|
|
208
|
+
.-narrow #status {
|
|
209
|
+
display: none;
|
|
210
|
+
}
|
|
@@ -59,27 +59,55 @@ def _smart_token(tok: str) -> str:
|
|
|
59
59
|
return tok.upper() if tok.lower() in _ACRONYMS else tok.capitalize()
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
def
|
|
62
|
+
def _cap_key_width(width: int | None, avail: int | None) -> int | None:
|
|
63
|
+
"""Cap a first-column width at ~⅓ of the available table width so a long key
|
|
64
|
+
(a header name, a whois field) can't dominate the row; the column then wraps
|
|
65
|
+
within that cap. ``avail`` unknown (pre-layout) → the width is left uncapped.
|
|
66
|
+
``width`` None means "no content-fit width yet" → take the cap outright."""
|
|
67
|
+
if avail and avail > 0:
|
|
68
|
+
cap = max(6, avail // 3)
|
|
69
|
+
return min(width, cap) if width else cap
|
|
70
|
+
return width
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_result(name: str, data: Any, narrow: bool = False, avail: int | None = None) -> RenderableType:
|
|
63
74
|
"""Render a module's result: multi-column Grid, stacked sub-tables (Sections),
|
|
64
|
-
or one key/value table.
|
|
75
|
+
or one key/value table.
|
|
76
|
+
|
|
77
|
+
``narrow`` collapses wide (>2 column) Grids to their first + last column so a
|
|
78
|
+
multi-column tab (Tech: Name/…/Version) still reads on a phone-width terminal.
|
|
79
|
+
``avail`` is the table's available character width; when known, the first column
|
|
80
|
+
is capped at ~⅓ of it and wraps rather than running the row wide.
|
|
81
|
+
"""
|
|
65
82
|
if isinstance(data, Grid):
|
|
66
|
-
return render_grid(data)
|
|
83
|
+
return render_grid(data, narrow=narrow, avail=avail)
|
|
67
84
|
if isinstance(data, Sections):
|
|
68
85
|
# SEO pins all sub-tables to one fixed first-column width so its four
|
|
69
86
|
# tables (Content/Keywords/Robots/Schema) line up exactly.
|
|
70
|
-
return render_sections(data, key_width=12 if name == "seo" else None)
|
|
87
|
+
return render_sections(data, key_width=12 if name == "seo" else None, narrow=narrow, avail=avail)
|
|
71
88
|
mode = "smart" if name in _SMART_LABEL_TABS else "upper"
|
|
72
|
-
return render_table(data, TAB_HEADERS.get(name), mode=mode)
|
|
89
|
+
return render_table(data, TAB_HEADERS.get(name), mode=mode, avail=avail)
|
|
73
90
|
|
|
74
91
|
|
|
75
|
-
def render_grid(grid: Grid) -> Table:
|
|
92
|
+
def render_grid(grid: Grid, narrow: bool = False, avail: int | None = None) -> Table:
|
|
76
93
|
"""Render a multi-column table (e.g. Tech: Name/Category/Confidence/…).
|
|
77
94
|
|
|
78
95
|
First column is the primary name (bold); the rest are dim, matching the
|
|
79
96
|
key/value tables. Long list columns fold rather than truncate. When the Grid
|
|
80
97
|
carries ``widths``, every column is pinned to that fixed width (and the table
|
|
81
98
|
stops expanding) so sibling Grids on one tab line up identically.
|
|
99
|
+
|
|
100
|
+
``narrow`` (phone-width terminals) keeps only the first + last column — for
|
|
101
|
+
Tech that's Name + Version, the two that matter — and pins both to a fixed 50/50
|
|
102
|
+
``ratio`` (not content-fit) so every sibling sub-table on the tab lines up
|
|
103
|
+
identically instead of each fitting its own name lengths.
|
|
82
104
|
"""
|
|
105
|
+
collapsed = narrow and len(grid.columns) > 2
|
|
106
|
+
if collapsed:
|
|
107
|
+
keep = [0, len(grid.columns) - 1]
|
|
108
|
+
columns = [grid.columns[i] for i in keep]
|
|
109
|
+
rows = [[row[i] for i in keep] for row in grid]
|
|
110
|
+
grid = Grid(columns, rows) # widths dropped → columns sized by the ratio below
|
|
83
111
|
widths = grid.widths
|
|
84
112
|
table = Table(
|
|
85
113
|
show_header=True,
|
|
@@ -93,7 +121,20 @@ def render_grid(grid: Grid) -> Table:
|
|
|
93
121
|
)
|
|
94
122
|
for i, col in enumerate(grid.columns):
|
|
95
123
|
w = widths[i] if widths else None
|
|
96
|
-
if
|
|
124
|
+
if collapsed:
|
|
125
|
+
# Fixed 50/50 across every sub-table, so the Name/Version columns align
|
|
126
|
+
# regardless of each group's name lengths.
|
|
127
|
+
style = KEY_STYLE if i == 0 else MUTED
|
|
128
|
+
table.add_column(col, style=style, ratio=1, no_wrap=False, overflow="fold")
|
|
129
|
+
elif i == 0 and w is None:
|
|
130
|
+
# No fixed widths (e.g. the narrow 2-column Tech): content-fit the Name
|
|
131
|
+
# column but cap it at ~⅓ and let it wrap, like the key/value tables.
|
|
132
|
+
name_fit = min(
|
|
133
|
+
MAX_KEY_WIDTH,
|
|
134
|
+
max((len(str(row[0])) for row in grid), default=len(col)),
|
|
135
|
+
)
|
|
136
|
+
table.add_column(col, style=KEY_STYLE, width=_cap_key_width(name_fit, avail), no_wrap=False, overflow="fold")
|
|
137
|
+
elif i == 0:
|
|
97
138
|
table.add_column(col, style=KEY_STYLE, width=w, no_wrap=True, overflow="ellipsis")
|
|
98
139
|
else:
|
|
99
140
|
table.add_column(col, style=MUTED, width=w, no_wrap=False, overflow="fold")
|
|
@@ -105,13 +146,17 @@ def render_grid(grid: Grid) -> Table:
|
|
|
105
146
|
return table
|
|
106
147
|
|
|
107
148
|
|
|
108
|
-
def render_sections(sections: Sections, key_width: int | None = None) -> Group:
|
|
149
|
+
def render_sections(sections: Sections, key_width: int | None = None, narrow: bool = False, avail: int | None = None) -> Group:
|
|
109
150
|
"""Render several titled sub-tables stacked; content-fit sections share one
|
|
110
151
|
first-column width, ratio sections use their fixed proportions. A caller may
|
|
111
|
-
pass ``key_width`` to pin that shared first-column width explicitly.
|
|
152
|
+
pass ``key_width`` to pin that shared first-column width explicitly. ``narrow``
|
|
153
|
+
is forwarded to any Grid sub-tables (Tech) so they collapse to two columns.
|
|
154
|
+
``avail`` caps the shared first-column width at ~⅓ of the table width."""
|
|
112
155
|
if key_width is None:
|
|
113
156
|
widths = [_col1_width(s.data, s.headers, mode="raw") for s in sections if not s.ratio]
|
|
114
157
|
key_width = max([w for w in widths if w], default=None)
|
|
158
|
+
# Cap the shared key column at ~⅓ so a long field can't widen every sub-table.
|
|
159
|
+
key_width = _cap_key_width(key_width, avail)
|
|
115
160
|
parts: list[RenderableType] = [Text("")] # space above the first section title
|
|
116
161
|
for i, sec in enumerate(sections):
|
|
117
162
|
if i:
|
|
@@ -121,7 +166,7 @@ def render_sections(sections: Sections, key_width: int | None = None) -> Group:
|
|
|
121
166
|
parts.append(Text(f" {sec.title.upper()} ", style=SECTION_STYLE))
|
|
122
167
|
if isinstance(sec.data, Grid):
|
|
123
168
|
# e.g. Tech's per-group tables — multi-column, not key/value
|
|
124
|
-
parts.append(render_grid(sec.data))
|
|
169
|
+
parts.append(render_grid(sec.data, narrow=narrow, avail=avail))
|
|
125
170
|
else:
|
|
126
171
|
parts.append(
|
|
127
172
|
render_table(sec.data, sec.headers, mode="raw", spaced=sec.spaced, key_width=key_width, ratio=sec.ratio)
|
|
@@ -197,6 +242,7 @@ def render_table(
|
|
|
197
242
|
spaced: bool = True,
|
|
198
243
|
key_width: int | None = None,
|
|
199
244
|
ratio: tuple[int, int] | None = None,
|
|
245
|
+
avail: int | None = None,
|
|
200
246
|
) -> Table:
|
|
201
247
|
"""Generic key/value (dict), pair-list, or indexed (list) table.
|
|
202
248
|
|
|
@@ -204,7 +250,8 @@ def render_table(
|
|
|
204
250
|
``"smart"`` (Title Case, acronyms upper), or ``"raw"``. ``spaced`` inserts a
|
|
205
251
|
blank line between rows. ``key_width`` fixes the first-column width (content-fit
|
|
206
252
|
by default). ``ratio`` (col1, col2) forces proportional columns instead of
|
|
207
|
-
content-fit (e.g. (3, 2) for 60/40).
|
|
253
|
+
content-fit (e.g. (3, 2) for 60/40). ``avail`` caps the content-fit first column
|
|
254
|
+
at ~⅓ of the table width (it then wraps instead of running the row wide).
|
|
208
255
|
"""
|
|
209
256
|
table = Table(
|
|
210
257
|
show_header=headers is not None,
|
|
@@ -218,6 +265,7 @@ def render_table(
|
|
|
218
265
|
)
|
|
219
266
|
if key_width is None:
|
|
220
267
|
key_width = _col1_width(data, headers, mode)
|
|
268
|
+
key_width = _cap_key_width(key_width, avail)
|
|
221
269
|
|
|
222
270
|
if isinstance(data, dict) or _is_pairs(data):
|
|
223
271
|
if isinstance(data, dict):
|
|
@@ -234,8 +282,9 @@ def render_table(
|
|
|
234
282
|
# Table(expand=True) routes all surplus width there and the fixed-width
|
|
235
283
|
# key column stays put — otherwise Rich spreads the surplus across both
|
|
236
284
|
# columns proportionally and the key column widens when values are short
|
|
237
|
-
# (e.g. an empty Robots/Schema section in the SEO tab).
|
|
238
|
-
|
|
285
|
+
# (e.g. an empty Robots/Schema section in the SEO tab). The key column
|
|
286
|
+
# folds (not ellipsis) so a field wider than its ~⅓ cap wraps in place.
|
|
287
|
+
table.add_column(c1, style=KEY_STYLE, width=key_width, no_wrap=False, overflow="fold")
|
|
239
288
|
table.add_column(c2, ratio=1, no_wrap=False, overflow="fold")
|
|
240
289
|
_add_spaced(table, rows, spaced)
|
|
241
290
|
elif isinstance(data, (list, tuple)):
|
|
@@ -6,9 +6,10 @@ from collections import deque
|
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
|
|
8
8
|
from rich.markup import escape
|
|
9
|
+
from rich.table import Table
|
|
9
10
|
from rich.text import Text
|
|
10
11
|
from textual.binding import Binding
|
|
11
|
-
from textual.containers import Horizontal
|
|
12
|
+
from textual.containers import Horizontal, HorizontalScroll
|
|
12
13
|
from textual.message import Message
|
|
13
14
|
from textual.widgets import RichLog, Static, Tree
|
|
14
15
|
|
|
@@ -29,10 +30,14 @@ class Tab(Static):
|
|
|
29
30
|
self.tab_name = tab_name
|
|
30
31
|
super().__init__()
|
|
31
32
|
|
|
32
|
-
def __init__(self, name: str, label: str) -> None:
|
|
33
|
+
def __init__(self, name: str, label: str, pseudo: bool = False) -> None:
|
|
33
34
|
super().__init__(label, id=f"tab-{name}")
|
|
34
35
|
self.tab_name = name
|
|
35
36
|
self.add_class("-pending")
|
|
37
|
+
# Pseudo tabs (Activity/Server) exist only in the narrow layout; CSS hides
|
|
38
|
+
# them on wide terminals where those are fixed panels instead.
|
|
39
|
+
if pseudo:
|
|
40
|
+
self.add_class("-pseudo")
|
|
36
41
|
|
|
37
42
|
def set_status(self, status: ModuleStatus) -> None:
|
|
38
43
|
self.remove_class(*_STATUS_CLASSES)
|
|
@@ -45,12 +50,28 @@ class Tab(Static):
|
|
|
45
50
|
self.post_message(self.Clicked(self.tab_name))
|
|
46
51
|
|
|
47
52
|
|
|
48
|
-
class TabBar(
|
|
49
|
-
|
|
53
|
+
class TabBar(HorizontalScroll):
|
|
54
|
+
"""The row of tabs. Scrolls horizontally so the selected tab is always brought
|
|
55
|
+
into view when the row is wider than the terminal (narrow screens) — Textual's
|
|
56
|
+
plain ``Horizontal`` would just clip the overflow with no way to reach it.
|
|
57
|
+
|
|
58
|
+
``pseudo`` tabs (Activity/Server) are rendered first but hidden by CSS on wide
|
|
59
|
+
terminals; they surface only in the narrow layout, where those panels become tabs.
|
|
60
|
+
|
|
61
|
+
``can_focus=False`` keeps single-key nav (↑/↓ for the Sitemap tree, etc.) from
|
|
62
|
+
being swallowed by the scroll container's own bindings.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
can_focus = False
|
|
66
|
+
|
|
67
|
+
def __init__(self, modules, pseudo: tuple[tuple[str, str], ...] = (), **kwargs) -> None:
|
|
50
68
|
super().__init__(**kwargs)
|
|
51
69
|
self._modules = list(modules)
|
|
70
|
+
self._pseudo = list(pseudo)
|
|
52
71
|
|
|
53
72
|
def compose(self):
|
|
73
|
+
for name, label in self._pseudo:
|
|
74
|
+
yield Tab(name, label, pseudo=True)
|
|
54
75
|
for module in self._modules:
|
|
55
76
|
yield Tab(module.name, module.label)
|
|
56
77
|
|
|
@@ -58,8 +79,14 @@ class TabBar(Horizontal):
|
|
|
58
79
|
self.query_one(f"#tab-{name}", Tab).set_status(status)
|
|
59
80
|
|
|
60
81
|
def set_selected(self, name: str) -> None:
|
|
82
|
+
selected = None
|
|
61
83
|
for tab in self.query(Tab):
|
|
62
84
|
tab.set_selected(tab.tab_name == name)
|
|
85
|
+
if tab.tab_name == name:
|
|
86
|
+
selected = tab
|
|
87
|
+
# Keep the active tab visible when the row overflows the terminal width.
|
|
88
|
+
if selected is not None and selected.display:
|
|
89
|
+
self.scroll_to_widget(selected, animate=False)
|
|
63
90
|
|
|
64
91
|
|
|
65
92
|
class MapPanel(Static):
|
|
@@ -111,13 +138,15 @@ class StatusPanel(Static):
|
|
|
111
138
|
class ActivityLog(RichLog):
|
|
112
139
|
"""Fixed scan log under the main panel; the newest line is always in view.
|
|
113
140
|
|
|
114
|
-
Lines are composed by ``activity.py`` — this only stamps the time
|
|
115
|
-
body,
|
|
116
|
-
without an app.
|
|
141
|
+
Lines are composed by ``activity.py`` — this only stamps the time and paints the
|
|
142
|
+
body, so the wording stays testable without an app.
|
|
117
143
|
|
|
118
|
-
Each line is
|
|
119
|
-
|
|
120
|
-
|
|
144
|
+
Each line is a two-column ``Table.grid``: a fixed-width timestamp column and a
|
|
145
|
+
wrapping body column. When the body is wider than the panel it **wraps** (word
|
|
146
|
+
boundaries), and the grid's second column keeps every continuation line indented
|
|
147
|
+
past the timestamp — a hanging indent aligned under the first body character. The
|
|
148
|
+
grid is rendered at the panel's exact width, so a wider terminal simply fits more
|
|
149
|
+
on each line before wrapping.
|
|
121
150
|
|
|
122
151
|
``can_focus=False`` is load-bearing, not tidiness: RichLog inherits
|
|
123
152
|
ScrollableContainer's ungated up/down bindings, so a focused log would swallow
|
|
@@ -133,35 +162,38 @@ class ActivityLog(RichLog):
|
|
|
133
162
|
|
|
134
163
|
def __init__(self, **kwargs) -> None:
|
|
135
164
|
# min_width=0: the 78 default renders every write at >=78 cells, overflowing
|
|
136
|
-
# a narrower panel.
|
|
137
|
-
# overflow="ignore" on Text and the tail vanishes with no ellipsis.
|
|
165
|
+
# a narrower panel.
|
|
138
166
|
super().__init__(markup=True, wrap=True, min_width=0, max_lines=self._MAX_LINES, **kwargs)
|
|
139
167
|
# RichLog renders to strips at write time and never re-renders, so keep the
|
|
140
|
-
# markup to replay when the width changes.
|
|
141
|
-
self._entries: deque[str] = deque(maxlen=self._MAX_LINES)
|
|
168
|
+
# (stamp, body) markup pairs to replay when the width changes.
|
|
169
|
+
self._entries: deque[tuple[str, str]] = deque(maxlen=self._MAX_LINES)
|
|
142
170
|
self._rendered_width: int | None = None
|
|
143
171
|
|
|
144
172
|
def add(self, body: str) -> None:
|
|
145
173
|
"""Write one line, stamped with a muted [HH:MM:SS]."""
|
|
146
|
-
stamp = escape(f
|
|
147
|
-
|
|
148
|
-
self._entries.append(
|
|
149
|
-
self._write(
|
|
150
|
-
|
|
151
|
-
def _write(self,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
174
|
+
stamp = f"[{MUTED}]{escape(f'[{datetime.now():%H:%M:%S}]')}[/]"
|
|
175
|
+
body_markup = f"[{BODY}]{body}[/]"
|
|
176
|
+
self._entries.append((stamp, body_markup))
|
|
177
|
+
self._write(stamp, body_markup)
|
|
178
|
+
|
|
179
|
+
def _write(self, stamp: str, body: str) -> None:
|
|
180
|
+
# Two columns: the timestamp at a fixed width (its 10 chars + a 1-cell gap),
|
|
181
|
+
# then the body in a flexible column that wraps. The grid keeps the body
|
|
182
|
+
# column left edge constant, so wrapped continuation lines hang-indent under
|
|
183
|
+
# the first body character rather than sliding back under the timestamp.
|
|
184
|
+
grid = Table.grid(expand=True)
|
|
185
|
+
grid.add_column(width=11, no_wrap=True)
|
|
186
|
+
grid.add_column(ratio=1)
|
|
187
|
+
grid.add_row(Text.from_markup(stamp), Text.from_markup(body))
|
|
155
188
|
# Explicit width, because RichLog's own sizing measures against
|
|
156
189
|
# `app.console`, which is a plain 80 columns regardless of how wide the app
|
|
157
|
-
# actually is —
|
|
158
|
-
#
|
|
159
|
-
# laid out yet: let RichLog defer the write, and on_resize replays it.
|
|
190
|
+
# actually is — the body would wrap at 80 on a wider terminal. Width 0 means
|
|
191
|
+
# we aren't laid out yet: let RichLog defer the write, and on_resize replays it.
|
|
160
192
|
width = self.scrollable_content_region.width
|
|
161
193
|
if width:
|
|
162
|
-
self.write(
|
|
194
|
+
self.write(grid, width=width)
|
|
163
195
|
else:
|
|
164
|
-
self.write(
|
|
196
|
+
self.write(grid)
|
|
165
197
|
|
|
166
198
|
def clear(self) -> "ActivityLog":
|
|
167
199
|
self._entries.clear()
|
|
@@ -178,13 +210,13 @@ class ActivityLog(RichLog):
|
|
|
178
210
|
if not self._entries:
|
|
179
211
|
return
|
|
180
212
|
# RichLog renders to strips at write time and never re-renders, so replay at
|
|
181
|
-
# the new width: a wider terminal
|
|
182
|
-
# re-
|
|
183
|
-
# rendered by RichLog without our explicit width.
|
|
213
|
+
# the new width: a wider terminal fits more before wrapping, a narrower one
|
|
214
|
+
# re-wraps to more lines. Also fixes the first sizing, where deferred lines
|
|
215
|
+
# were rendered by RichLog without our explicit width.
|
|
184
216
|
entries = list(self._entries)
|
|
185
217
|
super().clear()
|
|
186
|
-
for
|
|
187
|
-
self._write(
|
|
218
|
+
for stamp, body in entries:
|
|
219
|
+
self._write(stamp, body)
|
|
188
220
|
|
|
189
221
|
|
|
190
222
|
class SitemapTree(Tree):
|
|
@@ -206,6 +238,17 @@ class SitemapTree(Tree):
|
|
|
206
238
|
BINDINGS = [
|
|
207
239
|
Binding("enter", "toggle_node", "Toggle", show=False),
|
|
208
240
|
Binding("space", "toggle_all", "Expand/collapse all", show=False),
|
|
241
|
+
# Tree's base (ScrollView) binds left/right to horizontal scroll, which would
|
|
242
|
+
# swallow the app's ←/→ tab navigation whenever a long URL overflows the panel.
|
|
243
|
+
# Redirect them to the app so ←/→ always changes tab from the Sitemap too.
|
|
244
|
+
Binding("left", "prev_tab", show=False),
|
|
245
|
+
Binding("right", "next_tab", show=False),
|
|
246
|
+
# The tree is height:auto inside the #main VerticalScroll, so it has nothing
|
|
247
|
+
# of its own to scroll — its inherited page up/down bindings would just no-op
|
|
248
|
+
# and swallow the keys. Redirect them to #main so paging works like every
|
|
249
|
+
# other tab.
|
|
250
|
+
Binding("pageup", "page_main_up", show=False),
|
|
251
|
+
Binding("pagedown", "page_main_down", show=False),
|
|
209
252
|
]
|
|
210
253
|
|
|
211
254
|
#: Textual gives the expand/collapse chevron no component class of its own — it
|
|
@@ -270,6 +313,18 @@ class SitemapTree(Tree):
|
|
|
270
313
|
if 0 <= index < len(self._leaf_urls):
|
|
271
314
|
self.app.open_url(self._leaf_urls[index])
|
|
272
315
|
|
|
316
|
+
def action_prev_tab(self) -> None:
|
|
317
|
+
self.app.action_prev_tab()
|
|
318
|
+
|
|
319
|
+
def action_next_tab(self) -> None:
|
|
320
|
+
self.app.action_next_tab()
|
|
321
|
+
|
|
322
|
+
def action_page_main_up(self) -> None:
|
|
323
|
+
self.app.action_scroll_main_up()
|
|
324
|
+
|
|
325
|
+
def action_page_main_down(self) -> None:
|
|
326
|
+
self.app.action_scroll_main_down()
|
|
327
|
+
|
|
273
328
|
def action_toggle_all(self) -> None:
|
|
274
329
|
branches = self._branches()
|
|
275
330
|
if not branches:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|