From 5645ef24873e81499b81208704371eda66241d2d Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 5 Jul 2026 07:08:46 -0700 Subject: [PATCH 1/6] feat: redesign settings ui --- Cargo.lock | 8 + Cargo.toml | 1 + .../src/compositor/output.rs | 18 +- crates/hearthspace-ipc/src/lib.rs | 9 +- crates/hearthspace-settings/Cargo.toml | 1 + crates/hearthspace-settings/src/main.rs | 7 +- crates/hearthspace-settings/src/state.rs | 27 ++ crates/hearthspace-settings/src/ui.rs | 256 +++++++++++++----- crates/hearthspace-shell/src/command.rs | 2 +- crates/ui-components/Cargo.toml | 15 + crates/ui-components/src/lib.rs | 163 +++++++++++ 11 files changed, 416 insertions(+), 91 deletions(-) create mode 100644 crates/ui-components/Cargo.toml create mode 100644 crates/ui-components/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0f10443..fe99c51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2318,6 +2318,7 @@ version = "0.1.0" dependencies = [ "hearthspace-common", "hearthspace-ipc", + "hearthspace-ui-components", "rfd", "wayland-client", "xilem", @@ -2347,6 +2348,13 @@ dependencies = [ "xilem", ] +[[package]] +name = "hearthspace-ui-components" +version = "0.1.0" +dependencies = [ + "xilem", +] + [[package]] name = "heck" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index e5da483..8dfc451 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/hearthspace-settings", "crates/hearthspace-settingsd", "crates/hearthspace-shell", + "crates/ui-components", "crates/waydriver-hearthspace", ] default-members = ["crates/hearthspace-session"] diff --git a/crates/hearthspace-compositor/src/compositor/output.rs b/crates/hearthspace-compositor/src/compositor/output.rs index 9e43a28..5f37252 100644 --- a/crates/hearthspace-compositor/src/compositor/output.rs +++ b/crates/hearthspace-compositor/src/compositor/output.rs @@ -32,6 +32,14 @@ pub(in crate::compositor) struct OutputRecord { location: Point, } +type OutputDebugEntry = ( + String, + Point, + Size, + Size, + i32, +); + #[cfg(feature = "udev")] pub(in crate::compositor) struct OutputRenderView { pub(in crate::compositor) name: String, @@ -126,15 +134,7 @@ impl OutputSet { debug!(outputs = ?self.output_debug(), "applied logical output layout"); } - fn output_debug( - &self, - ) -> Vec<( - String, - Point, - Size, - Size, - i32, - )> { + fn output_debug(&self) -> Vec { std::iter::once(&self.primary) .chain(self.secondary.iter()) .map(|output| { diff --git a/crates/hearthspace-ipc/src/lib.rs b/crates/hearthspace-ipc/src/lib.rs index c82be66..72d64b1 100644 --- a/crates/hearthspace-ipc/src/lib.rs +++ b/crates/hearthspace-ipc/src/lib.rs @@ -15,8 +15,9 @@ pub struct Settings { pub monitors: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum BackgroundMode { + #[default] RepeatWithCanvas, Stretch, Contains, @@ -42,12 +43,6 @@ impl BackgroundMode { } } -impl Default for BackgroundMode { - fn default() -> Self { - Self::RepeatWithCanvas - } -} - impl std::fmt::Display for BackgroundMode { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(self.as_str()) diff --git a/crates/hearthspace-settings/Cargo.toml b/crates/hearthspace-settings/Cargo.toml index 88d2f60..0b0ad02 100644 --- a/crates/hearthspace-settings/Cargo.toml +++ b/crates/hearthspace-settings/Cargo.toml @@ -8,6 +8,7 @@ license = "Apache-2.0" [dependencies] hearthspace-common = { path = "../hearthspace-common" } hearthspace-ipc = { path = "../hearthspace-ipc" } +hearthspace-ui-components = { path = "../ui-components" } rfd = { version = "0.17.2", features = ["xdg-portal"] } wayland-client = "0.31.14" xilem = { git = "https://github.com/crutchcorn/xilem", branch = "wayland-app-id" } diff --git a/crates/hearthspace-settings/src/main.rs b/crates/hearthspace-settings/src/main.rs index 104769c..5dec97e 100644 --- a/crates/hearthspace-settings/src/main.rs +++ b/crates/hearthspace-settings/src/main.rs @@ -17,9 +17,10 @@ fn main() -> Result<(), Box> { state, app_logic, WindowOptions::new("Hearthspace Settings") - .with_initial_inner_size(LogicalSize::new(840.0, 720.0)) - .with_min_inner_size(LogicalSize::new(680.0, 600.0)), - ); + .with_initial_inner_size(LogicalSize::new(980.0, 720.0)) + .with_min_inner_size(LogicalSize::new(780.0, 600.0)), + ) + .with_default_properties(hearthspace_ui_components::hearthspace_light_properties()); app.run_in(EventLoop::with_user_event())?; Ok(()) diff --git a/crates/hearthspace-settings/src/state.rs b/crates/hearthspace-settings/src/state.rs index b60c10d..341e952 100644 --- a/crates/hearthspace-settings/src/state.rs +++ b/crates/hearthspace-settings/src/state.rs @@ -11,6 +11,7 @@ use crate::{ pub(crate) struct SettingsApp { pub(crate) settings: Settings, pub(crate) live_outputs: Vec, + pub(crate) selected_category: SettingsCategory, pub(crate) selected_monitor_id: Option, pub(crate) background_picker_request_sender: Option>, pub(crate) background_picker_pending: bool, @@ -18,6 +19,31 @@ pub(crate) struct SettingsApp { pub(crate) config_label: String, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SettingsCategory { + Display, + Appearance, + Advanced, +} + +impl SettingsCategory { + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Display => "Display", + Self::Appearance => "Appearance", + Self::Advanced => "Advanced", + } + } + + pub(crate) const fn description(self) -> &'static str { + match self { + Self::Display => "Monitor placement and scale", + Self::Appearance => "Canvas background image and fit", + Self::Advanced => "Diagnostics and configuration", + } + } +} + #[derive(Debug)] pub(crate) enum BackgroundPickerRequest { PickFile, @@ -56,6 +82,7 @@ pub(crate) fn initial_state() -> SettingsApp { SettingsApp { settings, live_outputs, + selected_category: SettingsCategory::Display, selected_monitor_id, background_picker_request_sender: None, background_picker_pending: false, diff --git a/crates/hearthspace-settings/src/ui.rs b/crates/hearthspace-settings/src/ui.rs index 4763391..0dc2d77 100644 --- a/crates/hearthspace-settings/src/ui.rs +++ b/crates/hearthspace-settings/src/ui.rs @@ -1,13 +1,14 @@ use hearthspace_ipc::{BackgroundMode, MonitorConfig, client_set_test}; +use hearthspace_ui_components as components; use rfd::FileDialog; use xilem::{ AnyWidgetView, WidgetView, core::fork, - masonry::{layout::AsUnit, parley::fontique::GenericFamily}, + masonry::layout::AsUnit, style::Style, view::{ - checkbox, flex_col, flex_row, label, radio_button, radio_group, sized_box, text_button, - worker, + CrossAxisAlignment, checkbox, flex_col, flex_item, flex_row, label, radio_button, + radio_group, sized_box, text_button, worker, }, }; @@ -18,11 +19,135 @@ use crate::{ MONITOR_SCALE_OPTIONS, monitor_logical_height, monitor_logical_width, settings_with_live_outputs, }, - state::{BackgroundPickerRequest, BackgroundPickerResponse, SettingsApp}, + state::{BackgroundPickerRequest, BackgroundPickerResponse, SettingsApp, SettingsCategory}, }; pub(crate) fn app_logic(state: &mut SettingsApp) -> impl WidgetView + use<> { - let test_checked = state.settings.test; + let content = components::app_background( + flex_row(( + sized_box(sidebar_view(state)).fixed_width(220.0.px()), + flex_item(category_view(state), 1.0), + )) + .cross_axis_alignment(CrossAxisAlignment::Stretch) + .gap(20.0.px()), + ); + + fork( + content, + worker( + |proxy, mut rx| async move { + while let Some(BackgroundPickerRequest::PickFile) = rx.recv().await { + let response = match xilem::tokio::task::spawn_blocking(|| { + FileDialog::new() + .add_filter("PNG image", &["png"]) + .pick_file() + }) + .await + { + Ok(Some(path)) => BackgroundPickerResponse::Selected(path), + Ok(None) => BackgroundPickerResponse::Cancelled, + Err(error) => BackgroundPickerResponse::Failed(error.to_string()), + }; + + drop(proxy.message(response)); + } + }, + |state: &mut SettingsApp, sender| { + state.background_picker_request_sender = Some(sender); + }, + |state: &mut SettingsApp, response| { + state.background_picker_pending = false; + match response { + BackgroundPickerResponse::Selected(path) => { + save_background_image_path(state, Some(path)); + } + BackgroundPickerResponse::Cancelled => { + state.status = "Background image selection cancelled".to_string(); + } + BackgroundPickerResponse::Failed(error) => { + state.status = format!("Background image picker failed: {error}"); + } + } + }, + ), + ) +} + +fn sidebar_view(state: &SettingsApp) -> impl WidgetView + use<> { + let selected_category = state.selected_category; + + components::sidebar_panel( + flex_col(( + components::page_title("Settings"), + components::muted_text("Hearthspace"), + components::sidebar_item( + SettingsCategory::Display.label(), + selected_category == SettingsCategory::Display, + |state: &mut SettingsApp| { + state.selected_category = SettingsCategory::Display; + }, + ), + components::sidebar_item( + SettingsCategory::Appearance.label(), + selected_category == SettingsCategory::Appearance, + |state: &mut SettingsApp| { + state.selected_category = SettingsCategory::Appearance; + }, + ), + components::sidebar_item( + SettingsCategory::Advanced.label(), + selected_category == SettingsCategory::Advanced, + |state: &mut SettingsApp| { + state.selected_category = SettingsCategory::Advanced; + }, + ), + )) + .cross_axis_alignment(CrossAxisAlignment::Stretch) + .gap(8.0.px()), + ) +} + +fn category_view(state: &SettingsApp) -> impl WidgetView + use<> { + let category = state.selected_category; + + sized_box( + flex_col(( + components::page_title(category.label()), + components::muted_text(category.description()), + selected_category_view(state), + status_footer(state), + )) + .cross_axis_alignment(CrossAxisAlignment::Stretch) + .gap(16.0.px()), + ) +} + +fn selected_category_view(state: &SettingsApp) -> Box> { + match state.selected_category { + SettingsCategory::Display => display_settings_view(state), + SettingsCategory::Appearance => appearance_settings_view(state), + SettingsCategory::Advanced => advanced_settings_view(state), + } +} + +fn display_settings_view(state: &SettingsApp) -> Box> { + let monitor_summary = format_monitor_summary(&state.settings.monitors); + + components::settings_section( + "Monitor Layout", + flex_col(( + components::muted_text("Drag monitors to arrange their relative positions."), + sized_box(monitor_layout_editor()).fixed_height(320.0.px()), + components::muted_text(monitor_summary), + monitor_details_view(state), + )) + .cross_axis_alignment(CrossAxisAlignment::Stretch) + .gap(12.0.px()), + ) + .boxed() +} + +fn appearance_settings_view(state: &SettingsApp) -> Box> { let background_mode = state.settings.background_mode; let background_image_label = state .settings @@ -35,36 +160,13 @@ pub(crate) fn app_logic(state: &mut SettingsApp) -> impl WidgetView } else { "Select background image" }; - let monitor_summary = format_monitor_summary(&state.settings.monitors); - let content = sized_box( + components::settings_section( + "Canvas Background", flex_col(( - label("Hearthspace Settings") - .font(GenericFamily::SystemUi) - .text_size(22.0), - label("Drag monitors to arrange their relative positions."), - sized_box(monitor_layout_editor()).fixed_height(320.0.px()), - label(monitor_summary), - monitor_details_view(state), - checkbox("test", test_checked, |state: &mut SettingsApp, checked| { - let previous = state.settings.test; - state.settings.test = checked; - - match client_set_test(checked) { - Ok(settings) => { - let saved_test = settings.test; - state.settings = settings_with_live_outputs(settings, &state.live_outputs); - state.status = format!("Saved test = {saved_test}"); - } - Err(error) => { - state.settings.test = previous; - state.status = format!("Failed to save setting: {error}"); - } - } - }), - label("Background fit"), + components::field_label("Background fit"), background_mode_picker(background_mode), - label(format!("Background image: {background_image_label}")), + components::field_label(format!("Background image: {background_image_label}")), flex_row(( text_button(select_background_button_label, |state: &mut SettingsApp| { if state.background_picker_pending { @@ -92,52 +194,60 @@ pub(crate) fn app_logic(state: &mut SettingsApp) -> impl WidgetView save_background_image_path(state, None); }), )), - label(state.config_label.clone()), - label(state.status.clone()), )) + .cross_axis_alignment(CrossAxisAlignment::Start) .gap(12.0.px()), ) - .padding(20.0.px()); + .boxed() +} - fork( - content, - worker( - |proxy, mut rx| async move { - while let Some(BackgroundPickerRequest::PickFile) = rx.recv().await { - let response = match xilem::tokio::task::spawn_blocking(|| { - FileDialog::new() - .add_filter("PNG image", &["png"]) - .pick_file() - }) - .await - { - Ok(Some(path)) => BackgroundPickerResponse::Selected(path), - Ok(None) => BackgroundPickerResponse::Cancelled, - Err(error) => BackgroundPickerResponse::Failed(error.to_string()), - }; +fn advanced_settings_view(state: &SettingsApp) -> Box> { + let test_checked = state.settings.test; - drop(proxy.message(response)); - } - }, - |state: &mut SettingsApp, sender| { - state.background_picker_request_sender = Some(sender); - }, - |state: &mut SettingsApp, response| { - state.background_picker_pending = false; - match response { - BackgroundPickerResponse::Selected(path) => { - save_background_image_path(state, Some(path)); - } - BackgroundPickerResponse::Cancelled => { - state.status = "Background image selection cancelled".to_string(); - } - BackgroundPickerResponse::Failed(error) => { - state.status = format!("Background image picker failed: {error}"); + components::settings_section( + "Diagnostics", + flex_col(( + checkbox( + "Test setting", + test_checked, + |state: &mut SettingsApp, checked| { + let previous = state.settings.test; + state.settings.test = checked; + + match client_set_test(checked) { + Ok(settings) => { + let saved_test = settings.test; + state.settings = + settings_with_live_outputs(settings, &state.live_outputs); + state.status = format!("Saved test = {saved_test}"); + } + Err(error) => { + state.settings.test = previous; + state.status = format!("Failed to save setting: {error}"); + } } - } - }, - ), + }, + ), + components::muted_text(state.config_label.clone()), + )) + .cross_axis_alignment(CrossAxisAlignment::Start) + .gap(12.0.px()), + ) + .boxed() +} + +fn status_footer(state: &SettingsApp) -> impl WidgetView + use<> { + sized_box( + flex_col(( + components::field_label("Status"), + components::muted_text(state.status.clone()), + )) + .cross_axis_alignment(CrossAxisAlignment::Stretch) + .gap(4.0.px()), ) + .background_color(components::SURFACE_SUBTLE) + .corner_radius(8.0.px()) + .padding(12.0.px()) } fn background_mode_picker(selected_mode: BackgroundMode) -> impl WidgetView + use<> { @@ -174,6 +284,10 @@ fn background_mode_picker(selected_mode: BackgroundMode) -> impl WidgetView String { + if monitors.is_empty() { + return "No monitors are currently available.".to_string(); + } + monitors .iter() .map(|monitor| { diff --git a/crates/hearthspace-shell/src/command.rs b/crates/hearthspace-shell/src/command.rs index 3bbfe4b..f06f6fa 100644 --- a/crates/hearthspace-shell/src/command.rs +++ b/crates/hearthspace-shell/src/command.rs @@ -205,7 +205,7 @@ fn hex_encode(input: &str) -> String { } fn hex_decode_to_string(input: &str) -> Option { - if input.len() % 2 != 0 { + if !input.len().is_multiple_of(2) { return None; } diff --git a/crates/ui-components/Cargo.toml b/crates/ui-components/Cargo.toml new file mode 100644 index 0000000..2ddd9c6 --- /dev/null +++ b/crates/ui-components/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hearthspace-ui-components" +version = "0.1.0" +edition = "2024" +rust-version = "1.93.1" +license = "Apache-2.0" + +[dependencies] +xilem = { git = "https://github.com/crutchcorn/xilem", branch = "wayland-app-id" } + +[lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[lints.clippy] +all = { level = "deny", priority = -1 } diff --git a/crates/ui-components/src/lib.rs b/crates/ui-components/src/lib.rs new file mode 100644 index 0000000..fa2ebc5 --- /dev/null +++ b/crates/ui-components/src/lib.rs @@ -0,0 +1,163 @@ +use xilem::{ + Color, TextAlign, WidgetView, + masonry::{ + core::{ArcStr, DefaultProperties, PropertyStack, Selector}, + layout::AsUnit, + parley::{fontique::GenericFamily, style::FontWeight}, + properties::{ + Background, BorderColor, BorderWidth, CaretColor, ContentColor, CornerRadius, Padding, + PlaceholderColor, + }, + theme::default_property_set, + widgets::{Button, Checkbox, Label, RadioButton, TextArea, TextInput}, + }, + style::Style, + view::{CrossAxisAlignment, button, flex_col, label, sized_box}, +}; + +pub const APP_BACKGROUND: Color = Color::from_rgb8(0xf6, 0xf7, 0xf9); +pub const SURFACE: Color = Color::from_rgb8(0xff, 0xff, 0xff); +pub const SURFACE_SUBTLE: Color = Color::from_rgb8(0xf0, 0xf2, 0xf5); +pub const BORDER: Color = Color::from_rgb8(0xd8, 0xde, 0xe8); +pub const TEXT: Color = Color::from_rgb8(0x1f, 0x24, 0x2a); +pub const TEXT_MUTED: Color = Color::from_rgb8(0x68, 0x72, 0x80); +pub const ACCENT: Color = Color::from_rgb8(0x2f, 0x76, 0xc2); +pub const ACCENT_SUBTLE: Color = Color::from_rgb8(0xe5, 0xf0, 0xfb); +pub const BUTTON_HOVER: Color = Color::from_rgb8(0xe8, 0xec, 0xf2); +pub const BUTTON_ACTIVE: Color = Color::from_rgb8(0xd9, 0xe2, 0xee); +pub const DANGER_SUBTLE: Color = Color::from_rgb8(0xfd, 0xee, 0xed); + +pub fn hearthspace_light_properties() -> DefaultProperties { + let mut properties = default_property_set(); + + properties.insert::(Background::Color(Color::TRANSPARENT)); + properties.insert::(BorderWidth { width: 0.0.px() }); + properties.insert::(CornerRadius { radius: 6.0.px() }); + properties.insert::(ContentColor::new(TEXT)); + properties.insert::(Padding::from_vh(7.0.px(), 10.0.px())); + { + let mut stack = PropertyStack::new(); + stack.push( + Selector::new().with_hovered(true), + Background::Color(BUTTON_HOVER), + ); + stack.push( + Selector::new().with_active(true), + Background::Color(BUTTON_ACTIVE), + ); + properties.insert_stack::