Move window controls into the title bar
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
10
src/app.rs
10
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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -57,6 +57,7 @@ diesel::table! {
|
||||
endpoint_port -> Integer,
|
||||
endpoint_enabled -> Bool,
|
||||
endpoint_cors -> Bool,
|
||||
sidebar_collapsed -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user