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: