Move window controls into the title bar
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4
assets/icons/sidebar.svg
Normal file
4
assets/icons/sidebar.svg
Normal 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 |
1
migrations/20260725150000_add_sidebar_state/down.sql
Normal file
1
migrations/20260725150000_add_sidebar_state/down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE preferences DROP COLUMN sidebar_collapsed;
|
||||||
2
migrations/20260725150000_add_sidebar_state/up.sql
Normal file
2
migrations/20260725150000_add_sidebar_state/up.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE preferences ADD COLUMN sidebar_collapsed BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
CHECK (sidebar_collapsed IN (FALSE, TRUE));
|
||||||
10
src/app.rs
10
src/app.rs
@@ -193,6 +193,7 @@ pub(crate) enum Message {
|
|||||||
ToggleArchivedSessions(i32),
|
ToggleArchivedSessions(i32),
|
||||||
ShowChat,
|
ShowChat,
|
||||||
ShowStats,
|
ShowStats,
|
||||||
|
ToggleSidebar,
|
||||||
MetricsTick,
|
MetricsTick,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +404,15 @@ impl App {
|
|||||||
}
|
}
|
||||||
Message::ShowChat => self.detail_tab = DetailTab::Chat,
|
Message::ShowChat => self.detail_tab = DetailTab::Chat,
|
||||||
Message::ShowStats => self.detail_tab = DetailTab::Stats,
|
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::MetricsTick => self.sample_metrics(),
|
||||||
Message::PreferenceModelChanged(model) => {
|
Message::PreferenceModelChanged(model) => {
|
||||||
self.preference_draft.model = model;
|
self.preference_draft.model = model;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use iced::widget::{
|
|||||||
Button, Space, Svg, button, checkbox, column, container, horizontal_rule, markdown, opaque,
|
Button, Space, Svg, button, checkbox, column, container, horizontal_rule, markdown, opaque,
|
||||||
pick_list, progress_bar, row, scrollable, stack, svg, text, text_input,
|
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::collections::VecDeque;
|
||||||
use std::path::Path;
|
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_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg");
|
||||||
const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
|
const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
|
||||||
const ICON_PIN: &[u8] = include_bytes!("../../assets/icons/pin.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");
|
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 {
|
impl App {
|
||||||
pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> {
|
pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> {
|
||||||
if self.model_manager_window == Some(id) {
|
if self.model_manager_window == Some(id) {
|
||||||
@@ -52,11 +62,22 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main_view(&self) -> Element<'_, Message> {
|
fn main_view(&self) -> Element<'_, Message> {
|
||||||
let mut shell = column![
|
let mut body = row![].width(Length::Fill).height(Length::Fill);
|
||||||
row![self.sidebar(), self.detail()]
|
if !self.preferences.sidebar_collapsed {
|
||||||
|
body = body.push(self.sidebar());
|
||||||
|
}
|
||||||
|
body = body.push(
|
||||||
|
container(self.detail())
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(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 {
|
if let ModelDownload::Active(download) = &self.model_download {
|
||||||
shell = shell.push(download_status_bar(download));
|
shell = shell.push(download_status_bar(download));
|
||||||
}
|
}
|
||||||
@@ -232,7 +253,7 @@ impl App {
|
|||||||
)
|
)
|
||||||
.width(276)
|
.width(276)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.padding(16)
|
.padding(Padding::new(16.0).top(16.0 + TITLE_BAR_HEIGHT))
|
||||||
.style(sidebar_style)
|
.style(sidebar_style)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -273,19 +294,39 @@ impl App {
|
|||||||
.into()
|
.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 chat_active = self.detail_tab == DetailTab::Chat;
|
||||||
let stats_active = self.detail_tab == DetailTab::Stats;
|
let stats_active = self.detail_tab == DetailTab::Stats;
|
||||||
let tabs = container(
|
let tabs = container(
|
||||||
row![
|
row![
|
||||||
button(text("Chat").size(13))
|
button(text("Chat").size(13))
|
||||||
.width(92)
|
.width(88)
|
||||||
.padding([7, 18])
|
.height(TITLE_BAR_CONTROL - 4.0)
|
||||||
|
.padding([0, 16])
|
||||||
.on_press(Message::ShowChat)
|
.on_press(Message::ShowChat)
|
||||||
.style(move |theme, status| segmented_button_style(theme, status, chat_active)),
|
.style(move |theme, status| segmented_button_style(theme, status, chat_active)),
|
||||||
button(text("Stats").size(13))
|
button(text("Stats").size(13))
|
||||||
.width(92)
|
.width(88)
|
||||||
.padding([7, 18])
|
.height(TITLE_BAR_CONTROL - 4.0)
|
||||||
|
.padding([0, 16])
|
||||||
.on_press(Message::ShowStats)
|
.on_press(Message::ShowStats)
|
||||||
.style(move |theme, status| segmented_button_style(
|
.style(move |theme, status| segmented_button_style(
|
||||||
theme,
|
theme,
|
||||||
@@ -295,19 +336,16 @@ impl App {
|
|||||||
]
|
]
|
||||||
.spacing(2),
|
.spacing(2),
|
||||||
)
|
)
|
||||||
.padding(3)
|
.padding(2)
|
||||||
.style(segmented_control_style);
|
.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::Chat => self.chat_detail(),
|
||||||
DetailTab::Stats => self.stats_dashboard(),
|
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> {
|
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 {
|
border: Border {
|
||||||
color: Color::from_rgb8(57, 57, 60),
|
color: Color::from_rgb8(57, 57, 60),
|
||||||
width: 1.0,
|
width: 1.0,
|
||||||
radius: 18.0.into(),
|
radius: (TITLE_BAR_CONTROL / 2.0).into(),
|
||||||
},
|
},
|
||||||
..container::Style::default()
|
..container::Style::default()
|
||||||
}
|
}
|
||||||
@@ -682,7 +720,7 @@ fn segmented_button_style(_: &Theme, status: button::Status, selected: bool) ->
|
|||||||
background,
|
background,
|
||||||
text_color: if selected { Color::WHITE } else { muted_text() },
|
text_color: if selected { Color::WHITE } else { muted_text() },
|
||||||
border: Border {
|
border: Border {
|
||||||
radius: 14.0.into(),
|
radius: (TITLE_BAR_CONTROL / 2.0 - 2.0).into(),
|
||||||
..Border::default()
|
..Border::default()
|
||||||
},
|
},
|
||||||
..button::Style::default()
|
..button::Style::default()
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub struct AppPreferences {
|
|||||||
pub endpoint_port: i32,
|
pub endpoint_port: i32,
|
||||||
pub endpoint_enabled: bool,
|
pub endpoint_enabled: bool,
|
||||||
pub endpoint_cors: bool,
|
pub endpoint_cors: bool,
|
||||||
|
pub sidebar_collapsed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppPreferences {
|
impl Default for AppPreferences {
|
||||||
@@ -96,6 +97,7 @@ impl Default for AppPreferences {
|
|||||||
endpoint_port: 4000,
|
endpoint_port: 4000,
|
||||||
endpoint_enabled: true,
|
endpoint_enabled: true,
|
||||||
endpoint_cors: false,
|
endpoint_cors: false,
|
||||||
|
sidebar_collapsed: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,6 +433,14 @@ impl Database {
|
|||||||
.map_err(|error| error.to_string())
|
.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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn update_preferences(
|
pub fn update_preferences(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -840,12 +850,15 @@ mod tests {
|
|||||||
let loaded = database.load_projects().unwrap();
|
let loaded = database.load_projects().unwrap();
|
||||||
assert_eq!(loaded[0].sessions[0].id, ordinary);
|
assert_eq!(loaded[0].sessions[0].id, ordinary);
|
||||||
|
|
||||||
|
database.set_sidebar_collapsed(true).unwrap();
|
||||||
|
|
||||||
database.delete_project(project.id).unwrap();
|
database.delete_project(project.id).unwrap();
|
||||||
assert!(database.load_projects().unwrap().is_empty());
|
assert!(database.load_projects().unwrap().is_empty());
|
||||||
drop(database);
|
drop(database);
|
||||||
|
|
||||||
let mut reopened = Database::open(&path).unwrap();
|
let mut reopened = Database::open(&path).unwrap();
|
||||||
let preferences = reopened.load_preferences().unwrap();
|
let preferences = reopened.load_preferences().unwrap();
|
||||||
|
assert!(preferences.sidebar_collapsed);
|
||||||
assert_eq!(preferences.selected_model, "glm-5.2");
|
assert_eq!(preferences.selected_model, "glm-5.2");
|
||||||
assert_eq!(preferences.idle_timeout_minutes, 30);
|
assert_eq!(preferences.idle_timeout_minutes, 30);
|
||||||
assert_eq!(preferences.endpoint_port, 4567);
|
assert_eq!(preferences.endpoint_port, 4567);
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ fn main() -> iced::Result {
|
|||||||
size: Size::new(1120.0, 720.0),
|
size: Size::new(1120.0, 720.0),
|
||||||
min_size: Some(Size::new(760.0, 480.0)),
|
min_size: Some(Size::new(760.0, 480.0)),
|
||||||
icon: Some(app_icon()),
|
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()
|
..Default::default()
|
||||||
});
|
});
|
||||||
(App::load(main_window), open.map(Message::WindowOpened))
|
(App::load(main_window), open.map(Message::WindowOpened))
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ diesel::table! {
|
|||||||
endpoint_port -> Integer,
|
endpoint_port -> Integer,
|
||||||
endpoint_enabled -> Bool,
|
endpoint_enabled -> Bool,
|
||||||
endpoint_cors -> Bool,
|
endpoint_cors -> Bool,
|
||||||
|
sidebar_collapsed -> Bool,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user