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.
This commit is contained in:
Username
2026-02-24 12:31:55 +01:00
parent e92adeda7f
commit 590e5e8f0f
2 changed files with 59 additions and 9 deletions

View File

@@ -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

View File

@@ -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: