feat(ui): draggable, persisted dividers between room panels

Add a reusable Divider canvas widget and place two in the room screen: a
vertical divider between the Participants and Controls panels (drag to resize
the Participants width) and a horizontal divider between the main row and the
Chat dock (drag to resize the dock height). The Participants panel and Chat dock
size from persisted config values; the Controls panel and main row fill the rest.

The widget reports drag motion as a pixel delta along its axis (mirroring the
GateMeter drag handling, so a drag continues past the thin strip). update()
applies the delta and clamps it: clamp_participants_width / clamp_chat_height
keep both sides of each divider above a minimum. Sizes are re-clamped on window
resize (window size tracked from window::Event::Resized) and clamped again on
load (a size saved under a different window could be out of range).

Persistence: participants_width / chat_height are new serde-default AppConfig
fields; the divider publishes PersistConfig on drag release so the final
position is written once (not per pixel). 3 clamp unit tests (incl. a tiny-window
degenerate case) + config backward-compat assertions for the new fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 22:44:53 -04:00
co-authored by Claude Opus 4.8
parent 68d78ff411
commit 5279a53400
2 changed files with 282 additions and 7 deletions
+263 -7
View File
@@ -39,6 +39,43 @@ struct ChatEntry {
/// Cap on retained chat history so a long call can't grow it without bound.
const CHAT_HISTORY_MAX: usize = 300;
/// Which room-screen divider a drag is resizing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DividerKind {
/// Vertical divider between the Participants and Controls panels (resizes the
/// Participants panel width).
Panels,
/// Horizontal divider between the main row and the Chat dock (resizes the
/// Chat dock height).
Chat,
}
/// Minimum width of the Participants panel (px).
const PARTICIPANTS_MIN_W: f32 = 200.0;
/// Minimum width reserved for the Controls panel when resizing Participants (px).
const CONTROLS_MIN_W: f32 = 220.0;
/// Minimum height of the Chat dock (px).
const CHAT_MIN_H: f32 = 110.0;
/// Minimum height reserved above the Chat dock (header + main row) when resizing
/// the dock (px).
const ABOVE_CHAT_MIN_H: f32 = 300.0;
/// Thickness of a draggable divider (px).
const DIVIDER_THICKNESS: f32 = 8.0;
/// Clamp the Participants panel width so neither it nor the Controls panel drops
/// below its minimum, given the current window width.
fn clamp_participants_width(width: f32, window_w: f32) -> f32 {
let max = (window_w - CONTROLS_MIN_W).max(PARTICIPANTS_MIN_W);
width.clamp(PARTICIPANTS_MIN_W, max)
}
/// Clamp the Chat dock height so neither it nor the area above it drops below its
/// minimum, given the current window height.
fn clamp_chat_height(height: f32, window_h: f32) -> f32 {
let max = (window_h - ABOVE_CHAT_MIN_H).max(CHAT_MIN_H);
height.clamp(CHAT_MIN_H, max)
}
#[derive(Debug, Clone)]
pub enum AppMessage {
NicknameChanged(String),
@@ -81,6 +118,9 @@ pub enum AppMessage {
ChatInputChanged(String),
/// Send the current chat input line (Enter or the Send button).
ChatSubmit,
/// A room divider was dragged by the given pixel delta along its drag axis
/// (horizontal for the Panels divider, vertical for the Chat divider).
DividerDragged(DividerKind, f32),
}
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
@@ -128,6 +168,9 @@ pub struct AppState {
/// Room text-chat history (newest last) and the pending input line.
chat_messages: Vec<ChatEntry>,
chat_input: String,
/// Last known window size, tracked so divider clamps stay valid on resize.
/// (The divider positions themselves are persisted in `config`.)
window_size: Size,
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
mic_level: f32,
/// Whether the standalone (off-call) mic test stream is running.
@@ -163,7 +206,12 @@ impl Default for AppState {
let controller = Arc::new(CoreController::new(ui_tx));
let _ = UI_RX.set(Mutex::new(Some(ui_rx)));
let config = AppConfig::load();
let mut config = AppConfig::load();
// A persisted divider size from a differently-sized window could be out of
// range for the default window — clamp it before first render.
config.participants_width =
clamp_participants_width(config.participants_width, 900.0);
config.chat_height = clamp_chat_height(config.chat_height, 760.0);
notify::set_enabled(config.notifications_enabled);
let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold));
let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
@@ -203,6 +251,7 @@ impl Default for AppState {
recording_started: None,
chat_messages: Vec::new(),
chat_input: String::new(),
window_size: Size::new(900.0, 760.0),
mic_level: 0.0,
mic_test_active: false,
connecting: HashSet::new(),
@@ -510,6 +559,26 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::ChatInputChanged(val) => {
state.chat_input = val;
}
AppMessage::DividerDragged(kind, delta) => {
// Apply live; the final position is persisted on drag release (the
// divider publishes PersistConfig then) to avoid per-pixel disk writes.
match kind {
DividerKind::Panels => {
state.config.participants_width = clamp_participants_width(
state.config.participants_width + delta,
state.window_size.width,
);
}
DividerKind::Chat => {
// Dragging the divider down (positive delta) gives the main row
// more room and shrinks the dock below it, so subtract.
state.config.chat_height = clamp_chat_height(
state.config.chat_height - delta,
state.window_size.height,
);
}
}
}
AppMessage::ChatSubmit => {
let text = state.chat_input.trim().to_string();
if !text.is_empty() {
@@ -548,6 +617,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
let _ = state.controller.send(CoreCommand::SetPttActive(false));
}
}
AppMessage::EventOccurred(Event::Window(iced::window::Event::Resized(size))) => {
state.window_size = size;
// Keep divider positions valid for the new window dimensions. (Saved
// with the next drag-release or other config write; not worth a disk
// write on every resize tick.)
state.config.participants_width =
clamp_participants_width(state.config.participants_width, size.width);
state.config.chat_height =
clamp_chat_height(state.config.chat_height, size.height);
}
AppMessage::EventOccurred(_) => {}
AppMessage::NavigateToSettings => {
state.current_screen = Screen::Settings;
@@ -1133,7 +1212,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
)
.style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
.padding(15)
.width(iced::Length::FillPortion(2))
.width(iced::Length::Fixed(state.config.participants_width))
.height(iced::Length::Fill);
// Control Panel Column
@@ -1222,11 +1301,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
)
.style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
.padding(15)
.width(iced::Length::FillPortion(1))
.width(iced::Length::Fill)
.height(iced::Length::Fill);
let main_layout = row![peers_panel, control_panel]
.spacing(15)
// Draggable vertical divider between Participants and Controls.
let panels_divider = Canvas::new(Divider {
kind: DividerKind::Panels,
vertical: true,
line: color_surface,
grip: color_lavender,
})
.width(iced::Length::Fixed(DIVIDER_THICKNESS))
.height(iced::Length::Fill);
let main_layout = row![peers_panel, panels_divider, control_panel]
.spacing(0)
.height(iced::Length::Fill);
// --- CHAT DOCK (full-width strip along the bottom) ---
@@ -1279,7 +1368,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.style(c_style(color_mantle, color_surface, 8.0))
.padding(12)
.width(iced::Length::Fill)
.height(iced::Length::Fixed(180.0));
.height(iced::Length::Fixed(state.config.chat_height));
// Draggable horizontal divider between the main row and the Chat dock.
let chat_divider = Canvas::new(Divider {
kind: DividerKind::Chat,
vertical: false,
line: color_surface,
grip: color_lavender,
})
.width(iced::Length::Fill)
.height(iced::Length::Fixed(DIVIDER_THICKNESS));
container(
column![
@@ -1287,7 +1386,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
header_container,
vertical_space(12.0),
main_layout,
vertical_space(12.0),
chat_divider,
chat_dock
]
)
@@ -1428,6 +1527,132 @@ impl Program<AppMessage> for GateMeter {
}
}
/// A draggable splitter between two panels. Reports drag motion along its axis as
/// `AppMessage::DividerDragged(kind, delta_px)`; the parent applies + clamps it.
/// `vertical` = a vertical bar dragged horizontally (resizes width); otherwise a
/// horizontal bar dragged vertically (resizes height). Modeled on [`GateMeter`]'s
/// drag handling: the drag is tracked off the global cursor so it survives the
/// pointer leaving the thin divider strip.
struct Divider {
kind: DividerKind,
/// True = vertical bar (horizontal drag); false = horizontal bar (vertical drag).
vertical: bool,
/// Centre line colour.
line: Color,
/// Grip-dot colour.
grip: Color,
}
/// Drag state: the last cursor coordinate along the drag axis while dragging.
#[derive(Default)]
struct DividerState {
last: Option<f32>,
}
impl Program<AppMessage> for Divider {
type State = DividerState;
fn update(
&self,
state: &mut Self::State,
event: &Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<AppMessage>> {
// Cursor coordinate along the drag axis (x for a vertical bar, else y).
let axis = |p: Point| if self.vertical { p.x } else { p.y };
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if cursor.is_over(bounds)
&& let Some(p) = cursor.position()
{
// Record the absolute start coordinate; subsequent moves yield
// deltas. Capture with a zero-delta no-op.
state.last = Some(axis(p));
return Some(
Action::publish(AppMessage::DividerDragged(self.kind, 0.0)).and_capture(),
);
}
}
// Track globally so the drag continues past the thin strip's bounds.
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.last.is_some() => {
if let Some(p) = cursor.position() {
let cur = axis(p);
let last = state.last.unwrap();
state.last = Some(cur);
return Some(
Action::publish(AppMessage::DividerDragged(self.kind, cur - last))
.and_capture(),
);
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
if state.last.is_some() =>
{
state.last = None;
// Persist the final position once, on release (not per pixel).
return Some(Action::publish(AppMessage::PersistConfig).and_capture());
}
_ => {}
}
None
}
fn draw(
&self,
_state: &Self::State,
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
let mut frame = Frame::new(renderer, bounds.size());
let w = bounds.width;
let h = bounds.height;
if self.vertical {
let x = w / 2.0;
frame.fill(&Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)), self.line);
let cy = h / 2.0;
for i in -1..=1 {
let dy = cy + i as f32 * 6.0;
frame.fill(
&Path::rectangle(Point::new(x - 1.5, dy - 1.5), Size::new(3.0, 3.0)),
self.grip,
);
}
} else {
let y = h / 2.0;
frame.fill(&Path::rectangle(Point::new(0.0, y - 1.0), Size::new(w, 2.0)), self.line);
let cx = w / 2.0;
for i in -1..=1 {
let dx = cx + i as f32 * 6.0;
frame.fill(
&Path::rectangle(Point::new(dx - 1.5, y - 1.5), Size::new(3.0, 3.0)),
self.grip,
);
}
}
vec![frame.into_geometry()]
}
fn mouse_interaction(
&self,
state: &Self::State,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if state.last.is_some() || cursor.is_over(bounds) {
if self.vertical {
mouse::Interaction::ResizingHorizontally
} else {
mouse::Interaction::ResizingVertically
}
} else {
mouse::Interaction::default()
}
}
}
#[cfg(test)]
mod tests {
use super::{format_duration, reconnect_attempt_chime, reconnected_chime, GateMeter, METER_MAX};
@@ -1445,6 +1670,37 @@ mod tests {
assert_eq!(format_duration(3661), "1:01:01");
assert_eq!(format_duration(3725), "1:02:05");
}
use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W};
#[test]
fn participants_width_clamps_to_min_and_leaves_room_for_controls() {
let window_w = 900.0;
// Mid-range value passes through unchanged.
assert_eq!(clamp_participants_width(500.0, window_w), 500.0);
// Below the minimum snaps up to it.
assert_eq!(clamp_participants_width(50.0, window_w), PARTICIPANTS_MIN_W);
// Too wide leaves at least CONTROLS_MIN_W (220) for the controls panel.
assert_eq!(clamp_participants_width(window_w, window_w), window_w - 220.0);
}
#[test]
fn chat_height_clamps_to_min_and_leaves_room_above() {
let window_h = 760.0;
assert_eq!(clamp_chat_height(200.0, window_h), 200.0);
assert_eq!(clamp_chat_height(10.0, window_h), CHAT_MIN_H);
// Too tall leaves at least ABOVE_CHAT_MIN_H (300) above the dock.
assert_eq!(clamp_chat_height(window_h, window_h), window_h - 300.0);
}
#[test]
fn divider_clamps_are_finite_on_a_tiny_window() {
// A window smaller than the reserves must not produce NaN/inverted ranges.
let pw = clamp_participants_width(300.0, 100.0);
assert!(pw.is_finite() && pw >= PARTICIPANTS_MIN_W);
let ch = clamp_chat_height(300.0, 100.0);
assert!(ch.is_finite() && ch >= CHAT_MIN_H);
}
use crate::notify::Sound;
use iroh::EndpointId;
use std::collections::HashSet;
+19
View File
@@ -44,6 +44,14 @@ fn default_volume() -> f32 {
1.0
}
fn default_participants_width() -> f32 {
540.0
}
fn default_chat_height() -> f32 {
180.0
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AppConfig {
pub input_device: String,
@@ -63,6 +71,12 @@ pub struct AppConfig {
pub echo_cancellation_enabled: bool,
#[serde(default = "default_true")]
pub notifications_enabled: bool,
/// Persisted room-screen divider positions (px): Participants panel width and
/// Chat dock height. Re-clamped to the window on load and on resize.
#[serde(default = "default_participants_width")]
pub participants_width: f32,
#[serde(default = "default_chat_height")]
pub chat_height: f32,
#[serde(default)]
pub custom_sound_self_join: Option<String>,
#[serde(default)]
@@ -92,6 +106,8 @@ impl Default for AppConfig {
network_mode: NetworkMode::default(),
echo_cancellation_enabled: false,
notifications_enabled: true,
participants_width: default_participants_width(),
chat_height: default_chat_height(),
custom_sound_self_join: None,
custom_sound_peer_join: None,
custom_sound_peer_leave: None,
@@ -157,6 +173,9 @@ mod tests {
// Configs predating the volume sliders must load at unity gain.
assert_eq!(deserialized.input_volume, 1.0);
assert_eq!(deserialized.output_volume, 1.0);
// Configs predating the draggable dividers must load the default sizes.
assert_eq!(deserialized.participants_width, 540.0);
assert_eq!(deserialized.chat_height, 180.0);
assert!(deserialized.custom_sound_self_join.is_none());
assert!(deserialized.custom_sound_peer_join.is_none());
assert!(deserialized.custom_sound_peer_leave.is_none());