From 7f35d7f58d81a8672e20d7a703f8eec89d30a429 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Sat, 25 Jul 2026 23:35:57 +0200 Subject: [PATCH] Move window controls into the title bar Co-Authored-By: Claude Opus 5 --- assets/icons/sidebar.svg | 4 + .../20260725150000_add_sidebar_state/down.sql | 1 + .../20260725150000_add_sidebar_state/up.sql | 2 + src/app.rs | 10 +++ src/app/view.rs | 82 ++++++++++++++----- src/database.rs | 13 +++ src/main.rs | 8 ++ src/schema.rs | 1 + 8 files changed, 99 insertions(+), 22 deletions(-) create mode 100644 assets/icons/sidebar.svg create mode 100644 migrations/20260725150000_add_sidebar_state/down.sql create mode 100644 migrations/20260725150000_add_sidebar_state/up.sql diff --git a/assets/icons/sidebar.svg b/assets/icons/sidebar.svg new file mode 100644 index 0000000..88a1377 --- /dev/null +++ b/assets/icons/sidebar.svg @@ -0,0 +1,4 @@ + + + + diff --git a/migrations/20260725150000_add_sidebar_state/down.sql b/migrations/20260725150000_add_sidebar_state/down.sql new file mode 100644 index 0000000..bc7dcd8 --- /dev/null +++ b/migrations/20260725150000_add_sidebar_state/down.sql @@ -0,0 +1 @@ +ALTER TABLE preferences DROP COLUMN sidebar_collapsed; diff --git a/migrations/20260725150000_add_sidebar_state/up.sql b/migrations/20260725150000_add_sidebar_state/up.sql new file mode 100644 index 0000000..e43d830 --- /dev/null +++ b/migrations/20260725150000_add_sidebar_state/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE preferences ADD COLUMN sidebar_collapsed BOOLEAN NOT NULL DEFAULT FALSE + CHECK (sidebar_collapsed IN (FALSE, TRUE)); diff --git a/src/app.rs b/src/app.rs index 5580986..70d32e1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -193,6 +193,7 @@ pub(crate) enum Message { ToggleArchivedSessions(i32), ShowChat, ShowStats, + ToggleSidebar, MetricsTick, } @@ -403,6 +404,15 @@ impl App { } Message::ShowChat => self.detail_tab = DetailTab::Chat, Message::ShowStats => self.detail_tab = DetailTab::Stats, + Message::ToggleSidebar => { + let collapsed = !self.preferences.sidebar_collapsed; + self.preferences.sidebar_collapsed = collapsed; + if let Some(database) = self.database.as_mut() + && let Err(error) = database.set_sidebar_collapsed(collapsed) + { + self.error = Some(error); + } + } Message::MetricsTick => self.sample_metrics(), Message::PreferenceModelChanged(model) => { self.preference_draft.model = model; diff --git a/src/app/view.rs b/src/app/view.rs index 8af98fa..f7d465c 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -19,7 +19,7 @@ use iced::widget::{ Button, Space, Svg, button, checkbox, column, container, horizontal_rule, markdown, opaque, pick_list, progress_bar, row, scrollable, stack, svg, text, text_input, }; -use iced::{Alignment, Background, Border, Color, Element, Length, Theme, window}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme, window}; use std::collections::VecDeque; use std::path::Path; @@ -35,8 +35,18 @@ const ICON_SEND: &[u8] = include_bytes!("../../assets/icons/send.svg"); const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg"); const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg"); const ICON_PIN: &[u8] = include_bytes!("../../assets/icons/pin.svg"); +const ICON_SIDEBAR: &[u8] = include_bytes!("../../assets/icons/sidebar.svg"); const ICON_ARCHIVE: &[u8] = include_bytes!("../../assets/icons/archive.svg"); +/// Height of the strip the window content shares with the native title bar. +/// Keep it close to the 28pt macOS title bar so our controls line up with the +/// traffic lights. +const TITLE_BAR_HEIGHT: f32 = 30.0; +/// Height of every control in that strip. +const TITLE_BAR_CONTROL: f32 = 24.0; +/// Space the macOS traffic lights need before our own controls start. +const TRAFFIC_LIGHT_WIDTH: f32 = 78.0; + impl App { pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> { if self.model_manager_window == Some(id) { @@ -52,11 +62,22 @@ impl App { } fn main_view(&self) -> Element<'_, Message> { - let mut shell = column![ - row![self.sidebar(), self.detail()] + let mut body = row![].width(Length::Fill).height(Length::Fill); + if !self.preferences.sidebar_collapsed { + body = body.push(self.sidebar()); + } + body = body.push( + container(self.detail()) .width(Length::Fill) .height(Length::Fill) - ]; + .padding(Padding::ZERO.top(TITLE_BAR_HEIGHT)), + ); + // The title bar floats above the panels so their backgrounds run all the + // way to the top edge; the spacer below it lets clicks through. + let mut shell = column![stack![ + body, + column![self.title_bar(), Space::with_height(Length::Fill)], + ]]; if let ModelDownload::Active(download) = &self.model_download { shell = shell.push(download_status_bar(download)); } @@ -232,7 +253,7 @@ impl App { ) .width(276) .height(Length::Fill) - .padding(16) + .padding(Padding::new(16.0).top(16.0 + TITLE_BAR_HEIGHT)) .style(sidebar_style) .into() } @@ -273,19 +294,39 @@ impl App { .into() } - fn detail(&self) -> Element<'_, Message> { + /// Content that shares the strip with the native traffic lights. + fn title_bar(&self) -> Element<'_, Message> { + let toggle = button(icon(ICON_SIDEBAR, 16)) + .height(TITLE_BAR_CONTROL) + .padding([0, 6]) + .on_press(Message::ToggleSidebar) + .style(button::text); + stack![ + container(self.detail_tabs()) + .center_x(Length::Fill) + .center_y(Length::Fill), + container(row![Space::with_width(TRAFFIC_LIGHT_WIDTH), toggle]).center_y(Length::Fill), + ] + .width(Length::Fill) + .height(TITLE_BAR_HEIGHT) + .into() + } + + fn detail_tabs(&self) -> Element<'_, Message> { let chat_active = self.detail_tab == DetailTab::Chat; let stats_active = self.detail_tab == DetailTab::Stats; let tabs = container( row![ button(text("Chat").size(13)) - .width(92) - .padding([7, 18]) + .width(88) + .height(TITLE_BAR_CONTROL - 4.0) + .padding([0, 16]) .on_press(Message::ShowChat) .style(move |theme, status| segmented_button_style(theme, status, chat_active)), button(text("Stats").size(13)) - .width(92) - .padding([7, 18]) + .width(88) + .height(TITLE_BAR_CONTROL - 4.0) + .padding([0, 16]) .on_press(Message::ShowStats) .style(move |theme, status| segmented_button_style( theme, @@ -295,19 +336,16 @@ impl App { ] .spacing(2), ) - .padding(3) + .padding(2) .style(segmented_control_style); - let body = match self.detail_tab { + tabs.into() + } + + fn detail(&self) -> Element<'_, Message> { + match self.detail_tab { DetailTab::Chat => self.chat_detail(), DetailTab::Stats => self.stats_dashboard(), - }; - column![ - container(tabs).center_x(Length::Fill).padding([12, 0]), - body - ] - .width(Length::Fill) - .height(Length::Fill) - .into() + } } fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> { @@ -664,7 +702,7 @@ fn segmented_control_style(_: &Theme) -> container::Style { border: Border { color: Color::from_rgb8(57, 57, 60), width: 1.0, - radius: 18.0.into(), + radius: (TITLE_BAR_CONTROL / 2.0).into(), }, ..container::Style::default() } @@ -682,7 +720,7 @@ fn segmented_button_style(_: &Theme, status: button::Status, selected: bool) -> background, text_color: if selected { Color::WHITE } else { muted_text() }, border: Border { - radius: 14.0.into(), + radius: (TITLE_BAR_CONTROL / 2.0 - 2.0).into(), ..Border::default() }, ..button::Style::default() diff --git a/src/database.rs b/src/database.rs index 8efe38b..af8f998 100644 --- a/src/database.rs +++ b/src/database.rs @@ -54,6 +54,7 @@ pub struct AppPreferences { pub endpoint_port: i32, pub endpoint_enabled: bool, pub endpoint_cors: bool, + pub sidebar_collapsed: bool, } impl Default for AppPreferences { @@ -96,6 +97,7 @@ impl Default for AppPreferences { endpoint_port: 4000, endpoint_enabled: true, endpoint_cors: false, + sidebar_collapsed: false, } } } @@ -431,6 +433,14 @@ impl Database { .map_err(|error| error.to_string()) } + pub fn set_sidebar_collapsed(&mut self, collapsed: bool) -> Result<(), String> { + diesel::update(preferences::table.find(1)) + .set(preferences::sidebar_collapsed.eq(collapsed)) + .execute(&mut self.connection) + .map(|_| ()) + .map_err(|error| error.to_string()) + } + #[allow(clippy::too_many_arguments)] pub fn update_preferences( &mut self, @@ -840,12 +850,15 @@ mod tests { let loaded = database.load_projects().unwrap(); assert_eq!(loaded[0].sessions[0].id, ordinary); + database.set_sidebar_collapsed(true).unwrap(); + database.delete_project(project.id).unwrap(); assert!(database.load_projects().unwrap().is_empty()); drop(database); let mut reopened = Database::open(&path).unwrap(); let preferences = reopened.load_preferences().unwrap(); + assert!(preferences.sidebar_collapsed); assert_eq!(preferences.selected_model, "glm-5.2"); assert_eq!(preferences.idle_timeout_minutes, 30); assert_eq!(preferences.endpoint_port, 4567); diff --git a/src/main.rs b/src/main.rs index f5bb844..5d16ff4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,14 @@ fn main() -> iced::Result { size: Size::new(1120.0, 720.0), min_size: Some(Size::new(760.0, 480.0)), icon: Some(app_icon()), + // The title bar is ours: content runs to the top edge and the + // native traffic lights float over it. + #[cfg(target_os = "macos")] + platform_specific: window::settings::PlatformSpecific { + title_hidden: true, + titlebar_transparent: true, + fullsize_content_view: true, + }, ..Default::default() }); (App::load(main_window), open.map(Message::WindowOpened)) diff --git a/src/schema.rs b/src/schema.rs index 810f594..da9e329 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -57,6 +57,7 @@ diesel::table! { endpoint_port -> Integer, endpoint_enabled -> Bool, endpoint_cors -> Bool, + sidebar_collapsed -> Bool, } }