diff --git a/otty/src/app.rs b/otty/src/app.rs index ad9a1bb..4ee1604 100644 --- a/otty/src/app.rs +++ b/otty/src/app.rs @@ -51,7 +51,9 @@ impl App { let initial_settings = settings.settings_data().clone(); theme_manager.set_custom_palette(initial_settings.to_color_palette()); let current_theme = theme_manager.current(); - let fonts = FontsConfig::default(); + let mut fonts = FontsConfig::default(); + // 从 settings.json 的 font.size 读取终端字号 + fonts.terminal.size = initial_settings.font_size(); let terminal_settings = terminal_settings(current_theme, &fonts); let shell_path = initial_settings.terminal_shell().to_string(); let shell_session = match setup_shell_session_with_shell(&shell_path) { diff --git a/otty/src/components/primitive/icon_button.rs b/otty/src/components/primitive/icon_button.rs index 3b75d2c..90ec599 100644 --- a/otty/src/components/primitive/icon_button.rs +++ b/otty/src/components/primitive/icon_button.rs @@ -1,7 +1,7 @@ use iced::widget::{button, container, svg}; use iced::{Element, Length, alignment}; -use crate::layout::BUTTON_RADIUS_ROUNDED; +use crate::layout::RADIUS_CONTROL; use crate::theme::{StyleOverrides, ThemeProps}; /// UI events emitted by an icon button. @@ -76,7 +76,7 @@ pub(crate) fn view<'a>( .height(Length::Fixed(props.size)) .style(|_, _| iced::widget::button::Style { border: iced::Border { - radius: iced::border::Radius::from(BUTTON_RADIUS_ROUNDED), + radius: iced::border::Radius::from(RADIUS_CONTROL), ..Default::default() }, ..Default::default() diff --git a/otty/src/components/primitive/sidebar_workspace_panel.rs b/otty/src/components/primitive/sidebar_workspace_panel.rs index d535ddf..1e89e0d 100644 --- a/otty/src/components/primitive/sidebar_workspace_panel.rs +++ b/otty/src/components/primitive/sidebar_workspace_panel.rs @@ -1,6 +1,7 @@ use iced::widget::{Space, container}; use iced::{Element, Length, Theme}; +use crate::layout::RADIUS_INNER; use crate::theme::ThemeProps; /// Props for rendering sidebar workspace panel container. @@ -27,8 +28,13 @@ pub(crate) fn view<'a, Message: 'a>( .height(Length::Fill) .clip(true) .style(move |_| iced::widget::container::Style { - background: Some(palette.dim_black.into()), + // 侧边栏表面色(VS Code sideBar.background) + background: Some(palette.sidebar.into()), text_color: Some(palette.foreground), + border: iced::Border { + radius: iced::border::Radius::from(RADIUS_INNER), + ..Default::default() + }, ..Default::default() }) .into() diff --git a/otty/src/layout.rs b/otty/src/layout.rs index 27e11a2..0361085 100644 --- a/otty/src/layout.rs +++ b/otty/src/layout.rs @@ -1,7 +1,5 @@ use iced::Size; -use crate::app::view::HEADER_SEPARATOR_HEIGHT; -use crate::widgets::chrome::view::action_bar::ACTION_BAR_HEIGHT; use crate::widgets::tabs::view::tab_bar::TAB_BAR_HEIGHT; /// Shared compact control size used by dense toolbars and menus. @@ -13,12 +11,18 @@ pub(crate) const BUTTON_SIZE_RAIL: f32 = 44.0; /// Shared rounded corner radius for standard buttons. pub(crate) const BUTTON_RADIUS_ROUNDED: f32 = 6.0; -pub(crate) fn screen_size_from_window(window_size: Size) -> Size { - let height = - (window_size.height - ACTION_BAR_HEIGHT - HEADER_SEPARATOR_HEIGHT) - .max(0.0); +// ============================================================ +// Modern UI 分级圆角 token(精确对齐 VS Code styleOverrides 范式): +// CONTROL(4px) = 可交互控件:按钮/输入/列表行/标签页 +// INNER(6px) = 内嵌容器:侧边栏/面板 +// OUTER(8px) = 悬浮层 + 编辑器主角卡片 +// ============================================================ +pub(crate) const RADIUS_CONTROL: f32 = 4.0; +pub(crate) const RADIUS_INNER: f32 = 6.0; +pub(crate) const RADIUS_OUTER: f32 = 8.0; - Size::new(window_size.width, height) +pub(crate) fn screen_size_from_window(window_size: Size) -> Size { + Size::new(window_size.width, window_size.height) } pub(crate) fn pane_grid_size( diff --git a/otty/src/main.rs b/otty/src/main.rs index 0b7d466..7cd05bd 100644 --- a/otty/src/main.rs +++ b/otty/src/main.rs @@ -35,7 +35,7 @@ fn main() -> iced::Result { .theme(App::theme) .antialiasing(true) .window(window::Settings { - decorations: false, + decorations: true, min_size: Some(Size { width: MIN_WINDOW_WIDTH, height: MIN_WINDOW_HEIGHT, diff --git a/otty/src/style.rs b/otty/src/style.rs index 1a8292e..567a5ec 100644 --- a/otty/src/style.rs +++ b/otty/src/style.rs @@ -1,14 +1,16 @@ use iced::Background; use iced::widget::{container, scrollable}; -use super::theme::{IcedColorPalette, ThemeProps}; +use super::theme::{IcedColorPalette, ThemeProps, mix_color}; +use crate::layout::{RADIUS_CONTROL, RADIUS_OUTER}; pub(crate) fn thin_scroll_style( palette: IcedColorPalette, ) -> impl Fn(&iced::Theme, scrollable::Status) -> scrollable::Style + 'static { move |theme, status| { let mut style = scrollable::default(theme, status); - let radius = iced::border::Radius::from(0.0); + // 滚动条滑块:控件级圆角(VS Code 范式 control=4px) + let radius = iced::border::Radius::from(RADIUS_CONTROL); style.vertical_rail.border.radius = radius; style.vertical_rail.scroller.border.radius = radius; @@ -34,14 +36,11 @@ pub(crate) fn tree_row_style( is_selected: bool, is_hovered: bool, ) -> container::Style { + // 自适应混合(color-mix 等价):选中=玫瑰红~35%(对应 VS Code list.activeSelectionBackground),hover=前景 8% let background = if is_selected { - let mut color = palette.dim_blue; - color.a = 0.7; - Some(color.into()) + Some(mix_color(palette.accent, palette.background, 0.35).into()) } else if is_hovered { - let mut color = palette.overlay; - color.a = 0.6; - Some(color.into()) + Some(mix_color(palette.foreground, palette.background, 0.08).into()) } else { None }; @@ -63,7 +62,8 @@ pub(crate) fn menu_panel_style( border: iced::Border { width: 0.25, color: palette.overlay, - radius: iced::border::Radius::new(4.0), + // 菜单属于悬浮层:外层圆角 8px(VS Code 范式 outer=8px) + radius: iced::border::Radius::new(RADIUS_OUTER), }, ..Default::default() } diff --git a/otty/src/theme.rs b/otty/src/theme.rs index 1e70811..e310759 100644 --- a/otty/src/theme.rs +++ b/otty/src/theme.rs @@ -2,6 +2,17 @@ use iced::theme::Palette; use iced::{Color, Theme}; use otty_ui_term::{ColorPalette as TerminalColorPalette, parse_hex_color}; +/// 自适应混合色:等价 CSS `color-mix(in srgb, fg P%, bg)`。 +/// 用前景色按百分比混合背景色,生成跟随主题的半透明态(hover/选中/高亮)。 +pub(crate) fn mix_color(foreground: Color, background: Color, percent: f32) -> Color { + Color::from_rgba( + foreground.r * percent + background.r * (1.0 - percent), + foreground.g * percent + background.g * (1.0 - percent), + foreground.b * percent + background.b * (1.0 - percent), + background.a, + ) +} + /// Raw string-based color palette used for serialization and settings. #[derive(Debug, Clone)] pub struct ColorPalette { @@ -34,6 +45,12 @@ pub struct ColorPalette { pub dim_white: String, pub dim_foreground: String, pub overlay: String, + /// 侧边栏表面色(对应 VS Code sideBar.background) + pub sidebar: String, + /// 活动栏表面色(对应 VS Code activityBar.background) + pub activity_bar: String, + /// UI 强调色(对应 VS Code statusBar.background / 主色) + pub accent: String, } impl Default for ColorPalette { @@ -68,6 +85,9 @@ impl Default for ColorPalette { dim_cyan: String::from("#326B73"), dim_white: String::from("#6C7385"), overlay: String::from("#232530"), + sidebar: String::from("#232530"), + activity_bar: String::from("#1B1D23"), + accent: String::from("#C45A6D"), } } } @@ -140,6 +160,9 @@ pub struct IcedColorPalette { pub dim_white: Color, pub dim_foreground: Color, pub overlay: Color, + pub sidebar: Color, + pub activity_bar: Color, + pub accent: Color, } impl From<&ColorPalette> for IcedColorPalette { @@ -174,6 +197,9 @@ impl From<&ColorPalette> for IcedColorPalette { dim_white: parse_hex_color(&p.dim_white), dim_foreground: parse_hex_color(&p.dim_foreground), overlay: parse_hex_color(&p.overlay), + sidebar: parse_hex_color(&p.sidebar), + activity_bar: parse_hex_color(&p.activity_bar), + accent: parse_hex_color(&p.accent), } } } @@ -213,7 +239,7 @@ impl From<&AppTheme> for Theme { let palette = Palette { background: palette.background, text: palette.foreground, - primary: palette.blue, + primary: palette.accent, success: palette.green, danger: palette.red, warning: palette.yellow, @@ -312,6 +338,6 @@ mod tests { let theme = Theme::from(&app_theme); - assert_eq!(theme.palette().primary, app_theme.iced_palette().blue); + assert_eq!(theme.palette().primary, app_theme.iced_palette().accent); } } diff --git a/otty/src/view.rs b/otty/src/view.rs index e7b2257..458fc8f 100644 --- a/otty/src/view.rs +++ b/otty/src/view.rs @@ -7,9 +7,9 @@ use crate::components::primitive::{ menu_item, resize_grips, sidebar_workspace_panel, }; use crate::geometry::{anchor_position, menu_height_for_items}; -use crate::layout::BUTTON_SIZE_COMPACT; +use crate::layout::{BUTTON_SIZE_COMPACT, RADIUS_OUTER}; use crate::style::menu_panel_style; -use crate::theme::ThemeProps; +use crate::theme::{ThemeProps, mix_color}; use crate::widgets::chrome::ChromeEvent; use crate::widgets::chrome::view::action_bar; use crate::widgets::explorer::ExplorerEvent; @@ -124,13 +124,8 @@ pub(super) fn view(app: &App) -> Element<'_, AppEvent, Theme, iced::Renderer> { }) }; - let header = view_header(app, theme_props); - let root_layers: Vec> = vec![ - column![header, content_stack] - .width(Length::Fill) - .height(Length::Fill) - .into(), + content_stack.into(), resize_grips_layer, ]; @@ -301,9 +296,27 @@ fn view_tab_area<'a>( let content = view_tab_content(app, theme_props); - column![tab_bar, content] + // VS Code "Editor Border" 范式:编辑器区域 = 主角卡片 + // 1px 边框(前景 12% 混合)+ 8px 圆角 + overflow hidden, + // 把编辑器从周围 chrome(侧边栏/活动栏/面板)中独立出来。 + container(column![tab_bar, content]) .width(Length::Fill) .height(Length::Fill) + .clip(true) + .style(move |_| { + let palette = theme_props.theme.iced_palette(); + let border_color = + mix_color(palette.foreground, palette.background, 0.12); + iced::widget::container::Style { + border: iced::Border { + width: 1.0, + color: border_color, + radius: iced::border::Radius::from(RADIUS_OUTER), + ..Default::default() + }, + ..Default::default() + } + }) .into() } diff --git a/otty/src/widgets/quick_launch/view/sidebar_tree.rs b/otty/src/widgets/quick_launch/view/sidebar_tree.rs index 94bad0b..612f2bd 100644 --- a/otty/src/widgets/quick_launch/view/sidebar_tree.rs +++ b/otty/src/widgets/quick_launch/view/sidebar_tree.rs @@ -41,7 +41,7 @@ pub(crate) fn view( ) -> Element<'_, QuickLaunchIntent, Theme, iced::Renderer> { let palette = props.theme.theme.iced_palette().clone(); let icon_color = palette.foreground; - let highlight_icon_color = palette.blue; + let highlight_icon_color = palette.accent; let error_color = palette.red; let overlay = palette.overlay; let row_palette = palette.clone(); diff --git a/otty/src/widgets/quick_launch/view/wizard_form.rs b/otty/src/widgets/quick_launch/view/wizard_form.rs index f34549d..f7a549b 100644 --- a/otty/src/widgets/quick_launch/view/wizard_form.rs +++ b/otty/src/widgets/quick_launch/view/wizard_form.rs @@ -5,7 +5,7 @@ use iced::{Element, Length, Theme, alignment}; use super::super::event::QuickLaunchIntent; use super::super::state::WizardEditorState; use super::super::types::QuickLaunchType; -use crate::layout::{BUTTON_RADIUS_ROUNDED, BUTTON_SIZE_REGULAR}; +use crate::layout::{BUTTON_SIZE_REGULAR, RADIUS_CONTROL}; use crate::theme::{IcedColorPalette, ThemeProps}; const SECTION_SPACING: f32 = 16.0; @@ -441,7 +441,7 @@ fn button_style( background, text_color, border: iced::Border { - radius: iced::border::Radius::from(BUTTON_RADIUS_ROUNDED), + radius: iced::border::Radius::from(RADIUS_CONTROL), ..Default::default() }, ..Default::default() diff --git a/otty/src/widgets/settings/types.rs b/otty/src/widgets/settings/types.rs index 768947c..38250c5 100644 --- a/otty/src/widgets/settings/types.rs +++ b/otty/src/widgets/settings/types.rs @@ -47,11 +47,24 @@ impl ThemeSettingsData { } } +/// Font-related settings. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct FontSettingsData { + size: f32, +} + +impl Default for FontSettingsData { + fn default() -> Self { + Self { size: 14.0 } + } +} + /// Typed settings payload used for persistence and UI state. #[derive(Debug, Clone, PartialEq, Serialize, Default)] pub(crate) struct SettingsData { terminal: TerminalSettingsData, theme: ThemeSettingsData, + font: FontSettingsData, } impl SettingsData { @@ -103,6 +116,11 @@ impl SettingsData { self.theme.to_color_palette() } + /// Return terminal font size (in points). + pub(crate) fn font_size(&self) -> f32 { + self.font.size + } + /// Parse settings from a raw JSON value, falling back to defaults. pub(crate) fn from_json(value: &serde_json::Value) -> Self { let mut settings = SettingsData::default(); @@ -127,6 +145,12 @@ impl SettingsData { settings.theme.palette = palette; } + if let Some(font) = value.get("font") + && let Some(size) = read_number_field(font, "size") + { + settings.font.size = size; + } + settings } @@ -152,9 +176,17 @@ impl SettingsData { defaults.theme.palette }; + // 字体大小限制在合理范围(6.0 ~ 48.0) + let font_size = if (6.0..=48.0).contains(&self.font.size) { + self.font.size + } else { + defaults.font.size + }; + Self { terminal: TerminalSettingsData { shell, editor }, theme: ThemeSettingsData { palette }, + font: FontSettingsData { size: font_size }, } } } @@ -310,6 +342,13 @@ fn read_string_field(value: &serde_json::Value, key: &str) -> Option { .map(ToString::to_string) } +fn read_number_field(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(serde_json::Value::as_f64) + .map(|value| value as f32) +} + fn read_palette(value: Option<&serde_json::Value>) -> Option> { let palette_value = value?; let entries = palette_value.as_array()?; @@ -341,7 +380,7 @@ fn default_shell() -> String { std::env::var("SHELL").unwrap_or_else(|_| FALLBACK_SHELL.to_string()) } -pub(super) const PALETTE_LABELS: [&str; 29] = [ +pub(super) const PALETTE_LABELS: [&str; 32] = [ "Foreground", "Background", "Black", @@ -371,6 +410,9 @@ pub(super) const PALETTE_LABELS: [&str; 29] = [ "Dim White", "Dim Foreground", "Overlay", + "Sidebar", + "Activity Bar", + "Accent", ]; #[derive(Debug, Clone, Copy)] @@ -404,9 +446,12 @@ enum PaletteField { DimWhite, DimForeground, Overlay, + Sidebar, + ActivityBar, + Accent, } -const PALETTE_FIELDS: [PaletteField; 29] = [ +const PALETTE_FIELDS: [PaletteField; 32] = [ PaletteField::Foreground, PaletteField::Background, PaletteField::Black, @@ -436,6 +481,9 @@ const PALETTE_FIELDS: [PaletteField; 29] = [ PaletteField::DimWhite, PaletteField::DimForeground, PaletteField::Overlay, + PaletteField::Sidebar, + PaletteField::ActivityBar, + PaletteField::Accent, ]; fn palette_value(palette: &ColorPalette, field: PaletteField) -> &str { @@ -469,6 +517,9 @@ fn palette_value(palette: &ColorPalette, field: PaletteField) -> &str { PaletteField::DimWhite => &palette.dim_white, PaletteField::DimForeground => &palette.dim_foreground, PaletteField::Overlay => &palette.overlay, + PaletteField::Sidebar => &palette.sidebar, + PaletteField::ActivityBar => &palette.activity_bar, + PaletteField::Accent => &palette.accent, } } @@ -507,6 +558,9 @@ fn set_palette_value( PaletteField::DimWhite => palette.dim_white = value, PaletteField::DimForeground => palette.dim_foreground = value, PaletteField::Overlay => palette.overlay = value, + PaletteField::Sidebar => palette.sidebar = value, + PaletteField::ActivityBar => palette.activity_bar = value, + PaletteField::Accent => palette.accent = value, } } diff --git a/otty/src/widgets/settings/view/settings_form.rs b/otty/src/widgets/settings/view/settings_form.rs index bbc7582..dfadb36 100644 --- a/otty/src/widgets/settings/view/settings_form.rs +++ b/otty/src/widgets/settings/view/settings_form.rs @@ -13,7 +13,7 @@ use super::super::event::SettingsIntent; use super::super::model::SettingsViewModel; use super::super::services::is_valid_hex_color; use super::super::types::{SettingsNode, SettingsPreset, SettingsSection}; -use crate::layout::{BUTTON_RADIUS_ROUNDED, BUTTON_SIZE_COMPACT}; +use crate::layout::{BUTTON_SIZE_COMPACT, RADIUS_CONTROL}; use crate::style::{thin_scroll_style, tree_row_style}; use crate::theme::{IcedColorPalette, ThemeProps}; use crate::widgets::settings::types::PALETTE_LABELS; @@ -420,7 +420,7 @@ fn button_style( text_color, border: iced::Border { width: 0.0, - radius: iced::border::Radius::from(BUTTON_RADIUS_ROUNDED), + radius: iced::border::Radius::from(RADIUS_CONTROL), ..Default::default() }, ..Default::default() @@ -433,7 +433,7 @@ fn text_input_style( let palette = theme.theme.iced_palette().clone(); move |base: &Theme, status| { let mut style = iced::widget::text_input::default(base, status); - style.selection = palette.blue; + style.selection = palette.accent; style } } @@ -445,7 +445,7 @@ fn pick_list_style( move |_, status| { let border_color = match status { PickListStatus::Hovered | PickListStatus::Opened { .. } => { - palette.blue + palette.accent }, PickListStatus::Active => palette.overlay, }; @@ -458,7 +458,7 @@ fn pick_list_style( border: iced::Border { width: 1.0, color: border_color, - radius: iced::border::Radius::from(BUTTON_RADIUS_ROUNDED), + radius: iced::border::Radius::from(RADIUS_CONTROL), }, } } @@ -473,7 +473,7 @@ fn pick_list_menu_style( border: iced::Border { width: 1.0, color: palette.overlay, - radius: iced::border::Radius::from(BUTTON_RADIUS_ROUNDED), + radius: iced::border::Radius::from(RADIUS_CONTROL), }, text_color: palette.foreground, selected_text_color: palette.dim_black, diff --git a/otty/src/widgets/sidebar/view/mod.rs b/otty/src/widgets/sidebar/view/mod.rs index a682e39..efaa652 100644 --- a/otty/src/widgets/sidebar/view/mod.rs +++ b/otty/src/widgets/sidebar/view/mod.rs @@ -6,7 +6,7 @@ use super::event::SidebarIntent; use super::model::SidebarViewModel; use super::types::SidebarItem; use crate::icons; -use crate::layout::{BUTTON_RADIUS_ROUNDED, BUTTON_SIZE_RAIL}; +use crate::layout::{BUTTON_SIZE_RAIL, RADIUS_CONTROL}; use crate::theme::ThemeProps; const MENU_BUTTON_SIZE: f32 = BUTTON_SIZE_RAIL; @@ -94,7 +94,8 @@ pub(crate) fn view( .width(Length::Fixed(SIDEBAR_MENU_WIDTH)) .height(Length::Fill) .style(move |_| iced::widget::container::Style { - background: Some(palette.dim_black.into()), + // 活动栏表面色(VS Code activityBar.background) + background: Some(palette.activity_bar.into()), ..Default::default() }) .into() @@ -108,8 +109,8 @@ fn sidebar_button<'a>( ) -> Element<'a, SidebarIntent, Theme, iced::Renderer> { let palette = theme.theme.iced_palette(); let base_color = palette.dim_foreground; - let hover_color = palette.blue; - let active_color = palette.blue; + let hover_color = palette.accent; + let active_color = palette.accent; let icon_svg = svg::Svg::new(svg::Handle::from_memory(icon)) .width(Length::Fixed(MENU_ICON_SIZE)) @@ -134,7 +135,7 @@ fn sidebar_button<'a>( .padding(MENU_BUTTON_PADDING); let border_color = if is_active { - palette.blue + palette.accent } else { iced::Color::TRANSPARENT }; @@ -161,7 +162,7 @@ fn sidebar_button<'a>( .style(|_, _| iced::widget::button::Style { background: None, border: Border { - radius: iced::border::Radius::from(BUTTON_RADIUS_ROUNDED), + radius: iced::border::Radius::from(RADIUS_CONTROL), ..Default::default() }, ..Default::default() diff --git a/otty/src/widgets/tabs/view/tab_bar.rs b/otty/src/widgets/tabs/view/tab_bar.rs index 40faeb3..252bb63 100644 --- a/otty/src/widgets/tabs/view/tab_bar.rs +++ b/otty/src/widgets/tabs/view/tab_bar.rs @@ -7,8 +7,8 @@ use iced::{Alignment, Element, Length, alignment}; use super::super::event::TabsIntent; use crate::icons; -use crate::layout::BUTTON_SIZE_COMPACT; -use crate::theme::{StyleOverrides, ThemeProps}; +use crate::layout::{BUTTON_SIZE_COMPACT, RADIUS_CONTROL}; +use crate::theme::{StyleOverrides, ThemeProps, mix_color}; pub(crate) const TAB_BAR_HEIGHT: f32 = BUTTON_SIZE_COMPACT; pub(crate) const TAB_BAR_SCROLL_ID: &str = "tab_bar_scroll"; @@ -62,7 +62,8 @@ pub(crate) fn view<'a>(props: TabBarProps<'a>) -> Element<'a, TabsIntent> { .height(Length::Fixed(TAB_BAR_HEIGHT)) .width(Length::Fill) .style(move |_| iced::widget::container::Style { - background: Some(palette.dim_black.into()), + // Modern UI:标签栏透明,仅药丸标签可见 + background: Some(iced::Color::TRANSPARENT.into()), text_color: None, ..Default::default() }) @@ -146,9 +147,19 @@ fn tab_button<'a>( let overrides = theme_props.overrides; move |_| { if is_active { - tab_button_style(background, foreground, overrides) + // Modern UI 药丸:前景色 16% 混合背景(color-mix 等价) + tab_button_style( + mix_color(foreground, background, 0.16), + foreground, + overrides, + ) } else { - tab_button_style(dim_black, dim_foreground, overrides) + // 未激活:完全透明,仅悬停/激活才显示高亮 + tab_button_style( + iced::Color::TRANSPARENT, + dim_foreground, + overrides, + ) } } }); @@ -169,6 +180,11 @@ fn tab_button_style( let mut style = iced::widget::container::Style { background: Some(background.into()), text_color: Some(foreground), + // Modern UI 标签:控件级圆角 4px + border: iced::Border { + radius: iced::border::Radius::from(RADIUS_CONTROL), + ..Default::default() + }, ..Default::default() };