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.
Files changed (43) hide show
  1. {web_scanner-2.2.0 → web_scanner-2.2.1}/PKG-INFO +1 -1
  2. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/__init__.py +1 -1
  3. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/app.py +157 -15
  4. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/app.tcss +36 -0
  5. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/tables.py +62 -13
  6. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/widgets.py +88 -33
  7. {web_scanner-2.2.0 → web_scanner-2.2.1}/.gitignore +0 -0
  8. {web_scanner-2.2.0 → web_scanner-2.2.1}/LICENSE +0 -0
  9. {web_scanner-2.2.0 → web_scanner-2.2.1}/README.md +0 -0
  10. {web_scanner-2.2.0 → web_scanner-2.2.1}/app.py +0 -0
  11. {web_scanner-2.2.0 → web_scanner-2.2.1}/pyproject.toml +0 -0
  12. {web_scanner-2.2.0 → web_scanner-2.2.1}/requirements.txt +0 -0
  13. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/__main__.py +0 -0
  14. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/cli.py +0 -0
  15. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/colors.py +0 -0
  16. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/core/__init__.py +0 -0
  17. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/core/context.py +0 -0
  18. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/core/models.py +0 -0
  19. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/core/module.py +0 -0
  20. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/core/scanner.py +0 -0
  21. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/helpers.py +0 -0
  22. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/__init__.py +0 -0
  23. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/dns.py +0 -0
  24. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/headers.py +0 -0
  25. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/links.py +0 -0
  26. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/security.py +0 -0
  27. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/seo.py +0 -0
  28. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/sitemap.py +0 -0
  29. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/ssl.py +0 -0
  30. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/subdomains.py +0 -0
  31. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/tech.py +0 -0
  32. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/modules/whois.py +0 -0
  33. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/__init__.py +0 -0
  34. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/agents.py +0 -0
  35. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/data/public_suffix_list.dat +0 -0
  36. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/http.py +0 -0
  37. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/psl.py +0 -0
  38. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/net/version_check.py +0 -0
  39. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/__init__.py +0 -0
  40. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/activity.py +0 -0
  41. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/data/countries.json +0 -0
  42. {web_scanner-2.2.0 → web_scanner-2.2.1}/webscanner/ui/export.py +0 -0
  43. {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.0
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
@@ -1,3 +1,3 @@
1
1
  """web-scanner v2 — async website reconnaissance TUI package."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.2.1"
@@ -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
- self._update_main_title()
229
- self._set_keybar(editing=False)
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 = next(m.label for m in self.modules if m.name == self.selected)
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
- result = self.results.get(self.selected)
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
- self.query_one("#main-loading", LoadingIndicator).display = loading
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
- content.update(render_result(self.selected, result.data))
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._module_names()
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._module_names()
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
- pairs = [
542
- ("←/→", "Tab"), ("↑/↓", "Move"), ("enter", "Toggle"),
543
- ("space", "All"), ("r", "Rescan"), ("esc", "Edit"),
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 render_result(name: str, data: Any) -> RenderableType:
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 i == 0:
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
- table.add_column(c1, style=KEY_STYLE, width=key_width, no_wrap=True, overflow="ellipsis")
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(Horizontal):
49
- def __init__(self, modules, **kwargs) -> None:
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, paints the
115
- body, and decides how a too-long line is cut, so the wording stays testable
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 written as a ``no_wrap`` / ``overflow="ellipsis"`` Text at the
119
- panel's exact width, so it's truncated with "…" at the panel edge and a wider
120
- terminal reveals more of it. Nothing is pre-cropped to an assumed width.
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. wrap=True: with wrap=False, RichLog forces
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"[{datetime.now():%H:%M:%S}]")
147
- markup = f"[{MUTED}]{stamp}[/] [{BODY}]{body}[/]"
148
- self._entries.append(markup)
149
- self._write(markup)
150
-
151
- def _write(self, markup: str) -> None:
152
- text = Text.from_markup(markup)
153
- text.no_wrap = True
154
- text.overflow = "ellipsis"
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 — every line would ellipsise at 80 on a wider terminal. Passing
158
- # width bypasses measure/shrink/min_width entirely. Width 0 means we aren't
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(text, width=width)
194
+ self.write(grid, width=width)
163
195
  else:
164
- self.write(text)
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 reveals more of each line, a narrower one
182
- # re-ellipsises. Also fixes the first sizing, where deferred lines were
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 markup in entries:
187
- self._write(markup)
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