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:
+263
-7
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user