From 27948998cbb35ba1c651c2ce4473ac79dfdfd5c6 Mon Sep 17 00:00:00 2001 From: SHASHANK Date: Sat, 26 Sep 2026 14:58:05 +0000 Subject: [PATCH] Apple-style glass UI: blurred translucent material, SF Pro, macOS controls Glass: - The launcher paints one macOS window material (graphite in dark mode, near-white in light) with a light rim and a specular top-edge sheen. Every section inside is transparent, so the material is drawn once. - When the compositor blurs (KWin announces _KDE_NET_WM_BLUR_BEHIND_REGION on the root window), the material is translucent and RustCast asks for blur behind a region clipped to the rounded corners. The compositor blurs on the GPU; RustCast does no blurring itself. - New `theme.glass` setting (Automatic / Always / Off) in Appearance. Always suits picom with blur enabled; otherwise the material is opaque. - The window clears to fully transparent. It used the theme background at alpha 0, which a premultiplied-alpha compositor still adds, so light themes cleared to solid white with square corners. - winit's whole-window blur is off (it blurred the square corners). macOS look: - Spotlight-style field: 22pt SF Pro Display on the bare material. - Rows: 14/11pt labels, inset accent (systemBlue) selection with white text, 30px icons, systemPink favourite heart; long paths are clipped. - Semantic label/fill/separator tokens shared by the clipboard and recorder component kit, which now draws translucent overlays. - Settings: segmented tabs, accent checkboxes/radios, focus rings on fields, default Save button, macOS slider and push buttons. Fonts: - With no font configured, use SF Pro (Text, Display for large titles) when installed, then Inter, then the desktop sans-serif. - Theme::font() leaked the family name on every call (every text widget, every frame); names are now interned once. Sizing: one RESULT_ROW_HEIGHT drives both the rows and the window height, so the list fills the window exactly instead of clipping or leaving gaps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HrArrtzFMPZjqAZ2TgqAsH --- src/app.rs | 24 +- src/app/apps.rs | 69 +++-- src/app/pages/emoji.rs | 41 ++- src/app/pages/settings.rs | 178 +++++++----- src/app/pages/ui.rs | 81 +++--- src/app/tile.rs | 11 + src/app/tile/elm.rs | 190 +++++++------ src/app/tile/update.rs | 40 +-- src/config.rs | 64 ++++- src/fonts.rs | 108 ++++++++ src/main.rs | 2 + src/platform/linux/mod.rs | 10 + src/platform/linux/x11.rs | 101 ++++++- src/platform/mod.rs | 10 + src/styles.rs | 553 +++++++++++++++++++++++++------------- 15 files changed, 1013 insertions(+), 469 deletions(-) create mode 100644 src/fonts.rs diff --git a/src/app.rs b/src/app.rs index 8fec187..c0bc32a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use crate::app::apps::{App, AppCommand, ICNS_ICON}; use crate::commands::Function; -use crate::config::{Config, MainPage, Shelly, ThemeMode}; +use crate::config::{Config, GlassMode, MainPage, Shelly, ThemeMode}; use crate::debounce::DebouncePolicy; use crate::platform::launching::Shortcut; use crate::utils::icns_data_to_handle; @@ -24,6 +24,21 @@ pub const WINDOW_WIDTH: f32 = 500.; /// The default window height pub const DEFAULT_WINDOW_HEIGHT: f32 = 100.; +/// Height of one search result row, selection inset included. +pub const RESULT_ROW_HEIGHT: f32 = 52.; + +/// Space above and below the results list. +pub const RESULTS_LIST_PADDING: f32 = 6.; + +/// Launcher height showing `rows` result rows under the search field. +pub const fn results_window_height(rows: usize) -> f32 { + if rows == 0 { + DEFAULT_WINDOW_HEIGHT + } else { + DEFAULT_WINDOW_HEIGHT + rows as f32 * RESULT_ROW_HEIGHT + 2. * RESULTS_LIST_PADDING + } +} + /// The clipboard history page gets a big, purpose-built window. pub const CLIPBOARD_WIDTH: f32 = 860.; pub const CLIPBOARD_HEIGHT: f32 = 600.; @@ -210,6 +225,7 @@ pub enum ResetField { TextColor, BackgroundColor, ThemeMode, + Glass, Aliases, Modes, SearchDirs, @@ -369,6 +385,7 @@ pub enum SetConfigThemeFields { ShowIcons(bool), Font(String), ThemeMode(ThemeMode), + Glass(GlassMode), } #[derive(Debug, Clone)] @@ -385,7 +402,10 @@ pub fn default_settings() -> Settings { minimizable: false, level: window::Level::AlwaysOnTop, transparent: true, - blur: true, + // winit's blur asks for the whole window rectangle, which shows + // blurred square corners. The rounded region is requested per resize + // instead, and only when the glass is translucent. + blur: false, // Map centred on the active monitor so the window never flashes at the // window manager's default spot; `position_launcher` then nudges it up // to the Spotlight/Raycast upper-third anchor once it is mapped. diff --git a/src/app/apps.rs b/src/app/apps.rs index 6337575..afda809 100644 --- a/src/app/apps.rs +++ b/src/app/apps.rs @@ -5,8 +5,8 @@ use std::io::Cursor; use iced::{ - Alignment, - Length::{self, Fill}, + Alignment, Color, + Length::Fill, widget::{ Button, Row, Text, container, image::{Handle, Viewer}, @@ -18,7 +18,10 @@ use crate::{ app::{Message, Page, RUSTCAST_DESC_NAME}, clipboard::ClipBoardContentType, commands::Function, - styles::{favourite_button_style, result_button_style, result_row_container_style}, + styles::{ + PRIMARY, SECONDARY, favourite_button_style, label, result_button_style, + result_row_container_style, + }, utils::icns_data_to_handle, }; @@ -250,50 +253,59 @@ impl App { on_press: Option, ) -> iced::Element<'static, Message> { let focused = focussed_id == id_num; + // On the accent-filled selection, labels turn white like macOS lists. + let (title_color, subtitle_color) = if focused { + (Color::WHITE, Color::from_rgba(1.0, 1.0, 1.0, 0.78)) + } else { + (label(&theme, PRIMARY), label(&theme, SECONDARY)) + }; - // Title + subtitle (Raycast style) + // Title + subtitle (Spotlight style: 14pt title, 11pt subtitle) let text_block = iced::widget::Column::new() - .spacing(2) + .spacing(1) .push( Text::new(self.display_name) .font(theme.font()) - .size(16) + .size(14) .wrapping(Wrapping::None) - .color(theme.text_color(1.0)), + .color(title_color), ) .push( Text::new(self.desc) .font(theme.font()) - .size(13) + .size(11) .wrapping(Wrapping::None) - .color(theme.text_color(0.55)), + .color(subtitle_color), ); let mut row = Row::new() .align_y(Alignment::Center) .width(Fill) .spacing(10) - .height(50); + .padding([0, 10]) + .height(Fill); if theme.show_icons && let Some(icon) = &self.icons { row = row.push( - container(Viewer::new(icon).height(40).width(40)) - .width(40) - .height(40), + container(Viewer::new(icon).height(30).width(30)) + .width(30) + .height(30), ); } - row = row.push(container(text_block).width(Fill)); + row = row.push(container(text_block).width(Fill).clip(true)); let name = self.search_name.clone(); let theme_clone = theme.clone(); let is_favourite = self.ranking == -1; row = row.push( - Button::new(Text::new("♥️").width(Length::Fill).align_x(Alignment::End)) + Button::new(Text::new("♥").size(13)) .on_press_with(move || Message::ToggleFavouriteApp(name.clone())) - .width(Length::Fill) - .style(move |_, status| favourite_button_style(&theme_clone, status, is_favourite)), + .padding([4, 6]) + .style(move |_, status| { + favourite_button_style(&theme_clone, status, is_favourite, focused) + }), ); let msg = on_press.or(match self.open_command.clone() { @@ -308,14 +320,21 @@ impl App { .on_press_maybe(msg) .style(move |_, _| result_button_style(&theme_clone)) .width(Fill) - .padding(0) - .height(50); + .height(Fill) + .padding(0); - container(content) - .id(format!("result-{}", id_num)) - .style(move |_| result_row_container_style(&theme, focused)) - .padding(8) - .width(Fill) - .into() + // The selection pill is inset from the window edge; the outer + // container gives the row its fixed height. + container( + container(content) + .style(move |_| result_row_container_style(&theme, focused)) + .width(Fill) + .height(Fill), + ) + .id(format!("result-{}", id_num)) + .padding([2, 6]) + .width(Fill) + .height(crate::app::RESULT_ROW_HEIGHT) + .into() } } diff --git a/src/app/pages/emoji.rs b/src/app/pages/emoji.rs index 797b854..e9a28c4 100644 --- a/src/app/pages/emoji.rs +++ b/src/app/pages/emoji.rs @@ -5,7 +5,7 @@ use crate::{ app::pages::prelude::*, clipboard::ClipBoardContentType, commands::Function, - styles::{glass_border, glass_surface, with_alpha}, + styles::{PRIMARY, label, rim, window_fill, with_alpha}, }; /// The emoji pages element to render @@ -55,7 +55,7 @@ pub fn emoji_page( .on_press(Message::RunFunction(Function::CopyToClipboard( ClipBoardContentType::Text(emoji.display_name), ))) - .style(move |_, _| emoji_button_style(&value)), + .style(move |_, status| emoji_button_style(&value, status)), ) .width(70) .height(70) @@ -64,11 +64,23 @@ pub fn emoji_page( container( Text::new(emoji.desc) .font(tile_theme.font()) - .size(20) - .color(tile_theme.text_color(0.7)), + .size(12) + .color(label(&tile_theme, PRIMARY)), ) + .padding([4, 8]) .style(move |_| container::Style { - background: Some(Background::Color(value_two.bg_color())), + // Tooltips are solid so they read over the emoji grid. + background: Some(Background::Color(with_alpha(window_fill(&value_two), 1.0))), + border: Border { + color: rim(&value_two), + width: 1.0, + radius: Radius::new(7.0), + }, + shadow: iced::Shadow { + color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.25), + offset: iced::Vector::new(0.0, 3.0), + blur_radius: 10.0, + }, ..Default::default() }), tooltip::Position::Top, @@ -80,27 +92,8 @@ pub fn emoji_page( column.push(container(emoji_row_element).center_y(70).into()); } - let tile_theme_clone = tile_theme.clone(); container(Column::from_vec(column).spacing(10)) .padding(10) - .style(move |_| container::Style { - background: Some(Background::Color(glass_surface( - tile_theme_clone.bg_color(), - false, - ))), - text_color: None, - border: Border { - color: glass_border(tile_theme_clone.text_color(1.0), false), - width: 0.5, - radius: Radius::new(14.0).top(0), - }, - shadow: iced::Shadow { - color: with_alpha(iced::Color::TRANSPARENT, 0.), - offset: iced::Vector::new(0.0, 10.0), - blur_radius: 28.0, - }, - snap: false, - }) .center_x(WINDOW_WIDTH) .into() } diff --git a/src/app/pages/settings.rs b/src/app/pages/settings.rs index b41c055..02fdc3b 100644 --- a/src/app/pages/settings.rs +++ b/src/app/pages/settings.rs @@ -12,8 +12,7 @@ use iced::widget::checkbox; use iced::widget::radio; use iced::widget::text_input; -use crate::styles::tint; -use crate::styles::with_alpha; +use crate::styles; use crate::app::Editable; use crate::app::FileDialogAction; @@ -24,6 +23,7 @@ use crate::app::SetConfigRecorderFields; use crate::app::SetConfigThemeFields; use crate::app::SettingsTab; use crate::commands::Function; +use crate::config::GlassMode; use crate::config::MainPage; use crate::config::Shelly; use crate::config::ThemeMode; @@ -31,6 +31,7 @@ use crate::styles::delete_button_style; use crate::styles::settings_add_button_style; use crate::styles::settings_checkbox_style; use crate::styles::settings_container_style; +use crate::styles::settings_primary_button_style; use crate::styles::settings_radio_button_style; use crate::styles::settings_save_button_style; use crate::styles::settings_slider_style; @@ -72,6 +73,21 @@ pub fn settings_page(config: Config, settings_tab: SettingsTab) -> Element<'stat ]) .spacing(2) .width(Length::Fill); + let track_theme = theme.clone(); + let tabs_row = container(tabs_row) + .padding(2) + .style(move |_| container::Style { + background: Some(Background::Color(styles::fill( + &track_theme, + styles::QUATERNARY_FILL, + ))), + border: Border { + color: styles::separator(&track_theme), + width: 0.5, + radius: Radius::new(9.), + }, + ..Default::default() + }); let tab_content: Column<'static, Message> = match settings_tab { SettingsTab::General => general_tab(config.clone(), theme.clone()), @@ -127,24 +143,26 @@ fn tab_button( fn reset_button(theme: crate::config::Theme, field: ResetField) -> Element<'static, Message> { let theme_clone = theme.clone(); Button::new( - Text::new("R") + Text::new("↺") .align_x(Alignment::Center) .align_y(Alignment::Center) - .size(13) + .size(14) .font(theme.font()), ) - .style(move |_, _| button::Style { - text_color: theme_clone.text_color(0.5), - background: Some(Background::Color(with_alpha( - tint(theme_clone.bg_color(), 0.06), - 0.20, - ))), - border: Border { - color: theme_clone.text_color(0.15), - width: 0.5, - radius: Radius::new(4), - }, - ..Default::default() + .style(move |_, status| { + let level = match status { + button::Status::Hovered | button::Status::Pressed => styles::TERTIARY_FILL, + _ => styles::QUATERNARY_FILL, + }; + button::Style { + text_color: styles::label(&theme_clone, styles::SECONDARY), + background: Some(Background::Color(styles::fill(&theme_clone, level))), + border: Border { + radius: Radius::new(6), + ..Border::default() + }, + ..Default::default() + } }) .width(30) .height(26) @@ -161,7 +179,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat .on_input(|input| Message::SetConfig(SetConfigFields::ToggleHotkey(input.clone()))) .on_submit(Message::WriteConfig(false)) .width(Length::Fill) - .style(move |_, _| settings_text_input_item_style(&theme_clone)) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) .into(), notice_item(theme.clone(), "Use \"+\" as a seperator"), ]), @@ -179,7 +197,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat }) .on_submit(Message::WriteConfig(false)) .width(Length::Fill) - .style(move |_, _| settings_text_input_item_style(&theme_clone)) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) .into(), notice_item(theme.clone(), "Use \"+\" as a seperator"), ]), @@ -195,7 +213,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat .on_input(|input| Message::SetConfig(SetConfigFields::PlaceHolder(input.clone()))) .on_submit(Message::WriteConfig(false)) .width(Length::Fill) - .style(move |_, _| settings_text_input_item_style(&theme_clone)) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) .into(), notice_item(theme.clone(), "What the text box shows when its empty"), ]), @@ -211,7 +229,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat .on_input(|input| Message::SetConfig(SetConfigFields::SearchUrl(input.clone()))) .on_submit(Message::WriteConfig(false)) .width(Length::Fill) - .style(move |_, _| settings_text_input_item_style(&theme_clone)) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) .into(), notice_item(theme.clone(), "Which search engine to use (%s = query)"), ]), @@ -231,7 +249,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat }) .on_submit(Message::WriteConfig(false)) .width(Length::Fill) - .style(move |_, _| settings_text_input_item_style(&theme_clone)) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) .into(), notice_item( theme.clone(), @@ -247,7 +265,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat settings_item_row([ settings_hint_text(theme.clone(), "Start at login"), checkbox(config.clone().start_at_login) - .style(move |_, _| settings_checkbox_style(&theme_clone)) + .style(move |_, status| settings_checkbox_style(&theme_clone, status)) .on_toggle(Message::ToggleAutoStartup) .into(), notice_item(theme.clone(), "If you want rustcast to start on login"), @@ -261,7 +279,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat settings_item_row([ settings_hint_text(theme.clone(), "Show menubar icon"), checkbox(config.clone().show_trayicon) - .style(move |_, _| settings_checkbox_style(&theme_clone)) + .style(move |_, status| settings_checkbox_style(&theme_clone, status)) .on_toggle(|input| Message::SetConfig(SetConfigFields::ShowMenubarIcon(input))) .into(), notice_item( @@ -278,7 +296,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat Row::from_iter([ settings_hint_text(theme.clone(), "Enable Clipboard history"), checkbox(config.clone().cbhist) - .style(move |_, _| settings_checkbox_style(&theme_clone)) + .style(move |_, status| settings_checkbox_style(&theme_clone, status)) .on_toggle(|input| Message::SetConfig(SetConfigFields::ClipboardHistory(input))) .into(), notice_item( @@ -299,7 +317,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat Row::from_iter([ settings_hint_text(theme.clone(), "Paste on select"), checkbox(config.clone().cbhist_paste_on_select) - .style(move |_, _| settings_checkbox_style(&theme_clone)) + .style(move |_, status| settings_checkbox_style(&theme_clone, status)) .on_toggle(|input| { Message::SetConfig(SetConfigFields::ClipboardPasteOnSelect(input)) }) @@ -327,7 +345,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat ) .style({ let theme_clone = theme_clone.clone(); - move |_, _| settings_radio_button_style(&theme_clone.clone()) + move |_, status| settings_radio_button_style(&theme_clone, status) }) .into(), radio( @@ -338,7 +356,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat ) .style({ let theme_clone = theme_clone.clone(); - move |_, _| settings_radio_button_style(&theme_clone.clone()) + move |_, status| settings_radio_button_style(&theme_clone, status) }) .into(), radio("Events", MainPage::Events, Some(config.main_page), |page| { @@ -346,13 +364,13 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat }) .style({ let theme_clone = theme_clone.clone(); - move |_, _| settings_radio_button_style(&theme_clone.clone()) + move |_, status| settings_radio_button_style(&theme_clone, status) }) .into(), radio("Nothing", MainPage::Blank, Some(config.main_page), |page| { Message::SetConfig(SetConfigFields::SetPage(page)) }) - .style(move |_, _| settings_radio_button_style(&theme_clone.clone())) + .style(move |_, status| settings_radio_button_style(&theme_clone, status)) .into(), ]) .spacing(30) @@ -396,7 +414,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s ) .style({ let theme_clone = theme_clone.clone(); - move |_, _| settings_radio_button_style(&theme_clone.clone()) + move |_, status| settings_radio_button_style(&theme_clone, status) }) .into(), radio( @@ -411,7 +429,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s ) .style({ let theme_clone = theme_clone.clone(); - move |_, _| settings_radio_button_style(&theme_clone.clone()) + move |_, status| settings_radio_button_style(&theme_clone, status) }) .into(), radio( @@ -424,7 +442,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s )) }, ) - .style(move |_, _| settings_radio_button_style(&theme_clone.clone())) + .style(move |_, status| settings_radio_button_style(&theme_clone, status)) .into(), ]) .spacing(30) @@ -438,12 +456,42 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s theme.clone(), ); + let glass_radio = |label: &'static str, mode: GlassMode| -> Element<'static, Message> { + let theme_clone = theme.clone(); + radio(label, mode, Some(config.theme.glass), |mode| { + Message::SetConfig(SetConfigFields::SetThemeFields( + SetConfigThemeFields::Glass(mode), + )) + }) + .style(move |_, status| settings_radio_button_style(&theme_clone, status)) + .into() + }; + let glass_setting = settings_row_with_reset( + settings_item_column([ + settings_hint_text(theme.clone(), "Glass"), + settings_item_row([ + glass_radio("Automatic", GlassMode::Auto), + glass_radio("Always", GlassMode::On), + glass_radio("Off", GlassMode::Off), + ]) + .spacing(30) + .into(), + notice_item( + theme.clone(), + "Automatic uses translucent glass when the compositor blurs (KDE); \ + Always suits picom with blur enabled", + ), + ]), + ResetField::Glass, + theme.clone(), + ); + let theme_clone = theme.clone(); let show_scrollbar = settings_row_with_reset( settings_item_row([ settings_hint_text(theme.clone(), "Show scrollbar"), checkbox(config.theme.show_scroll_bar) - .style(move |_, _| settings_checkbox_style(&theme_clone)) + .style(move |_, status| settings_checkbox_style(&theme_clone, status)) .on_toggle(|input| { Message::SetConfig(SetConfigFields::SetThemeFields( SetConfigThemeFields::ShowScrollBar(input), @@ -461,7 +509,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s settings_item_row([ settings_hint_text(theme.clone(), "Clear on hide"), checkbox(config.clone().buffer_rules.clear_on_hide) - .style(move |_, _| settings_checkbox_style(&theme_clone)) + .style(move |_, status| settings_checkbox_style(&theme_clone, status)) .on_toggle(move |input| { Message::SetConfig(SetConfigFields::SetBufferFields( SetConfigBufferFields::ClearOnHide(input), @@ -482,7 +530,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s settings_item_row([ settings_hint_text(theme.clone(), "Clear on enter"), checkbox(config.clone().buffer_rules.clear_on_enter) - .style(move |_, _| settings_checkbox_style(&theme_clone)) + .style(move |_, status| settings_checkbox_style(&theme_clone, status)) .on_toggle(move |input| { Message::SetConfig(SetConfigFields::SetBufferFields( SetConfigBufferFields::ClearOnEnter(input), @@ -503,7 +551,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s settings_item_row([ settings_hint_text(theme.clone(), "Show icons"), checkbox(config.clone().theme.show_icons) - .style(move |_, _| settings_checkbox_style(&theme_clone)) + .style(move |_, status| settings_checkbox_style(&theme_clone, status)) .on_toggle(move |input| { Message::SetConfig(SetConfigFields::SetThemeFields( SetConfigThemeFields::ShowIcons(input), @@ -531,7 +579,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s }) .on_submit(Message::WriteConfig(false)) .width(Length::Fill) - .style(move |_, _| settings_text_input_item_style(&theme_clone)) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) .into(), notice_item(theme.clone(), "What font rustcast should use"), ]), @@ -549,7 +597,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s }) .on_submit(Message::WriteConfig(false)) .width(Length::Fill) - .style(move |_, _| settings_text_input_item_style(&theme_clone)) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) .into(), notice_item( theme.clone(), @@ -583,7 +631,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s )) }, ) - .style(move |_, _| settings_slider_style(&theme_clone_1)) + .style(move |_, status| settings_slider_style(&theme_clone_1, status)) .width((WINDOW_WIDTH / 5.) * 4.) .into(), settings_hint_text( @@ -601,7 +649,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s )) }, ) - .style(move |_, _| settings_slider_style(&theme_clone_2)) + .style(move |_, status| settings_slider_style(&theme_clone_2, status)) .width((WINDOW_WIDTH / 5.) * 4.) .into(), settings_hint_text( @@ -619,7 +667,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s )) }, ) - .style(move |_, _| settings_slider_style(&theme_clone_3)) + .style(move |_, status| settings_slider_style(&theme_clone_3, status)) .width((WINDOW_WIDTH / 5.) * 4.) .into(), notice_item(theme.clone(), "Text colour in RGB format"), @@ -656,7 +704,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s )) }, ) - .style(move |_, _| settings_slider_style(&theme_clone_1)) + .style(move |_, status| settings_slider_style(&theme_clone_1, status)) .width((WINDOW_WIDTH / 5.) * 4.) .into(), settings_hint_text( @@ -674,7 +722,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s )) }, ) - .style(move |_, _| settings_slider_style(&theme_clone_2)) + .style(move |_, status| settings_slider_style(&theme_clone_2, status)) .width((WINDOW_WIDTH / 5.) * 4.) .into(), settings_hint_text( @@ -692,7 +740,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s )) }, ) - .style(move |_, _| settings_slider_style(&theme_clone_3)) + .style(move |_, status| settings_slider_style(&theme_clone_3, status)) .width((WINDOW_WIDTH / 5.) * 4.) .into(), notice_item(theme.clone(), "Background colour in RGB format"), @@ -708,6 +756,7 @@ fn appearance_tab(config: Box, theme: crate::config::Theme) -> Column<'s Column::from_iter([ theme_mode_setting, + glass_setting, show_scrollbar, clear_on_hide, clear_on_enter, @@ -736,7 +785,7 @@ fn recorder_text_setting( .on_input(on_input) .on_submit(Message::WriteConfig(false)) .width(Length::Fill) - .style(move |_, _| settings_text_input_item_style(&theme_clone)) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) .into(), notice_item(theme.clone(), notice), ]), @@ -757,7 +806,7 @@ fn recorder_checkbox_setting( settings_item_row([ settings_hint_text(theme.clone(), title), checkbox(opt.get(&config.recorder)) - .style(move |_, _| settings_checkbox_style(&theme_clone)) + .style(move |_, status| settings_checkbox_style(&theme_clone, status)) .on_toggle(move |value| { Message::SetConfig(SetConfigFields::SetRecorderFields( SetConfigRecorderFields::Option(opt, value), @@ -817,7 +866,7 @@ fn recorder_tab(config: Box, theme: crate::config::Theme) -> Column<'sta }) .on_submit(Message::WriteConfig(false)) .width(Length::Fill) - .style(move |_, _| settings_text_input_item_style(&theme_clone)) + .style(move |_, status| settings_text_input_item_style(&theme_clone, status)) .into(), Text::new("×") .font(theme.font()) @@ -833,7 +882,7 @@ fn recorder_tab(config: Box, theme: crate::config::Theme) -> Column<'sta }) .on_submit(Message::WriteConfig(false)) .width(Length::Fill) - .style(move |_, _| settings_text_input_item_style(&theme_clone_2)) + .style(move |_, status| settings_text_input_item_style(&theme_clone_2, status)) .into(), ]) .spacing(8) @@ -954,7 +1003,7 @@ fn savebutton(theme: Theme) -> Element<'static, Message> { .width(Length::Fill) .font(theme.font()), ) - .style(move |_, _| settings_save_button_style(&theme)) + .style(move |_, status| settings_primary_button_style(&theme, status)) .width(Length::Fill) .on_press(Message::WriteConfig(true)) .into() @@ -967,7 +1016,7 @@ fn wiki_button(theme: Theme) -> Element<'static, Message> { .width(Length::Fill) .font(theme.font()), ) - .style(move |_, _| settings_save_button_style(&theme)) + .style(move |_, status| settings_save_button_style(&theme, status)) .width(Length::Fill) .on_press(Message::RunFunction(crate::commands::Function::OpenApp( std::env::var("HOME").unwrap_or("".to_string()) + "/.config/rustcast/config.toml", @@ -983,7 +1032,7 @@ fn copy_config_button(config: Box) -> Element<'static, Message> { .width(Length::Fill) .font(theme.font()), ) - .style(move |_, _| settings_save_button_style(&theme)) + .style(move |_, status| settings_save_button_style(&theme, status)) .width(Length::Fill) .on_press(Message::RunFunction(Function::CopyToClipboard( crate::clipboard::ClipBoardContentType::Text( @@ -998,7 +1047,8 @@ fn settings_hint_text(theme: Theme, text: impl ToString) -> Element<'static, Mes Text::new(text) .font(theme.font()) - .color(theme.text_color(0.7)) + .size(13) + .color(styles::label(&theme, styles::PRIMARY)) .into() } @@ -1023,8 +1073,8 @@ fn settings_item_row( fn notice_item(theme: Theme, notice: impl ToString) -> Element<'static, Message> { Text::new(notice.to_string()) .font(theme.font()) - .color(theme.text_color(0.7)) - .size(10) + .color(styles::label(&theme, styles::SECONDARY)) + .size(11) .width(Length::Fill) .align_x(Alignment::End) .into() @@ -1066,7 +1116,7 @@ fn aliases_item(aliases: HashMap, theme: &Theme) -> Element<'sta .on_press(Message::SetConfig(SetConfigFields::Aliases( Editable::Delete((key.clone(), value.clone())), ))) - .style(move |_, _| delete_button_style(&theme_clone_2)) + .style(move |_, status| delete_button_style(&theme_clone_2, status)) .into(), ]) .spacing(10) @@ -1082,7 +1132,7 @@ fn aliases_item(aliases: HashMap, theme: &Theme) -> Element<'sta .align_x(Alignment::Center) .align_y(Alignment::Center), ) - .style(move |_, _| settings_add_button_style(&theme_clone.clone())) + .style(move |_, status| settings_add_button_style(&theme_clone, status)) .on_press(Message::SetConfig(SetConfigFields::Aliases( Editable::Create((String::new(), String::new())), ))) @@ -1109,7 +1159,7 @@ fn search_dirs_item(theme: &Theme, search_dirs: Vec) -> Element<'static, .on_press(Message::SetConfig(SetConfigFields::SearchDirs( Editable::Delete(dir.clone()), ))) - .style(move |_, _| delete_button_style(&theme_clone_2)) + .style(move |_, status| delete_button_style(&theme_clone_2, status)) .into(), ]) .spacing(10) @@ -1174,13 +1224,13 @@ fn modes_item(modes: HashMap, theme: &Theme) -> Element<'static, .on_press(Message::OpenFileDialog(FileDialogAction::PickModeFile( key.to_owned(), ))) - .style(move |_, _| settings_add_button_style(&theme_clone_1.clone())) + .style(move |_, status| settings_add_button_style(&theme_clone_1, status)) .into(), Button::new("Delete") .on_press(Message::SetConfig(SetConfigFields::Modes( Editable::Delete((key.clone(), value.clone())), ))) - .style(move |_, _| delete_button_style(&theme_clone_2)) + .style(move |_, status| delete_button_style(&theme_clone_2, status)) .into(), ]) .spacing(10) @@ -1199,7 +1249,7 @@ fn modes_item(modes: HashMap, theme: &Theme) -> Element<'static, .on_press(Message::SetConfig(SetConfigFields::Modes( Editable::Create((String::new(), String::new())), ))) - .style(move |_, _| settings_add_button_style(&theme_clone.clone())) + .style(move |_, status| settings_add_button_style(&theme_clone, status)) .into(), ]) .spacing(10) @@ -1214,13 +1264,13 @@ fn dir_picker_button(directory: String, dir: &str, theme: Theme) -> Button<'stat .on_press(Message::OpenFileDialog(FileDialogAction::EditSearchDir( directory.clone(), ))) - .style(move |_, _| settings_add_button_style(&theme.clone())) + .style(move |_, status| settings_add_button_style(&theme, status)) } fn dir_adder_button(dir: &str, theme: Theme) -> Button<'static, Message> { Button::new(Text::new(dir.to_owned())) .on_press(Message::OpenFileDialog(FileDialogAction::AddSearchDir)) - .style(move |_, _| settings_add_button_style(&theme.clone())) + .style(move |_, status| settings_add_button_style(&theme, status)) } fn shell_commands_item(shells: Vec, theme: Theme) -> Element<'static, Message> { @@ -1236,7 +1286,7 @@ fn shell_commands_item(shells: Vec, theme: Theme) -> Element<'static, Me .align_x(Alignment::Center) .align_y(Alignment::Center), ) - .style(move |_, _| settings_add_button_style(&theme_clone.clone())) + .style(move |_, status| settings_add_button_style(&theme_clone, status)) .on_press(Message::SetConfig(SetConfigFields::ShellCommands( Editable::Create(Shelly::default()), ))), @@ -1356,7 +1406,7 @@ impl Shelly { ))) .style({ let theme = theme.clone(); - move |_, _| delete_button_style(&theme) + move |_, status| delete_button_style(&theme, status) }) .into(), notice_item(theme.clone(), "Icon path and hotkey are optional"), diff --git a/src/app/pages/ui.rs b/src/app/pages/ui.rs index 9251bea..04aa997 100644 --- a/src/app/pages/ui.rs +++ b/src/app/pages/ui.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use iced::animation::Easing; use iced::border::Radius; -use iced::font::{Family, Weight}; +use iced::font::Weight; use iced::widget::{Space, button, container, text}; use iced::{Background, Border, Color, Element, Font, Length, Shadow, Vector}; @@ -18,28 +18,21 @@ use crate::app::Message; use crate::config::Theme; use crate::styles::with_alpha; -/// The accent used for selection and primary actions (#2f6fec). +pub use crate::styles::{faded, mix}; + +/// The accent used for selection and primary actions: macOS systemBlue. pub fn accent(a: f32) -> Color { - Color::from_rgba(0.184, 0.435, 0.925, a) + Color::from_rgba(0.039, 0.518, 1.0, a) } -/// The recording red. +/// The recording red (systemRed). pub fn red(a: f32) -> Color { - Color::from_rgba(0.95, 0.26, 0.21, a) + Color::from_rgba(1.0, 0.271, 0.227, a) } +/// systemGreen. pub fn green(a: f32) -> Color { - Color::from_rgba(0.30, 0.78, 0.45, a) -} - -pub fn mix(a: Color, b: Color, t: f32) -> Color { - let t = t.clamp(0.0, 1.0); - Color { - r: a.r + (b.r - a.r) * t, - g: a.g + (b.g - a.g) * t, - b: a.b + (b.b - a.b) * t, - a: a.a + (b.a - a.a) * t, - } + Color::from_rgba(0.188, 0.820, 0.345, a) } pub fn lerp(a: f32, b: f32, t: f32) -> f32 { @@ -48,28 +41,25 @@ pub fn lerp(a: f32, b: f32, t: f32) -> f32 { // ----- tokens --------------------------------------------------------------- // -// Surfaces are opaque and derived from the theme (background blended toward -// the text color), so they read the same over any wallpaper and work in both -// light and dark mode. Elevation goes window < panel < card < hover < selected. +// Surfaces are systemFill-style overlays on the window material (see +// `styles`), so they stay translucent over glass and solid over the opaque +// material, in light and dark mode alike. Elevation goes +// window < panel < card < hover < selected. pub const WINDOW: f32 = 0.035; -pub const PANEL: f32 = 0.06; -pub const CARD: f32 = 0.08; -pub const HOVER: f32 = 0.11; +pub const PANEL: f32 = 0.05; +pub const CARD: f32 = 0.07; +pub const HOVER: f32 = 0.10; pub const SELECTED: f32 = 0.14; -/// Opaque surface at elevation `level` (one of the constants above). +/// Surface overlay at elevation `level` (one of the constants above). pub fn surface(theme: &Theme, level: f32) -> Color { - mix( - with_alpha(theme.bg_color(), 1.0), - theme.text_color(1.0), - level, - ) + crate::styles::fill(theme, level) } /// 1px separators and resting borders. pub fn hairline(theme: &Theme, fade: f32) -> Color { - theme.text_color(0.10 * fade) + faded(crate::styles::separator(theme), fade) } /// Text hierarchy: titles and body, supporting detail, hints. @@ -92,15 +82,12 @@ pub fn font(theme: &Theme, weight: Weight) -> Font { } /// Font for large titles (20px and up). SF Pro ships two optical sizes: -/// Text for UI sizes and Display for headings, so swap to Display here. +/// Text for UI sizes and Display for headings, so swap to Display here when +/// it is installed. pub fn display_font(theme: &Theme, weight: Weight) -> Font { let base = theme.font(); - let family = match base.family { - Family::Name(name) if name.starts_with("SF Pro") => Family::Name("SF Pro Display"), - other => other, - }; Font { - family, + family: crate::fonts::display_variant(base.family), weight, ..base } @@ -152,9 +139,10 @@ pub fn enter<'a>(content: impl Into>, t: f32) -> Element<'a /// `hovered` adds a lighter lift. pub fn card_style(theme: &Theme, focus: f32, hovered: bool, fade: f32) -> container::Style { let rest = surface(theme, if hovered { HOVER } else { CARD }); - let bg = mix(rest, surface(theme, SELECTED), focus); + // Selected cards take an accent tint, like a focused macOS list row. + let bg = mix(rest, accent(0.22), focus); container::Style { - background: Some(Background::Color(with_alpha(bg, fade))), + background: Some(Background::Color(faded(bg, fade))), border: Border { color: with_alpha( mix(theme.text_color(1.0), accent(1.0), focus), @@ -164,9 +152,9 @@ pub fn card_style(theme: &Theme, focus: f32, hovered: bool, fade: f32) -> contai radius: Radius::new(10.0), }, shadow: Shadow { - color: Color::from_rgba(0.0, 0.0, 0.0, 0.28 * focus * fade), - offset: Vector::new(0.0, 4.0 * focus), - blur_radius: 14.0 * focus, + color: Color::from_rgba(0.0, 0.0, 0.0, 0.18 * focus * fade), + offset: Vector::new(0.0, 3.0 * focus), + blur_radius: 10.0 * focus, }, text_color: Some(theme.text_color(fade)), snap: false, @@ -235,7 +223,7 @@ pub fn kbd<'a>(label: impl ToString, theme: &Theme, fade: f32) -> Element<'a, Me ) .padding([1, 6]) .style(move |_| container::Style { - background: Some(Background::Color(with_alpha(surface(&t, HOVER), fade))), + background: Some(Background::Color(faded(surface(&t, HOVER), fade))), border: Border { color: t.text_color(0.14 * fade), width: 1.0, @@ -394,7 +382,7 @@ pub fn panel_style(theme: &Theme, tone: Option, fade: f32) -> container:: None => base, }; container::Style { - background: Some(Background::Color(with_alpha(bg, fade))), + background: Some(Background::Color(faded(bg, fade))), border: Border { color: tone .map(|c| with_alpha(c, 0.40 * fade)) @@ -446,12 +434,9 @@ pub fn segmented<'a>( .style(move |_, status| { let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); let bg = if on { - Some(Background::Color(with_alpha( - surface(&t, SELECTED + 0.03), - fade, - ))) + Some(Background::Color(faded(surface(&t, SELECTED + 0.03), fade))) } else if hover { - Some(Background::Color(with_alpha(surface(&t, HOVER), fade))) + Some(Background::Color(faded(surface(&t, HOVER), fade))) } else { None }; @@ -485,7 +470,7 @@ pub fn segmented<'a>( container(iced::widget::row(segs).spacing(2)) .padding(3) .style(move |_| container::Style { - background: Some(Background::Color(with_alpha(surface(&t, WINDOW), fade))), + background: Some(Background::Color(faded(surface(&t, WINDOW), fade))), border: Border { color: hairline(&t, fade), width: 1.0, diff --git a/src/app/tile.rs b/src/app/tile.rs index 600b925..3a8a150 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -426,6 +426,17 @@ impl Tile { Some(self.theme.clone()) } + /// The window's clear color: fully transparent, so the rounded material + /// drawn by the view is all that shows. The compositor reads the surface + /// as premultiplied alpha, so the theme's background at alpha 0 would + /// still add its color (a light theme would clear to solid white). + pub fn style(&self, theme: &Theme) -> iced::theme::Style { + iced::theme::Style { + background_color: iced::Color::TRANSPARENT, + text_color: theme.palette().text, + } + } + /// This handles the subscriptions of the window /// /// The subscriptions are: diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index cb3ca0f..133e4ca 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -4,11 +4,10 @@ use std::collections::HashMap; use std::fs; -use iced::border::Radius; use iced::widget::scrollable::{Anchor, Direction, Scrollbar}; use iced::widget::text::LineHeight; -use iced::widget::{Column, Row, Scrollable, Text, container, space}; -use iced::{Alignment, Color, Length, Vector, window}; +use iced::widget::{Column, Row, Scrollable, Text, container, space, stack}; +use iced::{Alignment, window}; use iced::{Element, Task}; use iced::{Length::Fill, widget::text_input}; @@ -24,7 +23,8 @@ use crate::config::Theme; use crate::debounce::Debouncer; use crate::platform::events::Event; use crate::styles::{ - contents_style, glass_border, glass_surface, results_scrollbar_style, rustcast_text_input_style, + SECONDARY, WINDOW_RADIUS, contents_style, label, results_scrollbar_style, + rustcast_text_input_style, separator, sheen, }; use crate::{app::pages::clipboard::clipboard_page, platform::get_installed_apps}; use crate::{ @@ -106,13 +106,24 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { let title_input = text_input(tile.config.placeholder.as_str(), &tile.query) .on_input(move |a| Message::SearchQueryChanged(a, wid)) .on_paste(move |a| Message::SearchQueryChanged(a, wid)) - .font(tile.config.theme.font()) + // Spotlight's field: 22pt SF Pro Display on the bare material. + // 20 + 28 + 19 plus the 1px separator keeps the 68px header. + .font(crate::app::pages::ui::display_font( + &tile.config.theme, + iced::font::Weight::Normal, + )) + .size(22) .on_submit(Message::OpenFocused) .id("query") .width(Fill) - .line_height(LineHeight::Relative(1.75)) + .line_height(LineHeight::Absolute(28.into())) .style(move |_, _| rustcast_text_input_style(&tile.config.theme)) - .padding(20); + .padding(iced::Padding { + top: 20., + bottom: 19., + left: 20., + right: 20., + }); let scrollbar_direction = if !tile.config.theme.show_scroll_bar || tile.page == Page::Settings { @@ -148,6 +159,7 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { ) }), )) + .padding([crate::app::RESULTS_LIST_PADDING, 0.]) .into(), }; @@ -159,14 +171,6 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { Page::Settings => 0, }; - // This determines the height of the scrollable window - let height = match tile.page { - Page::ClipboardHistory | Page::Settings => 385, - // Height of each emoji is EMOJI_HEIGHT + 20 for padding - Page::EmojiSearch => std::cmp::min(tile.results.len().div_ceil(6) * 90, 290), - _ => std::cmp::min(tile.results.len() * 60, 290), - }; - let theme = tile.config.theme.clone(); let body: Element<'_, Message> = match tile.page { Page::ClipboardHistory => page_surface( @@ -190,10 +194,12 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { ), &tile.config.theme, ), + // The window is sized to the rows it shows, so the list takes + // whatever is left between the field and the footer. _ => Scrollable::with_direction(results, scrollbar_direction) .style(move |_, _| results_scrollbar_style(&theme)) .id("results") - .height(height as u32) + .height(Fill) .into(), }; @@ -212,29 +218,44 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { } }; - let contents = container( - Column::new() - .push(title_input) - .push(body) - .push(footer( - tile.config.theme.clone(), - tile.current_mode.clone(), - text, - )) - .spacing(0), + let has_body = !matches!(tile.page, Page::Main | Page::FileSearch) || results_count > 0; + let theme = &tile.config.theme; + let contents = Column::new() + .push(title_input) + // The field's separator only shows when something sits under it; + // otherwise the footer's own separator is enough. + .push(if has_body { + hairline(theme) + } else { + space().height(1).into() + }) + .push(body) + .push(footer(theme.clone(), tile.current_mode.clone(), text)) + .spacing(0); + + // The top-edge highlight floats over the content so it takes no + // layout space. + let sheen_theme = theme.clone(); + let sheen = container( + container(space().width(Fill).height(1)) + .style(move |_| container::Style { + background: Some(sheen(&sheen_theme)), + ..Default::default() + }) + .width(Fill), ) - .style(|_| container::Style { - text_color: None, - background: None, - border: iced::Border { - color: Color::TRANSPARENT, - width: 0., - radius: Radius::new(15), - }, - ..Default::default() - }); + .padding(iced::Padding { + top: 1., + left: WINDOW_RADIUS, + right: WINDOW_RADIUS, + bottom: 0., + }) + .width(Fill); - container(contents) + container(stack![contents, sheen]) + .width(Fill) + .height(Fill) + .clip(true) .style(|_| contents_style(&tile.config.theme)) .into() } else { @@ -242,81 +263,56 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { } } -/// Glass surface behind a purpose-built page body, filling the space -/// between the search field and the footer. -fn page_surface<'a>(content: Element<'a, Message>, theme: &Theme) -> Element<'a, Message> { +/// A 1px separatorColor line across the window. +fn hairline<'a>(theme: &Theme) -> Element<'a, Message> { let theme = theme.clone(); - container(content) + container(space().width(Fill).height(1)) .width(Fill) - .height(Fill) + .height(1) .style(move |_| container::Style { - background: Some(iced::Background::Color(glass_surface( - theme.bg_color(), - false, - ))), + background: Some(iced::Background::Color(separator(&theme))), ..Default::default() }) .into() } +/// A purpose-built page body, filling the space between the search field +/// and the footer. It draws on the window material. +fn page_surface<'a>(content: Element<'a, Message>, _theme: &Theme) -> Element<'a, Message> { + container(content).width(Fill).height(Fill).into() +} + /// The footer at the bottom displaying the mode and results found, and its styling fn footer(theme: Theme, current_mode: String, text: String) -> Element<'static, Message> { - let radius = 15.0; - let current_mode = format!( "{}{} Mode", current_mode.split_at(1).0.to_uppercase(), current_mode.split_at(1).1 ); - container( - Row::new() - .push( - Text::new(text) - .size(12) - .height(30) - .color(theme.text_color(0.7)) - .font(theme.font()) - .align_y(Alignment::Center) - .align_x(Alignment::Center), - ) - .push( - Text::new(current_mode) - .size(12) - .height(30) - .color(theme.text_color(0.7)) - .font(theme.font()) - .width(Fill) + let caption = |s: String| { + Text::new(s) + .size(11) + .color(label(&theme, SECONDARY)) + .font(crate::app::pages::ui::font( + &theme, + iced::font::Weight::Medium, + )) + }; + Column::new() + .push(hairline(&theme)) + .push( + container( + Row::new() + .push(caption(text)) + .push(space().width(Fill)) + .push(caption(current_mode)) .align_y(Alignment::Center) - .align_x(Alignment::End), + .width(Fill), ) - .align_y(Alignment::Center) - .padding(4) - .width(Fill) - .height(Fill), - ) - .align_y(Alignment::Center) - .center(Length::Fill) - .width(Fill) - .padding(5) - .height(30) - .style(move |_| container::Style { - text_color: None, - background: Some(iced::Background::Color(glass_surface( - theme.bg_color(), - false, - ))), - border: iced::Border { - color: glass_border(theme.text_color(1.0), false), - width: 0., - radius: Radius::new(radius).top(0.0), - }, - - shadow: iced::Shadow { - color: Color::TRANSPARENT, - offset: Vector::ZERO, - blur_radius: 0., - }, - snap: false, - }) - .into() + .padding([0, 14]) + .center_y(29) + .width(Fill), + ) + .height(30) + .into() } diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 4825a41..21726b4 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -418,6 +418,18 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { let (old_w, old_h) = crate::app::launcher_size(); crate::app::set_launcher_size(width, height); let resize = window::resize(id, iced::Size { width, height }); + let resize = if crate::styles::translucent(&tile.config.theme) { + resize.chain( + window::run(id, move |handle| { + if let Ok(h) = handle.window_handle() { + crate::platform::blur_behind(&h, width, crate::styles::WINDOW_RADIUS); + } + }) + .discard(), + ) + } else { + resize + }; // Re-centre when the footprint changes a lot (switching page kinds). if (old_w - width).abs() > 1.0 || (old_h - height).abs() > 200.0 { resize.chain( @@ -565,7 +577,7 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { if !tile.visible { tile.last_open = Some(std::time::Instant::now()); tile.height = if is_clipboard_hotkey { - ((7 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32 + crate::app::results_window_height(7) } else { DEFAULT_WINDOW_HEIGHT }; @@ -595,7 +607,7 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { tile.page = Page::Settings; Task::batch([ Task::done(Message::OpenWindow), - open_window(((7 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32), + open_window(crate::app::results_window_height(7)), ]) } @@ -612,18 +624,12 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { } window::latest().map(|x| { let id = x.unwrap(); - Message::ResizeWindow( - id, - ((7 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, - ) + Message::ResizeWindow(id, crate::app::results_window_height(7)) }) } Page::Settings => window::latest().map(|x| { let id = x.unwrap(); - Message::ResizeWindow( - id, - ((7 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, - ) + Message::ResizeWindow(id, crate::app::results_window_height(7)) }), _ => Task::none(), }; @@ -878,7 +884,7 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { return window::latest().map(move |x| { Message::ResizeWindow( x.unwrap(), - ((new_display_count * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, + crate::app::results_window_height(new_display_count), ) }); } @@ -1109,6 +1115,9 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { final_config.theme.text_color = text; final_config.theme.background_color = bg; } + SetConfigFields::SetThemeFields(SetConfigThemeFields::Glass(glass)) => { + final_config.theme.glass = glass + } SetConfigFields::SetThemeFields(SetConfigThemeFields::TextColor(r, g, b)) => { final_config.theme.text_color = (r, g, b) } @@ -1177,6 +1186,7 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { tile.config.theme.text_color = text; tile.config.theme.background_color = bg; } + ResetField::Glass => tile.config.theme.glass = default.theme.glass, ResetField::ShowScrollbar => { tile.config.theme.show_scroll_bar = default.theme.show_scroll_bar } @@ -1418,7 +1428,7 @@ fn update_inner(tile: &mut Tile, message: Message) -> Task { } /// Height of the launcher on the recorder page (header + 5 rows + footer). -const RECORDER_WINDOW_HEIGHT: f32 = ((5 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32; +const RECORDER_WINDOW_HEIGHT: f32 = crate::app::results_window_height(5); /// Write the config file in the background (used for quick toggles that /// don't go through the settings page's Save button). @@ -1524,7 +1534,7 @@ fn zero_item_resize_task(id: Id) -> Task { fn resize_task(id: Id, count: u32) -> Task { Task::done(Message::ResizeWindow( id, - (55 * count) as f32 + DEFAULT_WINDOW_HEIGHT, + crate::app::results_window_height(count as usize), )) } @@ -1539,7 +1549,7 @@ fn resize_for_results_count(id: Id, count: usize) -> Task { let max_elem = min(5, count); Task::done(Message::ResizeWindow( id, - ((max_elem * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, + crate::app::results_window_height(max_elem), )) } @@ -1801,7 +1811,7 @@ fn execute_query(tile: &mut Tile, id: Id) -> Task { return task.chain(Task::batch([ Task::done(Message::ResizeWindow( id, - ((max_elem * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, + crate::app::results_window_height(max_elem), )), Task::done(Message::ChangeFocus(ArrowKey::Left, 1)), ])); diff --git a/src/config.rs b/src/config.rs index bd09062..d365b02 100644 --- a/src/config.rs +++ b/src/config.rs @@ -128,6 +128,22 @@ impl ThemeMode { } } +/// Whether the launcher is drawn as translucent glass over a blurred desktop. +/// +/// - `Auto`: glass when the compositor blurs behind windows (KWin announces +/// this on the root window), frosted opaque material otherwise. +/// - `On`: always translucent. For compositors that blur without announcing +/// it, e.g. picom with `blur-background = true`. +/// - `Off`: always opaque. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Copy, Default)] +#[serde(rename_all = "lowercase")] +pub enum GlassMode { + #[default] + Auto, + On, + Off, +} + /// The settings you can set for the theme #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(default)] @@ -135,6 +151,7 @@ pub struct Theme { pub text_color: (f32, f32, f32), pub background_color: (f32, f32, f32), pub blur: bool, + pub glass: GlassMode, pub show_icons: bool, pub show_scroll_bar: bool, pub font: Option, @@ -148,6 +165,7 @@ impl Default for Theme { text_color: text, background_color: bg, blur: false, + glass: GlassMode::Auto, show_icons: true, show_scroll_bar: false, font: None, @@ -212,20 +230,25 @@ impl Theme { } } - /// Return the font in the theme config of type [`iced::Font`] + /// Return the font in the theme config of type [`iced::Font`]. With no + /// font configured this is the macOS system font (SF Pro) when installed. pub fn font(&self) -> Font { - let opt_font_name = self.font.clone(); - match opt_font_name { - Some(font_name) => Font { - family: Family::Name(font_name.leak()), - ..Default::default() - }, - None => Font { - family: Family::SansSerif, - ..Default::default() - }, + let family = match self.font.as_deref().map(str::trim) { + Some(name) if !name.is_empty() => Family::Name(crate::fonts::intern(name)), + _ => crate::fonts::system_family(), + }; + Font { + family, + ..Default::default() } } + + /// True when the background is light, so materials and accents can pick + /// their light-appearance variants. + pub fn is_light(&self) -> bool { + let (r, g, b) = self.background_color; + 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.5 + } } /// Settings for the built-in screen recorder. @@ -367,6 +390,25 @@ mod tests { assert_eq!(config.main_page, MainPage::Blank); assert_eq!(config.recorder_hotkey, "SUPER+SHIFT+R"); assert!(config.recorder.aspect_lock); + assert_eq!(config.theme.glass, GlassMode::Auto); + } + + #[test] + fn glass_mode_parses_lowercase() { + let cfg: Config = toml::from_str("[theme]\nglass = \"off\"\n").unwrap(); + assert_eq!(cfg.theme.glass, GlassMode::Off); + } + + #[test] + fn light_background_is_detected() { + let (text, bg) = ThemeMode::Light.presets(false); + let light = Theme { + text_color: text, + background_color: bg, + ..Theme::default() + }; + assert!(light.is_light()); + assert!(!Theme::default().is_light()); } #[test] diff --git a/src/fonts.rs b/src/fonts.rs new file mode 100644 index 0000000..e26db4f --- /dev/null +++ b/src/fonts.rs @@ -0,0 +1,108 @@ +//! Font family lookup: the macOS system font (SF Pro) when it is installed, +//! and interning of configured family names. +//! +//! iced wants `&'static str` family names. Names are interned once instead of +//! leaked on every `Theme::font()` call, which runs for each text widget on +//! every frame. + +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; + +use iced::font::Family; +use once_cell::sync::Lazy; + +/// Families installed on this system, lowercased. Read once from fontconfig. +static INSTALLED: Lazy> = Lazy::new(installed_families); + +static INTERNED: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +/// macOS' UI fonts first, then the closest open lookalikes. +const SYSTEM_PREFERENCE: &[&str] = &[ + "SF Pro Text", + "SF Pro", + ".AppleSystemUIFont", + "Inter", + "Inter Variable", +]; + +/// A `&'static str` for `name`, allocated at most once per distinct name. +pub fn intern(name: &str) -> &'static str { + let mut map = INTERNED.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(s) = map.get(name) { + return s; + } + let leaked: &'static str = Box::leak(name.to_string().into_boxed_str()); + map.insert(name.to_string(), leaked); + leaked +} + +pub fn is_installed(family: &str) -> bool { + INSTALLED.contains(&family.to_lowercase()) +} + +/// The default UI family: SF Pro when available, else Inter, else the +/// desktop's sans-serif. +pub fn system_family() -> Family { + static CHOSEN: Lazy = Lazy::new(|| { + SYSTEM_PREFERENCE + .iter() + .find(|name| is_installed(name)) + .map(|name| Family::Name(name)) + .unwrap_or(Family::SansSerif) + }); + *CHOSEN +} + +/// The Display optical size for a Text family (SF Pro Text → SF Pro Display), +/// only if that family is installed. Apple uses Display from 20pt up. +pub fn display_variant(family: Family) -> Family { + match family { + Family::Name(name) if name.starts_with("SF Pro") && is_installed("SF Pro Display") => { + Family::Name("SF Pro Display") + } + other => other, + } +} + +fn installed_families() -> HashSet { + let mut set = HashSet::new(); + let Ok(out) = std::process::Command::new("fc-list") + .args([":", "family"]) + .output() + else { + return set; + }; + for line in String::from_utf8_lossy(&out.stdout).lines() { + // One font per line; localized names are comma-separated. + for name in line.split(',') { + let name = name.trim(); + if !name.is_empty() { + set.insert(name.to_lowercase()); + } + } + } + set +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intern_reuses_allocation() { + let a = intern("Some Font"); + let b = intern("Some Font"); + assert!(std::ptr::eq(a, b)); + assert_eq!(a, "Some Font"); + } + + #[test] + fn display_variant_leaves_other_families_alone() { + assert_eq!(display_variant(Family::SansSerif), Family::SansSerif); + assert_eq!( + display_variant(Family::Name("Inter")), + Family::Name("Inter") + ); + } +} diff --git a/src/main.rs b/src/main.rs index 1fcafb1..6ff2b0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod clipboard; mod commands; mod config; mod debounce; +mod fonts; mod jev; mod jev_model; mod persist; @@ -162,5 +163,6 @@ fn main() -> iced::Result { ) .subscription(Tile::subscription) .theme(Tile::theme) + .style(Tile::style) .run() } diff --git a/src/platform/linux/mod.rs b/src/platform/linux/mod.rs index 471292d..ec589a9 100644 --- a/src/platform/linux/mod.rs +++ b/src/platform/linux/mod.rs @@ -142,6 +142,16 @@ pub fn position_launcher(handle: &WindowHandle) { }); } +/// Blur the desktop behind the launcher (KWin's blur-behind protocol), clipped +/// to its rounded corners. No-op for non-X11 handles. +pub fn blur_behind(handle: &WindowHandle, logical_width: f32, radius: f32) { + if let RawWindowHandle::Xlib(h) = handle.as_raw() { + x11::set_blur_behind(h.window as u32, logical_width, radius); + } else if let RawWindowHandle::Xcb(h) = handle.as_raw() { + x11::set_blur_behind(h.window.get(), logical_width, radius); + } +} + /// winit already focuses the launcher window when it opens; nothing to do. pub fn focus_this_app() {} diff --git a/src/platform/linux/x11.rs b/src/platform/linux/x11.rs index 8991769..5dffa24 100644 --- a/src/platform/linux/x11.rs +++ b/src/platform/linux/x11.rs @@ -4,10 +4,12 @@ use x11rb::connection::Connection; use x11rb::protocol::xproto::{ - Atom, AtomEnum, ClientMessageEvent, ConnectionExt, EventMask, InputFocus, MapState, Window, + Atom, AtomEnum, ClientMessageEvent, ConnectionExt, EventMask, InputFocus, MapState, PropMode, + Window, }; use x11rb::protocol::xtest::ConnectionExt as _; use x11rb::rust_connection::RustConnection; +use x11rb::wrapper::ConnectionExt as _; const KEYSYM_CONTROL_L: u32 = 0xffe3; const KEYSYM_V: u32 = 0x0076; @@ -295,6 +297,80 @@ pub fn set_overlay_states(win: u32) -> Option<()> { Some(()) } +/// KWin's blur-behind protocol: a window lists the rectangles (x, y, w, h) the +/// compositor should blur behind it. KWin announces support by setting this +/// property on the root window while its blur effect is loaded. +const BLUR_BEHIND: &str = "_KDE_NET_WM_BLUR_BEHIND_REGION"; + +/// True when a running compositor blurs behind windows that ask for it. +pub fn compositor_blurs() -> bool { + let Some(x) = X11::open() else { + return false; + }; + let Some(atom) = x.atom(BLUR_BEHIND) else { + return false; + }; + x.conn + .get_property(false, x.root, atom, AtomEnum::ANY, 0, 0) + .ok() + .and_then(|c| c.reply().ok()) + .is_some_and(|r| r.type_ != u32::from(AtomEnum::NONE)) +} + +/// Rectangles covering a `w`×`h` rounded rectangle of corner `radius`, one +/// per corner row plus the body, so the blur stops at the rounded edge instead +/// of showing in the transparent corners. +pub fn rounded_region(w: u32, h: u32, radius: u32) -> Vec { + let r = radius.min(w / 2).min(h / 2); + let mut rects = Vec::with_capacity(4 * (r as usize * 2 + 1)); + for i in 0..r { + let dy = r as f32 - i as f32 - 0.5; + let inset = (r as f32 - (r as f32 * r as f32 - dy * dy).max(0.0).sqrt()).round() as u32; + let row_w = w.saturating_sub(inset * 2); + rects.extend_from_slice(&[inset, i, row_w, 1]); + rects.extend_from_slice(&[inset, h - 1 - i, row_w, 1]); + } + if h > 2 * r { + rects.extend_from_slice(&[0, r, w, h - 2 * r]); + } + rects +} + +/// Ask the compositor to blur behind `win`, clipped to its rounded corners. +/// +/// `logical_width` and `radius` are in iced's logical pixels; the window's +/// real size is read back from the server, which also gives the scale factor. +/// A resize is granted by the window manager a moment after it is requested, +/// so the region is refreshed while the size settles (~0.4 s, off-thread). +pub fn set_blur_behind(win: u32, logical_width: f32, radius: f32) { + std::thread::spawn(move || { + let Some(x) = X11::open() else { return }; + let Some(atom) = x.atom(BLUR_BEHIND) else { + return; + }; + let mut last = (0, 0); + for _ in 0..25 { + if let Some(g) = x.conn.get_geometry(win).ok().and_then(|c| c.reply().ok()) { + let size = (g.width as u32, g.height as u32); + if size != last && size.0 > 0 && size.1 > 0 { + last = size; + let scale = (size.0 as f32 / logical_width.max(1.0)).clamp(1.0, 4.0); + let region = rounded_region(size.0, size.1, (radius * scale).round() as u32); + let _ = x.conn.change_property32( + PropMode::REPLACE, + win, + atom, + AtomEnum::CARDINAL, + ®ion, + ); + let _ = x.conn.flush(); + } + } + std::thread::sleep(std::time::Duration::from_millis(16)); + } + }); +} + /// A normal toplevel application window, as listed in `_NET_CLIENT_LIST`. #[derive(Debug, Clone, PartialEq)] pub struct ClientWindow { @@ -491,3 +567,26 @@ pub fn close_window(win: u32) -> Option<()> { x.send_client_message(win, atom, [0, 2, 0, 0, 0]); Some(()) } + +#[cfg(test)] +mod tests { + use super::rounded_region; + + #[test] + fn rounded_region_covers_body_and_trims_corners() { + let rects = rounded_region(100, 60, 10); + let rows: Vec<&[u32]> = rects.chunks(4).collect(); + // First corner row is inset, the body spans the full width. + assert!(rows[0][0] > 0); + assert_eq!(rows.last().unwrap(), &[0, 10, 100, 40]); + // Every rectangle stays inside the window. + for r in rows { + assert!(r[0] + r[2] <= 100 && r[1] + r[3] <= 60); + } + } + + #[test] + fn rounded_region_without_radius_is_one_rect() { + assert_eq!(rounded_region(50, 20, 0), vec![0, 0, 50, 20]); + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index e40d5b4..7850866 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -30,6 +30,16 @@ pub fn position_launcher(handle: &WindowHandle) { self::linux::position_launcher(handle); } +/// Ask the compositor to blur behind the launcher's rounded window. +pub fn blur_behind(handle: &WindowHandle, logical_width: f32, radius: f32) { + self::linux::blur_behind(handle, logical_width, radius); +} + +/// True when the compositor blurs behind windows that ask for it. +pub fn compositor_blurs() -> bool { + self::linux::x11::compositor_blurs() +} + pub fn simulate_paste(pid: i32) { self::linux::simulate_paste(pid); } diff --git a/src/styles.rs b/src/styles.rs index b3a2710..6b4dd9a 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -1,7 +1,9 @@ //! This handles most of the styling for the rustcast elements -use crate::config::Theme as ConfigTheme; +use crate::config::{GlassMode, Theme as ConfigTheme}; +use iced::Radians; use iced::Shadow; use iced::border::Radius; +use iced::gradient::Linear; use iced::widget::{button, checkbox, container, radio, scrollable, slider}; use iced::{Background, Border, Color, widget::text_input}; @@ -19,48 +21,50 @@ pub fn with_alpha(mut c: Color, a: f32) -> Color { c } -/// Styling for the main text box +/// The search field: no box of its own, it sits directly on the window +/// material like Spotlight's. pub fn rustcast_text_input_style(theme: &ConfigTheme) -> text_input::Style { - let base = theme.bg_color(); - let focused = false; // if you have state, pass it in and use it - let surface = glass_surface(base, focused); text_input::Style { - background: Background::Color(surface), - border: Border { - color: glass_border(theme.text_color(0.), focused), - width: 0., - radius: Radius::new(10.).bottom(0.), - }, - icon: theme.text_color(0.), - placeholder: theme.text_color(0.42), - value: theme.text_color(0.96), - selection: crate::app::pages::ui::accent(0.45), + background: Background::Color(Color::TRANSPARENT), + border: Border::default(), + icon: label(theme, 0.), + placeholder: label(theme, TERTIARY), + value: label(theme, PRIMARY), + selection: with_alpha(accent(theme), 0.40), } } -/// Container styling for all the elements in the rustcast window +/// The launcher window: one material fill, a light rim and rounded corners. +/// Every section inside draws on top of this and stays transparent, so the +/// material (and the compositor's blur behind it) is painted exactly once. pub fn contents_style(theme: &ConfigTheme) -> container::Style { container::Style { - background: None, - text_color: None, - border: iced::Border { - color: theme.text_color(0.14), + background: Some(Background::Color(window_fill(theme))), + text_color: Some(label(theme, PRIMARY)), + border: Border { + color: rim(theme), width: 1.0, - radius: Radius::new(14.0), + radius: Radius::new(WINDOW_RADIUS), }, ..Default::default() } } -pub fn delete_button_style(theme: &ConfigTheme) -> button::Style { - let red_clr = Color::from_rgb(1.0, 0.2, 0.2); +pub fn delete_button_style(theme: &ConfigTheme, status: button::Status) -> button::Style { + // systemRed; ghost until hovered, like a destructive toolbar button. + let red = Color::from_rgb(1.0, 0.271, 0.227); + let bg = match status { + button::Status::Hovered => Some(Background::Color(with_alpha(red, 0.14))), + button::Status::Pressed => Some(Background::Color(with_alpha(red, 0.24))), + _ => Some(Background::Color(fill(theme, QUATERNARY_FILL))), + }; button::Style { - text_color: red_clr, - background: Some(Background::Color(theme.bg_color())), + text_color: red, + background: bg, border: Border { - color: with_alpha(red_clr, 0.3), - width: 0.5, - radius: Radius::new(15), + color: Color::TRANSPARENT, + width: 0., + radius: Radius::new(7), }, ..Default::default() } @@ -69,39 +73,36 @@ pub fn delete_button_style(theme: &ConfigTheme) -> button::Style { /// Styling for each of the buttons that are what the "results" of rustcast are pub fn result_button_style(theme: &ConfigTheme) -> button::Style { button::Style { - text_color: theme.text_color(1.), - background: Some(Background::Color(theme.bg_color())), + text_color: label(theme, PRIMARY), + background: None, ..Default::default() } } +/// The favourite heart: systemPink when set, a faint outline-like glyph +/// otherwise that brightens on hover. pub fn favourite_button_style( theme: &ConfigTheme, status: button::Status, is_favourite: bool, + on_selection: bool, ) -> button::Style { - let (base, pressed, hovered) = if is_favourite { - (1.0, 0.8, 0.9) - } else { - (0.1, 1.0, 0.5) + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); + let text_color = match (is_favourite, on_selection) { + (true, true) => Color::WHITE, + (true, false) => Color::from_rgb(1.0, 0.216, 0.373), + (false, true) => Color::from_rgba(1.0, 1.0, 1.0, if hovered { 0.9 } else { 0.45 }), + (false, false) => label(theme, if hovered { SECONDARY } else { 0.0 }), }; - - let text_color = match status { - button::Status::Pressed => theme.text_color(pressed), - button::Status::Hovered => theme.text_color(hovered), - _ => theme.text_color(base), - }; - button::Style { text_color, - background: Some(Background::Color(theme.bg_color())), + background: None, ..Default::default() } } +/// macOS overlay scroller: a thin rounded thumb, no track. pub fn results_scrollbar_style(tile: &ConfigTheme) -> scrollable::Style { - let clr = with_alpha(tile.bg_color(), 0.7); - scrollable::Style { container: container::Style { text_color: None, @@ -112,17 +113,13 @@ pub fn results_scrollbar_style(tile: &ConfigTheme) -> scrollable::Style { }, vertical_rail: scrollable::Rail { background: None, - border: Border { - color: clr, - width: 1., - radius: Radius::new(10), - }, + border: Border::default(), scroller: scrollable::Scroller { - background: Background::Color(tile.text_color(0.7)), + background: Background::Color(label(tile, 0.35)), border: Border { - color: tile.text_color(1.), - width: 0.1, - radius: Radius::new(0), + color: Color::TRANSPARENT, + width: 0., + radius: Radius::new(3), }, }, }, @@ -144,26 +141,38 @@ pub fn results_scrollbar_style(tile: &ConfigTheme) -> scrollable::Style { } } -pub fn settings_radio_button_style(theme: &ConfigTheme) -> radio::Style { +pub fn settings_radio_button_style(theme: &ConfigTheme, status: radio::Status) -> radio::Style { + let selected = matches!( + status, + radio::Status::Active { is_selected: true } | radio::Status::Hovered { is_selected: true } + ); radio::Style { - background: Background::Color(Color::TRANSPARENT), - dot_color: theme.text_color(0.4), - border_width: 1., - border_color: theme.text_color(0.7), - text_color: Some(theme.text_color(1.)), + background: Background::Color(if selected { + accent(theme) + } else { + fill(theme, TERTIARY_FILL) + }), + dot_color: Color::WHITE, + border_width: if selected { 0. } else { 1. }, + border_color: label(theme, 0.22), + text_color: Some(label(theme, PRIMARY)), } } -/// Each rustcast results rows style +/// A result row. Selection is the macOS list highlight: an accent-filled +/// rounded rectangle inset from the window edge, with white text on it. pub fn result_row_container_style(tile: &ConfigTheme, focused: bool) -> container::Style { container::Style { - background: Some(Background::Color(glass_surface(tile.bg_color(), focused))), + background: focused.then(|| Background::Color(accent(tile))), border: Border { - color: glass_border(tile.text_color(1.), focused), - width: 0., - radius: Radius::new(0.0), + radius: Radius::new(ROW_RADIUS), + ..Border::default() }, - text_color: Some(tile.text_color(1.0)), + text_color: Some(if focused { + Color::WHITE + } else { + label(tile, PRIMARY) + }), ..Default::default() } } @@ -173,194 +182,374 @@ pub fn result_row_container_style(tile: &ConfigTheme, focused: bool) -> containe /// Takes a focused boolean, to know if this specific button is focused or not pub fn emoji_button_container_style(tile_theme: &ConfigTheme, focused: bool) -> container::Style { container::Style { - background: Some(Background::Color(glass_surface( - tile_theme.bg_color(), - focused, - ))), - text_color: Some(tile_theme.text_color(1.0)), + background: focused.then(|| Background::Color(with_alpha(accent(tile_theme), 0.85))), + text_color: Some(label(tile_theme, PRIMARY)), border: Border { - color: glass_border(tile_theme.text_color(1.0), focused), - width: 1.0, - radius: Radius::new(10.0), + color: Color::TRANSPARENT, + width: 0.0, + radius: Radius::new(12.0), }, ..Default::default() } } /// Emoji buttons styling -pub fn emoji_button_style(tile_theme: &ConfigTheme) -> button::Style { - let base = tile_theme.bg_color(); - let bg = with_alpha(tint(base, 0.10), 0.28); +pub fn emoji_button_style(tile_theme: &ConfigTheme, status: button::Status) -> button::Style { + let level = match status { + button::Status::Hovered | button::Status::Pressed => SECONDARY_FILL, + _ => QUATERNARY_FILL, + }; button::Style { - background: Some(Background::Color(bg)), - text_color: tile_theme.text_color(1.0), + background: Some(Background::Color(fill(tile_theme, level))), + text_color: label(tile_theme, PRIMARY), border: Border { - color: glass_border(tile_theme.text_color(1.0), false), - width: 1.0, - radius: Radius::new(10.0), + color: Color::TRANSPARENT, + width: 0.0, + radius: Radius::new(12.0), }, ..Default::default() } } -pub fn settings_text_input_item_style(theme: &ConfigTheme) -> text_input::Style { - let base = theme.bg_color(); - let surface = glass_surface(base, false); +/// macOS text field: recessed fill, hairline border, accent focus ring. +pub fn settings_text_input_item_style( + theme: &ConfigTheme, + status: text_input::Status, +) -> text_input::Style { + let focused = matches!(status, text_input::Status::Focused { .. }); text_input::Style { - background: Background::Color(surface), + background: Background::Color(fill(theme, QUATERNARY_FILL)), border: Border { - color: glass_border(theme.text_color(1.0), false), - width: 0.2, - radius: Radius::new(10.), + color: if focused { + with_alpha(accent(theme), 0.75) + } else { + separator(theme) + }, + width: if focused { 2.0 } else { 1.0 }, + radius: Radius::new(7.), }, - icon: theme.text_color(0.75), - placeholder: theme.text_color(0.50), - value: theme.text_color(1.0), - selection: with_alpha(theme.text_color(1.0), 0.20), + icon: label(theme, SECONDARY), + placeholder: label(theme, TERTIARY), + value: label(theme, PRIMARY), + selection: with_alpha(accent(theme), 0.40), } } -pub fn settings_save_button_style(theme: &ConfigTheme) -> button::Style { +/// Push button on the window material (Copy config, Open file). +pub fn settings_save_button_style(theme: &ConfigTheme, status: button::Status) -> button::Style { + let level = match status { + button::Status::Pressed => PRIMARY_FILL, + button::Status::Hovered => SECONDARY_FILL, + _ => TERTIARY_FILL, + }; button::Style { - text_color: theme.text_color(1.), - background: Some(Background::Color(with_alpha(theme.bg_color(), 0.3))), + text_color: label(theme, PRIMARY), + background: Some(Background::Color(fill(theme, level))), border: Border { - color: theme.text_color(0.7), - width: 0.1, - radius: Radius::new(5), + color: separator(theme), + width: 0.5, + radius: Radius::new(7), + }, + shadow: Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.12), + offset: iced::Vector::new(0.0, 0.5), + blur_radius: 1.0, }, ..Default::default() } } -pub fn settings_add_button_style(theme: &ConfigTheme) -> button::Style { +/// The default button (Save): accent fill, white label. +pub fn settings_primary_button_style(theme: &ConfigTheme, status: button::Status) -> button::Style { + let base = accent(theme); + let bg = match status { + button::Status::Pressed => mix(base, Color::BLACK, 0.15), + button::Status::Hovered => mix(base, Color::WHITE, 0.10), + _ => base, + }; button::Style { - background: None, - text_color: theme.text_color(1.), + text_color: Color::WHITE, + background: Some(Background::Color(bg)), + border: Border { + color: Color::from_rgba(1.0, 1.0, 1.0, 0.12), + width: 0.5, + radius: Radius::new(7), + }, + shadow: Shadow { + color: with_alpha(base, 0.30), + offset: iced::Vector::new(0.0, 1.0), + blur_radius: 4.0, + }, + ..Default::default() + } +} + +pub fn settings_add_button_style(theme: &ConfigTheme, status: button::Status) -> button::Style { + let level = match status { + button::Status::Pressed => SECONDARY_FILL, + button::Status::Hovered => TERTIARY_FILL, + _ => QUATERNARY_FILL, + }; + button::Style { + background: Some(Background::Color(fill(theme, level))), + text_color: label(theme, PRIMARY), border: Border { - color: theme.text_color(0.7), - width: 0.7, - radius: Radius::new(10), + color: separator(theme), + width: 0.5, + radius: Radius::new(7), }, ..Default::default() } } -/// Style for settings tab buttons with active/inactive and hover states. +/// Settings tabs as a macOS segmented control: the active segment is a +/// raised pill, the others are plain labels. pub fn settings_tab_style( theme: &ConfigTheme, active: bool, status: button::Status, ) -> button::Style { - let base = theme.bg_color(); - if active { - let bg_alpha = match status { - button::Status::Pressed => 0.55, - button::Status::Hovered => 0.45, - _ => 0.35, - }; - let tc_alpha = match status { - button::Status::Pressed => 0.7, - _ => 1.0, + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); + let (bg, text, shadow) = if active { + let raised = if theme.is_light() { + Color::WHITE + } else { + fill(theme, 0.24) }; - button::Style { - text_color: theme.text_color(tc_alpha), - background: Some(Background::Color(with_alpha(tint(base, 0.12), bg_alpha))), - border: Border { - color: theme.text_color(0.25), - width: 0.5, - radius: Radius::new(6.).top(6.), + ( + Some(Background::Color(raised)), + label(theme, PRIMARY), + Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.18), + offset: iced::Vector::new(0.0, 1.0), + blur_radius: 2.0, }, - ..Default::default() - } + ) + } else if hovered { + ( + Some(Background::Color(fill(theme, QUATERNARY_FILL))), + label(theme, PRIMARY), + Shadow::default(), + ) } else { - let (bg_opt, text_alpha) = match status { - button::Status::Pressed => ( - Some(Background::Color(with_alpha(tint(base, 0.06), 0.30))), - 0.7, - ), - button::Status::Hovered => ( - Some(Background::Color(with_alpha(tint(base, 0.04), 0.20))), - 0.9, - ), - _ => (None, 0.5), - }; - button::Style { - text_color: theme.text_color(text_alpha), - background: bg_opt, - border: Border { - color: theme.text_color(0.10), - width: 0.0, - radius: Radius::new(6.).top(6.), - }, - ..Default::default() - } + (None, label(theme, SECONDARY), Shadow::default()) + }; + button::Style { + text_color: text, + background: bg, + border: Border { + color: Color::TRANSPARENT, + width: 0., + radius: Radius::new(7.), + }, + shadow, + ..Default::default() } } -/// Clean container style for the settings panel (non-glass, flat). +/// The settings panel sits straight on the window material. pub fn settings_container_style(theme: &ConfigTheme) -> container::Style { container::Style { - background: Some(Background::Color(with_alpha( - tint(theme.bg_color(), 0.04), - 0.25, - ))), - border: Border { - color: theme.text_color(0.15), - width: 0.5, - radius: Radius::new(10), - }, - text_color: Some(theme.text_color(1.0)), + background: None, + text_color: Some(label(theme, PRIMARY)), ..Default::default() } } -pub fn settings_checkbox_style(theme: &ConfigTheme) -> checkbox::Style { +/// macOS checkbox: rounded square, accent fill with a white check when on. +pub fn settings_checkbox_style(theme: &ConfigTheme, status: checkbox::Status) -> checkbox::Style { + let checked = matches!( + status, + checkbox::Status::Active { is_checked: true } + | checkbox::Status::Hovered { is_checked: true } + | checkbox::Status::Disabled { is_checked: true } + ); checkbox::Style { - background: Background::Color(Color::TRANSPARENT), - icon_color: theme.text_color(1.), + background: Background::Color(if checked { + accent(theme) + } else { + fill(theme, TERTIARY_FILL) + }), + icon_color: Color::WHITE, border: iced::Border { - color: theme.text_color(1.), + color: if checked { + Color::TRANSPARENT + } else { + label(theme, 0.22) + }, width: 1., - radius: Radius::new(2.), + radius: Radius::new(4.), }, text_color: None, } } -pub fn settings_slider_style(theme: &ConfigTheme) -> slider::Style { +/// macOS slider: thin track filled with the accent, white knob with a shadow. +pub fn settings_slider_style(theme: &ConfigTheme, _status: slider::Status) -> slider::Style { slider::Style { rail: slider::Rail { backgrounds: ( - Background::Color(theme.text_color(1.)), - Background::Color(theme.bg_color()), + Background::Color(accent(theme)), + Background::Color(fill(theme, PRIMARY_FILL)), ), - width: 1.5, + width: 4., border: Border { - color: theme.text_color(1.), - width: 0.3, - radius: Radius::new(0), + color: Color::TRANSPARENT, + width: 0., + radius: Radius::new(2), }, }, handle: slider::Handle { - shape: slider::HandleShape::Circle { radius: 10. }, - background: Background::Color(theme.text_color(1.)), - border_width: 0.1, - border_color: Color::WHITE, + shape: slider::HandleShape::Circle { radius: 9. }, + background: Background::Color(Color::WHITE), + border_width: 0.5, + border_color: Color::from_rgba(0.0, 0.0, 0.0, 0.18), }, } } -/// Launcher surface color. Opaque on purpose: Linux compositors don't blur -/// behind the window, so any transparency lets whatever is underneath bleed -/// through the text. -pub fn glass_surface(base: Color, focused: bool) -> Color { - let t = if focused { 0.13 } else { 0.045 }; - with_alpha(tint(base, t), 1.0) +// ----- macOS material ------------------------------------------------------- +// +// Values follow AppKit's semantic colors (labelColor, separatorColor, +// systemFill, controlAccentColor) and the Spotlight/HUD window material. The +// window paints one fill; with a blurring compositor it is translucent so the +// desktop shows through frosted, otherwise it is solid. Blur is done by the +// compositor on the GPU, RustCast itself never blurs anything. + +/// Corner radius of the launcher window. +pub const WINDOW_RADIUS: f32 = 16.0; +/// Corner radius of a selected row. +pub const ROW_RADIUS: f32 = 10.0; + +/// labelColor / secondaryLabelColor / tertiaryLabelColor opacities. +pub const PRIMARY: f32 = 0.88; +pub const SECONDARY: f32 = 0.55; +pub const TERTIARY: f32 = 0.30; + +/// systemFill levels, from most to least prominent. +pub const PRIMARY_FILL: f32 = 0.18; +pub const SECONDARY_FILL: f32 = 0.12; +pub const TERTIARY_FILL: f32 = 0.08; +pub const QUATERNARY_FILL: f32 = 0.05; + +/// Whether the window is translucent glass (the compositor blurs behind it). +pub fn translucent(theme: &ConfigTheme) -> bool { + static BLURS: std::sync::LazyLock = + std::sync::LazyLock::new(crate::platform::compositor_blurs); + match theme.glass { + GlassMode::On => true, + GlassMode::Off => false, + GlassMode::Auto => *BLURS, + } +} + +/// The window material. Dark: #1E1E1E-ish graphite; light: near-white. +/// Derived from the theme so custom background colors still apply. +pub fn window_fill(theme: &ConfigTheme) -> Color { + let base = with_alpha(theme.bg_color(), 1.0); + let (tone, alpha) = if theme.is_light() { + (tint(base, 0.55), 0.72) + } else { + (mix(base, theme.text_color(1.0), 0.12), 0.70) + }; + with_alpha(tone, if translucent(theme) { alpha } else { 1.0 }) +} + +/// The glass rim: a light edge in dark mode, a soft dark edge in light mode. +pub fn rim(theme: &ConfigTheme) -> Color { + if theme.is_light() { + Color::from_rgba(0.0, 0.0, 0.0, 0.12) + } else { + Color::from_rgba(1.0, 1.0, 1.0, 0.16) + } +} + +/// The specular highlight along the top edge of the glass. +pub fn sheen(theme: &ConfigTheme) -> Background { + let peak = if theme.is_light() { 0.9 } else { 0.30 }; + Background::Gradient( + Linear::new(Radians(std::f32::consts::FRAC_PI_2)) + .add_stop(0.0, Color::from_rgba(1.0, 1.0, 1.0, 0.0)) + .add_stop(0.5, Color::from_rgba(1.0, 1.0, 1.0, peak)) + .add_stop(1.0, Color::from_rgba(1.0, 1.0, 1.0, 0.0)) + .into(), + ) +} + +/// separatorColor. +pub fn separator(theme: &ConfigTheme) -> Color { + label(theme, if theme.is_light() { 0.10 } else { 0.09 }) +} + +/// A label color at `opacity` (one of PRIMARY / SECONDARY / TERTIARY). +pub fn label(theme: &ConfigTheme, opacity: f32) -> Color { + // Light-mode labels are black at these opacities; dark-mode labels are + // the theme's text color. + theme.text_color(opacity) +} + +/// A systemFill overlay at `level`, drawn over the window material. +pub fn fill(theme: &ConfigTheme, level: f32) -> Color { + let k = if theme.is_light() { 0.55 } else { 1.0 }; + theme.text_color(level * k) +} + +/// controlAccentColor: systemBlue (#0A84FF dark, #007AFF light). +pub fn accent(theme: &ConfigTheme) -> Color { + if theme.is_light() { + Color::from_rgb(0.0, 0.478, 1.0) + } else { + Color::from_rgb(0.039, 0.518, 1.0) + } +} + +pub fn mix(a: Color, b: Color, t: f32) -> Color { + let t = t.clamp(0.0, 1.0); + Color { + r: a.r + (b.r - a.r) * t, + g: a.g + (b.g - a.g) * t, + b: a.b + (b.b - a.b) * t, + a: a.a + (b.a - a.a) * t, + } +} + +/// Multiply a color's alpha by `fade` (for fade-in animations). +pub fn faded(mut c: Color, fade: f32) -> Color { + c.a *= fade.clamp(0.0, 1.0); + c } -/// Helper fn for making a borders color look like its glassy -pub fn glass_border(base_text: Color, focused: bool) -> Color { - let a = if focused { 0.35 } else { 0.22 }; - with_alpha(base_text, a) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opaque_material_when_glass_is_off() { + let theme = ConfigTheme { + glass: GlassMode::Off, + ..ConfigTheme::default() + }; + assert_eq!(window_fill(&theme).a, 1.0); + let glass = ConfigTheme { + glass: GlassMode::On, + ..ConfigTheme::default() + }; + assert!(window_fill(&glass).a < 1.0); + } + + #[test] + fn dark_material_is_graphite_not_black() { + let theme = ConfigTheme { + glass: GlassMode::Off, + ..ConfigTheme::default() + }; + let c = window_fill(&theme); + assert!(c.r > 0.08 && c.r < 0.16); + } + + #[test] + fn faded_multiplies_alpha() { + let c = faded(Color::from_rgba(1.0, 1.0, 1.0, 0.5), 0.5); + assert!((c.a - 0.25).abs() < f32::EPSILON); + } }