feat(ui): selectable room layouts with a thumbnail picker

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 00:23:27 -04:00
co-authored by Claude Opus 4.8
parent b436b57f13
commit 2f12d54a80
2 changed files with 541 additions and 57 deletions
+489 -57
View File
@@ -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<Item = UiEvent> {
@@ -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<AppMessage> {
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<AppMessage> {
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<AppMessage> 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<AppMessage> for LayoutIcon {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
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<AppMessage> for LayoutThumb {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
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;
+52
View File
@@ -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<String>,
#[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());