This commit is contained in:
nose
2025-12-20 23:57:44 -08:00
parent b75faa49a2
commit 8ca5783970
39 changed files with 4294 additions and 1722 deletions

View File

@@ -12,11 +12,18 @@ Features:
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Callable
from typing import Any, Dict, List, Optional, Callable, Set
from pathlib import Path
import json
import shutil
from rich.box import SIMPLE
from rich.console import Group
from rich.panel import Panel
from rich.prompt import Prompt
from rich.table import Table as RichTable
from rich.text import Text
# Optional Textual imports - graceful fallback if not available
try:
from textual.widgets import Tree
@@ -26,7 +33,7 @@ except ImportError:
def _sanitize_cell_text(value: Any) -> str:
"""Coerce to a single-line, tab-free string suitable for ASCII tables."""
"""Coerce to a single-line, tab-free string suitable for terminal display."""
if value is None:
return ""
text = str(value)
@@ -136,10 +143,15 @@ class ResultRow:
def add_column(self, name: str, value: Any) -> None:
"""Add a column to this row."""
# Normalize column header names.
normalized_name = str(name or "").strip()
if normalized_name.lower() == "name":
normalized_name = "Title"
str_value = _sanitize_cell_text(value)
# Normalize extension columns globally and cap to 5 characters
if str(name).strip().lower() == "ext":
if normalized_name.lower() == "ext":
str_value = str_value.strip().lstrip(".")
for idx, ch in enumerate(str_value):
if not ch.isalnum():
@@ -147,7 +159,7 @@ class ResultRow:
break
str_value = str_value[:5]
self.columns.append(ResultColumn(name, str_value))
self.columns.append(ResultColumn(normalized_name, str_value))
def get_column(self, name: str) -> Optional[str]:
"""Get column value by name."""
@@ -195,6 +207,30 @@ class ResultTable:
preserve_order: When True, skip automatic sorting so row order matches source
"""
self.title = title
try:
import pipeline as ctx
cmdlet_name = ""
try:
cmdlet_name = ctx.get_current_cmdlet_name("") if hasattr(ctx, "get_current_cmdlet_name") else ""
except Exception:
cmdlet_name = ""
stage_text = ""
try:
stage_text = ctx.get_current_stage_text("") if hasattr(ctx, "get_current_stage_text") else ""
except Exception:
stage_text = ""
if cmdlet_name and stage_text:
normalized_cmd = str(cmdlet_name).replace("_", "-").strip().lower()
normalized_title = str(self.title or "").strip().lower()
normalized_stage = str(stage_text).strip()
if normalized_stage and normalized_stage.lower().startswith(normalized_cmd):
if (not normalized_title) or normalized_title.replace("_", "-").startswith(normalized_cmd):
self.title = normalized_stage
except Exception:
pass
self.title_width = title_width
self.max_columns = max_columns if max_columns is not None else 5 # Default 5 for cleaner display
self.rows: List[ResultRow] = []
@@ -214,6 +250,26 @@ class ResultTable:
self.table: Optional[str] = None
"""Table type (e.g., 'youtube', 'soulseek') for context-aware selection logic."""
self.value_case: str = "lower"
"""Display-only value casing: 'lower' (default), 'upper', or 'preserve'."""
def set_value_case(self, value_case: str) -> "ResultTable":
"""Configure display-only casing for rendered cell values."""
case = str(value_case or "").strip().lower()
if case not in {"lower", "upper", "preserve"}:
case = "lower"
self.value_case = case
return self
def _apply_value_case(self, text: str) -> str:
if not text:
return ""
if self.value_case == "upper":
return text.upper()
if self.value_case == "preserve":
return text
return text.lower()
def set_table(self, table: str) -> "ResultTable":
"""Set the table type for context-aware selection logic."""
self.table = table
@@ -459,7 +515,7 @@ class ResultTable:
# Size (for files)
if hasattr(result, 'size_bytes') and result.size_bytes:
row.add_column("Size (Mb)", _format_size(result.size_bytes, integer_only=True))
row.add_column("Size", _format_size(result.size_bytes, integer_only=False))
# Annotations
if hasattr(result, 'annotations') and result.annotations:
@@ -505,9 +561,9 @@ class ResultTable:
elif getattr(item, 'store', None):
row.add_column("Storage", str(getattr(item, 'store')))
# Size (for files) - integer MB only
# Size (for files)
if hasattr(item, 'size_bytes') and item.size_bytes:
row.add_column("Size (Mb)", _format_size(item.size_bytes, integer_only=True))
row.add_column("Size", _format_size(item.size_bytes, integer_only=False))
def _add_tag_item(self, row: ResultRow, item: Any) -> None:
"""Extract and add TagItem fields to row (compact tag display).
@@ -575,9 +631,9 @@ class ResultTable:
Priority field groups (first match per group):
- title | name | filename
- ext
- size | size_bytes
- store | table | source
- size | size_bytes
- ext
"""
# Helper to determine if a field should be hidden from display
def is_hidden_field(field_name: Any) -> bool:
@@ -670,9 +726,9 @@ class ResultTable:
# Explicitly set which columns to display in order
priority_groups = [
('title', ['title', 'name', 'filename']),
('ext', ['ext']),
('size', ['size', 'size_bytes']),
('store', ['store', 'table', 'source']),
('size', ['size', 'size_bytes']),
('ext', ['ext']),
]
# Add priority field groups first - use first match in each group
@@ -681,9 +737,9 @@ class ResultTable:
break
for field in field_options:
if field in visible_data and field not in added_fields:
# Special handling for size fields - format as MB integer
# Special handling for size fields - format with unit and decimals
if field in ['size', 'size_bytes']:
value_str = _format_size(visible_data[field], integer_only=True)
value_str = _format_size(visible_data[field], integer_only=False)
else:
value_str = format_value(visible_data[field])
@@ -694,7 +750,7 @@ class ResultTable:
if field in ['store', 'table', 'source']:
col_name = "Store"
elif field in ['size', 'size_bytes']:
col_name = "Size (Mb)"
col_name = "Size"
elif field in ['title', 'name', 'filename']:
col_name = "Title"
else:
@@ -727,115 +783,56 @@ class ResultTable:
row.add_column(key.replace('_', ' ').title(), value_str)
def format_plain(self) -> str:
"""Format table as plain text with aligned columns and row numbers.
Returns:
Formatted table string
"""
def to_rich(self):
"""Return a Rich renderable representing this table."""
if not self.rows:
return "No results"
empty = Text("No results")
return Panel(empty, title=self.title) if self.title else empty
# Cap rendering to terminal width so long tables don't hard-wrap and
# visually break the border/shape.
term_width = shutil.get_terminal_size(fallback=(120, 24)).columns
if not term_width or term_width <= 0:
term_width = 120
# Calculate column widths
col_widths: Dict[str, int] = {}
col_names: List[str] = []
seen: Set[str] = set()
for row in self.rows:
for col in row.columns:
col_name = col.name
value_width = len(col.value)
if col_name.lower() == "ext":
value_width = min(value_width, 5)
col_widths[col_name] = max(
col_widths.get(col_name, 0),
len(col.name),
value_width
)
# Calculate row number column width (skip if no-choice)
num_width = 0 if self.no_choice else len(str(len(self.rows))) + 1
if col.name not in seen:
seen.add(col.name)
col_names.append(col.name)
# Preserve column order
column_names = list(col_widths.keys())
table = RichTable(
show_header=True,
header_style="bold",
box=SIMPLE,
expand=True,
show_lines=False,
)
def capped_width(name: str) -> int:
if not self.no_choice:
table.add_column("#", justify="right", no_wrap=True)
# Render headers in uppercase, but keep original column keys for lookup.
header_by_key: Dict[str, str] = {name: str(name).upper() for name in col_names}
for name in col_names:
header = header_by_key.get(name, str(name).upper())
if name.lower() == "ext":
cap = 5
table.add_column(header, no_wrap=True)
else:
# Single-column tables (e.g., get-tag) can use more horizontal space,
# but still must stay within the terminal to avoid hard wrapping.
if len(column_names) == 1:
# Keep room for side walls and optional row-number column.
cap = max(30, min(240, term_width - 6))
else:
cap = 90
return min(col_widths[name], cap)
table.add_column(header)
widths = ([] if self.no_choice else [num_width]) + [capped_width(name) for name in column_names]
base_inner_width = sum(widths) + (len(widths) - 1) * 3 # account for " | " separators
for row_idx, row in enumerate(self.rows, 1):
cells: List[str] = []
if not self.no_choice:
cells.append(str(row_idx))
for name in col_names:
val = row.get_column(name) or ""
cells.append(self._apply_value_case(_sanitize_cell_text(val)))
table.add_row(*cells)
# Compute final table width (with side walls) to accommodate headers/titles
table_width = base_inner_width + 2 # side walls
if self.title:
table_width = max(table_width, len(self.title) + 2)
if self.header_lines:
table_width = max(table_width, max(len(line) for line in self.header_lines) + 2)
if self.title or self.header_lines:
header_bits = [Text(line) for line in (self.header_lines or [])]
renderable = Group(*header_bits, table) if header_bits else table
return Panel(renderable, title=self.title) if self.title else renderable
# Ensure final render doesn't exceed terminal width (minus 1 safety column).
safe_term_width = max(20, term_width - 1)
table_width = min(table_width, safe_term_width)
def wrap(text: str) -> str:
"""Wrap content with side walls and pad to table width."""
if len(text) > table_width - 2:
text = text[: table_width - 5] + "..." # keep walls intact
return "|" + text.ljust(table_width - 2) + "|"
lines = []
# Title block
if self.title:
lines.append("|" + "=" * (table_width - 2) + "|")
safe_title = _sanitize_cell_text(self.title)
lines.append(wrap(safe_title.ljust(table_width - 2)))
lines.append("|" + "=" * (table_width - 2) + "|")
# Optional header metadata lines
for meta in self.header_lines:
safe_meta = _sanitize_cell_text(meta)
lines.append(wrap(safe_meta))
# Add header with # column
header_parts = [] if self.no_choice else ["#".ljust(num_width)]
separator_parts = [] if self.no_choice else ["-" * num_width]
for col_name in column_names:
width = capped_width(col_name)
header_parts.append(col_name.ljust(width))
separator_parts.append("-" * width)
lines.append(wrap(" | ".join(header_parts)))
lines.append(wrap("-+-".join(separator_parts)))
# Add rows with row numbers
for row_num, row in enumerate(self.rows, 1):
row_parts = [] if self.no_choice else [str(row_num).ljust(num_width)]
for col_name in column_names:
width = capped_width(col_name)
col_value = row.get_column(col_name) or ""
col_value = _sanitize_cell_text(col_value)
if len(col_value) > width:
col_value = col_value[: width - 3] + "..."
row_parts.append(col_value.ljust(width))
lines.append(wrap(" | ".join(row_parts)))
# Bottom border to close the rectangle
lines.append("|" + "=" * (table_width - 2) + "|")
return "\n".join(lines)
return table
def format_compact(self) -> str:
"""Format table in compact form (one line per row).
@@ -880,8 +877,16 @@ class ResultTable:
}
def __str__(self) -> str:
"""String representation (plain text format)."""
return self.format_plain()
"""String representation.
Rich is the primary rendering path. This keeps accidental `print(table)`
usage from emitting ASCII box-drawn tables.
"""
label = self.title or "ResultTable"
return f"{label} ({len(self.rows)} rows)"
def __rich__(self):
return self.to_rich()
def __repr__(self) -> str:
"""Developer representation."""
@@ -921,20 +926,24 @@ class ResultTable:
If accept_args=True: Dict with "indices" and "args" keys, or None if cancelled
"""
if self.no_choice:
print(f"\n{self}")
print("Selection is disabled for this table.")
from rich_display import stdout_console
stdout_console().print(self)
stdout_console().print(Panel(Text("Selection is disabled for this table.")))
return None
# Display the table
print(f"\n{self}")
from rich_display import stdout_console
stdout_console().print(self)
# Get user input
while True:
try:
if accept_args:
choice = input(f"\n{prompt} (e.g., '5' or '2 -storage hydrus' or 'q' to quit): ").strip()
choice = Prompt.ask(f"{prompt} (e.g., '5' or '2 -storage hydrus' or 'q' to quit)").strip()
else:
choice = input(f"\n{prompt} (e.g., '5' or '3-5' or '1,3,5' or 'q' to quit): ").strip()
choice = Prompt.ask(f"{prompt} (e.g., '5' or '3-5' or '1,3,5' or 'q' to quit)").strip()
if choice.lower() == 'q':
return None
@@ -944,18 +953,18 @@ class ResultTable:
result = self._parse_selection_with_args(choice)
if result is not None:
return result
print(f"Invalid format. Use: selection (5 or 3-5 or 1,3,5) optionally followed by flags (e.g., '5 -storage hydrus').")
stdout_console().print(Panel(Text("Invalid format. Use: selection (5 or 3-5 or 1,3,5) optionally followed by flags (e.g., '5 -storage hydrus').")))
else:
# Parse just the selection
selected_indices = self._parse_selection(choice)
if selected_indices is not None:
return selected_indices
print(f"Invalid format. Use: single (5), range (3-5), list (1,3,5), combined (1-3,7,9-11), or 'q' to quit.")
stdout_console().print(Panel(Text("Invalid format. Use: single (5), range (3-5), list (1,3,5), combined (1-3,7,9-11), or 'q' to quit.")))
except (ValueError, EOFError):
if accept_args:
print(f"Invalid format. Use: selection (5 or 3-5 or 1,3,5) optionally followed by flags (e.g., '5 -storage hydrus').")
stdout_console().print(Panel(Text("Invalid format. Use: selection (5 or 3-5 or 1,3,5) optionally followed by flags (e.g., '5 -storage hydrus').")))
else:
print(f"Invalid format. Use: single (5), range (3-5), list (1,3,5), combined (1-3,7,9-11), or 'q' to quit.")
stdout_console().print(Panel(Text("Invalid format. Use: single (5), range (3-5), list (1,3,5), combined (1-3,7,9-11), or 'q' to quit.")))
def _parse_selection(self, selection_str: str) -> Optional[List[int]]:
"""Parse user selection string into list of 0-based indices.
@@ -1317,10 +1326,10 @@ def _format_size(size: Any, integer_only: bool = False) -> str:
Args:
size: Size in bytes or already formatted string
integer_only: If True, show MB as integer only (e.g., "250 MB" not "250.5 MB")
integer_only: If True, show MB as an integer (e.g., "250 MB")
Returns:
Formatted size string (e.g., "250 MB", "1.5 MB" or "250 MB" if integer_only=True)
Formatted size string with units (e.g., "3.53 MB", "0.57 MB", "1.2 GB")
"""
if isinstance(size, str):
return size if size else ""
@@ -1329,23 +1338,22 @@ def _format_size(size: Any, integer_only: bool = False) -> str:
bytes_val = int(size)
if bytes_val < 0:
return ""
if integer_only:
# For table display: always show as integer MB if >= 1MB
mb_val = int(bytes_val / (1024 * 1024))
if mb_val > 0:
return str(mb_val)
kb_val = int(bytes_val / 1024)
if kb_val > 0:
return str(kb_val)
return str(bytes_val)
# Keep display consistent with the CLI expectation: show MB with unit
# (including values under 1 MB as fractional MB), and show GB for very
# large sizes.
if bytes_val >= 1024**3:
value = bytes_val / (1024**3)
unit = "GB"
else:
# For descriptions: show with one decimal place
for unit, divisor in [("GB", 1024**3), ("MB", 1024**2), ("KB", 1024)]:
if bytes_val >= divisor:
return f"{bytes_val / divisor:.1f} {unit}"
return f"{bytes_val} B"
value = bytes_val / (1024**2)
unit = "MB"
if integer_only:
return f"{int(round(value))} {unit}"
num = f"{value:.2f}".rstrip("0").rstrip(".")
return f"{num} {unit}"
except (ValueError, TypeError):
return ""