summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/tui/widgets.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/tui/widgets.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/widgets.py109
1 files changed, 109 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/widgets.py b/packages/meshbay-cli/src/meshbay_cli/tui/widgets.py
new file mode 100644
index 0000000..66fca4e
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/tui/widgets.py
@@ -0,0 +1,109 @@
+"""A question with a few answers, and the strip that shows transfers."""
+
+import time
+from dataclasses import dataclass
+
+from rich.text import Text
+from textual.app import ComposeResult
+from textual.containers import Horizontal, Vertical
+from textual.screen import ModalScreen
+from textual.widgets import Button, Static
+
+from ..output import human_size, safe
+
+
+class Choice(ModalScreen[str]):
+ """One question, asked once for a whole batch — never once per file."""
+
+ DEFAULT_CSS = """
+ Choice { align: center middle; }
+ Choice > Vertical { width: 72; height: auto; max-height: 80%;
+ border: thick $accent; background: $surface; padding: 1 2; }
+ Choice Static { height: auto; margin-bottom: 1; }
+ Choice Horizontal { height: auto; align: center middle; }
+ Choice Button { margin: 0 1; }
+ """
+ BINDINGS = [("escape", "dismiss('cancel')", "Cancel")]
+
+ def __init__(self, message: str, options: list[tuple[str, str]]):
+ super().__init__()
+ self.message = message
+ self.options = options
+
+ def compose(self) -> ComposeResult:
+ with Vertical():
+ yield Static(Text(self.message))
+ with Horizontal():
+ for value, label in self.options:
+ yield Button(label, id=f"choice-{value}")
+
+ def on_mount(self) -> None:
+ self.query(Button).first().focus()
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ self.dismiss(event.button.id.removeprefix("choice-"))
+
+
+@dataclass
+class Job:
+ arrow: str
+ name: str
+ size: int
+ done: int = 0
+ state: str = "waiting" # waiting, queued, running, done, failed
+ note: str = ""
+ started: float = 0.0
+
+
+class TransferStrip(Static):
+ """The running transfers, then the latest finished ones."""
+
+ DEFAULT_CSS = """
+ TransferStrip { height: 6; border-top: solid $panel; padding: 0 1; }
+ """
+ SHOWN = 5
+
+ def __init__(self, **kwargs):
+ super().__init__("", markup=False, **kwargs)
+ self.jobs: list[Job] = []
+
+ def on_mount(self) -> None:
+ self.refresh_text()
+
+ def add(self, arrow: str, name: str, size: int) -> Job:
+ job = Job(arrow, name, size)
+ self.jobs.append(job)
+ self.refresh_text()
+ return job
+
+ def update_job(self, job: Job, **fields) -> None:
+ for k, v in fields.items():
+ setattr(job, k, v)
+ if job.state == "running" and not job.started:
+ job.started = time.monotonic()
+ self.refresh_text()
+
+ def refresh_text(self) -> None:
+ active = [j for j in self.jobs if j.state in ("running", "queued", "waiting")]
+ ended = [j for j in self.jobs if j.state in ("done", "failed")]
+ lines = [self._line(j) for j in active[:self.SHOWN]]
+ if len(active) > self.SHOWN:
+ lines.append(f" … and {len(active) - self.SHOWN} more waiting")
+ for j in reversed(ended[-(self.SHOWN - len(lines)):] if len(lines) < self.SHOWN
+ else []):
+ lines.append(self._line(j))
+ self.update(Text("\n".join(lines) or "No transfers."))
+
+ def _line(self, j: Job) -> str:
+ name = safe(j.name)
+ if j.state == "running":
+ pct = 100 * j.done // j.size if j.size else 100
+ rate = j.done / max(time.monotonic() - j.started, 0.001)
+ return f"{j.arrow} {name} {pct}% {human_size(int(rate))}/s"
+ if j.state == "queued":
+ return f"{j.arrow} {name} waiting for a free slot on the node {j.note}"
+ if j.state == "waiting":
+ return f"{j.arrow} {name} waiting"
+ if j.state == "done":
+ return f"{j.arrow} {name} done{(' — ' + j.note) if j.note else ''}"
+ return f"{j.arrow} {name} FAILED: {j.note}"