diff --git a/src/app/mod.rs b/src/app/mod.rs index e82f121..63bbc7d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { @@ -128,6 +168,9 @@ pub struct AppState { /// Room text-chat history (newest last) and the pending input line. chat_messages: Vec, 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::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 { 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 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, +} + +impl Program for Divider { + type State = DividerState; + + fn update( + &self, + state: &mut Self::State, + event: &Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + // 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 { + 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; diff --git a/src/config.rs b/src/config.rs index 44a6e19..7b2ccc5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, #[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());