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:
+489
-57
@@ -2,11 +2,11 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
|
|||||||
use crate::network::PeerState;
|
use crate::network::PeerState;
|
||||||
use crate::notify::{self, Sound};
|
use crate::notify::{self, Sound};
|
||||||
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
|
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
|
||||||
use crate::config::{AppConfig, NetworkMode};
|
use crate::config::{AppConfig, NetworkMode, RoomLayout};
|
||||||
|
|
||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
|
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::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
||||||
use iced::{
|
use iced::{
|
||||||
@@ -48,6 +48,12 @@ pub enum DividerKind {
|
|||||||
/// Horizontal divider between the main row and the Chat dock (resizes the
|
/// Horizontal divider between the main row and the Chat dock (resizes the
|
||||||
/// Chat dock height).
|
/// Chat dock height).
|
||||||
Chat,
|
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).
|
/// 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)
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AppMessage {
|
pub enum AppMessage {
|
||||||
NicknameChanged(String),
|
NicknameChanged(String),
|
||||||
@@ -121,6 +145,13 @@ pub enum AppMessage {
|
|||||||
/// A room divider was dragged by the given pixel delta along its drag axis
|
/// A room divider was dragged by the given pixel delta along its drag axis
|
||||||
/// (horizontal for the Panels divider, vertical for the Chat divider).
|
/// (horizontal for the Panels divider, vertical for the Chat divider).
|
||||||
DividerDragged(DividerKind, f32),
|
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> {
|
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.
|
/// Last known window size, tracked so divider clamps stay valid on resize.
|
||||||
/// (The divider positions themselves are persisted in `config`.)
|
/// (The divider positions themselves are persisted in `config`.)
|
||||||
window_size: Size,
|
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.
|
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
||||||
mic_level: f32,
|
mic_level: f32,
|
||||||
/// Whether the standalone (off-call) mic test stream is running.
|
/// Whether the standalone (off-call) mic test stream is running.
|
||||||
@@ -212,6 +247,8 @@ impl Default for AppState {
|
|||||||
config.participants_width =
|
config.participants_width =
|
||||||
clamp_participants_width(config.participants_width, 900.0);
|
clamp_participants_width(config.participants_width, 900.0);
|
||||||
config.chat_height = clamp_chat_height(config.chat_height, 760.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);
|
notify::set_enabled(config.notifications_enabled);
|
||||||
let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold));
|
let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold));
|
||||||
let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
|
let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
|
||||||
@@ -253,6 +290,8 @@ impl Default for AppState {
|
|||||||
chat_messages: Vec::new(),
|
chat_messages: Vec::new(),
|
||||||
chat_input: String::new(),
|
chat_input: String::new(),
|
||||||
window_size: Size::new(900.0, 760.0),
|
window_size: Size::new(900.0, 760.0),
|
||||||
|
layout_picker_open: false,
|
||||||
|
drawer_chat_open: false,
|
||||||
mic_level: 0.0,
|
mic_level: 0.0,
|
||||||
mic_test_active: false,
|
mic_test_active: false,
|
||||||
connecting: HashSet::new(),
|
connecting: HashSet::new(),
|
||||||
@@ -589,8 +628,38 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.window_size.height,
|
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 => {
|
AppMessage::ChatSubmit => {
|
||||||
let text = sanitize_chat(&state.chat_input);
|
let text = sanitize_chat(&state.chat_input);
|
||||||
if !text.is_empty() {
|
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);
|
clamp_participants_width(state.config.participants_width, size.width);
|
||||||
state.config.chat_height =
|
state.config.chat_height =
|
||||||
clamp_chat_height(state.config.chat_height, size.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::EventOccurred(_) => {}
|
||||||
AppMessage::NavigateToSettings => {
|
AppMessage::NavigateToSettings => {
|
||||||
@@ -786,11 +859,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
|
|
||||||
let top_bar = row![
|
let top_bar = row![
|
||||||
horizontal_space(),
|
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))
|
button(text("⚙ Settings").size(14))
|
||||||
.on_press(AppMessage::NavigateToSettings)
|
.on_press(AppMessage::NavigateToSettings)
|
||||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||||
.padding(8)
|
.padding(8)
|
||||||
].width(iced::Length::Fill).padding(10);
|
].width(iced::Length::Fill).padding(10).spacing(8);
|
||||||
|
|
||||||
if state.current_screen == Screen::Settings {
|
if state.current_screen == Screen::Settings {
|
||||||
let path_field = |label: &'static str, sound: Sound| {
|
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),
|
].align_y(iced::alignment::Vertical::Center).spacing(8),
|
||||||
].spacing(6).width(iced::Length::Fill);
|
].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(
|
let settings_content = scrollable(
|
||||||
column![
|
column![
|
||||||
text("Settings").size(24).color(color_blue),
|
text("Settings").size(24).color(color_blue),
|
||||||
@@ -926,6 +1033,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
].spacing(4).width(iced::Length::Fill),
|
].spacing(4).width(iced::Length::Fill),
|
||||||
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
|
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
|
||||||
vertical_space(12.0),
|
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![
|
column![
|
||||||
text("Notification Chimes").size(14).color(color_subtext),
|
text("Notification Chimes").size(14).color(color_subtext),
|
||||||
checkbox(state.config.notifications_enabled)
|
checkbox(state.config.notifications_enabled)
|
||||||
@@ -1059,7 +1176,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
|
|
||||||
let scroll = scrollable(content);
|
let scroll = scrollable(content);
|
||||||
|
|
||||||
container(
|
let home = container(
|
||||||
column![
|
column![
|
||||||
top_bar,
|
top_bar,
|
||||||
vertical_space(20.0),
|
vertical_space(20.0),
|
||||||
@@ -1068,8 +1185,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
)
|
)
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.height(iced::Length::Fill)
|
.height(iced::Length::Fill)
|
||||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0))
|
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
|
||||||
.into()
|
|
||||||
|
with_layout_picker(home.into(), state)
|
||||||
} else {
|
} else {
|
||||||
// --- ROOM SCREEN ---
|
// --- ROOM SCREEN ---
|
||||||
let participant_count = state.peers.len() + 1; // peers + you
|
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))
|
button(text("Copy Ticket").size(12))
|
||||||
.on_press(AppMessage::CopyToClipboard)
|
.on_press(AppMessage::CopyToClipboard)
|
||||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
.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)
|
.spacing(16)
|
||||||
.align_y(iced::alignment::Vertical::Center);
|
.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))
|
.style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
|
||||||
.padding(15)
|
.padding(15)
|
||||||
.width(iced::Length::Fixed(state.config.participants_width))
|
|
||||||
.height(iced::Length::Fill);
|
.height(iced::Length::Fill);
|
||||||
|
|
||||||
// Control Panel Column
|
// Control Panel Column
|
||||||
@@ -1320,6 +1456,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Controls panel — width is set per layout below.
|
||||||
let control_panel = container(
|
let control_panel = container(
|
||||||
column![
|
column![
|
||||||
text("Controls").size(18).color(color_blue),
|
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))
|
.style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
|
||||||
.padding(15)
|
.padding(15)
|
||||||
.width(iced::Length::Fill)
|
|
||||||
.height(iced::Length::Fill);
|
.height(iced::Length::Fill);
|
||||||
|
|
||||||
// Draggable vertical divider between Participants and Controls.
|
// Reusable chat body (title + bottom-anchored scrollback + input row),
|
||||||
let panels_divider = Canvas::new(Divider {
|
// wrapped differently by each layout.
|
||||||
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) ---
|
|
||||||
let mut chat_col = Column::new().spacing(4).width(iced::Length::Fill);
|
let mut chat_col = Column::new().spacing(4).width(iced::Length::Fill);
|
||||||
if state.chat_messages.is_empty() {
|
if state.chat_messages.is_empty() {
|
||||||
chat_col = chat_col.push(
|
chat_col = chat_col.push(
|
||||||
@@ -1372,7 +1495,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.height(iced::Length::Fill)
|
.height(iced::Length::Fill)
|
||||||
.anchor_bottom();
|
.anchor_bottom();
|
||||||
|
|
||||||
let chat_input_row = row![
|
let chat_input_row = row![
|
||||||
text_input("Message the room…", &state.chat_input)
|
text_input("Message the room…", &state.chat_input)
|
||||||
.on_input(AppMessage::ChatInputChanged)
|
.on_input(AppMessage::ChatInputChanged)
|
||||||
@@ -1386,45 +1508,124 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(iced::alignment::Vertical::Center);
|
.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(
|
// Divider constructors (fresh widget per call).
|
||||||
column![
|
let vdiv = |kind| {
|
||||||
text("Chat").size(16).color(color_blue),
|
Canvas::new(Divider { kind, vertical: true, line: color_surface, grip: color_lavender })
|
||||||
chat_scroll,
|
.width(iced::Length::Fixed(DIVIDER_THICKNESS))
|
||||||
chat_input_row,
|
.height(iced::Length::Fill)
|
||||||
]
|
};
|
||||||
.spacing(8),
|
let hdiv = || {
|
||||||
)
|
Canvas::new(Divider {
|
||||||
.style(c_style(color_mantle, color_surface, 8.0))
|
kind: DividerKind::Chat,
|
||||||
.padding(12)
|
vertical: false,
|
||||||
.width(iced::Length::Fill)
|
line: color_surface,
|
||||||
.height(iced::Length::Fixed(state.config.chat_height));
|
grip: color_lavender,
|
||||||
|
})
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.height(iced::Length::Fixed(DIVIDER_THICKNESS))
|
||||||
|
};
|
||||||
|
|
||||||
// Draggable horizontal divider between the main row and the Chat dock.
|
// Assemble the body per the chosen room layout. `chat_inner` is moved into
|
||||||
let chat_divider = Canvas::new(Divider {
|
// exactly one arm (allowed across mutually-exclusive match arms).
|
||||||
kind: DividerKind::Chat,
|
let pw = state.config.participants_width;
|
||||||
vertical: false,
|
let body: Element<'_, AppMessage> = match state.config.room_layout {
|
||||||
line: color_surface,
|
RoomLayout::BottomDock => {
|
||||||
grip: color_lavender,
|
// Cap Participants so the Fill Controls panel keeps its minimum.
|
||||||
})
|
let avail = state.window_size.width - 30.0;
|
||||||
.width(iced::Length::Fill)
|
let pwb = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
|
||||||
.height(iced::Length::Fixed(DIVIDER_THICKNESS));
|
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(
|
let room = container(
|
||||||
column![
|
column![top_bar, header_container, vertical_space(12.0), body]
|
||||||
top_bar,
|
|
||||||
header_container,
|
|
||||||
vertical_space(12.0),
|
|
||||||
main_layout,
|
|
||||||
chat_divider,
|
|
||||||
chat_dock
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
.padding(15)
|
.padding(15)
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.height(iced::Length::Fill)
|
.height(iced::Length::Fill)
|
||||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0))
|
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
|
||||||
.into()
|
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{format_duration, reconnect_attempt_chime, reconnected_chime, GateMeter, METER_MAX};
|
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);
|
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 crate::notify::Sound;
|
||||||
use iroh::EndpointId;
|
use iroh::EndpointId;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|||||||
@@ -25,6 +25,34 @@ impl NetworkMode {
|
|||||||
[NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly];
|
[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 {
|
impl std::fmt::Display for NetworkMode {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
let label = match self {
|
let label = match self {
|
||||||
@@ -56,6 +84,14 @@ fn default_chat_height() -> f32 {
|
|||||||
180.0
|
180.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_controls_width() -> f32 {
|
||||||
|
280.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_chat_drawer_width() -> f32 {
|
||||||
|
320.0
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
/// Last nickname used to join/create a room; pre-filled on the launch screen.
|
/// 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,
|
pub participants_width: f32,
|
||||||
#[serde(default = "default_chat_height")]
|
#[serde(default = "default_chat_height")]
|
||||||
pub chat_height: f32,
|
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)]
|
#[serde(default)]
|
||||||
pub custom_sound_self_join: Option<String>,
|
pub custom_sound_self_join: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -116,6 +161,9 @@ impl Default for AppConfig {
|
|||||||
notifications_enabled: true,
|
notifications_enabled: true,
|
||||||
participants_width: default_participants_width(),
|
participants_width: default_participants_width(),
|
||||||
chat_height: default_chat_height(),
|
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_self_join: None,
|
||||||
custom_sound_peer_join: None,
|
custom_sound_peer_join: None,
|
||||||
custom_sound_peer_leave: None,
|
custom_sound_peer_leave: None,
|
||||||
@@ -186,6 +234,10 @@ mod tests {
|
|||||||
assert_eq!(deserialized.chat_height, 180.0);
|
assert_eq!(deserialized.chat_height, 180.0);
|
||||||
// Configs predating the remembered username load the default nickname.
|
// Configs predating the remembered username load the default nickname.
|
||||||
assert_eq!(deserialized.username, "Peer");
|
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_self_join.is_none());
|
||||||
assert!(deserialized.custom_sound_peer_join.is_none());
|
assert!(deserialized.custom_sound_peer_join.is_none());
|
||||||
assert!(deserialized.custom_sound_peer_leave.is_none());
|
assert!(deserialized.custom_sound_peer_leave.is_none());
|
||||||
|
|||||||
Reference in New Issue
Block a user