Move window controls into the title bar

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Georg Bauer
2026-07-25 23:35:57 +02:00
parent ae4222a573
commit 7f35d7f58d
8 changed files with 99 additions and 22 deletions

4
assets/icons/sidebar.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<rect x="2.75" y="3.75" width="14.5" height="12.5" rx="2.25"/>
<path d="M8.25 3.75v12.5"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

View File

@@ -0,0 +1 @@
ALTER TABLE preferences DROP COLUMN sidebar_collapsed;

View File

@@ -0,0 +1,2 @@
ALTER TABLE preferences ADD COLUMN sidebar_collapsed BOOLEAN NOT NULL DEFAULT FALSE
CHECK (sidebar_collapsed IN (FALSE, TRUE));

View File

@@ -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;

View File

@@ -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()

View File

@@ -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);

View File

@@ -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))

View File

@@ -57,6 +57,7 @@ diesel::table! {
endpoint_port -> Integer,
endpoint_enabled -> Bool,
endpoint_cors -> Bool,
sidebar_collapsed -> Bool,
}
}