From 2f12d54a806984ee49fc6555af69df09f51e611c Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sat, 6 Jun 2026 00:20:41 -0400 Subject: [PATCH] feat(ui): selectable room layouts with a thumbnail picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three in-call room layouts β€” 3-Column (Participants | Chat | Controls), Bottom Dock (Participants+Controls over a full-width Chat strip), and Drawer (Participants | Controls with a collapsible Chat panel) β€” chosen via one persisted RoomLayout config setting and applied live. Picker UX: a square layout button (drawn LayoutIcon glyph) in the top bar of the launch and in-call screens opens a popup gallery (dimmed click-to-dismiss backdrop + centered panel) of clickable schematic thumbnails; the Settings screen shows the same thumbnails inline (no button). Thumbnails are drawn with the canvas widget (new LayoutThumb program β€” colored panel boxes, blue border on the selected one), so no image-decoding dependency is added. Each layout's panel boundaries are draggable (DividerKind gains Controls + ChatDrawer for the 3-column right divider and the drawer's left edge; new clamp_controls_width / clamp_chat_drawer_width, persisted + re-clamped on resize). Participants width is shared across layouts but capped per layout at render time so a fixed panel can't starve the Fill panel (e.g. a wide Participants width set in the dock layout won't collapse Chat in 3-column or Controls in the drawer). The Drawer layout adds a header chat-toggle. +1 clamp test (now 127 tests). Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 546 +++++++++++++++++++++++++++++++++++++++++++------ src/config.rs | 52 +++++ 2 files changed, 541 insertions(+), 57 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index fb0549d..8e9e326 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,11 +2,11 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}}; use crate::network::PeerState; use crate::notify::{self, Sound}; use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices}; -use crate::config::{AppConfig, NetworkMode}; +use crate::config::{AppConfig, NetworkMode, RoomLayout}; use iced::widget::{ container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, - progress_bar, canvas, Canvas, Column, + progress_bar, canvas, Canvas, Column, stack, mouse_area, }; use iced::widget::canvas::{Action, Frame, Geometry, Path, Program}; use iced::{ @@ -48,6 +48,12 @@ pub enum DividerKind { /// Horizontal divider between the main row and the Chat dock (resizes the /// Chat dock height). Chat, + /// Vertical divider between Chat and Controls in the 3-column layout (resizes + /// the Controls panel width). + Controls, + /// Vertical divider on the left edge of the Chat drawer (resizes the drawer + /// width) in the drawer layout. + ChatDrawer, } /// Minimum width of the Participants panel (px). @@ -76,6 +82,24 @@ fn clamp_chat_height(height: f32, window_h: f32) -> f32 { height.clamp(CHAT_MIN_H, max) } +/// Minimum width of the Chat column / drawer (px). +const CHAT_MIN_W: f32 = 200.0; + +/// Clamp the Controls panel width (3-column layout) so neither it nor the rest of +/// the row drops below its minimum, given the current window width. +fn clamp_controls_width(width: f32, window_w: f32) -> f32 { + // Leave room for the Participants panel + a minimum Chat column. + let max = (window_w - PARTICIPANTS_MIN_W - CHAT_MIN_W).max(CONTROLS_MIN_W); + width.clamp(CONTROLS_MIN_W, max) +} + +/// Clamp the Chat drawer width (drawer layout) so neither it nor the rest of the +/// row drops below its minimum, given the current window width. +fn clamp_chat_drawer_width(width: f32, window_w: f32) -> f32 { + let max = (window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W).max(CHAT_MIN_W); + width.clamp(CHAT_MIN_W, max) +} + #[derive(Debug, Clone)] pub enum AppMessage { NicknameChanged(String), @@ -121,6 +145,13 @@ pub enum AppMessage { /// 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), + /// Open / close the room-layout picker popup. + OpenLayoutPicker, + CloseLayoutPicker, + /// Choose a room layout (applied live + persisted, closes the popup). + SelectRoomLayout(RoomLayout), + /// Toggle the Chat drawer open/closed (drawer layout). + ToggleDrawerChat, } fn core_subscription() -> impl iced::futures::Stream { @@ -171,6 +202,10 @@ pub struct AppState { /// Last known window size, tracked so divider clamps stay valid on resize. /// (The divider positions themselves are persisted in `config`.) window_size: Size, + /// Whether the room-layout picker popup is open (launch + in-call screens). + layout_picker_open: bool, + /// Whether the Chat drawer is open (drawer layout only). + drawer_chat_open: bool, /// 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. @@ -212,6 +247,8 @@ impl Default for AppState { config.participants_width = clamp_participants_width(config.participants_width, 900.0); config.chat_height = clamp_chat_height(config.chat_height, 760.0); + config.controls_width = clamp_controls_width(config.controls_width, 900.0); + config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, 900.0); notify::set_enabled(config.notifications_enabled); let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold)); let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume)); @@ -253,6 +290,8 @@ impl Default for AppState { chat_messages: Vec::new(), chat_input: String::new(), window_size: Size::new(900.0, 760.0), + layout_picker_open: false, + drawer_chat_open: false, mic_level: 0.0, mic_test_active: false, connecting: HashSet::new(), @@ -589,8 +628,38 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.window_size.height, ); } + DividerKind::Controls => { + // Controls sits on the right; dragging the divider right (positive + // delta) gives Chat more room and shrinks Controls. + state.config.controls_width = clamp_controls_width( + state.config.controls_width - delta, + state.window_size.width, + ); + } + DividerKind::ChatDrawer => { + // The drawer sits on the right; dragging its left-edge divider + // left (negative delta) widens the drawer. + state.config.chat_drawer_width = clamp_chat_drawer_width( + state.config.chat_drawer_width - delta, + state.window_size.width, + ); + } } } + AppMessage::OpenLayoutPicker => { + state.layout_picker_open = true; + } + AppMessage::CloseLayoutPicker => { + state.layout_picker_open = false; + } + AppMessage::SelectRoomLayout(layout) => { + state.config.room_layout = layout; + state.config.save(); + state.layout_picker_open = false; + } + AppMessage::ToggleDrawerChat => { + state.drawer_chat_open = !state.drawer_chat_open; + } AppMessage::ChatSubmit => { let text = sanitize_chat(&state.chat_input); if !text.is_empty() { @@ -638,6 +707,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { clamp_participants_width(state.config.participants_width, size.width); state.config.chat_height = clamp_chat_height(state.config.chat_height, size.height); + state.config.controls_width = + clamp_controls_width(state.config.controls_width, size.width); + state.config.chat_drawer_width = + clamp_chat_drawer_width(state.config.chat_drawer_width, size.width); } AppMessage::EventOccurred(_) => {} AppMessage::NavigateToSettings => { @@ -786,11 +859,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let top_bar = row![ horizontal_space(), + button( + Canvas::new(LayoutIcon { fg: color_text }) + .width(iced::Length::Fixed(18.0)) + .height(iced::Length::Fixed(18.0)) + ) + .on_press(AppMessage::OpenLayoutPicker) + .style(b_style(color_surface, color_blue, color_text, 6.0)) + .padding(8), button(text("βš™ Settings").size(14)) .on_press(AppMessage::NavigateToSettings) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8) - ].width(iced::Length::Fill).padding(10); + ].width(iced::Length::Fill).padding(10).spacing(8); if state.current_screen == Screen::Settings { let path_field = |label: &'static str, sound: Sound| { @@ -870,6 +951,32 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ].align_y(iced::alignment::Vertical::Center).spacing(8), ].spacing(6).width(iced::Length::Fill); + // Inline room-layout chooser (Settings shows the thumbnails outright, no + // popup button). Same SelectRoomLayout message, applied live + persisted. + let layout_choice = |layout: RoomLayout, label: &'static str| -> Element<'_, AppMessage> { + let selected = state.config.room_layout == layout; + let tile = Canvas::new(LayoutThumb { + layout, + selected, + base: color_base, + surface: color_surface, + overlay: Color::from_rgb8(69, 71, 90), + border: if selected { color_blue } else { color_surface }, + }) + .width(iced::Length::Fixed(132.0)) + .height(iced::Length::Fixed(86.0)); + column![ + button(tile) + .on_press(AppMessage::SelectRoomLayout(layout)) + .padding(2) + .style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)), + text(label).size(11).color(if selected { color_blue } else { color_subtext }), + ] + .spacing(4) + .align_x(iced::alignment::Horizontal::Center) + .into() + }; + let settings_content = scrollable( column![ text("Settings").size(24).color(color_blue), @@ -926,6 +1033,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ].spacing(4).width(iced::Length::Fill), ].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill), vertical_space(12.0), + column![ + text("Room Layout").size(14).color(color_subtext), + row![ + layout_choice(RoomLayout::ThreeColumn, "3-Column"), + layout_choice(RoomLayout::BottomDock, "Bottom Dock"), + layout_choice(RoomLayout::Drawer, "Drawer"), + ].spacing(16), + text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext), + ].spacing(8).width(iced::Length::Fill), + vertical_space(12.0), column![ text("Notification Chimes").size(14).color(color_subtext), checkbox(state.config.notifications_enabled) @@ -1059,7 +1176,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let scroll = scrollable(content); - container( + let home = container( column![ top_bar, vertical_space(20.0), @@ -1068,8 +1185,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ) .width(iced::Length::Fill) .height(iced::Length::Fill) - .style(c_style(color_crust, Color::TRANSPARENT, 0.0)) - .into() + .style(c_style(color_crust, Color::TRANSPARENT, 0.0)); + + with_layout_picker(home.into(), state) } else { // --- ROOM SCREEN --- let participant_count = state.peers.len() + 1; // peers + you @@ -1104,7 +1222,26 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { button(text("Copy Ticket").size(12)) .on_press(AppMessage::CopyToClipboard) .style(b_style(color_surface, color_blue, color_text, 6.0)) - .padding(6) + .padding(6), + // Drawer layout: a chat toggle (the drawer is collapsed by default). + { + let el: Element<'_, AppMessage> = + if state.config.room_layout == RoomLayout::Drawer { + let (lbl, bg, fg) = if state.drawer_chat_open { + ("πŸ’¬ Hide chat", color_blue, color_crust) + } else { + ("πŸ’¬ Chat", color_surface, color_text) + }; + button(text(lbl).size(12)) + .on_press(AppMessage::ToggleDrawerChat) + .style(b_style(bg, color_blue, fg, 6.0)) + .padding(6) + .into() + } else { + iced::widget::Space::new().width(0.0).height(0.0).into() + }; + el + } ] .spacing(16) .align_y(iced::alignment::Vertical::Center); @@ -1242,7 +1379,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ) .style(c_style(color_mantle, Color::TRANSPARENT, 0.0)) .padding(15) - .width(iced::Length::Fixed(state.config.participants_width)) .height(iced::Length::Fill); // Control Panel Column @@ -1320,6 +1456,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .width(iced::Length::Fill) ]; + // Controls panel β€” width is set per layout below. let control_panel = container( column![ text("Controls").size(18).color(color_blue), @@ -1331,24 +1468,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ) .style(c_style(color_mantle, Color::TRANSPARENT, 0.0)) .padding(15) - .width(iced::Length::Fill) .height(iced::Length::Fill); - // 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) --- + // Reusable chat body (title + bottom-anchored scrollback + input row), + // wrapped differently by each layout. let mut chat_col = Column::new().spacing(4).width(iced::Length::Fill); if state.chat_messages.is_empty() { chat_col = chat_col.push( @@ -1372,7 +1495,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .width(iced::Length::Fill) .height(iced::Length::Fill) .anchor_bottom(); - let chat_input_row = row![ text_input("Message the room…", &state.chat_input) .on_input(AppMessage::ChatInputChanged) @@ -1386,45 +1508,124 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ] .spacing(8) .align_y(iced::alignment::Vertical::Center); + let chat_inner = column![ + text("Chat").size(16).color(color_blue), + chat_scroll, + chat_input_row, + ] + .spacing(8); - let chat_dock = container( - column![ - text("Chat").size(16).color(color_blue), - chat_scroll, - chat_input_row, - ] - .spacing(8), - ) - .style(c_style(color_mantle, color_surface, 8.0)) - .padding(12) - .width(iced::Length::Fill) - .height(iced::Length::Fixed(state.config.chat_height)); + // Divider constructors (fresh widget per call). + let vdiv = |kind| { + Canvas::new(Divider { kind, vertical: true, line: color_surface, grip: color_lavender }) + .width(iced::Length::Fixed(DIVIDER_THICKNESS)) + .height(iced::Length::Fill) + }; + let hdiv = || { + Canvas::new(Divider { + kind: DividerKind::Chat, + vertical: false, + line: color_surface, + grip: color_lavender, + }) + .width(iced::Length::Fill) + .height(iced::Length::Fixed(DIVIDER_THICKNESS)) + }; - // 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)); + // Assemble the body per the chosen room layout. `chat_inner` is moved into + // exactly one arm (allowed across mutually-exclusive match arms). + let pw = state.config.participants_width; + let body: Element<'_, AppMessage> = match state.config.room_layout { + RoomLayout::BottomDock => { + // Cap Participants so the Fill Controls panel keeps its minimum. + let avail = state.window_size.width - 30.0; + let pwb = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W)); + let main = row![ + peers_panel.width(iced::Length::Fixed(pwb)), + vdiv(DividerKind::Panels), + control_panel.width(iced::Length::Fill), + ] + .spacing(0) + .height(iced::Length::Fill); + let chat = container(chat_inner) + .style(c_style(color_mantle, color_surface, 8.0)) + .padding(12) + .width(iced::Length::Fill) + .height(iced::Length::Fixed(state.config.chat_height)); + column![main, hdiv(), chat].into() + } + RoomLayout::ThreeColumn => { + // Participants is fixed and Controls is fixed, so cap Participants + // (shared with the 2-panel layouts, where it's much wider) to leave + // the centre Chat column at least a minimum width. + let avail = state.window_size.width - 30.0; // outer padding + let pw3 = pw.min( + (avail - state.config.controls_width - CHAT_MIN_W - 2.0 * DIVIDER_THICKNESS) + .max(PARTICIPANTS_MIN_W), + ); + let chat = container(chat_inner) + .style(c_style(color_mantle, color_surface, 8.0)) + .padding(12) + .width(iced::Length::Fill) + .height(iced::Length::Fill); + row![ + peers_panel.width(iced::Length::Fixed(pw3)), + vdiv(DividerKind::Panels), + chat, + vdiv(DividerKind::Controls), + control_panel.width(iced::Length::Fixed(state.config.controls_width)), + ] + .spacing(0) + .height(iced::Length::Fill) + .into() + } + RoomLayout::Drawer => { + let avail = state.window_size.width - 30.0; + if state.drawer_chat_open { + // Participants + Chat drawer are both fixed; cap Participants so + // the Fill Controls panel between them keeps its minimum. + let pwd = pw.min( + (avail - state.config.chat_drawer_width - CONTROLS_MIN_W - 2.0 * DIVIDER_THICKNESS) + .max(PARTICIPANTS_MIN_W), + ); + let chat = container(chat_inner) + .style(c_style(color_mantle, color_surface, 8.0)) + .padding(12) + .width(iced::Length::Fixed(state.config.chat_drawer_width)) + .height(iced::Length::Fill); + row![ + peers_panel.width(iced::Length::Fixed(pwd)), + vdiv(DividerKind::Panels), + control_panel.width(iced::Length::Fill), + vdiv(DividerKind::ChatDrawer), + chat, + ] + .spacing(0) + .height(iced::Length::Fill) + .into() + } else { + let pwd = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W)); + row![ + peers_panel.width(iced::Length::Fixed(pwd)), + vdiv(DividerKind::Panels), + control_panel.width(iced::Length::Fill), + ] + .spacing(0) + .height(iced::Length::Fill) + .into() + } + } + }; - container( - column![ - top_bar, - header_container, - vertical_space(12.0), - main_layout, - chat_divider, - chat_dock - ] + let room = container( + column![top_bar, header_container, vertical_space(12.0), body] ) .padding(15) .width(iced::Length::Fill) .height(iced::Length::Fill) - .style(c_style(color_crust, Color::TRANSPARENT, 0.0)) - .into() + .style(c_style(color_crust, Color::TRANSPARENT, 0.0)); + + with_layout_picker(room.into(), state) } } @@ -1683,6 +1884,220 @@ impl Program for Divider { } } +/// Wrap a base screen with the room-layout picker popup when it's open: a dimmed, +/// click-to-dismiss backdrop plus a centered gallery of clickable layout +/// thumbnails. Returns the base unchanged when the picker is closed. Used by both +/// the launch and in-call screens (the Settings screen shows thumbnails inline). +fn with_layout_picker<'a>( + base: Element<'a, AppMessage>, + state: &'a AppState, +) -> Element<'a, AppMessage> { + if !state.layout_picker_open { + return base; + } + let crust = Color::from_rgb8(17, 17, 27); + let mantle = Color::from_rgb8(24, 24, 37); + let base_c = Color::from_rgb8(30, 30, 46); + let surface = Color::from_rgb8(49, 50, 68); + let overlay = Color::from_rgb8(69, 71, 90); + let text_c = Color::from_rgb8(205, 214, 244); + let subtext = Color::from_rgb8(166, 173, 200); + let blue = Color::from_rgb8(137, 180, 250); + let green = Color::from_rgb8(166, 227, 161); + + let backdrop = mouse_area( + container(horizontal_space()) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(Color { a: 0.55, ..crust })), + ..Default::default() + }), + ) + .on_press(AppMessage::CloseLayoutPicker); + + let thumb = |layout: RoomLayout, label: &'static str| -> Element<'a, AppMessage> { + let selected = state.config.room_layout == layout; + let tile = Canvas::new(LayoutThumb { + layout, + selected, + base: base_c, + surface, + overlay, + border: if selected { blue } else { surface }, + }) + .width(iced::Length::Fixed(168.0)) + .height(iced::Length::Fixed(112.0)); + let btn = button(tile) + .on_press(AppMessage::SelectRoomLayout(layout)) + .padding(2) + .style(move |_t: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered => surface, + _ => Color::TRANSPARENT, + })), + text_color: text_c, + border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 8.0.into() }, + ..Default::default() + }); + let (lbl_color, marker): (Color, Element<'a, AppMessage>) = if selected { + (blue, text("● current").size(10).color(green).into()) + } else { + (text_c, vertical_space(0.0).into()) + }; + column![btn, text(label).size(13).color(lbl_color), marker] + .spacing(4) + .align_x(iced::alignment::Horizontal::Center) + .into() + }; + + let gallery = container( + column![ + row![ + text("Choose room layout").size(16).color(blue), + horizontal_space(), + button(text("βœ•").size(16).color(subtext)) + .on_press(AppMessage::CloseLayoutPicker) + .style(|_t: &Theme, _s: button::Status| button::Style { + background: None, + ..Default::default() + }) + .padding(2), + ] + .align_y(iced::alignment::Vertical::Center), + row![ + thumb(RoomLayout::ThreeColumn, "3-Column"), + thumb(RoomLayout::BottomDock, "Bottom Dock"), + thumb(RoomLayout::Drawer, "Drawer"), + ] + .spacing(20), + text("Click a layout to apply it instantly.").size(11).color(subtext), + ] + .spacing(16), + ) + .style(move |_t: &Theme| container::Style { + text_color: Some(text_c), + background: Some(Background::Color(mantle)), + border: Border { color: surface, width: 1.0, radius: 12.0.into() }, + ..Default::default() + }) + .padding(20) + .width(iced::Length::Fixed(600.0)); + + stack![ + base, + backdrop, + container(gallery) + .center_x(iced::Length::Fill) + .center_y(iced::Length::Fill), + ] + .into() +} + +/// A small two-pane glyph for the square layout-picker button in the top bar. +struct LayoutIcon { + fg: Color, +} + +impl Program for LayoutIcon { + type State = (); + fn draw( + &self, + _state: &(), + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut f = Frame::new(renderer, bounds.size()); + let (w, h) = (bounds.width, bounds.height); + f.stroke( + &Path::rectangle(Point::new(1.0, 1.5), Size::new(w - 2.0, h - 3.0)), + canvas::Stroke::default().with_color(self.fg).with_width(1.5), + ); + // Vertical split into two panes. + f.fill( + &Path::rectangle(Point::new(w * 0.5 - 0.75, 1.5), Size::new(1.5, h - 3.0)), + self.fg, + ); + vec![f.into_geometry()] + } +} + +/// A schematic thumbnail of a [`RoomLayout`] (colored boxes for each panel), +/// drawn so the picker stays in sync with the theme and needs no image assets. +struct LayoutThumb { + layout: RoomLayout, + selected: bool, + base: Color, + surface: Color, + /// Accent shade for the chat panel, to distinguish it from the others. + overlay: Color, + /// Border colour (blue when selected, surface otherwise). + border: Color, +} + +impl LayoutThumb { + fn pane(f: &mut Frame, x: f32, y: f32, w: f32, h: f32, color: Color) { + f.fill(&Path::rectangle(Point::new(x, y), Size::new(w, h)), color); + } +} + +impl Program for LayoutThumb { + type State = (); + fn draw( + &self, + _state: &(), + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut f = Frame::new(renderer, bounds.size()); + let (w, h) = (bounds.width, bounds.height); + f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), self.base); + + let pad = 9.0; + let (ix, iy) = (pad, pad); + let (iw, ih) = (w - 2.0 * pad, h - 2.0 * pad); + let g = 4.0; + match self.layout { + RoomLayout::ThreeColumn => { + let c1 = iw * 0.34; + let c3 = iw * 0.24; + let c2 = iw - c1 - c3 - 2.0 * g; + Self::pane(&mut f, ix, iy, c1, ih, self.surface); + Self::pane(&mut f, ix + c1 + g, iy, c2, ih, self.overlay); + Self::pane(&mut f, ix + c1 + g + c2 + g, iy, c3, ih, self.surface); + } + RoomLayout::BottomDock => { + let toph = ih * 0.6; + let both = ih - toph - g; + let lw = iw * 0.66; + Self::pane(&mut f, ix, iy, lw, toph, self.surface); + Self::pane(&mut f, ix + lw + g, iy, iw - lw - g, toph, self.surface); + Self::pane(&mut f, ix, iy + toph + g, iw, both, self.overlay); + } + RoomLayout::Drawer => { + let dw = iw * 0.18; + let main = iw - dw - g; + let lw = main * 0.62; + Self::pane(&mut f, ix, iy, lw, ih, self.surface); + Self::pane(&mut f, ix + lw + g, iy, main - lw - g, ih, self.surface); + Self::pane(&mut f, ix + main + g, iy, dw, ih, self.overlay); + } + } + + // Border on top (thicker + blue when selected). + let bw: f32 = if self.selected { 2.0 } else { 1.0 }; + f.stroke( + &Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)), + canvas::Stroke::default().with_color(self.border).with_width(bw), + ); + vec![f.into_geometry()] + } +} + #[cfg(test)] mod tests { use super::{format_duration, reconnect_attempt_chime, reconnected_chime, GateMeter, METER_MAX}; @@ -1754,6 +2169,23 @@ mod tests { assert!(ch.is_finite() && ch >= CHAT_MIN_H); } + #[test] + fn controls_and_drawer_width_clamps() { + use super::{clamp_chat_drawer_width, clamp_controls_width, CHAT_MIN_W, CONTROLS_MIN_W}; + let window_w = 1000.0; + // Mid-range passes through. + assert_eq!(clamp_controls_width(300.0, window_w), 300.0); + assert_eq!(clamp_chat_drawer_width(320.0, window_w), 320.0); + // Below minimum snaps up. + assert_eq!(clamp_controls_width(10.0, window_w), CONTROLS_MIN_W); + assert_eq!(clamp_chat_drawer_width(10.0, window_w), CHAT_MIN_W); + // Tiny window stays finite and at/above the minimum (no inverted range). + let c = clamp_controls_width(400.0, 100.0); + assert!(c.is_finite() && c >= CONTROLS_MIN_W); + let d = clamp_chat_drawer_width(400.0, 100.0); + assert!(d.is_finite() && d >= CHAT_MIN_W); + } + use crate::notify::Sound; use iroh::EndpointId; use std::collections::HashSet; diff --git a/src/config.rs b/src/config.rs index 5bbcbdf..80d78be 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,6 +25,34 @@ impl NetworkMode { [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly]; } +/// Arrangement of the in-call room screen, chosen via the layout picker. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum RoomLayout { + /// Participants | Chat | Controls, all columns visible at once. + ThreeColumn, + /// Participants + Controls on top, full-width Chat docked along the bottom. + #[default] + BottomDock, + /// Participants | Controls, with a collapsible Chat drawer on the right edge. + Drawer, +} + +impl RoomLayout { + /// All variants, in picker display order. + pub const ALL: [RoomLayout; 3] = + [RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer]; +} + +impl std::fmt::Display for RoomLayout { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + RoomLayout::ThreeColumn => "3-Column", + RoomLayout::BottomDock => "Bottom Dock", + RoomLayout::Drawer => "Drawer", + }) + } +} + impl std::fmt::Display for NetworkMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let label = match self { @@ -56,6 +84,14 @@ fn default_chat_height() -> f32 { 180.0 } +fn default_controls_width() -> f32 { + 280.0 +} + +fn default_chat_drawer_width() -> f32 { + 320.0 +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct AppConfig { /// Last nickname used to join/create a room; pre-filled on the launch screen. @@ -84,6 +120,15 @@ pub struct AppConfig { pub participants_width: f32, #[serde(default = "default_chat_height")] pub chat_height: f32, + /// Controls panel width for the 3-column layout (px). + #[serde(default = "default_controls_width")] + pub controls_width: f32, + /// Chat drawer width for the drawer layout (px). + #[serde(default = "default_chat_drawer_width")] + pub chat_drawer_width: f32, + /// Chosen arrangement of the in-call room screen. + #[serde(default)] + pub room_layout: RoomLayout, #[serde(default)] pub custom_sound_self_join: Option, #[serde(default)] @@ -116,6 +161,9 @@ impl Default for AppConfig { notifications_enabled: true, participants_width: default_participants_width(), chat_height: default_chat_height(), + controls_width: default_controls_width(), + chat_drawer_width: default_chat_drawer_width(), + room_layout: RoomLayout::default(), custom_sound_self_join: None, custom_sound_peer_join: None, custom_sound_peer_leave: None, @@ -186,6 +234,10 @@ mod tests { assert_eq!(deserialized.chat_height, 180.0); // Configs predating the remembered username load the default nickname. assert_eq!(deserialized.username, "Peer"); + // Configs predating the layout picker load the default layout + sizes. + assert_eq!(deserialized.room_layout, RoomLayout::BottomDock); + assert_eq!(deserialized.controls_width, 280.0); + assert_eq!(deserialized.chat_drawer_width, 320.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());