From 590e5e8f0f87af7613b43229f6a67c4280dbeebb Mon Sep 17 00:00:00 2001 From: Username Date: Tue, 24 Feb 2026 12:31:55 +0100 Subject: [PATCH] app: responsive terminal layout Sidebar uses auto width (min 16, max 32) instead of fixed 24. Channel and user names truncate with ellipsis at widget boundary. Status bar adapts format based on available width. Resize events trigger re-render of width-aware widgets. --- ROADMAP.md | 1 + src/tuimble/app.py | 67 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1c9997f..c5533f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,6 +17,7 @@ ## Phase 3 — Polish +- [x] Responsive terminal layout (adaptive sidebar, truncation, resize) - [ ] Channel tree navigation - [ ] User list with status indicators - [ ] Volume control diff --git a/src/tuimble/app.py b/src/tuimble/app.py index ecb2b52..18520b5 100644 --- a/src/tuimble/app.py +++ b/src/tuimble/app.py @@ -56,21 +56,35 @@ class StatusBar(Static): server_info = reactive("") def render(self) -> str: + w = self.size.width if self.size.width > 0 else 80 + if self.connected: - conn = "[#9ece6a]\u25cf[/] connected" + conn_sym = "[#9ece6a]\u25cf[/]" + conn_full = f"{conn_sym} connected" else: - conn = "[#f7768e]\u25cb[/] disconnected" + conn_sym = "[#f7768e]\u25cb[/]" + conn_full = f"{conn_sym} disconnected" + if self.ptt_active: - ptt = "[#e0af68]\u25cf[/] TX" + ptt_sym = "[#e0af68]\u25cf[/]" + ptt_full = f"{ptt_sym} TX" else: - ptt = "[#565f89]\u25cb[/] idle" + ptt_sym = "[#565f89]\u25cb[/]" + ptt_full = f"{ptt_sym} idle" + + if w < 40: + return f" {conn_sym} {ptt_sym}" + if w < 60: + return f" {conn_full} {ptt_full}" info = f" [dim]{self.server_info}[/]" if self.server_info else "" - return f" {conn} {ptt}{info}" + return f" {conn_full} {ptt_full}{info}" class ChannelTree(Static): """Channel and user list.""" + DEFAULT_WIDTH = 24 + def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._channels: dict[int, Channel] = {} @@ -90,6 +104,23 @@ class ChannelTree(Static): self._users_by_channel = {} self.refresh() + @property + def _available_width(self) -> int: + """Usable character width, accounting for padding.""" + w = self.size.width if self.size.width > 0 else self.DEFAULT_WIDTH + return max(w, 12) + + @staticmethod + def _truncate(text: str, max_width: int) -> str: + """Clip text with ellipsis if it exceeds max_width.""" + if max_width < 1: + return "" + if len(text) <= max_width: + return text + if max_width <= 3: + return text[:max_width] + return text[: max_width - 3] + "..." + def render(self) -> str: if not self._channels: return " Channels\n [dim]\u2514\u2500 (not connected)[/]" @@ -116,9 +147,13 @@ class ChannelTree(Static): if ch is None: return + w = self._available_width prefix = " " * indent branch = "\u2514\u2500" if is_last else "\u251c\u2500" - lines.append(f"{prefix}{branch} [bold]{ch.name}[/]") + # indent + 2 branch chars + 1 space + name_max = w - indent - 3 + name = self._truncate(ch.name, name_max) + lines.append(f"{prefix}{branch} [bold]{name}[/]") users = self._users_by_channel.get(channel_id, []) children = [ @@ -127,11 +162,15 @@ class ChannelTree(Static): if c.parent_id == channel_id and c.channel_id != channel_id ] - sub_prefix = " " * (indent + 2) + sub_indent = indent + 2 + sub_prefix = " " * sub_indent + # sub_indent + 2 bullet chars + 1 space + user_max = w - sub_indent - 3 for i, user_name in enumerate(users): is_last_item = i == len(users) - 1 and not children bullet = "\u2514\u2500" if is_last_item else "\u251c\u2500" - lines.append(f"{sub_prefix}{bullet} [#7aa2f7]{user_name}[/]") + uname = self._truncate(user_name, user_max) + lines.append(f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]") for i, child in enumerate(children): self._render_tree( @@ -164,9 +203,12 @@ class TuimbleApp(App): height: 1fr; } #sidebar { - width: 24; + width: auto; + min-width: 16; + max-width: 32; border-right: solid #292e42; padding: 0 1; + overflow: hidden; } #chat-area { width: 1fr; @@ -385,6 +427,13 @@ class TuimbleApp(App): status = self.query_one("#status", StatusBar) status.ptt_active = transmitting + # -- resize -------------------------------------------------------------- + + def on_resize(self, _event: events.Resize) -> None: + """Refresh width-aware widgets when terminal resizes.""" + self.query_one("#sidebar", ChannelTree).refresh() + self.query_one("#status", StatusBar).refresh() + # -- lifecycle ----------------------------------------------------------- def action_quit(self) -> None: