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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user