diff --git a/crates/winisland-core/src/config/mod.rs b/crates/winisland-core/src/config/mod.rs index 81b6dd3f..63c37dce 100644 --- a/crates/winisland-core/src/config/mod.rs +++ b/crates/winisland-core/src/config/mod.rs @@ -87,6 +87,14 @@ pub struct AppConfig { pub lyrics_mode: String, pub lyrics_local_dir: Option, pub custom_font_path: Option, + pub font_family: Option, + #[educe(Default = "regular")] + #[setting(choice( + "light" => "font_weight_light", + "regular" => "font_weight_regular", + "bold" => "font_weight_bold", + ))] + pub font_weight: String, pub auto_start: bool, #[setting(toggle)] pub auto_hide: bool, diff --git a/crates/winisland-core/src/context/mod.rs b/crates/winisland-core/src/context/mod.rs index c7f7555e..b0b0b171 100644 --- a/crates/winisland-core/src/context/mod.rs +++ b/crates/winisland-core/src/context/mod.rs @@ -44,6 +44,15 @@ impl ActivityManager { .max_by_key(|item| (item.priority, item.updated_at, item.id)) } + /// Returns the activity `current` would pick if `id` were absent. + pub fn current_excluding(&self, id: u64) -> Option<&Activity> { + let now = Instant::now(); + self.activities + .iter() + .filter(|item| item.id != id && item.visible_at(now)) + .max_by_key(|item| (item.priority, item.updated_at, item.id)) + } + pub fn keeps_visible(&self) -> bool { let now = Instant::now(); self.activities diff --git a/crates/winisland-core/src/multitask.rs b/crates/winisland-core/src/multitask.rs index 7ff87609..1dccb512 100644 --- a/crates/winisland-core/src/multitask.rs +++ b/crates/winisland-core/src/multitask.rs @@ -2,10 +2,11 @@ use std::time::{Duration, Instant}; use crate::anim::{Curve, Tween}; -const SEPARATE_DURATION: Duration = Duration::from_millis(750); -const MERGE_DURATION: Duration = Duration::from_millis(500); -const EXPAND_MERGE_DURATION: Duration = Duration::from_millis(320); -const CONTENT_DURATION: Duration = Duration::from_millis(140); +const SEPARATE_DURATION: Duration = Duration::from_millis(540); +const RESEPARATE_DURATION: Duration = Duration::from_millis(320); +const MERGE_DURATION: Duration = Duration::from_millis(360); +const EXPAND_MERGE_DURATION: Duration = Duration::from_millis(230); +const CONTENT_DURATION: Duration = Duration::from_millis(100); #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct TaskId(pub u64); @@ -35,6 +36,7 @@ pub struct Multitask { current: Option>, separation: Tween, content: Tween, + detached: Option, } impl Default for Multitask { @@ -50,6 +52,7 @@ impl Multitask { current: None, separation: Tween::new(0.0), content: Tween::new(0.0), + detached: None, } } @@ -76,7 +79,11 @@ impl Multitask { && (compact_settled || self.separation.value() > 0.0) && !replacing && self.current.is_some(); - let duration = if detach { + let returning = + self.detached.is_some() && self.detached == self.current.map(|task| task.id); + let duration = if detach && returning { + RESEPARATE_DURATION + } else if detach { SEPARATE_DURATION } else if presentation == Presentation::Expanded { EXPAND_MERGE_DURATION @@ -100,6 +107,11 @@ impl Multitask { .retarget(1.0, SEPARATE_DURATION, Curve::EaseInRebound, now); } } + if self.current.is_none() { + self.detached = None; + } else if self.separation.value() >= 0.99 { + self.detached = self.current.map(|task| task.id); + } let show_content = self.current.is_some() && self.separation.value() >= 0.55 && detach; self.content.retarget( if show_content { 1.0 } else { 0.0 }, diff --git a/crates/winisland-platform-windows/Cargo.toml b/crates/winisland-platform-windows/Cargo.toml index 2ef2086c..b8bd6102 100644 --- a/crates/winisland-platform-windows/Cargo.toml +++ b/crates/winisland-platform-windows/Cargo.toml @@ -8,7 +8,7 @@ winisland-platform = { path = "../winisland-platform" } windows-core = "0.62.2" windows-collections = "0.3.2" windows-registry = "0.6.1" -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Wmi", "Win32_System_Variant", "Win32_Globalization", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_DataExchange", "Win32_System_Memory","Win32_System_SystemInformation", "Win32_System_Performance", "Win32_System_LibraryLoader", "Win32_System_EventLog", "Win32_System_WinRT", "Win32_System_WinRT_Composition", "Win32_Media_MediaFoundation", "Win32_Graphics_Direct2D", "Win32_Graphics_Direct2D_Common", "Win32_System_WinRT_Graphics_Direct2D", "Graphics", "Win32_Media_Audio", "Win32_Media_Audio_Endpoints", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Touch", "Win32_Storage_Packaging_Appx", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", "Win32_Graphics_Dwm", "Win32_UI_Magnification", "Win32_UI_HiDpi", "Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D11", "Win32_System_WinRT_Direct3D11", "Win32_System_WinRT_Graphics_Capture", "Graphics_Capture", "Graphics_DirectX", "Security_Authorization_AppCapabilityAccess", "Graphics_DirectX_Direct3D11", "Graphics_Effects", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Storage_FileSystem", "ApplicationModel", "Foundation", "Foundation_Metadata", "Globalization", "Foundation_Collections", "Storage_Streams", "UI_Notifications", "UI_Notifications_Management", "UI_Composition", "UI_Composition_Desktop", "Media_Control", "Graphics_Imaging", "Data_Xml_Dom", "System"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Wmi", "Win32_System_Variant", "Win32_Globalization", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_DataExchange", "Win32_System_Memory","Win32_System_SystemInformation", "Win32_System_Performance", "Win32_System_LibraryLoader", "Win32_System_EventLog", "Win32_System_WinRT", "Win32_System_WinRT_Composition", "Win32_Media_MediaFoundation", "Win32_Graphics_Direct2D", "Win32_Graphics_Direct2D_Common", "Win32_System_WinRT_Graphics_Direct2D", "Graphics", "Win32_Media_Audio", "Win32_Media_Audio_Endpoints", "Win32_Devices_FunctionDiscovery", "Win32_System_Com_StructuredStorage", "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Touch", "Win32_Storage_Packaging_Appx", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", "Win32_Graphics_Dwm", "Win32_UI_Magnification", "Win32_UI_HiDpi", "Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D11", "Win32_System_WinRT_Direct3D11", "Win32_System_WinRT_Graphics_Capture", "Graphics_Capture", "Graphics_DirectX", "Security_Authorization_AppCapabilityAccess", "Graphics_DirectX_Direct3D11", "Graphics_Effects", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Storage_FileSystem", "ApplicationModel", "Foundation", "Foundation_Metadata", "Globalization", "Foundation_Collections", "Storage_Streams", "UI_Notifications", "UI_Notifications_Management", "UI_Composition", "UI_Composition_Desktop", "Media_Control", "Graphics_Imaging", "Data_Xml_Dom", "System"] } dirs = "6.0" log = { version = "0.4", features = ["std"] } parking_lot = "0.12" diff --git a/crates/winisland-platform-windows/src/audio.rs b/crates/winisland-platform-windows/src/audio.rs index 4b2a8ad8..2843f13a 100644 --- a/crates/winisland-platform-windows/src/audio.rs +++ b/crates/winisland-platform-windows/src/audio.rs @@ -1,10 +1,12 @@ mod capture; +mod outputs; mod volume; use std::cell::RefCell; use winisland_platform::{ - AudioMeter, AudioProvider, PlatformError, ProcessCapture, VolumeEndpoint, VolumeState, + AudioMeter, AudioOutputDevice, AudioProvider, PlatformError, ProcessCapture, VolumeEndpoint, + VolumeState, }; pub struct WindowsAudio; @@ -76,4 +78,12 @@ impl AudioProvider for WindowsAudio { .is_some_and(VolumeEndpoint::take_device_change)) }) } + + fn output_devices(&self) -> Result, PlatformError> { + outputs::output_devices() + } + + fn set_default_output(&self, id: &str) -> Result<(), PlatformError> { + outputs::set_default_output(id) + } } diff --git a/crates/winisland-platform-windows/src/audio/outputs.rs b/crates/winisland-platform-windows/src/audio/outputs.rs new file mode 100644 index 00000000..d7099566 --- /dev/null +++ b/crates/winisland-platform-windows/src/audio/outputs.rs @@ -0,0 +1,116 @@ +use std::ffi::c_void; + +use windows::Win32::Devices::FunctionDiscovery::PKEY_Device_FriendlyName; +use windows::Win32::Media::Audio::{ + DEVICE_STATE_ACTIVE, DigitalAudioDisplayDevice, ERole, Headphones, Headset, IMMDevice, + IMMDeviceEnumerator, MMDeviceEnumerator, PKEY_AudioEndpoint_FormFactor, eCommunications, + eConsole, eMultimedia, eRender, +}; +use windows::Win32::System::Com::StructuredStorage::{PropVariantToBSTR, PropVariantToUInt32}; +use windows::Win32::System::Com::{CLSCTX_ALL, CoCreateInstance, CoTaskMemFree, STGM_READ}; +use windows::core::{GUID, HRESULT, HSTRING, IUnknown, IUnknown_Vtbl, Interface, PCWSTR, PWSTR}; +use winisland_platform::{AudioOutputDevice, AudioOutputKind, PlatformError}; + +use crate::com::ComGuard; + +const CLSID_POLICY_CONFIG: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9); +const IID_POLICY_CONFIG: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8); + +/// Leading slots of the undocumented `IPolicyConfig` interface Windows uses to switch outputs. +#[repr(C)] +struct PolicyConfigVtbl { + base: IUnknown_Vtbl, + unused: [usize; 10], + set_default_endpoint: unsafe extern "system" fn(*mut c_void, PCWSTR, ERole) -> HRESULT, +} + +fn take_string(value: PWSTR) -> String { + // SAFETY: The string was allocated by COM for the caller and is freed exactly once. + unsafe { + let text = value.to_string().unwrap_or_default(); + CoTaskMemFree(Some(value.0 as *const c_void)); + text + } +} + +fn device_id(device: &IMMDevice) -> Option { + // SAFETY: device is used on the COM thread that created it. + unsafe { device.GetId().ok().map(take_string) } +} + +fn describe(device: &IMMDevice, default_id: Option<&str>) -> Option { + let id = device_id(device)?; + // SAFETY: The property store and its values stay on the COM thread that opened them. + let (name, form_factor) = unsafe { + let store = device.OpenPropertyStore(STGM_READ).ok()?; + let name = store + .GetValue(&PKEY_Device_FriendlyName) + .and_then(|value| PropVariantToBSTR(&value)) + .map(|name| name.to_string()) + .unwrap_or_default(); + let form_factor = store + .GetValue(&PKEY_AudioEndpoint_FormFactor) + .and_then(|value| PropVariantToUInt32(&value)) + .map_or(-1, |value| value as i32); + (name, form_factor) + }; + let kind = if form_factor == Headphones.0 || form_factor == Headset.0 { + AudioOutputKind::Headphones + } else if form_factor == DigitalAudioDisplayDevice.0 { + AudioOutputKind::Display + } else { + AudioOutputKind::Speakers + }; + Some(AudioOutputDevice { + is_default: default_id == Some(id.as_str()), + id, + name, + kind, + }) +} + +pub(super) fn output_devices() -> Result, PlatformError> { + let _com = ComGuard::mta()?; + // SAFETY: The enumerator and collection are used on this COM-initialized thread. + unsafe { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) + .map_err(PlatformError::backend)?; + let default_id = enumerator + .GetDefaultAudioEndpoint(eRender, eConsole) + .ok() + .and_then(|device| device_id(&device)); + let collection = enumerator + .EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE) + .map_err(PlatformError::backend)?; + let count = collection.GetCount().map_err(PlatformError::backend)?; + Ok((0..count) + .filter_map(|index| collection.Item(index).ok()) + .filter_map(|device| describe(&device, default_id.as_deref())) + .collect()) + } +} + +pub(super) fn set_default_output(id: &str) -> Result<(), PlatformError> { + let _com = ComGuard::mta()?; + let id = HSTRING::from(id); + // SAFETY: The policy object is queried for its documented-by-convention IID and released + // when `policy` drops; the vtable layout matches IPolicyConfig on Windows 10 and later. + unsafe { + let unknown: IUnknown = CoCreateInstance(&CLSID_POLICY_CONFIG, None, CLSCTX_ALL) + .map_err(PlatformError::backend)?; + let mut raw = std::ptr::null_mut(); + unknown + .query(&IID_POLICY_CONFIG, &mut raw) + .ok() + .map_err(PlatformError::backend)?; + let policy = IUnknown::from_raw(raw); + let vtable = *(policy.as_raw() as *const *const PolicyConfigVtbl); + for role in [eConsole, eMultimedia, eCommunications] { + ((*vtable).set_default_endpoint)(policy.as_raw(), PCWSTR(id.as_ptr()), role) + .ok() + .map_err(PlatformError::backend)?; + } + } + Ok(()) +} diff --git a/crates/winisland-platform-windows/src/backdrop.rs b/crates/winisland-platform-windows/src/backdrop.rs index e7bbad87..97a4796d 100644 --- a/crates/winisland-platform-windows/src/backdrop.rs +++ b/crates/winisland-platform-windows/src/backdrop.rs @@ -415,10 +415,11 @@ impl WindowBackdrop { }) } - pub(crate) fn update(&self, width: f32, height: f32, radius: f32) -> windows::core::Result<()> { + pub(crate) fn update(&self, rect: [f32; 4], radius: f32) -> windows::core::Result<()> { + let [x, y, width, height] = rect; let shape = (width > 0.0 && height > 0.0).then(|| ShapeGeometry { - x: 0.0, - y: 0.0, + x: quantize(x), + y: quantize(y), width: quantize(width), height: quantize(height), radius: quantize(radius.max(0.0)), diff --git a/crates/winisland-platform-windows/src/window/mod.rs b/crates/winisland-platform-windows/src/window/mod.rs index b893412b..bfd1460e 100644 --- a/crates/winisland-platform-windows/src/window/mod.rs +++ b/crates/winisland-platform-windows/src/window/mod.rs @@ -592,6 +592,21 @@ impl WindowSystem for WindowsWindowSystem { } } + fn set_text_input(&self, id: WindowId, area: Option) { + let _ = with_window(id, |window| { + window.set_ime_allowed(area.is_some()); + if let Some(area) = area { + window.set_ime_cursor_area( + PhysicalPosition::new(area.left, area.top), + PhysicalSize::new( + (area.right - area.left).max(1) as u32, + (area.bottom - area.top).max(1) as u32, + ), + ); + } + }); + } + fn set_titlebar_theme(&self, id: WindowId, is_light: bool) { let _ = with_window(id, |window| { if let Some(hwnd) = window_hwnd(window) { @@ -678,7 +693,7 @@ impl WindowSystem for WindowsWindowSystem { Ok(()) } - fn update_window_backdrop(&self, id: WindowId, width: f32, height: f32, radius: f32) -> bool { + fn update_window_backdrop(&self, id: WindowId, rect: [f32; 4], radius: f32) -> bool { let window_backdrop = WINDOWS.with(|windows| { windows .borrow() @@ -688,7 +703,7 @@ impl WindowSystem for WindowsWindowSystem { let Some(window_backdrop) = window_backdrop else { return false; }; - if let Err(error) = window_backdrop.update(width, height, radius) { + if let Err(error) = window_backdrop.update(rect, radius) { log::warn!("Window backdrop update failed: {error}"); let released = WINDOWS.with(|windows| { windows diff --git a/crates/winisland-platform/src/audio.rs b/crates/winisland-platform/src/audio.rs index 426be428..04877c6c 100644 --- a/crates/winisland-platform/src/audio.rs +++ b/crates/winisland-platform/src/audio.rs @@ -1,4 +1,4 @@ -use crate::{PlatformError, VolumeCommand, VolumeState}; +use crate::{AudioOutputDevice, PlatformError, VolumeCommand, VolumeState}; /// Device-scoped meter. Use on an audio worker; calls return empty or zero when unavailable. pub trait AudioMeter { @@ -60,4 +60,8 @@ pub trait AudioProvider { fn toggle_mute(&self) -> Result<(), PlatformError>; /// Polls for a default device change, or returns a subscription error. fn poll_device_events(&self) -> Result; + /// Lists active playback devices, marking the default one. + fn output_devices(&self) -> Result, PlatformError>; + /// Makes `id` the default playback device for every role. + fn set_default_output(&self, id: &str) -> Result<(), PlatformError>; } diff --git a/crates/winisland-platform/src/values.rs b/crates/winisland-platform/src/values.rs index 65d66c96..9cf8a7e8 100644 --- a/crates/winisland-platform/src/values.rs +++ b/crates/winisland-platform/src/values.rs @@ -256,6 +256,21 @@ pub struct VolumeState { pub muted: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AudioOutputKind { + Speakers, + Headphones, + Display, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AudioOutputDevice { + pub id: String, + pub name: String, + pub kind: AudioOutputKind, + pub is_default: bool, +} + #[derive(Clone, Copy, Debug)] pub enum VolumeCommand { StepUp, diff --git a/crates/winisland-platform/src/window.rs b/crates/winisland-platform/src/window.rs index a1f58280..752944a5 100644 --- a/crates/winisland-platform/src/window.rs +++ b/crates/winisland-platform/src/window.rs @@ -110,6 +110,9 @@ pub trait WindowSystem { /// Sets the cursor icon; a missing window is ignored. fn set_cursor(&self, id: WindowId, cursor: CursorKind); fn set_plugin_input(&self, id: WindowId, capture: bool, keyboard: bool); + /// Enables IME text entry with candidates placed at `area` in client physical pixels, or + /// disables it with `None`; a missing window is ignored. + fn set_text_input(&self, id: WindowId, area: Option); /// Applies the titlebar theme; unsupported or missing windows are ignored. fn set_titlebar_theme(&self, id: WindowId, is_light: bool); /// Starts composition backdrop for a live overlay; unavailable support returns an error. @@ -127,9 +130,9 @@ pub trait WindowSystem { /// Starts composition backdrop beneath a settings window's own content; disabled transparency /// or unavailable support returns an error. fn start_window_backdrop(&self, id: WindowId) -> Result<(), PlatformError>; - /// Shapes the window backdrop from the client origin in physical pixels; false means absent, - /// and an update error releases it. - fn update_window_backdrop(&self, id: WindowId, width: f32, height: f32, radius: f32) -> bool; + /// Shapes the window backdrop as a rounded rectangle in client physical pixels; false means + /// absent, and an update error releases it. + fn update_window_backdrop(&self, id: WindowId, rect: [f32; 4], radius: f32) -> bool; /// Starts screen sampling beneath an overlay on the adapter with `adapter_luid`, excluding the /// overlay from all screen captures until stopped; unavailable support returns an error. fn start_backdrop_capture( diff --git a/crates/winisland-render/src/text/mod.rs b/crates/winisland-render/src/text/mod.rs index 7b81dc0f..9b490198 100644 --- a/crates/winisland-render/src/text/mod.rs +++ b/crates/winisland-render/src/text/mod.rs @@ -5,21 +5,32 @@ use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::OnceLock; -use skia_safe::{Font, FontMgr, FontStyle as SkFontStyle, Paint, Path, Typeface, image_filters}; +use skia_safe::font_arguments::{VariationPosition, variation_position::Coordinate}; +use skia_safe::font_style::{Slant as SkSlant, Width as SkWidth}; +use skia_safe::{ + Font, FontArguments, FontMgr, FontStyle as SkFontStyle, Paint, Path, Typeface, image_filters, + paint, +}; use crate::painter::Painter; -use crate::types::{BlurSpec, FontStyle, Rgba, Slant}; +use crate::types::{BlurSpec, FontStyle, FontWeight, Rgba}; mod plugin; pub use plugin::{PluginTextMetrics, PluginTextParams, PluginTextRun}; static GLOBAL_FONT_MANAGER: OnceLock = OnceLock::new(); -type TextGroup = (String, Typeface, bool, f32); +#[derive(Clone)] +struct Face { + typeface: Typeface, + synthetic: f32, +} + +type TextGroup = (String, Face, f32); type TextGroups = Vec; type TextCacheValue = (f32, TextGroups); type TextCacheMap = HashMap; -type TextPathCacheMap = HashMap>; +type TextPathCacheMap = HashMap>; type TextPrefixCacheMap = HashMap>; fn text_paint(color: Rgba, blur: Option) -> Paint { @@ -39,16 +50,12 @@ fn text_paint(color: Rgba, blur: Option) -> Paint { paint } -pub struct DrawTextInRectParams<'a> { - pub painter: Painter<'a>, - pub text: &'a str, - pub x: f32, - pub y: f32, - pub w: f32, - pub size: f32, - pub bold: bool, - pub color: Rgba, - pub blur: Option, +fn emboldened_paint(paint: &Paint, stroke_width: f32) -> Paint { + let mut paint = paint.clone(); + paint.set_style(paint::Style::StrokeAndFill); + paint.set_stroke_width(stroke_width); + paint.set_stroke_join(paint::Join::Round); + paint } pub struct DrawTextCachedParams<'a> { @@ -57,33 +64,54 @@ pub struct DrawTextCachedParams<'a> { pub x: f32, pub y: f32, pub size: f32, - pub bold: bool, + pub weight: FontWeight, pub color: Rgba, pub blur: Option, } +pub struct FontSelection<'a> { + pub family: Option<&'a str>, + pub file: Option<&'a str>, + pub weight_offset: i16, + pub locale: &'a str, +} + pub struct FontManager; -struct CustomTypefaceState { - path: Option, - typeface: Option, - load_attempted: bool, +struct FontChoice { + family: Option, + file: Option, + weight_offset: i16, + locale: String, + user: Option>, } thread_local! { static FONT_MGR: FontMgr = FontMgr::new(); - static FALLBACK_CACHE: RefCell> = RefCell::new(HashMap::new()); + static SYSTEM_TYPEFACE: Typeface = system_typeface(); + static FONT_CHOICE: RefCell = const { RefCell::new(FontChoice { + family: None, + file: None, + weight_offset: 0, + locale: String::new(), + user: None, + }) }; + static FACE_CACHE: RefCell> = RefCell::new(HashMap::new()); + static FALLBACK_CACHE: RefCell> = RefCell::new(HashMap::new()); static TEXT_CACHE: RefCell = RefCell::new(HashMap::new()); static TEXT_PATH_CACHE: RefCell = RefCell::new(HashMap::new()); static TEXT_PREFIX_CACHE: RefCell = RefCell::new(HashMap::new()); static TEXT_WRAP_CACHE: RefCell>> = RefCell::new(HashMap::new()); - static CUSTOM_TYPEFACE: RefCell = const { RefCell::new(CustomTypefaceState { - path: None, - typeface: None, - load_attempted: false, - }) }; } +const SYSTEM_FAMILIES: [&str; 3] = ["Segoe UI Variable Text", "Segoe UI", "Microsoft YaHei"]; +const FALLBACK_FAMILIES: [&str; 4] = [ + "Segoe UI Emoji", + "Microsoft YaHei", + "Segoe UI Symbol", + "Segoe UI", +]; +const DEFAULT_FALLBACK_LOCALE: &str = "zh-CN"; const FALLBACK_CACHE_LIMIT: usize = 2000; const TEXT_CACHE_LIMIT: usize = 500; const TEXT_PATH_CACHE_LIMIT: usize = 100; @@ -92,19 +120,6 @@ const TEXT_WRAP_CACHE_LIMIT: usize = 32; const NO_LINE_START: &str = ",.;:!?)%,。、;:!?)」』》"; const TEXT_PREFIX_WIDTH_LIMIT: usize = 256; -fn to_skia_font_style(style: FontStyle) -> SkFontStyle { - let slant = match style.slant() { - Slant::Upright => skia_safe::font_style::Slant::Upright, - Slant::Italic => skia_safe::font_style::Slant::Italic, - Slant::Oblique => skia_safe::font_style::Slant::Oblique, - }; - SkFontStyle::new( - i32::from(style.weight().value()).into(), - i32::from(style.width().value()).into(), - slant, - ) -} - fn evict_one_if_full(cache: &mut HashMap, limit: usize) where K: Clone + std::cmp::Eq + std::hash::Hash, @@ -153,42 +168,157 @@ fn hash_cache_key(text: &str, style: FontStyle, size: f32) -> u64 { hasher.finish() } -fn needs_synthetic_bold(tf: &Typeface, style: FontStyle) -> bool { - style.weight().value() >= 600 && *tf.font_style().weight() < 600 +fn system_typeface() -> Typeface { + FONT_MGR.with(|mgr| { + SYSTEM_FAMILIES + .into_iter() + .find_map(|family| mgr.match_family_style(family, SkFontStyle::normal())) + .unwrap_or_else(|| { + mgr.legacy_make_typeface(None, SkFontStyle::normal()) + .unwrap() + }) + }) } -fn make_font(tf: Typeface, size: f32, style: FontStyle) -> Font { - let embolden = needs_synthetic_bold(&tf, style); - let mut font = Font::from_typeface(tf, size); - font.set_subpixel(true); - if embolden { - font.set_embolden(true); +fn load_user_typeface(family: Option<&str>, file: Option<&str>) -> Option { + if let Some(path) = file { + let data = std::fs::read(path).ok()?; + return FONT_MGR.with(|mgr| mgr.new_from_data(&data, None)); } - font + let family = family?; + FONT_MGR.with(|mgr| mgr.match_family_style(family, SkFontStyle::normal())) } -fn get_custom_typeface() -> Option { - CUSTOM_TYPEFACE.with(|cache| { - let mut state = cache.borrow_mut(); - if state.load_attempted { - return state.typeface.clone(); +fn user_typeface() -> Option { + FONT_CHOICE.with(|choice| { + let mut choice = choice.borrow_mut(); + if choice.user.is_none() { + let typeface = load_user_typeface(choice.family.as_deref(), choice.file.as_deref()); + choice.user = Some(typeface); } - state.load_attempted = true; - let path = state.path.clone()?; - let data = std::fs::read(path).ok()?; - let typeface = FONT_MGR.with(|mgr| mgr.new_from_data(&data, None)); - state.typeface = typeface.clone(); - typeface + choice.user.clone().flatten() }) } -fn measure_group(text: &str, typeface: &Typeface, embolden: bool, size: f32) -> f32 { - let mut font = Font::from_typeface(typeface.clone(), size); - font.set_subpixel(true); - if embolden { - font.set_embolden(true); +fn is_font_file(typeface: &Typeface) -> bool { + FONT_CHOICE.with(|choice| { + let choice = choice.borrow(); + choice.file.is_some() + && choice + .user + .as_ref() + .and_then(Option::as_ref) + .is_some_and(|user| user.unique_id() == typeface.unique_id()) + }) +} + +fn primary_typeface() -> Typeface { + user_typeface().unwrap_or_else(|| SYSTEM_TYPEFACE.with(Typeface::clone)) +} + +fn effective_weight(style: FontStyle) -> u16 { + let offset = FONT_CHOICE.with(|choice| choice.borrow().weight_offset); + (i32::from(style.weight().value()) + i32::from(offset)).clamp(100, 900) as u16 +} + +fn weight_axis(typeface: &Typeface) -> Option<(f32, f32)> { + typeface + .variation_design_parameters()? + .into_iter() + .find(|axis| axis.tag == Coordinate::wght) + .map(|axis| (axis.min, axis.max)) +} + +/// Stroke width, as a fraction of the font size, that stands in for weight a family lacks. +fn synthetic_amount(typeface: &Typeface, missing: f32) -> f32 { + if missing < 40.0 || typeface.family_name().contains("Emoji") { + 0.0 + } else { + (0.005 + missing / 10_000.0).min(0.04) } - font.measure_str(text, None).0 +} + +/// Picks the heaviest face of the family that does not exceed `weight`. +fn closest_face(base: &Typeface, weight: u16) -> Typeface { + let family = base.family_name(); + let target = i32::from(weight); + FONT_MGR + .with(|mgr| { + let mut set = mgr.match_family(&family); + let mut below: Option<(i32, usize)> = None; + let mut above: Option<(i32, usize)> = None; + for index in 0..set.count() { + let (style, _) = set.style(index); + if style.slant() != SkSlant::Upright || style.width() != SkWidth::NORMAL { + continue; + } + let face_weight = *style.weight(); + if face_weight <= target { + if below.is_none_or(|(best, _)| face_weight > best) { + below = Some((face_weight, index)); + } + } else if above.is_none_or(|(best, _)| face_weight < best) { + above = Some((face_weight, index)); + } + } + below + .or(above) + .and_then(|(_, index)| set.new_typeface(index)) + }) + .unwrap_or_else(|| base.clone()) +} + +fn resolve_face(base: &Typeface, weight: u16) -> Face { + let key = (base.unique_id(), weight); + if let Some(face) = FACE_CACHE.with(|cache| cache.borrow().get(&key).cloned()) { + return face; + } + let variable = weight_axis(base).and_then(|(min, max)| { + let target = f32::from(weight).clamp(min, max); + let coordinates = [Coordinate { + axis: Coordinate::wght, + value: target, + }]; + let arguments = FontArguments::new().set_variation_design_position(VariationPosition { + coordinates: &coordinates, + }); + base.clone_with_arguments(&arguments).map(|typeface| Face { + synthetic: synthetic_amount(base, f32::from(weight) - target), + typeface, + }) + }); + let face = variable.unwrap_or_else(|| { + let typeface = if is_font_file(base) { + base.clone() + } else { + closest_face(base, weight) + }; + let missing = f32::from(weight) - *typeface.font_style().weight() as f32; + Face { + synthetic: synthetic_amount(&typeface, missing), + typeface, + } + }); + FACE_CACHE.with(|cache| cache.borrow_mut().insert(key, face.clone())); + face +} + +fn make_font(face: &Face, size: f32) -> Font { + let mut font = Font::from_typeface(face.typeface.clone(), size); + font.set_subpixel(true); + font +} + +fn synthetic_stroke(face: &Face, size: f32) -> f32 { + size * face.synthetic * (18.0 / size).clamp(1.0, 1.4) +} + +fn face_paint(paint: &Paint, face: &Face, size: f32) -> Option { + (face.synthetic > 0.0).then(|| emboldened_paint(paint, synthetic_stroke(face, size))) +} + +fn measure_group(text: &str, face: &Face, size: f32) -> f32 { + make_font(face, size).measure_str(text, None).0 } fn typeface_supports_char(typeface: &Typeface, character: char) -> bool { @@ -197,48 +327,52 @@ fn typeface_supports_char(typeface: &Typeface, character: char) -> bool { glyphs[0] != 0 } -fn get_typeface_for_char(c: char, style: FontStyle) -> (Typeface, bool) { - let sk_style = to_skia_font_style(style); - let s_key = style.cache_key(); +fn typeface_supports_text(typeface: &Typeface, text: &str) -> bool { + let characters: Vec = text.chars().map(|character| character as i32).collect(); + let mut glyphs = vec![0u16; characters.len()]; + typeface.unichars_to_glyphs(&characters, &mut glyphs); + glyphs.iter().all(|glyph| *glyph != 0) +} + +fn fallback_typeface(c: char) -> Typeface { + let locale = FONT_CHOICE.with(|choice| choice.borrow().locale.clone()); + let locale = if locale.is_empty() { + DEFAULT_FALLBACK_LOCALE + } else { + locale.as_str() + }; + FONT_MGR.with(|mgr| { + mgr.match_family_style_character("", SkFontStyle::normal(), &[locale], c as i32) + .filter(|tf| typeface_supports_char(tf, c)) + .or_else(|| { + FALLBACK_FAMILIES.into_iter().find_map(|family| { + mgr.match_family_style(family, SkFontStyle::normal()) + .filter(|tf| typeface_supports_char(tf, c)) + }) + }) + .unwrap_or_else(|| { + mgr.legacy_make_typeface(None, SkFontStyle::normal()) + .unwrap() + }) + }) +} + +fn face_for_char(c: char, weight: u16) -> Face { FALLBACK_CACHE.with(|cache| { - let mut cache = cache.borrow_mut(); - if let Some(tf) = cache.get(&(c, s_key)) { - let embolden = needs_synthetic_bold(tf, style); - return (tf.clone(), embolden); + if let Some(face) = cache.borrow().get(&(c, weight)) { + return face.clone(); } + let primary = primary_typeface(); + let base = if typeface_supports_char(&primary, c) { + primary + } else { + fallback_typeface(c) + }; + let face = resolve_face(&base, weight); + let mut cache = cache.borrow_mut(); evict_one_if_full(&mut cache, FALLBACK_CACHE_LIMIT); - - if let Some(tf) = get_custom_typeface() { - let mut glyphs = [0u16; 1]; - tf.unichars_to_glyphs(&[c as i32], &mut glyphs); - if glyphs[0] != 0 { - let embolden = needs_synthetic_bold(&tf, style); - cache.insert((c, s_key), tf.clone()); - return (tf, embolden); - } - } - - let tf = FONT_MGR.with(|mgr| { - mgr.match_family_style_character("", sk_style, &["zh-CN", "ja-JP", "en-US"], c as i32) - .filter(|tf| typeface_supports_char(tf, c)) - .or_else(|| { - [ - "Segoe UI Emoji", - "Microsoft YaHei", - "Segoe UI Symbol", - "Segoe UI", - ] - .into_iter() - .find_map(|family| { - mgr.match_family_style(family, sk_style) - .filter(|tf| typeface_supports_char(tf, c)) - }) - }) - .unwrap_or_else(|| mgr.legacy_make_typeface(None, sk_style).unwrap()) - }); - let embolden = needs_synthetic_bold(&tf, style); - cache.insert((c, s_key), tf.clone()); - (tf, embolden) + cache.insert((c, weight), face.clone()); + face }) } @@ -252,134 +386,152 @@ fn is_ascii_text(text: &str) -> bool { text.bytes().all(|b| b.is_ascii()) } +fn same_face(a: &Face, b: &Face) -> bool { + a.typeface.unique_id() == b.typeface.unique_id() && a.synthetic == b.synthetic +} + /// Compute text groups and total width. /// Falls back to a single typeface for ASCII-only text to skip per-char lookups. fn compute_text_groups(text: &str, size: f32, style: FontStyle) -> (f32, TextGroups) { - let sk_style = to_skia_font_style(style); - let mut current_w = 0.0; - let mut groups: TextGroups = Vec::new(); - - if is_ascii_text(text) { - let tf = get_custom_typeface().unwrap_or_else(|| { - FONT_MGR.with(|mgr| { - mgr.match_family_style("Microsoft YaHei", sk_style) - .or_else(|| mgr.match_family_style("Segoe UI", sk_style)) - .unwrap_or_else(|| mgr.legacy_make_typeface(None, sk_style).unwrap()) - }) - }); - let embolden = needs_synthetic_bold(&tf, style); - let mut font = Font::from_typeface(tf.clone(), size); - font.set_subpixel(true); - if embolden { - font.set_embolden(true); - } - let (w, _) = font.measure_str(text, None); - current_w += w; - groups.push((text.to_string(), tf, embolden, w)); - return (current_w, groups); + let weight = effective_weight(style); + let primary = primary_typeface(); + if is_ascii_text(text) && typeface_supports_text(&primary, text) { + let face = resolve_face(&primary, weight); + let width = measure_group(text, &face, size); + return (width, vec![(text.to_string(), face, width)]); } + let mut current_w = 0.0; + let mut groups: TextGroups = Vec::new(); let mut current_group = String::new(); - let mut last_tf: Option = None; - let mut last_embolden = false; + let mut last_face: Option = None; for c in text.chars() { - let (tf, embolden) = if inherits_previous_typeface(c) { - last_tf - .as_ref() - .map(|tf| (tf.clone(), last_embolden)) - .unwrap_or_else(|| get_typeface_for_char(c, style)) - } else { - get_typeface_for_char(c, style) + let face = match &last_face { + Some(face) if inherits_previous_typeface(c) => face.clone(), + _ => face_for_char(c, weight), }; - if let Some(ref ltf) = last_tf - && (ltf.unique_id() != tf.unique_id() || last_embolden != embolden) + if let Some(last) = &last_face + && !same_face(last, &face) { let group_text = std::mem::take(&mut current_group); - let width = measure_group(&group_text, ltf, last_embolden, size); + let width = measure_group(&group_text, last, size); current_w += width; - groups.push((group_text, ltf.clone(), last_embolden, width)); + groups.push((group_text, last.clone(), width)); } - last_tf = Some(tf); - last_embolden = embolden; + last_face = Some(face); current_group.push(c); } - if let Some(ltf) = last_tf { - let width = measure_group(¤t_group, <f, last_embolden, size); + if let Some(last) = last_face { + let width = measure_group(¤t_group, &last, size); current_w += width; - groups.push((current_group, ltf, last_embolden, width)); + groups.push((current_group, last, width)); } (current_w, groups) } -fn compute_text_paths(text: &str, size: f32, style: FontStyle) -> Vec { +fn compute_text_paths(text: &str, size: f32, style: FontStyle) -> Vec<(Path, f32)> { let (_, groups) = compute_text_groups(text, size, style); let mut x = 0.0; groups .into_iter() - .map(|(text, typeface, embolden, width)| { - let mut font = Font::from_typeface(typeface, size); - font.set_subpixel(true); - if embolden { - font.set_embolden(true); - } + .map(|(text, face, width)| { + let font = make_font(&face, size); let path = skia_safe::utils::text_utils::get_path(text.as_str(), (x, 0.0), &font); x += width; - path + (path, synthetic_stroke(&face, size)) }) .collect() } +fn with_text_groups( + text: &str, + size: f32, + style: FontStyle, + f: impl FnOnce(&TextCacheValue) -> R, +) -> R { + let cache_key = hash_cache_key(text, style, size); + TEXT_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if !cache.contains_key(&cache_key) { + evict_one_if_full(&mut cache, TEXT_CACHE_LIMIT); + } + let entry = cache + .entry(cache_key) + .or_insert_with(|| compute_text_groups(text, size, style)); + f(entry) + }) +} + impl FontManager { pub fn global() -> &'static FontManager { GLOBAL_FONT_MANAGER.get_or_init(|| FontManager) } - pub fn set_custom_font_path(&self, path: Option<&str>) { - let path = path.map(str::to_owned); - CUSTOM_TYPEFACE.with(|cache| { - let mut state = cache.borrow_mut(); - state.path = path; - state.typeface = None; - state.load_attempted = false; + /// Selects the interface font: a font file wins over an installed family, + /// `weight_offset` shifts every requested weight, and `locale` (BCP 47) picks the + /// regional glyphs of fallback fonts. + pub fn configure(&self, selection: FontSelection<'_>) { + FONT_CHOICE.with(|choice| { + let mut choice = choice.borrow_mut(); + choice.family = selection.family.map(str::to_owned); + choice.file = selection.file.map(str::to_owned); + choice.weight_offset = selection.weight_offset; + choice.locale = selection.locale.to_owned(); + choice.user = None; }); - self.clear_text_caches(); + FACE_CACHE.with(|cache| cache.borrow_mut().clear()); FALLBACK_CACHE.with(|cache| cache.borrow_mut().clear()); + self.clear_text_caches(); + } + + /// Family name of the font chosen through [`Self::configure`], when it loaded. + pub fn custom_family_name(&self) -> Option { + user_typeface().map(|typeface| typeface.family_name()) + } + + pub fn has_family(&self, family: &str) -> bool { + FONT_MGR.with(|mgr| mgr.match_family(family).count() > 0) + } + + pub fn installed_families(&self) -> Vec { + let mut families: Vec = FONT_MGR.with(|mgr| { + mgr.family_names() + .filter(|family| !family.is_empty() && !family.starts_with('@')) + .collect() + }); + families.sort_by_key(|family| family.to_lowercase()); + families.dedup(); + families } pub fn clear_text_caches(&self) { TEXT_CACHE.with(|cache| cache.borrow_mut().clear()); TEXT_PATH_CACHE.with(|cache| cache.borrow_mut().clear()); TEXT_PREFIX_CACHE.with(|cache| cache.borrow_mut().clear()); + TEXT_WRAP_CACHE.with(|cache| cache.borrow_mut().clear()); } - fn get_font(&self, size: f32, bold: bool) -> Font { - let style = if bold { - FontStyle::bold() - } else { - FontStyle::normal() - }; - if let Some(tf) = get_custom_typeface() { - return make_font(tf, size, style); - } - let sk_style = to_skia_font_style(style); - let typeface = FONT_MGR.with(|mgr| { - mgr.match_family_style("Microsoft YaHei", sk_style) - .or_else(|| mgr.match_family_style("Segoe UI", sk_style)) - .unwrap_or_else(|| mgr.legacy_make_typeface(None, sk_style).unwrap()) - }); - make_font(typeface, size, style) - } - - pub fn measure_str(&self, text: &str, size: f32, bold: bool) -> crate::types::Rect { - let font = self.get_font(size, bold); - let (_, bounds) = font.measure_str(text, None); - crate::types::Rect::from_ltrb(bounds.left, bounds.top, bounds.right, bounds.bottom) + pub fn measure_str(&self, text: &str, size: f32, weight: FontWeight) -> crate::types::Rect { + let paint = text_paint(Rgba::WHITE, None); + with_text_groups(text, size, weight.into(), |(_, groups)| { + let mut x = 0.0; + let mut bounds: Option = None; + for (text, face, width) in groups { + let synthetic = face_paint(&paint, face, size); + let (_, rect) = make_font(face, size).measure_str(text, synthetic.as_ref()); + let rect = rect.with_offset((x, 0.0)); + bounds = Some(bounds.map_or(rect, |bounds| skia_safe::Rect::join2(bounds, rect))); + x += width; + } + let bounds = bounds.unwrap_or_default(); + crate::types::Rect::from_ltrb(bounds.left, bounds.top, bounds.right, bounds.bottom) + }) } - pub fn ascent(&self, size: f32, bold: bool) -> f32 { - let font = self.get_font(size, bold); - let (_, metrics) = font.metrics(); + pub fn ascent(&self, size: f32, weight: FontWeight) -> f32 { + let face = resolve_face(&primary_typeface(), effective_weight(weight.into())); + let (_, metrics) = make_font(&face, size).metrics(); metrics.ascent } @@ -389,51 +541,29 @@ impl FontManager { text: &str, at: crate::types::Point, size: f32, - bold: bool, + weight: FontWeight, color: Rgba, ) { - let font = self.get_font(size, bold); - let paint = text_paint(color, None); - painter.canvas().draw_str(text, (at.x, at.y), &font, &paint); - } - - pub fn draw_text_in_rect(&self, params: DrawTextInRectParams<'_>) { - let font = self.get_font(params.size, params.bold); - let paint = text_paint(params.color, params.blur); - let canvas = params.painter.canvas(); - let (_, rect) = font.measure_str(params.text, None); - if rect.width() <= params.w { - canvas.draw_str( - params.text, - (params.x + (params.w - rect.width()) / 2.0, params.y), - &font, - &paint, - ); - } else { - let mut truncated = String::new(); - let mut current_w = 0.0; - let (ellipsis_w, _) = font.measure_str("...", None); - let max_w = params.w - ellipsis_w; - for c in params.text.chars() { - let (cw, _) = font.measure_str(c.to_string(), None); - if current_w + cw > max_w { - break; - } - current_w += cw; - truncated.push(c); - } - truncated.push_str("..."); - canvas.draw_str(&truncated, (params.x, params.y), &font, &paint); - } + self.draw_text_cached(DrawTextCachedParams { + painter, + text, + x: at.x, + y: at.y, + size, + weight, + color, + blur: None, + }); } pub fn wrap_text( &self, text: &str, size: f32, - style: FontStyle, + style: impl Into, max_width: f32, ) -> Vec { + let style = style.into(); let mut hasher = std::collections::hash_map::DefaultHasher::new(); hash_cache_key(text, style, size).hash(&mut hasher); max_width.to_bits().hash(&mut hasher); @@ -465,19 +595,8 @@ impl FontManager { lines } - pub fn measure_text_cached(&self, text: &str, size: f32, style: FontStyle) -> f32 { - let cache_key = hash_cache_key(text, style, size); - TEXT_CACHE.with(|cache| { - let mut cache_mut = cache.borrow_mut(); - if !cache_mut.contains_key(&cache_key) { - evict_one_if_full(&mut cache_mut, TEXT_CACHE_LIMIT); - } - let entry = cache_mut.entry(cache_key).or_insert_with(|| { - let (width, groups) = compute_text_groups(text, size, style); - (width, groups) - }); - entry.0 - }) + pub fn measure_text_cached(&self, text: &str, size: f32, style: impl Into) -> f32 { + with_text_groups(text, size, style.into(), |(width, _)| *width) } pub fn measure_text_prefixes_cached( @@ -486,8 +605,9 @@ impl FontManager { first_end: usize, second_end: usize, size: f32, - style: FontStyle, + style: impl Into, ) -> (f32, f32) { + let style = style.into(); let cache_key = hash_cache_key(text, style, size); TEXT_PREFIX_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); @@ -514,43 +634,31 @@ impl FontManager { } pub fn draw_text_cached(&self, params: DrawTextCachedParams<'_>) { - let style = if params.bold { - FontStyle::bold() - } else { - FontStyle::normal() - }; - let cache_key = hash_cache_key(params.text, style, params.size); let paint = text_paint(params.color, params.blur); - TEXT_CACHE.with(|cache| { - let mut cache_mut = cache.borrow_mut(); - if !cache_mut.contains_key(&cache_key) { - evict_one_if_full(&mut cache_mut, TEXT_CACHE_LIMIT); - } - let entry = cache_mut - .entry(cache_key) - .or_insert_with(|| compute_text_groups(params.text, params.size, style)); - let (_, groups) = entry; - let canvas = params.painter.canvas(); - let mut x = params.x; - let y = params.y; - for (s, tf, embolden, width) in groups { - let mut font = Font::from_typeface(tf.clone(), params.size); - font.set_subpixel(true); - if *embolden { - font.set_embolden(true); + with_text_groups( + params.text, + params.size, + params.weight.into(), + |(_, groups)| { + let canvas = params.painter.canvas(); + let mut x = params.x; + for (text, face, width) in groups { + let font = make_font(face, params.size); + let synthetic = face_paint(&paint, face, params.size); + canvas.draw_str( + &**text, + (x, params.y), + &font, + synthetic.as_ref().unwrap_or(&paint), + ); + x += *width; } - canvas.draw_str(&**s, (x, y), &font, &paint); - x += *width; - } - }); + }, + ); } pub fn draw_text_as_paths_cached(&self, params: DrawTextCachedParams<'_>) { - let style = if params.bold { - FontStyle::bold() - } else { - FontStyle::normal() - }; + let style = FontStyle::from(params.weight); let cache_key = hash_cache_key(params.text, style, params.size); let paint = text_paint(params.color, params.blur); TEXT_PATH_CACHE.with(|cache| { @@ -564,8 +672,12 @@ impl FontManager { let canvas = params.painter.canvas(); canvas.save(); canvas.translate((params.x, params.y)); - for path in paths { - canvas.draw_path(path, &paint); + for (path, stroke_width) in paths.iter() { + if *stroke_width > 0.0 { + canvas.draw_path(path, &emboldened_paint(&paint, *stroke_width)); + } else { + canvas.draw_path(path, &paint); + } } canvas.restore(); }); diff --git a/crates/winisland-render/src/text/plugin.rs b/crates/winisland-render/src/text/plugin.rs index 9a158168..01304a89 100644 --- a/crates/winisland-render/src/text/plugin.rs +++ b/crates/winisland-render/src/text/plugin.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use skia_safe::{Font, FontStyle as SkFontStyle}; -use super::{FONT_MGR, FontManager, get_custom_typeface, text_paint}; +use super::{FONT_MGR, FontManager, text_paint, user_typeface}; use crate::painter::Painter; use crate::types::{Rect, Rgba}; @@ -50,7 +50,7 @@ impl FontManager { }; let style = SkFontStyle::new(i32::from(weight).into(), 5.into(), slant); let typeface = if family.is_empty() { - get_custom_typeface() + user_typeface() } else { None } diff --git a/crates/winisland-render/src/types/text.rs b/crates/winisland-render/src/types/text.rs index 210dd89c..eb0372b4 100644 --- a/crates/winisland-render/src/types/text.rs +++ b/crates/winisland-render/src/types/text.rs @@ -36,6 +36,14 @@ impl FontStyle { } } + pub const fn with_weight(weight: FontWeight) -> Self { + Self { + weight, + width: FontWidth(5), + slant: Slant::Upright, + } + } + pub const fn italic() -> Self { Self { weight: FontWeight(400), @@ -70,6 +78,12 @@ impl FontStyle { } impl FontWeight { + pub const LIGHT: Self = Self(300); + pub const REGULAR: Self = Self(400); + pub const MEDIUM: Self = Self(500); + pub const SEMIBOLD: Self = Self(600); + pub const BOLD: Self = Self(700); + pub const fn new(weight: u16) -> Self { Self(weight) } @@ -83,6 +97,12 @@ impl FontWeight { } } +impl From for FontStyle { + fn from(weight: FontWeight) -> Self { + Self::with_weight(weight) + } +} + impl FontWidth { pub const fn new(width: u8) -> Self { Self(width) diff --git a/resources/in_app/guide/basics.png b/resources/in_app/guide/basics.png index 9f799985..46e901ac 100644 Binary files a/resources/in_app/guide/basics.png and b/resources/in_app/guide/basics.png differ diff --git a/resources/in_app/guide/builtin_pages.png b/resources/in_app/guide/builtin_pages.png index db8c942b..0af7cc5a 100644 Binary files a/resources/in_app/guide/builtin_pages.png and b/resources/in_app/guide/builtin_pages.png differ diff --git a/resources/in_app/guide/collapse.png b/resources/in_app/guide/collapse.png index 7a700ee9..2c59a5b7 100644 Binary files a/resources/in_app/guide/collapse.png and b/resources/in_app/guide/collapse.png differ diff --git a/resources/in_app/guide/copied_link.png b/resources/in_app/guide/copied_link.png index 10d437b1..9248ff70 100644 Binary files a/resources/in_app/guide/copied_link.png and b/resources/in_app/guide/copied_link.png differ diff --git a/resources/in_app/guide/customize.png b/resources/in_app/guide/customize.png index 026e9f13..53777726 100644 Binary files a/resources/in_app/guide/customize.png and b/resources/in_app/guide/customize.png differ diff --git a/resources/in_app/guide/drop_install.png b/resources/in_app/guide/drop_install.png index bc7f7381..7a715e52 100644 Binary files a/resources/in_app/guide/drop_install.png and b/resources/in_app/guide/drop_install.png differ diff --git a/resources/in_app/guide/expand.png b/resources/in_app/guide/expand.png index 41903bd6..98c9134a 100644 Binary files a/resources/in_app/guide/expand.png and b/resources/in_app/guide/expand.png differ diff --git a/resources/in_app/guide/fullscreen.png b/resources/in_app/guide/fullscreen.png index e91f7c20..b2d74305 100644 Binary files a/resources/in_app/guide/fullscreen.png and b/resources/in_app/guide/fullscreen.png differ diff --git a/resources/in_app/guide/gestures.png b/resources/in_app/guide/gestures.png index 48c1a50a..0637227d 100644 Binary files a/resources/in_app/guide/gestures.png and b/resources/in_app/guide/gestures.png differ diff --git a/resources/in_app/guide/gestures_shortcuts.png b/resources/in_app/guide/gestures_shortcuts.png index 3a6473ed..f7a12d90 100644 Binary files a/resources/in_app/guide/gestures_shortcuts.png and b/resources/in_app/guide/gestures_shortcuts.png differ diff --git a/resources/in_app/guide/hide_shortcut.png b/resources/in_app/guide/hide_shortcut.png index 28158ffe..59f20799 100644 Binary files a/resources/in_app/guide/hide_shortcut.png and b/resources/in_app/guide/hide_shortcut.png differ diff --git a/resources/in_app/guide/pages.png b/resources/in_app/guide/pages.png index 2ad5f44f..75221977 100644 Binary files a/resources/in_app/guide/pages.png and b/resources/in_app/guide/pages.png differ diff --git a/resources/in_app/guide/right_drag.png b/resources/in_app/guide/right_drag.png index edba0f2b..4d715277 100644 Binary files a/resources/in_app/guide/right_drag.png and b/resources/in_app/guide/right_drag.png differ diff --git a/resources/in_app/guide/shortcuts.png b/resources/in_app/guide/shortcuts.png index ae3f731f..05513069 100644 Binary files a/resources/in_app/guide/shortcuts.png and b/resources/in_app/guide/shortcuts.png differ diff --git a/resources/in_app/guide/swipe_side.png b/resources/in_app/guide/swipe_side.png index cbe16f67..0f1b19cd 100644 Binary files a/resources/in_app/guide/swipe_side.png and b/resources/in_app/guide/swipe_side.png differ diff --git a/resources/in_app/guide/swipe_up.png b/resources/in_app/guide/swipe_up.png index 47d84307..f80caa18 100644 Binary files a/resources/in_app/guide/swipe_up.png and b/resources/in_app/guide/swipe_up.png differ diff --git a/resources/in_app/guide/switch_pages.png b/resources/in_app/guide/switch_pages.png index 4739f6c3..d1182fa9 100644 Binary files a/resources/in_app/guide/switch_pages.png and b/resources/in_app/guide/switch_pages.png differ diff --git a/resources/in_app/guide/welcome.png b/resources/in_app/guide/welcome.png index 3abfdd73..7e9b00aa 100644 Binary files a/resources/in_app/guide/welcome.png and b/resources/in_app/guide/welcome.png differ diff --git a/resources/in_app/icons/airplay.audio.png b/resources/in_app/icons/airplay.audio.png new file mode 100644 index 00000000..9da7448e Binary files /dev/null and b/resources/in_app/icons/airplay.audio.png differ diff --git a/resources/in_app/icons/checkmark.png b/resources/in_app/icons/checkmark.png new file mode 100644 index 00000000..bc14c1c7 Binary files /dev/null and b/resources/in_app/icons/checkmark.png differ diff --git a/resources/in_app/icons/display.png b/resources/in_app/icons/display.png new file mode 100644 index 00000000..1cc02179 Binary files /dev/null and b/resources/in_app/icons/display.png differ diff --git a/resources/in_app/icons/headphones.png b/resources/in_app/icons/headphones.png new file mode 100644 index 00000000..64f75f75 Binary files /dev/null and b/resources/in_app/icons/headphones.png differ diff --git a/resources/in_app/icons/hifispeaker.fill.png b/resources/in_app/icons/hifispeaker.fill.png new file mode 100644 index 00000000..c6fd6884 Binary files /dev/null and b/resources/in_app/icons/hifispeaker.fill.png differ diff --git a/resources/in_app/icons/magnifyingglass.png b/resources/in_app/icons/magnifyingglass.png new file mode 100644 index 00000000..f5b0fc41 Binary files /dev/null and b/resources/in_app/icons/magnifyingglass.png differ diff --git a/resources/in_app/icons/speaker.fill.png b/resources/in_app/icons/speaker.fill.png new file mode 100644 index 00000000..971ee228 Binary files /dev/null and b/resources/in_app/icons/speaker.fill.png differ diff --git a/resources/in_app/icons/speaker.slash.fill.png b/resources/in_app/icons/speaker.slash.fill.png new file mode 100644 index 00000000..e51be990 Binary files /dev/null and b/resources/in_app/icons/speaker.slash.fill.png differ diff --git a/resources/in_app/icons/speaker.wave.1.fill.png b/resources/in_app/icons/speaker.wave.1.fill.png new file mode 100644 index 00000000..50753001 Binary files /dev/null and b/resources/in_app/icons/speaker.wave.1.fill.png differ diff --git a/resources/in_app/icons/speaker.wave.2.fill.png b/resources/in_app/icons/speaker.wave.2.fill.png new file mode 100644 index 00000000..2585a2ec Binary files /dev/null and b/resources/in_app/icons/speaker.wave.2.fill.png differ diff --git a/resources/in_app/icons/speaker.wave.3.fill.png b/resources/in_app/icons/speaker.wave.3.fill.png new file mode 100644 index 00000000..d3f68597 Binary files /dev/null and b/resources/in_app/icons/speaker.wave.3.fill.png differ diff --git a/resources/in_app/lang/en_us.lang b/resources/in_app/lang/en_us.lang index 137fd6f4..05419327 100644 --- a/resources/in_app/lang/en_us.lang +++ b/resources/in_app/lang/en_us.lang @@ -4,6 +4,10 @@ tab_music=Music tab_widgets=Widgets tab_plugins=Plugins tab_about=About +tab_appearance=Appearance +tab_layout=Size & Position +tab_behavior=Hiding & Interaction +tab_alerts=Notifications & Alerts plugin_drop_title=Drop a plugin here to install plugin_drop_hint=Accepts WinIsland plugin ZIP packages plugin_drop_release=Release to install plugin @@ -76,8 +80,8 @@ widget_now=Now widget_ready=Ready widget_playing=Playing widget_weather_empty=-- -section_appearance=APPEARANCE -section_effects=EFFECTS +section_appearance=Appearance +section_effects=Effects settings_theme=Settings Theme theme_system=Follow System theme_light=Light @@ -86,11 +90,24 @@ mini_cover_shape=Mini Cover Shape expanded_cover_shape=Expanded Cover Shape shape_square=Square shape_circle=Circle -section_behavior=BEHAVIOR -section_updates=UPDATES -section_playback=PLAYBACK -section_track_text=TRACK TEXT -section_lyrics=LYRICS +section_behavior=Behavior +section_updates=Updates +section_system=System +section_island_style=Island Style +section_font=Font +section_motion=Motion +section_position=Position +section_compact_size=Compact +section_expanded_size=Expanded +section_auto_hide=Auto Hide +section_interaction=Interaction +section_notifications=Notifications +section_system_alerts=System Indicators +search_placeholder=Search +search_no_results=No Results +section_playback=Playback +section_track_text=Track Text +section_lyrics=Lyrics compact_scale=Compact Size expanded_scale=Expanded Size base_width=Base Width @@ -107,7 +124,7 @@ motion_blur=Motion Blur cover_rotate=Cover Rotate audio_gate=Audio Gate auto_gate=Auto Gate -section_experimental=EXPERIMENTAL +section_experimental=Experimental mini_controls=Mini Controls island_style=Island Style style_default=Default @@ -124,9 +141,18 @@ liquid_glass_siri_note=Experimental option; performance is not guaranteed. style_liquid_note=Liquid Glass hides the island from screenshots and screen recordings. liquid_glass_compat_capture=Compatibility mode (visible in captures) liquid_glass_compat_capture_note=In compatibility mode the island is visible in screenshots, recordings, and remote desktop, but background latency and CPU usage increase. -custom_font=Custom Font -font_select=Select -font_reset=Reset +audio_outputs_empty=No output devices +font_family=Font +font_system_default=System Default +font_from_file=Choose File… +font_region_note=Using {} for the current language +font_weight=Weight +font_weight_light=Lighter +font_weight_regular=Standard +font_weight_bold=Bolder +font_recommended=Recommended: PingFang UI (variable) +font_download=Download +font_recommended_note=PingFang with variable weights. Install it, then pick it under Font. start_boot=Start at Boot auto_hide=Auto Hide When Inactive fullscreen_auto_hide=Auto Hide in Fullscreen @@ -168,7 +194,7 @@ lyrics_transition_slide=Slide lyrics_transition_fade=Fade folder_select=Select folder_clear=Clear -media_apps=MEDIA APPLICATIONS +media_apps=Media Apps scan_apps=Scan Apps no_sessions=No sessions detected platform_unavailable=Unavailable on this system diff --git a/resources/in_app/lang/es_es.lang b/resources/in_app/lang/es_es.lang index 2413448a..f2a2095e 100644 --- a/resources/in_app/lang/es_es.lang +++ b/resources/in_app/lang/es_es.lang @@ -4,6 +4,10 @@ tab_music=Música tab_widgets=Widgets tab_plugins=Plugins tab_about=Acerca de +tab_appearance=Apariencia +tab_layout=Tamaño y posición +tab_behavior=Ocultar e interacción +tab_alerts=Notificaciones y avisos plugin_drop_title=Arrastra un plugin aquí para instalarlo plugin_drop_hint=Acepta paquetes ZIP de plugins de WinIsland plugin_drop_release=Suelta para instalar el plugin @@ -76,8 +80,8 @@ widget_now=Ahora widget_ready=Listo widget_playing=Reproduciendo widget_weather_empty=-- -section_appearance=APARIENCIA -section_effects=EFECTOS +section_appearance=Apariencia +section_effects=Efectos settings_theme=Tema de la Isla theme_system=Seguir al Sistema theme_light=Claro @@ -86,11 +90,24 @@ mini_cover_shape=Forma de Portada Mini expanded_cover_shape=Forma de Portada Expandida shape_square=Cuadrado shape_circle=Círculo -section_behavior=COMPORTAMIENTO -section_updates=ACTUALIZACIONES -section_playback=REPRODUCCIÓN -section_track_text=TEXTO DE LA PISTA -section_lyrics=LETRAS +section_behavior=Comportamiento +section_updates=Actualizaciones +section_system=Sistema +section_island_style=Estilo de la isla +section_font=Fuente +section_motion=Movimiento +section_position=Posición +section_compact_size=Compacto +section_expanded_size=Expandido +section_auto_hide=Ocultación automática +section_interaction=Interacción +section_notifications=Notificaciones +section_system_alerts=Indicadores del sistema +search_placeholder=Buscar +search_no_results=Sin resultados +section_playback=Reproducción +section_track_text=Texto de la pista +section_lyrics=Letras compact_scale=Tamaño Compacto expanded_scale=Tamaño Expandido base_width=Ancho Base @@ -107,7 +124,7 @@ motion_blur=Desenfoque de Movimiento cover_rotate=Rotar Portada audio_gate=Umbral de Audio auto_gate=Umbral Automático -section_experimental=EXPERIMENTAL +section_experimental=Experimental mini_controls=Controles Mini island_style=Estilo de Isla style_default=Predeterminado @@ -124,9 +141,18 @@ liquid_glass_siri_note=Opción experimental; no se garantiza el rendimiento. style_liquid_note=El Vidrio Líquido oculta la isla en capturas y grabaciones de pantalla. liquid_glass_compat_capture=Modo de compatibilidad (visible en capturas) liquid_glass_compat_capture_note=En el modo de compatibilidad la isla es visible en capturas, grabaciones y escritorio remoto, pero aumentan la latencia del fondo y el uso de CPU. -custom_font=Fuente Personalizada -font_select=Seleccionar -font_reset=Restablecer +audio_outputs_empty=No hay dispositivos de salida +font_family=Fuente +font_system_default=Predeterminada del sistema +font_from_file=Elegir archivo… +font_region_note=Usando {} según el idioma actual +font_weight=Grosor +font_weight_light=Más fino +font_weight_regular=Estándar +font_weight_bold=Más grueso +font_recommended=Recomendada: PingFang UI (variable) +font_download=Descargar +font_recommended_note=PingFang con grosor variable. Instálala y elígela en Fuente. start_boot=Iniciar al Arrancar auto_hide=Ocultar sin actividad fullscreen_auto_hide=Ocultar en pantalla completa @@ -168,7 +194,7 @@ lyrics_transition_slide=Deslizamiento lyrics_transition_fade=Fundido folder_select=Seleccionar folder_clear=Limpiar -media_apps=APLICACIONES DE MEDIOS +media_apps=Aplicaciones de medios scan_apps=Escanear Apps no_sessions=No se detectaron sesiones platform_unavailable=No disponible en este sistema diff --git a/resources/in_app/lang/zh_cn.lang b/resources/in_app/lang/zh_cn.lang index 1f8c4862..38130a2e 100644 --- a/resources/in_app/lang/zh_cn.lang +++ b/resources/in_app/lang/zh_cn.lang @@ -1,9 +1,13 @@ !lang_name=中文 -tab_general=常规 +tab_general=通用 tab_music=音乐 tab_widgets=小组件 tab_plugins=插件 tab_about=关于 +tab_appearance=外观 +tab_layout=尺寸与位置 +tab_behavior=隐藏与交互 +tab_alerts=通知与提示 plugin_drop_title=将插件拖到这里安装 plugin_drop_hint=支持 WinIsland 插件 ZIP 包 plugin_drop_release=松开以安装插件 @@ -88,6 +92,19 @@ shape_square=方形 shape_circle=圆形 section_behavior=行为 section_updates=更新 +section_system=系统 +section_island_style=灵动岛样式 +section_font=字体 +section_motion=动画 +section_position=显示位置 +section_compact_size=收起状态 +section_expanded_size=展开状态 +section_auto_hide=自动隐藏 +section_interaction=交互 +section_notifications=通知 +section_system_alerts=系统提示 +search_placeholder=搜索 +search_no_results=没有结果 section_playback=播放 section_track_text=歌曲文字 section_lyrics=歌词 @@ -124,9 +141,18 @@ liquid_glass_siri_note=实验性选项,不保证性能表现。 style_liquid_note=液态玻璃模式下,截图和录屏中不会显示灵动岛。 liquid_glass_compat_capture=兼容模式(截图与录屏可见) liquid_glass_compat_capture_note=兼容模式下截图、录屏和远程桌面能灵动岛可见;但会增加背景延迟与CPU占用。 -custom_font=自定义字体 -font_select=选择 -font_reset=重置 +audio_outputs_empty=未找到输出设备 +font_family=字体 +font_system_default=系统默认 +font_from_file=从文件选择… +font_region_note=已根据当前语言使用 {} +font_weight=字重 +font_weight_light=较细 +font_weight_regular=标准 +font_weight_bold=较粗 +font_recommended=推荐字体:苹方 UI(可变字重) +font_download=前往下载 +font_recommended_note=支持连续字重的苹方可变字体。下载安装后,在上方「字体」中选择。 start_boot=开机启动 auto_hide=无活动时自动隐藏 fullscreen_auto_hide=全屏时自动隐藏 diff --git a/resources/in_app/settings/about.png b/resources/in_app/settings/about.png index e9e8851e..a91047bf 100644 Binary files a/resources/in_app/settings/about.png and b/resources/in_app/settings/about.png differ diff --git a/resources/in_app/settings/alerts.png b/resources/in_app/settings/alerts.png new file mode 100644 index 00000000..2b54ddd6 Binary files /dev/null and b/resources/in_app/settings/alerts.png differ diff --git a/resources/in_app/settings/appearance.png b/resources/in_app/settings/appearance.png new file mode 100644 index 00000000..da30f3c2 Binary files /dev/null and b/resources/in_app/settings/appearance.png differ diff --git a/resources/in_app/settings/behavior.png b/resources/in_app/settings/behavior.png new file mode 100644 index 00000000..d5ff2cc3 Binary files /dev/null and b/resources/in_app/settings/behavior.png differ diff --git a/resources/in_app/settings/layout.png b/resources/in_app/settings/layout.png new file mode 100644 index 00000000..2e280af2 Binary files /dev/null and b/resources/in_app/settings/layout.png differ diff --git a/resources/in_app/settings/music.png b/resources/in_app/settings/music.png index 40f59bab..d35ab403 100644 Binary files a/resources/in_app/settings/music.png and b/resources/in_app/settings/music.png differ diff --git a/resources/in_app/settings/plugin.png b/resources/in_app/settings/plugin.png index 5c759fd3..8173dbac 100644 Binary files a/resources/in_app/settings/plugin.png and b/resources/in_app/settings/plugin.png differ diff --git a/resources/in_app/settings/settings.png b/resources/in_app/settings/settings.png index a12132c3..91a43828 100644 Binary files a/resources/in_app/settings/settings.png and b/resources/in_app/settings/settings.png differ diff --git a/resources/in_app/settings/widget.png b/resources/in_app/settings/widget.png index ce1d15be..733b8686 100644 Binary files a/resources/in_app/settings/widget.png and b/resources/in_app/settings/widget.png differ diff --git a/src/core/audio.rs b/src/core/audio.rs index 515f76eb..cf35f7bc 100644 --- a/src/core/audio.rs +++ b/src/core/audio.rs @@ -26,6 +26,7 @@ const ADAPTIVE_LEVEL_DECAY: f32 = 0.995; const ADAPTIVE_LEVEL_LEARNING_RATE: f32 = 0.005; const SPECTRUM_NORMALIZATION_GAIN: f32 = 2.3; const LOOPBACK_SAMPLE_RATE: usize = 48_000; +const LOOPBACK_STALL_TIMEOUT: Duration = Duration::from_secs(2); struct AtomicF32(AtomicU32); @@ -142,6 +143,7 @@ struct ProcessCaptureContext { cancel: CancellationToken, generation: u32, worker_generation: Arc, + device_epoch: Arc, target_process_id: Arc, process_capture_active: Arc, spectrum: Arc>, @@ -185,6 +187,7 @@ pub struct AudioProcessor { target_process_id: Arc, process_capture_active: Arc, worker_generation: Arc, + device_epoch: Arc, workers: Mutex>, } @@ -205,6 +208,7 @@ impl AudioProcessor { target_process_id, process_capture_active, worker_generation, + device_epoch: Arc::new(AtomicU32::new(0)), workers: Mutex::new(None), }; log::info!("AudioProcessor created in idle state"); @@ -285,7 +289,8 @@ impl AudioProcessor { let target_app_id = self.target_app_id.clone(); let target_process_id = self.target_process_id.clone(); let worker_generation = self.worker_generation.clone(); - tokio::task::spawn_blocking(move || { + let device_epoch = self.device_epoch.clone(); + spawn_worker("audio-meter", move || { let mut meter = crate::platform::audio().open_meter().ok(); let host = cpal::default_host(); let mut current_device_name = None; @@ -317,6 +322,7 @@ impl AudioProcessor { } if default_device_name.is_some() { + device_epoch.fetch_add(1, Ordering::AcqRel); current_device_name = default_device_name; log::info!( "Audio meter thread: switched to device {current_device_name:?}" @@ -368,13 +374,14 @@ impl AudioProcessor { cancel, generation, worker_generation: self.worker_generation.clone(), + device_epoch: self.device_epoch.clone(), target_process_id: self.target_process_id.clone(), process_capture_active: self.process_capture_active.clone(), spectrum: self.spectrum.clone(), gate: self.gate.clone(), gate_override: self.gate_override.clone(), }; - tokio::task::spawn_blocking(move || { + spawn_worker("audio-process-capture", move || { let mut active_process_id = 0; let mut unavailable_process_id = None; let mut retry = Cooldown::ready(); @@ -426,7 +433,7 @@ impl AudioProcessor { worker_generation: worker_generation.clone(), generation, }; - tokio::task::spawn_blocking(move || { + spawn_worker("audio-fallback-capture", move || { let host = cpal::default_host(); let mut current_device_name = None; let mut current_stream: Option = None; @@ -572,16 +579,28 @@ fn capture_process_audio( context: &ProcessCaptureContext, analyzer: &mut SpectrumAnalyzer, ) -> Result<(), winisland_platform::PlatformError> { + let device_epoch = context.device_epoch.load(Ordering::Acquire); let mut capture = crate::platform::audio().open_process_capture(process_id)?; context.set_process_capture_active(true); let mut samples = Vec::new(); + let mut last_packet = Instant::now(); let result = (|| { while !context.cancel.is_cancelled() && context.is_current() && context.target_process_id.load(Ordering::Relaxed) == process_id + && context.device_epoch.load(Ordering::Acquire) == device_epoch { let captured = capture.read_cycle(&mut samples)?; + if !captured + && context.gate.get() > 0.0 + && last_packet.elapsed() > LOOPBACK_STALL_TIMEOUT + { + return Err(winisland_platform::PlatformError::Unavailable( + "loopback data after a device change", + )); + } if captured { + last_packet = Instant::now(); if analysis_enabled(&context.gate, &context.gate_override) { for &sample in &samples { analyzer.push_sample( @@ -666,12 +685,24 @@ where log::warn!("Audio capture stream interrupted: {error}; rebuilding"); stream_failed.store(true, Ordering::Release); } - _ => log::error!("Audio capture stream failed: {error}"), + _ => { + log::error!("Audio capture stream failed: {error}; rebuilding"); + stream_failed.store(true, Ordering::Release); + } }, None, ) } +fn spawn_worker(name: &str, work: impl FnOnce() + Send + 'static) { + if let Err(error) = std::thread::Builder::new() + .name(name.to_string()) + .spawn(work) + { + log::error!("Audio worker '{name}' could not start: {error}"); + } +} + fn analysis_enabled(gate: &AtomicF32, gate_override: &AtomicF32) -> bool { gate.get() > 0.0 && gate_override.get() > 0.0 } diff --git a/src/core/audio_outputs.rs b/src/core/audio_outputs.rs new file mode 100644 index 00000000..50c12cc6 --- /dev/null +++ b/src/core/audio_outputs.rs @@ -0,0 +1,74 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use parking_lot::Mutex; +use winisland_platform::AudioOutputDevice; + +#[derive(Default)] +struct OutputState { + devices: Vec, + revision: u64, +} + +/// Playback devices listed and switched on worker threads, read from the UI thread. +#[derive(Default)] +pub struct AudioOutputs { + state: Arc>, + busy: Arc, +} + +impl AudioOutputs { + pub fn refresh(&self) { + self.spawn(None); + } + + pub fn select(&self, id: &str) { + let mut state = self.state.lock(); + for device in &mut state.devices { + device.is_default = device.id == id; + } + state.revision += 1; + drop(state); + self.spawn(Some(id.to_string())); + } + + pub fn revision(&self) -> u64 { + self.state.lock().revision + } + + pub fn devices(&self) -> Vec { + self.state.lock().devices.clone() + } + + fn spawn(&self, select: Option) { + if self.busy.swap(true, Ordering::AcqRel) && select.is_none() { + return; + } + let state = self.state.clone(); + let busy = self.busy.clone(); + let spawned = std::thread::Builder::new() + .name("audio-outputs".to_string()) + .spawn(move || { + if let Some(id) = select + && let Err(error) = crate::platform::audio().set_default_output(&id) + { + log::warn!("Audio outputs: could not switch to {id}: {error}"); + } + match crate::platform::audio().output_devices() { + Ok(devices) => { + let mut state = state.lock(); + if state.devices != devices { + state.devices = devices; + state.revision += 1; + } + } + Err(error) => log::warn!("Audio outputs: could not list devices: {error}"), + } + busy.store(false, Ordering::Release); + }); + if let Err(error) = spawned { + self.busy.store(false, Ordering::Release); + log::warn!("Audio outputs: worker could not start: {error}"); + } + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 3c94a137..19cfe5c2 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,3 +1,4 @@ pub mod audio; +pub mod audio_outputs; pub mod persistence; pub mod smtc; diff --git a/src/icons/mod.rs b/src/icons/mod.rs index 395e1270..ec325983 100644 --- a/src/icons/mod.rs +++ b/src/icons/mod.rs @@ -5,9 +5,15 @@ pub mod settings; pub mod volume; use image::imageops::FilterType; -use winisland_render::Image; +use winisland_render::{Image, Rgba}; pub(crate) fn load_glyph(bytes: &[u8]) -> Option { + load_tinted_glyph(bytes, Rgba::WHITE).map(|(image, _)| image) +} + +/// Loads a glyph cropped to its visible pixels, filled with `color`, plus its width-to-height +/// ratio. +pub(crate) fn load_tinted_glyph(bytes: &[u8], color: Rgba) -> Option<(Image, f32)> { let source = image::load_from_memory(bytes).ok()?.to_rgba8(); let (width, height) = source.dimensions(); let mut bounds = (width, height, 0, 0); @@ -30,12 +36,14 @@ pub(crate) fn load_glyph(bytes: &[u8]) -> Option { bounds.3 - bounds.1 + 1, ) .to_image(); + let aspect = cropped.width() as f32 / cropped.height() as f32; let small = image::imageops::resize(&cropped, 64, 64, FilterType::Lanczos3); let mut pixels = small.into_raw(); + let tint = |channel: u8, alpha: u8| (u16::from(channel) * u16::from(alpha) / 255) as u8; for pixel in pixels.as_chunks_mut::<4>().0 { - pixel[0] = pixel[3]; - pixel[1] = pixel[3]; - pixel[2] = pixel[3]; + pixel[0] = tint(color.r(), pixel[3]); + pixel[1] = tint(color.g(), pixel[3]); + pixel[2] = tint(color.b(), pixel[3]); } - Image::from_rgba8_premul(64, 64, &pixels) + Image::from_rgba8_premul(64, 64, &pixels).map(|image| (image, aspect)) } diff --git a/src/ui/compact/link.rs b/src/ui/compact/link.rs index cf4087fb..25fe9c72 100644 --- a/src/ui/compact/link.rs +++ b/src/ui/compact/link.rs @@ -2,7 +2,7 @@ use std::time::{Duration, Instant}; use winisland_core::i18n::tr; use winisland_render::text::FontManager; -use winisland_render::{Painter, Point, Radius, Rect, Rgba, StrokeCap, Vec2}; +use winisland_render::{FontWeight, Painter, Point, Radius, Rect, Rgba, StrokeCap, Vec2}; use crate::ui::compact::CompactSize; @@ -66,16 +66,16 @@ fn ease_out_cubic(value: f32) -> f32 { 1.0 - (1.0 - value).powi(3) } -fn truncated(text: &str, size: f32, bold: bool, max_width: f32) -> String { +fn truncated(text: &str, size: f32, weight: FontWeight, max_width: f32) -> String { let fonts = FontManager::global(); - if fonts.measure_str(text, size, bold).width() <= max_width { + if fonts.measure_str(text, size, weight).width() <= max_width { return text.to_string(); } let mut chars: Vec = text.chars().collect(); while !chars.is_empty() { chars.pop(); let candidate = chars.iter().collect::() + "…"; - if fonts.measure_str(&candidate, size, bold).width() <= max_width { + if fonts.measure_str(&candidate, size, weight).width() <= max_width { return candidate; } } @@ -87,16 +87,16 @@ fn draw_text( text: &str, origin: Point, size: f32, - bold: bool, + weight: FontWeight, color: Rgba, ) -> f32 { - let bounds = FontManager::global().measure_str(text, size, bold); + let bounds = FontManager::global().measure_str(text, size, weight); FontManager::global().draw_str( painter, text, Point::new(origin.x - bounds.left, origin.y), size, - bold, + weight, color, ); bounds.width() @@ -249,22 +249,27 @@ impl LinkIndicator { let button = Self::button_rect(rect, scale); let text_left = icon_center.x + icon_radius + 12.0 * scale; let text_width = (button.left - 12.0 * scale - text_left).max(0.0); - let title = truncated(&tr("link_prompt_title"), 11.0 * scale, false, text_width); + let title = truncated( + &tr("link_prompt_title"), + 11.0 * scale, + FontWeight::REGULAR, + text_width, + ); draw_text( painter, &title, Point::new(text_left, rect.center_y() - 4.0 * scale), 11.0 * scale, - false, + FontWeight::REGULAR, Rgba::WHITE.with_alpha_f(0.65 * opacity), ); - let host = truncated(&self.host, 14.0 * scale, true, text_width); + let host = truncated(&self.host, 14.0 * scale, FontWeight::SEMIBOLD, text_width); draw_text( painter, &host, Point::new(text_left, rect.center_y() + 13.0 * scale), 14.0 * scale, - true, + FontWeight::SEMIBOLD, Rgba::WHITE.with_alpha_f(opacity), ); @@ -275,7 +280,8 @@ impl LinkIndicator { ); let label = tr("link_prompt_open"); let label_size = 12.0 * scale; - let label_bounds = FontManager::global().measure_str(&label, label_size, true); + let label_bounds = + FontManager::global().measure_str(&label, label_size, FontWeight::SEMIBOLD); FontManager::global().draw_str( painter, &label, @@ -284,7 +290,7 @@ impl LinkIndicator { button.center_y() - label_bounds.height() / 2.0 - label_bounds.top, ), label_size, - true, + FontWeight::SEMIBOLD, Rgba::WHITE.with_alpha_f(opacity), ); painter.restore(); diff --git a/src/ui/compact/mod.rs b/src/ui/compact/mod.rs index 4b5ff31d..de885af3 100644 --- a/src/ui/compact/mod.rs +++ b/src/ui/compact/mod.rs @@ -244,6 +244,17 @@ impl CompactOverlay { self.volume_indicator.finish_drag() } + pub fn volume_level(&self) -> Option<(f32, bool)> { + self.volume_monitor.can_set_level().then(|| { + let snapshot = self.volume_monitor.snapshot(); + (snapshot.level, snapshot.muted) + }) + } + + pub fn set_volume_level(&self, level: f32) { + self.volume_monitor.set_level(level); + } + pub fn is_volume_dragging(&self) -> bool { self.volume_indicator.is_dragging() } diff --git a/src/ui/compact/notification.rs b/src/ui/compact/notification.rs index ad6ebb6e..28ffd2ab 100644 --- a/src/ui/compact/notification.rs +++ b/src/ui/compact/notification.rs @@ -6,8 +6,8 @@ use winisland_platform::{ IconBounds, NotificationFeed, NotificationIconData, NotificationMonitorUpdate, NotificationPayload, }; -use winisland_render::FontStyle; use winisland_render::text::DrawTextCachedParams; +use winisland_render::{FontStyle, FontWeight}; use winisland_render::{Image, ImageOptions, Painter, Radius, Rect, Rgba, Sampling, Vec2}; use crate::ui::compact::{CompactOverlayState, CompactSize}; @@ -229,7 +229,7 @@ impl NotificationIndicator { x: content_left, y: top + 22.0 * scale, size: 11.0 * scale, - bold: false, + weight: FontWeight::MEDIUM, color: Rgba::WHITE.with_alpha((alpha as f32 * 0.65) as u8), blur: None, }, @@ -252,7 +252,7 @@ impl NotificationIndicator { x: content_left, y: title_y, size: 13.0 * scale, - bold: true, + weight: FontWeight::SEMIBOLD, color: Rgba::WHITE.with_alpha(alpha), blur: None, }, @@ -267,8 +267,8 @@ impl NotificationIndicator { text: &self.detail, x: content_left, y: title_y + DETAIL_LINE_GAP * scale, - size: 11.0 * scale, - bold: false, + size: 12.0 * scale, + weight: FontWeight::REGULAR, color: Rgba::WHITE.with_alpha((alpha as f32 * 0.72) as u8), blur: None, }, @@ -324,11 +324,7 @@ fn draw_notification_text( max_width: f32, scale: f32, ) { - let style = if params.bold { - FontStyle::bold() - } else { - FontStyle::normal() - }; + let style = FontStyle::from(params.weight); scroll.borrow_mut().draw(ScrollDrawParams { painter: params.painter, text: params.text, diff --git a/src/ui/compact/timer.rs b/src/ui/compact/timer.rs index b1347f2f..5d85596c 100644 --- a/src/ui/compact/timer.rs +++ b/src/ui/compact/timer.rs @@ -4,7 +4,7 @@ use std::time::{Duration, Instant}; use quick_xml::events::Event; use winisland_core::i18n::tr; use winisland_render::text::FontManager; -use winisland_render::{Angle, Painter, Path, Point, Rect, Rgba, StrokeCap, Vec2}; +use winisland_render::{Angle, FontWeight, Painter, Path, Point, Rect, Rgba, StrokeCap, Vec2}; use crate::ui::rolling_time::{RollingTime, TimeAnchor}; @@ -129,15 +129,22 @@ fn draw_finished_icon(painter: Painter<'_>, center: Point, scale: f32, color: Rg painter.restore_to(saved); } -fn draw_text(painter: Painter<'_>, text: &str, rect: Rect, size: f32, bold: bool, color: Rgba) { +fn draw_text( + painter: Painter<'_>, + text: &str, + rect: Rect, + size: f32, + weight: FontWeight, + color: Rgba, +) { let fonts = FontManager::global(); - let width = fonts.measure_str(text, size, bold).width(); + let width = fonts.measure_str(text, size, weight).width(); let size = if width > rect.width() && width > 0.0 { size * rect.width().max(0.0) / width } else { size }; - let bounds = fonts.measure_str(text, size, bold); + let bounds = fonts.measure_str(text, size, weight); fonts.draw_str( painter, text, @@ -146,7 +153,7 @@ fn draw_text(painter: Painter<'_>, text: &str, rect: Rect, size: f32, bold: bool rect.center_y() - bounds.height() / 2.0 - bounds.top, ), size, - bold, + weight, color, ); } @@ -156,6 +163,18 @@ pub(crate) fn countdown_width(timer: TimerContent, base_width: f32) -> f32 { base_width.max(EDGE_INSET * 2.0 + ICON_SIZE + 16.0 + width) } +pub(crate) fn draw_timer_icon( + painter: Painter<'_>, + center: Point, + scale: f32, + progress: f32, + paused: bool, + alpha: f32, +) { + let color = YELLOW.with_alpha_f(alpha * if paused { 0.65 } else { 1.0 }); + draw_progress(painter, center, scale, progress, color); +} + pub(crate) fn draw_countdown( painter: Painter<'_>, timer: TimerContent, @@ -308,7 +327,7 @@ impl TimerFinishedIndicator { 22.0 * scale, ), 18.0 * scale, - false, + FontWeight::SEMIBOLD, color, ); let center = Point::new(button.center_x(), button.center_y()); diff --git a/src/ui/compact/volume.rs b/src/ui/compact/volume.rs index 1d6478e8..9555051e 100644 --- a/src/ui/compact/volume.rs +++ b/src/ui/compact/volume.rs @@ -11,7 +11,7 @@ use crate::icons::brightness::draw_brightness_icon; use crate::icons::volume::draw_volume_icon; use crate::ui::compact::{CompactOverlayState, CompactSize}; use winisland_core::i18n::tr; -use winisland_render::FontStyle; +use winisland_render::FontWeight; use winisland_render::text::{DrawTextCachedParams, FontManager}; const POLL_INTERVAL: Duration = Duration::from_millis(50); @@ -396,7 +396,7 @@ impl VolumeIndicator { let label_width = FontManager::global().measure_text_cached( &self.label, 12.0 * scale, - FontStyle::normal(), + FontWeight::MEDIUM, ); let left = rect.left + (37.0 + 11.0) * scale + label_width; Rect::from_xywh( @@ -464,7 +464,7 @@ impl VolumeIndicator { x: label_x, y: center_y + 4.0 * scale, size: label_size, - bold: false, + weight: FontWeight::MEDIUM, color: Rgba::WHITE.with_alpha((alpha as f32 * 0.9) as u8), blur: None, }); diff --git a/src/ui/expanded/music_view.rs b/src/ui/expanded/music_view.rs index 6c51f8ac..8e346641 100644 --- a/src/ui/expanded/music_view.rs +++ b/src/ui/expanded/music_view.rs @@ -1,8 +1,17 @@ #![allow(deprecated)] +mod audio_controls; mod controls; mod palette; mod visualizer; +pub use audio_controls::{ + AudioPanel, audio_controls_animating, audio_panel, begin_output_scroll_drag, + close_audio_panel_soon, drag_output_scroll, end_output_scroll_drag, output_button_rect, + output_list_rect, output_row_at, output_scroll_dragging, scroll_output_list, set_audio_pointer, + set_audio_state, set_volume_dragging, step_volume, toggle_audio_panel, volume_bar_rect, + volume_button_rect, volume_level_at, +}; + pub use controls::{ get_cover_rect, get_next_btn_rect, get_pause_btn_rect, get_prev_btn_rect, get_progress_bar_rect, set_progress_dragging, set_progress_hover, snap_progress, @@ -18,8 +27,8 @@ use crate::utils::cover::decode_cover_image; use crate::utils::scroll::{ScrollDrawParams, ScrollText}; use winisland_render::text::{DrawTextCachedParams, FontManager}; use winisland_render::{ - BlurSpec, FontStyle, Image, ImageOptions, LayerSpec, Painter, Path, Radius, Rect, Rgba, - Sampling, Vec2, + BlurSpec, FontStyle, FontWeight, Image, ImageOptions, LayerSpec, Painter, Path, Radius, Rect, + Rgba, Sampling, Vec2, }; use std::cell::RefCell; @@ -29,6 +38,9 @@ use winisland_core::physics::Spring; const CONTENT_PADDING: f32 = 24.0; const COVER_SIZE: f32 = 64.0; +const COVER_RADIUS: f32 = 13.0; +const SECONDARY_TEXT_ALPHA: f32 = 0.55; +const OUTPUT_CHECK_COLOR: Rgba = Rgba::from_rgb(10, 132, 255); const TRACK_TEXT_GAP: f32 = 18.0; const TRACK_TEXT_RIGHT_INSET: f32 = 70.0; const TRACK_TITLE_BASELINE_OFFSET: f32 = 26.0; @@ -47,10 +59,10 @@ const PROGRESS_BAR_HOVER_GROWTH: f32 = 3.5; const PROGRESS_FILL_IDLE_BRIGHTNESS: f32 = 0.72; const PROGRESS_FILL_BRIGHTEN_DELAY: f32 = 0.18; const PROGRESS_TIME_BASELINE_SCALE: f32 = 0.35; -const PROGRESS_TIME_IDLE_ALPHA: f32 = 0.5; -const PROGRESS_TRACK_ALPHA: f32 = 0.12; +const PROGRESS_TIME_IDLE_ALPHA: f32 = 0.55; +const PROGRESS_TRACK_ALPHA: f32 = 0.2; const PLAYBACK_CONTROLS_TOP_GAP: f32 = 42.0; -const SKIP_BUTTON_GAP: f32 = 75.0; +const SKIP_BUTTON_GAP: f32 = 69.0; const SKIP_ANIMATION_DURATION_SECS: f32 = 0.5; const PLAY_STATE_RESPONSE: f32 = 0.18; pub(super) const PAUSE_CONTROL_PRESS_VELOCITY: f32 = -0.18; @@ -249,6 +261,9 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { spectrum_fill, } = params; + let (outputs_t, volume_t) = audio_controls::tick(dt, expansion_progress, ox, oy, w, scale); + let page_alpha = alpha; + let alpha = (page_alpha as f32 * (1.0 - outputs_t)).round() as u8; let base_img_size = COVER_SIZE * scale; let (img_size, img_x, img_y) = ( base_img_size, @@ -368,7 +383,8 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { let time_alpha_factor = PROGRESS_TIME_IDLE_ALPHA + (1.0 - PROGRESS_TIME_IDLE_ALPHA) * hover_t; - let time_color = text_color.with_alpha((alpha as f32 * time_alpha_factor) as u8); + let progress_alpha = (alpha as f32 * (1.0 - volume_t)).round() as u8; + let time_color = text_color.with_alpha((progress_alpha as f32 * time_alpha_factor) as u8); PROGRESS_TEXT_CACHE.with(|cell| { let mut cache = cell.borrow_mut(); @@ -391,7 +407,7 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { x: bar_full_left, y: text_baseline_y, size: time_font_size, - bold: false, + weight: FontWeight::MEDIUM, color: time_color, blur: None, }); @@ -399,7 +415,7 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { let remaining_w = FontManager::global().measure_text_cached( &cache.remaining_text, time_font_size, - FontStyle::normal(), + FontWeight::MEDIUM, ); draw_text_cached(DrawTextCachedParams { painter, @@ -407,13 +423,14 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { x: bar_full_right - remaining_w, y: text_baseline_y, size: time_font_size, - bold: false, + weight: FontWeight::MEDIUM, color: time_color, blur: None, }); }); - let track_color = text_color.with_alpha((alpha as f32 * PROGRESS_TRACK_ALPHA) as u8); + let track_color = + text_color.with_alpha((progress_alpha as f32 * PROGRESS_TRACK_ALPHA) as u8); let track_rect = Rect::from_xywh(bar_left, bar_center_y - bar_h / 2.0, bar_total_w, bar_h); painter.fill_round_rect(track_rect, Radius::uniform(bar_radius), track_color); @@ -425,7 +442,7 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { let fill_brightness = PROGRESS_FILL_IDLE_BRIGHTNESS + (1.0 - PROGRESS_FILL_IDLE_BRIGHTNESS) * fill_hover_t; let fill_color = Rgba::from_argb( - alpha, + progress_alpha, (text_color.r() as f32 * fill_brightness).round() as u8, (text_color.g() as f32 * fill_brightness).round() as u8, (text_color.b() as f32 * fill_brightness).round() as u8, @@ -445,8 +462,39 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { painter.restore(); } + let lanes = audio_controls::lanes(ox, oy, w, scale); + audio_controls::draw_volume_row(audio_controls::VolumeRowParams { + painter, + lanes: &lanes, + bar_h, + scale, + alpha: (alpha as f32 * volume_t).round() as u8, + text_color, + track_color: text_color + .with_alpha((alpha as f32 * volume_t * PROGRESS_TRACK_ALPHA) as u8), + }); + audio_controls::draw_output_list(audio_controls::OutputListParams { + painter, + ox, + oy, + w, + scale, + alpha: (page_alpha as f32 * outputs_t).round() as u8, + text_color, + accent: OUTPUT_CHECK_COLOR, + }); + audio_controls::draw_side_buttons(audio_controls::SideButtonsParams { + painter, + ox, + oy, + w, + scale, + alpha: page_alpha, + text_color, + }); + let btn_cx = ox + w / 2.0; - let btn_cy = bar_center_y + bar_h / 2.0 + PLAYBACK_CONTROLS_TOP_GAP * scale; + let btn_cy = lanes.controls_y; let skip_gap = SKIP_BUTTON_GAP * scale; let prev_t = PREV_SKIP_ANIM.with(|cell| { @@ -473,7 +521,7 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { btn_cy, true, prev_t, - alpha, + page_alpha, scale, use_blur, text_color, @@ -484,7 +532,7 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { != 0 { draw_pause_control( - painter, btn_cx, btn_cy, pause_t, alpha, scale, use_blur, dt, text_color, + painter, btn_cx, btn_cy, pause_t, page_alpha, scale, use_blur, dt, text_color, ); } @@ -510,7 +558,7 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { btn_cy, false, next_t, - alpha, + page_alpha, scale, use_blur, text_color, @@ -652,7 +700,7 @@ fn draw_cover(params: CoverParams) -> f32 { painter.clip_path(&Path::continuous_rounded_rect( Rect::from_xywh(img_x, img_y, img_size, img_size), - 16.0 * scale, + COVER_RADIUS * scale, )); if let Some(img) = cover_img { let final_alpha = (alpha as f32 * cover_brightness) / 255.0; @@ -747,7 +795,7 @@ fn draw_track_text(params: TrackTextParams) { } else { 15.0 * scale }; - let title_style = FontStyle::bold(); + let title_style = FontStyle::from(FontWeight::SEMIBOLD); TITLE_SCROLL.with(|cell| { let mut scroll = cell.borrow_mut(); @@ -784,7 +832,7 @@ fn draw_track_text(params: TrackTextParams) { max_w: max_text_w, size: artist_font_size, style: artist_style, - color: text_color_sec.with_alpha((alpha as f32 * 0.6) as u8), + color: text_color_sec.with_alpha((alpha as f32 * SECONDARY_TEXT_ALPHA) as u8), blur: None, scale, render_as_paths: true, diff --git a/src/ui/expanded/music_view/audio_controls.rs b/src/ui/expanded/music_view/audio_controls.rs new file mode 100644 index 00000000..57459d6f --- /dev/null +++ b/src/ui/expanded/music_view/audio_controls.rs @@ -0,0 +1,899 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use winisland_core::i18n::tr; +use winisland_platform::{AudioOutputDevice, AudioOutputKind}; +use winisland_render::text::{DrawTextCachedParams, FontManager}; +use winisland_render::{ + FontWeight, Image, ImageOptions, Painter, Point, Radius, Rect, Rgba, Sampling, Vec2, +}; + +use super::{ + CONTENT_PADDING, COVER_SIZE, PLAYBACK_CONTROLS_TOP_GAP, PROGRESS_BAR_HEIGHT, PROGRESS_TIME_GAP, + PROGRESS_TIME_WIDTH, PROGRESS_TOP_GAP, SKIP_BUTTON_GAP, +}; + +const SIDE_BUTTON_SLOTS: f32 = 2.0; +const SIDE_ICON_HEIGHT: f32 = 21.0; +const SIDE_BUTTON_HIT: f32 = 38.0; +const SIDE_ICON_ALPHA: f32 = 0.55; +const PANEL_RESPONSE: f32 = 0.28; +const VOLUME_IDLE_TIMEOUT: Duration = Duration::from_secs(3); +const VOLUME_SETTLE: Duration = Duration::from_millis(400); +const OUTPUT_CLOSE_DELAY: Duration = Duration::from_millis(380); +const ROW_HEIGHT: f32 = 32.0; +const ROW_ICON_HEIGHT: f32 = 15.0; +const ROW_TEXT_SIZE: f32 = 13.0; +const LIST_INSET: f32 = 8.0; +const SCROLL_GUTTER: f32 = 12.0; +const SCROLL_THUMB_WIDTH: f32 = 3.0; +const SCROLL_THUMB_MIN: f32 = 18.0; +const VOLUME_ICON_HEIGHT: f32 = 11.0; +const VOLUME_WHEEL_STEP: f32 = 0.04; +const VOLUME_GLYPH_FADE: Duration = Duration::from_millis(220); +const VOLUME_GLYPH_START_SCALE: f32 = 0.92; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum AudioPanel { + #[default] + None, + Outputs, + Volume, +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Glyph { + AirPlay, + Mute, + VolumeOne, + VolumeTwo, + VolumeLow, + VolumeHigh, + Headphones, + Speaker, + Display, + Check, +} + +impl Glyph { + fn bytes(self) -> &'static [u8] { + match self { + Self::AirPlay => include_bytes!("../../../../resources/in_app/icons/airplay.audio.png"), + Self::Mute => { + include_bytes!("../../../../resources/in_app/icons/speaker.slash.fill.png") + } + Self::VolumeOne => { + include_bytes!("../../../../resources/in_app/icons/speaker.wave.1.fill.png") + } + Self::VolumeTwo => { + include_bytes!("../../../../resources/in_app/icons/speaker.wave.2.fill.png") + } + Self::VolumeLow => { + include_bytes!("../../../../resources/in_app/icons/speaker.fill.png") + } + Self::VolumeHigh => { + include_bytes!("../../../../resources/in_app/icons/speaker.wave.3.fill.png") + } + Self::Headphones => include_bytes!("../../../../resources/in_app/icons/headphones.png"), + Self::Speaker => { + include_bytes!("../../../../resources/in_app/icons/hifispeaker.fill.png") + } + Self::Display => include_bytes!("../../../../resources/in_app/icons/display.png"), + Self::Check => include_bytes!("../../../../resources/in_app/icons/checkmark.png"), + } + } + + fn for_output(kind: AudioOutputKind) -> Self { + match kind { + AudioOutputKind::Headphones => Self::Headphones, + AudioOutputKind::Display => Self::Display, + AudioOutputKind::Speakers => Self::Speaker, + } + } + + fn for_volume(volume: Option<(f32, bool)>) -> Self { + match volume { + None => Self::VolumeTwo, + Some((level, muted)) if muted || level <= 0.005 => Self::Mute, + Some((level, _)) if level < 0.34 => Self::VolumeOne, + Some((level, _)) if level < 0.67 => Self::VolumeTwo, + Some(_) => Self::VolumeHigh, + } + } +} + +type GlyphCache = HashMap<(Glyph, u32), Option<(Image, f32)>>; + +struct State { + panel: AudioPanel, + outputs_t: f32, + volume_t: f32, + devices: Vec, + volume: Option<(f32, bool)>, + volume_hold_until: Option, + pointer: Option, + list_scroll: f32, + list_drag: Option, + reveal_default: bool, + volume_glyph: Option<(Glyph, Option, Instant)>, + volume_touched: Instant, + close_at: Option, + volume_dragging: bool, +} + +thread_local! { + static STATE: RefCell = RefCell::new(State { + panel: AudioPanel::None, + outputs_t: 0.0, + volume_t: 0.0, + devices: Vec::new(), + volume: None, + volume_hold_until: None, + pointer: None, + list_scroll: 0.0, + list_drag: None, + reveal_default: false, + volume_glyph: None, + volume_touched: Instant::now(), + close_at: None, + volume_dragging: false, + }); + static GLYPHS: RefCell = RefCell::new(HashMap::new()); +} + +fn glyph(kind: Glyph, color: Rgba) -> Option<(Image, f32)> { + let color = Rgba::from_rgb(color.r(), color.g(), color.b()); + GLYPHS.with(|cache| { + cache + .borrow_mut() + .entry(( + kind, + u32::from_be_bytes([0, color.r(), color.g(), color.b()]), + )) + .or_insert_with(|| crate::icons::load_tinted_glyph(kind.bytes(), color)) + .clone() + }) +} + +fn draw_glyph_at(painter: Painter<'_>, kind: Glyph, rect: Rect, color: Rgba) { + let Some((image, _)) = glyph(kind, color).filter(|_| color.a() > 0) else { + return; + }; + painter.draw_image( + &image, + rect, + &ImageOptions::default() + .with_sampling(Sampling::LinearLinear) + .with_alpha(color.a()), + ); +} + +fn glyph_width(kind: Glyph, height: f32) -> f32 { + glyph(kind, Rgba::WHITE).map_or(height, |(_, aspect)| height * aspect) +} + +fn draw_glyph(painter: Painter<'_>, kind: Glyph, center: Point, height: f32, color: Rgba) { + let width = glyph_width(kind, height); + draw_glyph_at( + painter, + kind, + Rect::from_xywh( + center.x - width / 2.0, + center.y - height / 2.0, + width, + height, + ), + color, + ); +} + +/// Draws a speaker glyph anchored at the speaker body so the waves grow to the right. +fn draw_volume_glyph( + painter: Painter<'_>, + kind: Glyph, + center: Point, + height: f32, + zoom: f32, + color: Rgba, +) { + let left = center.x - glyph_width(Glyph::VolumeHigh, height) / 2.0; + let height = height * zoom; + let width = glyph_width(kind, height); + draw_glyph_at( + painter, + kind, + Rect::from_xywh(left, center.y - height / 2.0, width, height), + color, + ); +} + +/// Tracks the speaker glyph for `volume`, returning the current glyph, the one it replaces, +/// and how far the crossfade between them has progressed. +fn volume_glyph_transition(state: &mut State) -> (Glyph, Option, f32) { + let target = Glyph::for_volume(state.volume); + let now = Instant::now(); + let (current, previous, started) = match state.volume_glyph { + Some((current, _, _)) if current != target => (target, Some(current), now), + Some(transition) => transition, + None => ( + target, + None, + now.checked_sub(VOLUME_GLYPH_FADE).unwrap_or(now), + ), + }; + state.volume_glyph = Some((current, previous, started)); + let progress = + (now.duration_since(started).as_secs_f32() / VOLUME_GLYPH_FADE.as_secs_f32()).min(1.0); + let eased = 1.0 - (1.0 - progress).powi(3); + (current, previous.filter(|_| progress < 1.0), eased) +} + +fn scaled(alpha: u8, factor: f32) -> u8 { + (alpha as f32 * factor.clamp(0.0, 1.0)).round() as u8 +} + +/// Shared vertical anchors of the playback area. +pub(super) struct Lanes { + pub(super) bar_y: f32, + pub(super) controls_y: f32, + pub(super) bar_full_left: f32, + pub(super) bar_full_right: f32, + pub(super) bar_left: f32, + pub(super) bar_right: f32, +} + +pub(super) fn lanes(ox: f32, oy: f32, w: f32, scale: f32) -> Lanes { + let bar_y = oy + (CONTENT_PADDING + COVER_SIZE + PROGRESS_TOP_GAP) * scale; + let bar_full_left = ox + CONTENT_PADDING * scale; + let bar_full_right = ox + w - CONTENT_PADDING * scale; + let time_w = (PROGRESS_TIME_WIDTH + PROGRESS_TIME_GAP) * scale; + Lanes { + bar_y, + controls_y: bar_y + (PROGRESS_BAR_HEIGHT / 2.0 + PLAYBACK_CONTROLS_TOP_GAP) * scale, + bar_full_left, + bar_full_right, + bar_left: bar_full_left + time_w, + bar_right: bar_full_right - time_w, + } +} + +fn side_center(ox: f32, oy: f32, w: f32, scale: f32, direction: f32) -> Point { + Point::new( + ox + w / 2.0 + direction * SKIP_BUTTON_GAP * SIDE_BUTTON_SLOTS * scale, + lanes(ox, oy, w, scale).controls_y, + ) +} + +fn hit_rect(center: Point, scale: f32) -> Rect { + let size = SIDE_BUTTON_HIT * scale; + Rect::from_xywh(center.x - size / 2.0, center.y - size / 2.0, size, size) +} + +pub fn output_button_rect(ox: f32, oy: f32, w: f32, scale: f32) -> Rect { + hit_rect(side_center(ox, oy, w, scale, -1.0), scale) +} + +pub fn volume_button_rect(ox: f32, oy: f32, w: f32, scale: f32) -> Rect { + hit_rect(side_center(ox, oy, w, scale, 1.0), scale) +} + +/// The whole volume row, including the speaker glyphs at both ends. +pub fn volume_bar_rect(ox: f32, oy: f32, w: f32, scale: f32) -> Rect { + let lanes = lanes(ox, oy, w, scale); + let hit = 20.0 * scale; + Rect::from_ltrb( + lanes.bar_full_left, + lanes.bar_y - hit / 2.0, + lanes.bar_full_right, + lanes.bar_y + hit / 2.0, + ) +} + +pub fn output_list_rect(ox: f32, oy: f32, w: f32, scale: f32) -> Rect { + let lanes = lanes(ox, oy, w, scale); + let top = oy + (CONTENT_PADDING - LIST_INSET) * scale; + let row_h = ROW_HEIGHT * scale; + let rows = ((lanes.bar_y + 14.0 * scale - top) / row_h) + .floor() + .max(1.0); + let height = rows * row_h; + let top = top + (lanes.bar_y + 14.0 * scale - top - height) / 2.0; + Rect::from_ltrb( + ox + (CONTENT_PADDING - LIST_INSET) * scale, + top, + ox + w - (CONTENT_PADDING - LIST_INSET) * scale, + top + height, + ) +} + +struct ListLayout { + rows: Rect, + max_scroll: f32, + track: Option, +} + +fn list_layout(state: &State, list: Rect, scale: f32) -> ListLayout { + let max_scroll = (state.devices.len() as f32 * ROW_HEIGHT * scale - list.height()).max(0.0); + if max_scroll <= 0.0 { + return ListLayout { + rows: list, + max_scroll, + track: None, + }; + } + let gutter = SCROLL_GUTTER * scale; + ListLayout { + rows: Rect::from_ltrb(list.left, list.top, list.right - gutter, list.bottom), + max_scroll, + track: Some(Rect::from_ltrb( + list.right - gutter, + list.top, + list.right, + list.bottom, + )), + } +} + +fn thumb_rect(track: Rect, list: Rect, scroll: f32, max_scroll: f32, scale: f32) -> Rect { + let inset = 3.0 * scale; + let travel_h = track.height() - inset * 2.0; + let height = (travel_h * list.height() / (list.height() + max_scroll)) + .max(SCROLL_THUMB_MIN * scale) + .min(travel_h); + let y = track.top + inset + (travel_h - height) * (scroll / max_scroll).clamp(0.0, 1.0); + let width = SCROLL_THUMB_WIDTH * scale; + Rect::from_xywh(track.center_x() - width / 2.0, y, width, height) +} + +pub fn output_row_at(ox: f32, oy: f32, w: f32, scale: f32, point: Point) -> Option { + let list = output_list_rect(ox, oy, w, scale); + STATE.with(|state| { + let state = state.borrow(); + let layout = list_layout(&state, list, scale); + if !layout.rows.contains(point) { + return None; + } + let index = ((point.y - list.top + state.list_scroll) / (ROW_HEIGHT * scale)) as usize; + state.devices.get(index).map(|device| device.id.clone()) + }) +} + +/// Starts dragging the list's scroll thumb; pressing the track jumps the thumb there first. +pub fn begin_output_scroll_drag(ox: f32, oy: f32, w: f32, scale: f32, point: Point) -> bool { + let list = output_list_rect(ox, oy, w, scale); + STATE.with(|state| { + let mut state = state.borrow_mut(); + let layout = list_layout(&state, list, scale); + let Some(track) = layout.track.filter(|track| track.contains(point)) else { + return false; + }; + let thumb = thumb_rect(track, list, state.list_scroll, layout.max_scroll, scale); + let grab = if point.y >= thumb.top && point.y <= thumb.bottom { + point.y - thumb.top + } else { + thumb.height() / 2.0 + }; + state.list_drag = Some(grab); + drop(state); + drag_output_scroll(ox, oy, w, scale, point.y); + true + }) +} + +pub fn drag_output_scroll(ox: f32, oy: f32, w: f32, scale: f32, y: f32) { + let list = output_list_rect(ox, oy, w, scale); + STATE.with(|state| { + let mut state = state.borrow_mut(); + let Some(grab) = state.list_drag else { + return; + }; + let layout = list_layout(&state, list, scale); + let Some(track) = layout.track else { + return; + }; + let thumb = thumb_rect(track, list, state.list_scroll, layout.max_scroll, scale); + let inset = 3.0 * scale; + let travel = track.height() - inset * 2.0 - thumb.height(); + if travel > 0.0 { + let ratio = ((y - grab - track.top - inset) / travel).clamp(0.0, 1.0); + state.list_scroll = ratio * layout.max_scroll; + } + }); +} + +pub fn end_output_scroll_drag() { + STATE.with(|state| state.borrow_mut().list_drag = None); +} + +pub fn output_scroll_dragging() -> bool { + STATE.with(|state| state.borrow().list_drag.is_some()) +} + +pub fn set_audio_state(devices: Option>, volume: Option<(f32, bool)>) { + STATE.with(|state| { + let mut state = state.borrow_mut(); + if let Some(devices) = devices { + state.devices = devices; + } + let holding = state + .volume_hold_until + .is_some_and(|until| Instant::now() < until); + if !state.volume_dragging && !holding { + state.volume = volume; + state.volume_hold_until = None; + } + }); +} + +pub fn set_audio_pointer(pointer: Option) { + STATE.with(|state| state.borrow_mut().pointer = pointer); +} + +pub fn audio_panel() -> AudioPanel { + STATE.with(|state| state.borrow().panel) +} + +pub fn toggle_audio_panel(panel: AudioPanel) { + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.close_at = None; + state.list_scroll = 0.0; + state.list_drag = None; + state.reveal_default = true; + state.volume_touched = Instant::now(); + state.panel = if state.panel == panel { + AudioPanel::None + } else { + panel + }; + }); +} + +pub fn close_audio_panel_soon() { + STATE.with(|state| state.borrow_mut().close_at = Some(Instant::now() + OUTPUT_CLOSE_DELAY)); +} + +pub fn audio_controls_animating() -> bool { + STATE.with(|state| { + let state = state.borrow(); + let outputs = if state.panel == AudioPanel::Outputs { + 1.0 + } else { + 0.0 + }; + let volume = if state.panel == AudioPanel::Volume { + 1.0 + } else { + 0.0 + }; + state.panel != AudioPanel::None + || (state.outputs_t - outputs).abs() > 0.001 + || (state.volume_t - volume).abs() > 0.001 + || state + .volume_glyph + .is_some_and(|(_, _, started)| started.elapsed() < VOLUME_GLYPH_FADE) + }) +} + +/// Sets the volume from a pointer position on the volume bar and returns the new level. +pub fn volume_level_at(ox: f32, oy: f32, w: f32, scale: f32, x: f32) -> f32 { + let lanes = lanes(ox, oy, w, scale); + let level = + ((x - lanes.bar_left) / (lanes.bar_right - lanes.bar_left).max(1.0)).clamp(0.0, 1.0); + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.volume = Some((level, level <= 0.0)); + state.volume_touched = Instant::now(); + state.volume_hold_until = Some(Instant::now() + VOLUME_SETTLE); + }); + level +} + +pub fn set_volume_dragging(dragging: bool) { + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.volume_dragging = dragging; + state.volume_touched = Instant::now(); + state.volume_hold_until = Some(Instant::now() + VOLUME_SETTLE); + }); +} + +/// Steps the volume for a wheel notch and returns the new level, opening the slider. +pub fn step_volume(notches: f32) -> Option { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let (level, _) = state.volume?; + let level = (level + notches * VOLUME_WHEEL_STEP).clamp(0.0, 1.0); + state.volume = Some((level, level <= 0.0)); + state.volume_touched = Instant::now(); + state.volume_hold_until = Some(Instant::now() + VOLUME_SETTLE); + state.panel = AudioPanel::Volume; + state.close_at = None; + Some(level) + }) +} + +pub fn scroll_output_list(ox: f32, oy: f32, w: f32, scale: f32, delta: f32) { + let list = output_list_rect(ox, oy, w, scale); + STATE.with(|state| { + let mut state = state.borrow_mut(); + let max = list_layout(&state, list, scale).max_scroll; + state.list_scroll = (state.list_scroll - delta).clamp(0.0, max); + }); +} + +/// Advances panel animations; returns how far the output list and the volume slider are shown. +pub(super) fn tick( + dt: f32, + expansion_progress: f32, + ox: f32, + oy: f32, + w: f32, + scale: f32, +) -> (f32, f32) { + let volume_row = volume_bar_rect(ox, oy, w, scale); + let volume_button = volume_button_rect(ox, oy, w, scale); + STATE.with(|state| { + let mut state = state.borrow_mut(); + let now = Instant::now(); + if state.pointer.is_some_and(|point| { + state.panel == AudioPanel::Volume + && (volume_row.contains(point) || volume_button.contains(point)) + }) { + state.volume_touched = now; + } + if expansion_progress < 0.95 + || state.close_at.is_some_and(|close_at| now >= close_at) + || (state.panel == AudioPanel::Volume + && !state.volume_dragging + && now.duration_since(state.volume_touched) > VOLUME_IDLE_TIMEOUT) + { + state.panel = AudioPanel::None; + state.close_at = None; + state.volume_dragging = false; + state.list_drag = None; + } + let factor = 1.0 - (1.0 - PANEL_RESPONSE).powf(dt.max(0.0)); + let ease = |value: &mut f32, target: f32| { + *value += (target - *value) * factor; + if (*value - target).abs() < 0.002 { + *value = target; + } + }; + let outputs = if state.panel == AudioPanel::Outputs { + 1.0 + } else { + 0.0 + }; + let volume = if state.panel == AudioPanel::Volume { + 1.0 + } else { + 0.0 + }; + ease(&mut state.outputs_t, outputs); + ease(&mut state.volume_t, volume); + (state.outputs_t, state.volume_t) + }) +} + +pub(super) struct SideButtonsParams<'a> { + pub(super) painter: Painter<'a>, + pub(super) ox: f32, + pub(super) oy: f32, + pub(super) w: f32, + pub(super) scale: f32, + pub(super) alpha: u8, + pub(super) text_color: Rgba, +} + +pub(super) fn draw_side_buttons(params: SideButtonsParams<'_>) { + let SideButtonsParams { + painter, + ox, + oy, + w, + scale, + alpha, + text_color, + } = params; + let (panel, pointer, output, volume) = STATE.with(|state| { + let mut state = state.borrow_mut(); + let volume = volume_glyph_transition(&mut state); + let output = state + .devices + .iter() + .find(|device| device.is_default) + .map_or(Glyph::AirPlay, |device| Glyph::for_output(device.kind)); + (state.panel, state.pointer, output, volume) + }); + let emphasis = |center: Point, active: bool| { + if active { + 1.0 + } else if pointer.is_some_and(|point| hit_rect(center, scale).contains(point)) { + 0.8 + } else { + SIDE_ICON_ALPHA + } + }; + let height = SIDE_ICON_HEIGHT * scale; + let output_center = side_center(ox, oy, w, scale, -1.0); + draw_glyph( + painter, + output, + output_center, + height, + text_color.with_alpha(scaled( + alpha, + emphasis(output_center, panel == AudioPanel::Outputs), + )), + ); + let volume_center = side_center(ox, oy, w, scale, 1.0); + let volume_alpha = scaled(alpha, emphasis(volume_center, panel == AudioPanel::Volume)); + let (current, previous, progress) = volume; + if let Some(previous) = previous { + draw_volume_glyph( + painter, + previous, + volume_center, + height, + 1.0, + text_color.with_alpha(scaled(volume_alpha, 1.0 - progress)), + ); + } + let (zoom, fade) = if previous.is_some() { + ( + VOLUME_GLYPH_START_SCALE + (1.0 - VOLUME_GLYPH_START_SCALE) * progress, + progress, + ) + } else { + (1.0, 1.0) + }; + draw_volume_glyph( + painter, + current, + volume_center, + height, + zoom, + text_color.with_alpha(scaled(volume_alpha, fade)), + ); +} + +pub(super) struct VolumeRowParams<'a> { + pub(super) painter: Painter<'a>, + pub(super) lanes: &'a Lanes, + pub(super) bar_h: f32, + pub(super) scale: f32, + pub(super) alpha: u8, + pub(super) text_color: Rgba, + pub(super) track_color: Rgba, +} + +pub(super) fn draw_volume_row(params: VolumeRowParams<'_>) { + let VolumeRowParams { + painter, + lanes, + bar_h, + scale, + alpha, + text_color, + track_color, + } = params; + if alpha == 0 { + return; + } + let level = STATE.with(|state| { + state + .borrow() + .volume + .map_or(0.0, |(level, muted)| if muted { 0.0 } else { level }) + }); + let icon_color = text_color.with_alpha(scaled(alpha, SIDE_ICON_ALPHA)); + let icon_h = VOLUME_ICON_HEIGHT * scale; + let time_w = PROGRESS_TIME_WIDTH * scale; + draw_glyph( + painter, + Glyph::VolumeLow, + Point::new(lanes.bar_full_left + time_w / 2.0, lanes.bar_y), + icon_h, + icon_color, + ); + draw_glyph( + painter, + Glyph::VolumeHigh, + Point::new(lanes.bar_full_right - time_w / 2.0, lanes.bar_y), + icon_h, + icon_color, + ); + let track = Rect::from_ltrb( + lanes.bar_left, + lanes.bar_y - bar_h / 2.0, + lanes.bar_right, + lanes.bar_y + bar_h / 2.0, + ); + let radius = Radius::uniform(bar_h / 2.0); + painter.fill_round_rect(track, radius, track_color); + let filled = track.width() * level; + if filled > 0.0 { + painter.save(); + painter.clip_round_rect(track, radius); + painter.fill_round_rect( + Rect::from_xywh(track.left, track.top, filled, bar_h), + Radius::uniform(0.0), + text_color.with_alpha(alpha), + ); + painter.restore(); + } +} + +pub(super) struct OutputListParams<'a> { + pub(super) painter: Painter<'a>, + pub(super) ox: f32, + pub(super) oy: f32, + pub(super) w: f32, + pub(super) scale: f32, + pub(super) alpha: u8, + pub(super) text_color: Rgba, + pub(super) accent: Rgba, +} + +pub(super) fn draw_output_list(params: OutputListParams<'_>) { + let OutputListParams { + painter, + ox, + oy, + w, + scale, + alpha, + text_color, + accent, + } = params; + if alpha == 0 { + return; + } + let list = output_list_rect(ox, oy, w, scale); + STATE.with(|state| { + let mut state = state.borrow_mut(); + let layout = list_layout(&state, list, scale); + if state.reveal_default + && let Some(index) = state.devices.iter().position(|device| device.is_default) + { + let row_h = ROW_HEIGHT * scale; + state.list_scroll = index as f32 * row_h - (list.height() - row_h) / 2.0; + state.reveal_default = false; + } + state.list_scroll = state.list_scroll.clamp(0.0, layout.max_scroll); + let fonts = FontManager::global(); + if state.devices.is_empty() { + let text = tr("audio_outputs_empty"); + let size = ROW_TEXT_SIZE * scale; + let width = fonts.measure_text_cached(&text, size, FontWeight::REGULAR); + fonts.draw_text_cached(DrawTextCachedParams { + painter, + text: &text, + x: list.center_x() - width / 2.0, + y: list.center_y() + size * 0.35, + size, + weight: FontWeight::REGULAR, + color: text_color.with_alpha(scaled(alpha, SIDE_ICON_ALPHA)), + blur: None, + }); + return; + } + let row_h = ROW_HEIGHT * scale; + let rows = layout.rows; + painter.save(); + painter.clip_rect(rows); + painter.translate(Vec2::new(0.0, -state.list_scroll)); + for (index, device) in state.devices.iter().enumerate() { + let row = Rect::from_xywh( + rows.left, + rows.top + index as f32 * row_h, + rows.width(), + row_h, + ); + if row.bottom < rows.top + state.list_scroll + || row.top > rows.bottom + state.list_scroll + { + continue; + } + let hovered = state.list_drag.is_none() + && state.pointer.is_some_and(|point| { + rows.contains(point) + && row.contains(Point::new(point.x, point.y + state.list_scroll)) + }); + if hovered || device.is_default { + painter.fill_round_rect( + row.inset(2.0 * scale), + Radius::uniform(9.0 * scale), + text_color.with_alpha(scaled(alpha, if hovered { 0.12 } else { 0.07 })), + ); + } + let content_left = row.left + (LIST_INSET + 4.0) * scale; + draw_glyph( + painter, + Glyph::for_output(device.kind), + Point::new(content_left + 9.0 * scale, row.center_y()), + ROW_ICON_HEIGHT * scale, + text_color.with_alpha(scaled(alpha, if device.is_default { 1.0 } else { 0.7 })), + ); + let check_x = row.right - (LIST_INSET + 12.0) * scale; + let text_x = content_left + 28.0 * scale; + let size = ROW_TEXT_SIZE * scale; + let weight = if device.is_default { + FontWeight::SEMIBOLD + } else { + FontWeight::REGULAR + }; + let name = fit_text( + fonts, + &device.name, + size, + weight, + check_x - 14.0 * scale - text_x, + ); + fonts.draw_text_cached(DrawTextCachedParams { + painter, + text: &name, + x: text_x, + y: row.center_y() + size * 0.35, + size, + weight, + color: text_color + .with_alpha(scaled(alpha, if device.is_default { 1.0 } else { 0.85 })), + blur: None, + }); + if device.is_default { + draw_glyph( + painter, + Glyph::Check, + Point::new(check_x, row.center_y()), + 11.0 * scale, + accent.with_alpha(alpha), + ); + } + } + painter.restore(); + if let Some(track) = layout.track { + let active = state.list_drag.is_some() + || state.pointer.is_some_and(|point| track.contains(point)); + let thumb = thumb_rect(track, list, state.list_scroll, layout.max_scroll, scale); + let thumb = if active { + Rect::from_xywh( + thumb.center_x() - thumb.width(), + thumb.top, + thumb.width() * 2.0, + thumb.height(), + ) + } else { + thumb + }; + painter.fill_round_rect( + thumb, + Radius::uniform(thumb.width() / 2.0), + text_color.with_alpha(scaled(alpha, if active { 0.55 } else { 0.3 })), + ); + } + }); +} + +fn fit_text( + fonts: &FontManager, + text: &str, + size: f32, + weight: FontWeight, + max_width: f32, +) -> String { + if fonts.measure_text_cached(text, size, weight) <= max_width { + return text.to_string(); + } + let mut fitted = String::new(); + for character in text.chars() { + fitted.push(character); + if fonts.measure_text_cached(&format!("{fitted}…"), size, weight) > max_width { + fitted.pop(); + break; + } + } + format!("{fitted}…") +} diff --git a/src/ui/expanded/music_view/controls.rs b/src/ui/expanded/music_view/controls.rs index c9ed4214..41cf11ba 100644 --- a/src/ui/expanded/music_view/controls.rs +++ b/src/ui/expanded/music_view/controls.rs @@ -1,8 +1,8 @@ use super::{ CONTENT_PADDING, COVER_FLIP_ANIM, COVER_FLIP_OLD_IMG, COVER_HOLD, COVER_SIZE, IMG_CACHE, - LOCAL_PLAY_STATE, NEXT_SKIP_ANIM, PAUSE_CONTROL_PRESS_VELOCITY, PAUSE_SPRING, - PLAYBACK_CONTROLS_TOP_GAP, PREV_SKIP_ANIM, PROGRESS_DRAGGING, PROGRESS_HOVER, PROGRESS_SMOOTH, - PROGRESS_TIME_GAP, PROGRESS_TIME_WIDTH, PROGRESS_TOP_GAP, SKIP_BUTTON_GAP, + LOCAL_PLAY_STATE, NEXT_SKIP_ANIM, PAUSE_CONTROL_PRESS_VELOCITY, PAUSE_SPRING, PREV_SKIP_ANIM, + PROGRESS_DRAGGING, PROGRESS_HOVER, PROGRESS_SMOOTH, PROGRESS_TIME_GAP, PROGRESS_TIME_WIDTH, + PROGRESS_TOP_GAP, SKIP_BUTTON_GAP, }; pub fn set_progress_dragging(active: bool) { @@ -75,27 +75,21 @@ pub fn get_cover_rect(ox: f32, oy: f32, scale: f32) -> (f32, f32, f32, f32) { } pub fn get_pause_btn_rect(ox: f32, oy: f32, w: f32, scale: f32) -> (f32, f32, f32, f32) { - let (_, img_y, img_size, _) = get_cover_rect(ox, oy, scale); - let bar_y = img_y + img_size + PROGRESS_TOP_GAP * scale; - let btn_cy = bar_y + PLAYBACK_CONTROLS_TOP_GAP * scale; + let btn_cy = super::audio_controls::lanes(ox, oy, w, scale).controls_y; let hit = 40.0 * scale; let btn_cx = ox + w / 2.0; (btn_cx - hit / 2.0, btn_cy - hit / 2.0, hit, hit) } pub fn get_prev_btn_rect(ox: f32, oy: f32, w: f32, scale: f32) -> (f32, f32, f32, f32) { - let (_, img_y, img_size, _) = get_cover_rect(ox, oy, scale); - let bar_y = img_y + img_size + PROGRESS_TOP_GAP * scale; - let btn_cy = bar_y + PLAYBACK_CONTROLS_TOP_GAP * scale; + let btn_cy = super::audio_controls::lanes(ox, oy, w, scale).controls_y; let hit = 36.0 * scale; let btn_cx = ox + w / 2.0 - SKIP_BUTTON_GAP * scale; (btn_cx - hit / 2.0, btn_cy - hit / 2.0, hit, hit) } pub fn get_next_btn_rect(ox: f32, oy: f32, w: f32, scale: f32) -> (f32, f32, f32, f32) { - let (_, img_y, img_size, _) = get_cover_rect(ox, oy, scale); - let bar_y = img_y + img_size + PROGRESS_TOP_GAP * scale; - let btn_cy = bar_y + PLAYBACK_CONTROLS_TOP_GAP * scale; + let btn_cy = super::audio_controls::lanes(ox, oy, w, scale).controls_y; let hit = 36.0 * scale; let btn_cx = ox + w / 2.0 + SKIP_BUTTON_GAP * scale; (btn_cx - hit / 2.0, btn_cy - hit / 2.0, hit, hit) diff --git a/src/ui/expanded/timer_view.rs b/src/ui/expanded/timer_view.rs index 32533213..75ce1709 100644 --- a/src/ui/expanded/timer_view.rs +++ b/src/ui/expanded/timer_view.rs @@ -6,7 +6,7 @@ use crate::ui::rolling_time::{RollingTime, TimeAnchor}; use crate::ui::widget::expanded::{draw_widget_text_centered, widget_grid_layout}; use winisland_core::i18n::{tr, tr_args}; use winisland_render::text::FontManager; -use winisland_render::{Angle, Painter, Point, Radius, Rect, Rgba, StrokeCap}; +use winisland_render::{Angle, FontWeight, Painter, Point, Radius, Rect, Rgba, StrokeCap}; const ORANGE: Rgba = Rgba::from_rgb(255, 159, 10); const GREEN: Rgba = Rgba::from_rgb(48, 209, 88); @@ -518,8 +518,10 @@ fn end_time_text(remaining: Duration) -> String { format!("{:02}:{:02}", minutes / 60, minutes % 60) } -fn fitted_size(text: &str, size: f32, max_width: f32) -> f32 { - let width = FontManager::global().measure_str(text, size, true).width(); +fn fitted_size(text: &str, size: f32, max_width: f32, weight: FontWeight) -> f32 { + let width = FontManager::global() + .measure_str(text, size, weight) + .width(); if width <= max_width || width <= f32::EPSILON { size } else { @@ -764,7 +766,12 @@ pub fn draw_timer_page( Phase::Paused { .. } => (tr("timer_pause"), ORANGE.with_alpha_f(0.85 * opacity)), Phase::Finished { .. } => (tr("timer_done"), ORANGE.with_alpha_f(opacity)), }; - let caption_size = fitted_size(&caption, (radius * 0.16).max(8.0 * scale), inner_width); + let caption_size = fitted_size( + &caption, + (radius * 0.16).max(8.0 * scale), + inner_width, + FontWeight::MEDIUM, + ); draw_widget_text_centered( painter, &caption, @@ -775,7 +782,7 @@ pub fn draw_timer_page( caption_size * 1.4, ), caption_size, - true, + FontWeight::MEDIUM, caption_color, ); @@ -800,13 +807,14 @@ pub fn draw_timer_page( &label, chip.height() * 0.5, chip.width() - chip.height() * 0.5, + FontWeight::MEDIUM, ); draw_widget_text_centered( painter, &label, *chip, label_size, - true, + FontWeight::MEDIUM, if selected { ORANGE.with_alpha_f(enabled_factor * opacity) } else { @@ -886,13 +894,14 @@ pub fn draw_timer_page( &label, (body.height() * 0.36).min(15.0 * scale), body.width() - body.height() * 0.5, + FontWeight::SEMIBOLD, ); draw_widget_text_centered( painter, &label, rect, label_size, - true, + FontWeight::SEMIBOLD, tint.with_alpha_f(label_alpha * weight * enabled_factor * opacity), ); } diff --git a/src/ui/island.rs b/src/ui/island.rs index 66fa2bf8..b209cfe7 100644 --- a/src/ui/island.rs +++ b/src/ui/island.rs @@ -7,8 +7,8 @@ use std::cell::RefCell; use std::collections::HashMap; pub(crate) use mini::{ - lyric_font_size as mini_lyric_font_size, lyric_insets as mini_lyric_insets, - lyric_pair_height as mini_lyric_pair_height, + LYRIC_WEIGHT as MINI_LYRIC_WEIGHT, lyric_font_size as mini_lyric_font_size, + lyric_insets as mini_lyric_insets, lyric_pair_height as mini_lyric_pair_height, }; use self::background::{BackgroundParams, draw_background}; diff --git a/src/ui/island/mini.rs b/src/ui/island/mini.rs index 28da33ce..ff445f93 100644 --- a/src/ui/island/mini.rs +++ b/src/ui/island/mini.rs @@ -9,8 +9,11 @@ use winisland_core::lyrics::LyricHighlight; use winisland_plugin_host::draw::replay::PreparedFrame; use winisland_plugin_host::host::PluginHost; use winisland_render::text::{DrawTextCachedParams, FontManager}; -use winisland_render::{BlurSpec, Image, ImageOptions, Painter, Radius, Rect, Rgba, Sampling}; +use winisland_render::{ + BlurSpec, FontWeight, Image, ImageOptions, Painter, Radius, Rect, Rgba, Sampling, +}; +pub(crate) const LYRIC_WEIGHT: FontWeight = FontWeight::MEDIUM; const PENDING_LYRIC_CHANNEL: u8 = 190; const SECONDARY_LYRIC_SCALE: f32 = 0.85; const LYRIC_PAIR_GAP_SCALE: f32 = 0.18; @@ -352,13 +355,8 @@ fn lyric_style(color: Rgba, alpha: u8, blur_sigma: f32) -> (Rgba, Option f32 { - let style = if bold { - winisland_render::FontStyle::bold() - } else { - winisland_render::FontStyle::normal() - }; - let measured = FontManager::global().measure_text_cached(text, size, style); +fn plugin_text_x(text: &str, size: f32, weight: FontWeight, text_x: f32, text_width: f32) -> f32 { + let measured = FontManager::global().measure_text_cached(text, size, weight); text_x + ((text_width - measured) * 0.5).max(0.0) } @@ -389,10 +387,10 @@ fn draw_plugin_content(params: &MiniContentParams<'_>, context: &ActivityText, a draw_text_cached(DrawTextCachedParams { painter: params.painter, text, - x: plugin_text_x(text, font_size, true, text_x, text_width), + x: plugin_text_x(text, font_size, FontWeight::SEMIBOLD, text_x, text_width), y: text_y, size: font_size, - bold: true, + weight: FontWeight::SEMIBOLD, color, blur, }); @@ -408,13 +406,13 @@ fn draw_plugin_content(params: &MiniContentParams<'_>, context: &ActivityText, a x: plugin_text_x( &context.body, font_size * PLUGIN_SECONDARY_FONT_SCALE, - false, + FontWeight::REGULAR, text_x, text_width, ), y: text_y + font_size * PLUGIN_SECONDARY_LINE_SPACING, size: font_size * PLUGIN_SECONDARY_FONT_SCALE, - bold: false, + weight: FontWeight::REGULAR, color: secondary_color, blur: secondary_blur, }); @@ -468,13 +466,9 @@ fn draw_lyric_pair(params: LyricPairParams<'_>) { } else { center_y + secondary_size / 3.0 }; - let text_x = |text: &str, text_size: f32, anchor_x: f32, centered: bool| { + let text_x = |text: &str, text_size: f32, weight: FontWeight, anchor_x: f32, centered: bool| { if centered { - let width = FontManager::global().measure_text_cached( - text, - text_size, - winisland_render::FontStyle::normal(), - ); + let width = FontManager::global().measure_text_cached(text, text_size, weight); anchor_x - width / 2.0 } else { anchor_x @@ -485,7 +479,13 @@ fn draw_lyric_pair(params: LyricPairParams<'_>) { draw_highlighted_lyric( painter, primary, - text_x(primary, size, primary_anchor_x, primary_centered), + text_x( + primary, + size, + LYRIC_WEIGHT, + primary_anchor_x, + primary_centered, + ), primary_y, size, color, @@ -503,10 +503,16 @@ fn draw_lyric_pair(params: LyricPairParams<'_>) { draw_text_cached(DrawTextCachedParams { painter, text: secondary, - x: text_x(secondary, secondary_size, secondary_center_x, true), + x: text_x( + secondary, + secondary_size, + FontWeight::REGULAR, + secondary_center_x, + true, + ), y: secondary_y, size: secondary_size, - bold: false, + weight: FontWeight::REGULAR, color: secondary_color, blur, }); @@ -531,7 +537,7 @@ fn draw_highlighted_lyric( x, y, size, - bold: false, + weight: LYRIC_WEIGHT, color, blur, }); @@ -554,7 +560,7 @@ fn draw_highlighted_lyric( )); let font_manager = FontManager::global(); - let style = winisland_render::FontStyle::normal(); + let style = LYRIC_WEIGHT; let (completed_width, active_width) = font_manager.measure_text_prefixes_cached( text, highlight.start_byte, diff --git a/src/ui/island/multitask/activities/device.rs b/src/ui/island/multitask/activities/device.rs index fcd22124..6ba8edcf 100644 --- a/src/ui/island/multitask/activities/device.rs +++ b/src/ui/island/multitask/activities/device.rs @@ -4,54 +4,100 @@ use winisland_core::anim::{Curve, Tween}; use winisland_platform::DeviceActivity; use winisland_render::{Painter, Point, Rect, Rgba}; +use crate::ui::compact::timer::TimerContent; + use super::Activity; -pub(crate) struct DeviceIndicators { +const PILL_EXTENSION: f32 = 0.75; +const FADE_DURATION: Duration = Duration::from_millis(100); +const SHARE_DURATION: Duration = Duration::from_millis(420); +const TIMER_ICON_DIAMETER: f32 = 26.0; + +pub(crate) struct StatusIndicators { camera: Tween, microphone: Tween, + timer: Tween, + shared: Tween, + timer_progress: f32, + timer_paused: bool, } -impl Default for DeviceIndicators { +impl Default for StatusIndicators { fn default() -> Self { Self { camera: Tween::new(0.0), microphone: Tween::new(0.0), + timer: Tween::new(0.0), + shared: Tween::new(0.0), + timer_progress: 0.0, + timer_paused: false, } } } -impl DeviceIndicators { - pub(crate) fn update(&mut self, state: DeviceActivity, now: Instant) -> bool { +impl StatusIndicators { + pub(crate) fn update( + &mut self, + state: DeviceActivity, + timer: Option, + now: Instant, + ) -> bool { let before = self.activity(); - for (animation, active) in [ - (&mut self.camera, state.camera), - (&mut self.microphone, state.microphone), + let camera = state.camera == Some(true); + let microphone = state.microphone == Some(true); + let timing = timer.is_some(); + let devices = camera || microphone; + let pace = |other: bool| if other { SHARE_DURATION } else { FADE_DURATION }; + for (animation, active, duration) in [ + (&mut self.camera, camera, pace(timing)), + (&mut self.microphone, microphone, pace(timing)), + (&mut self.timer, timing, pace(devices)), + (&mut self.shared, devices && timing, SHARE_DURATION), ] { - animation.retarget( - if active == Some(true) { 1.0 } else { 0.0 }, - Duration::from_millis(140), - Curve::Ease, - now, - ); + animation.retarget(if active { 1.0 } else { 0.0 }, duration, Curve::Ease, now); animation.update(now); } + if let Some(timer) = timer { + self.timer_progress = (timer.progress() * 720.0).round() / 720.0; + self.timer_paused = timer.paused; + } before != self.activity() } pub(crate) fn activity(&self) -> Option { let camera = self.camera.value(); let microphone = self.microphone.value(); - (camera > 0.0 || microphone > 0.0 || self.is_animating()) - .then_some(Activity::Devices { camera, microphone }) + let timer = self.timer.value(); + (camera > 0.0 || microphone > 0.0 || timer > 0.0 || self.is_animating()).then_some( + Activity::Status { + camera, + microphone, + timer, + shared: self.shared.value(), + timer_progress: self.timer_progress, + timer_paused: self.timer_paused, + }, + ) } pub(crate) fn is_animating(&self) -> bool { - self.camera.is_animating() || self.microphone.is_animating() + self.camera.is_animating() + || self.microphone.is_animating() + || self.timer.is_animating() + || self.shared.is_animating() } } -pub(super) fn width(height: f32) -> f32 { - height +pub(super) struct Indicators { + pub(super) camera: f32, + pub(super) microphone: f32, + pub(super) timer: f32, + pub(super) timer_progress: f32, + pub(super) timer_paused: bool, +} + +pub(super) fn width(height: f32, shared: f32) -> f32 { + height * (1.0 + PILL_EXTENSION * shared) } pub(super) fn draw( @@ -59,22 +105,40 @@ pub(super) fn draw( rect: Rect, _scale: f32, opacity: f32, - camera: f32, - microphone: f32, + indicators: Indicators, ) { let diameter = rect.width().min(rect.height()); + if indicators.timer > 0.0 { + crate::ui::compact::timer::draw_timer_icon( + painter, + Point::new(rect.left + diameter / 2.0, rect.center_y()), + diameter / TIMER_ICON_DIAMETER, + indicators.timer_progress, + indicators.timer_paused, + opacity * indicators.timer * indicators.timer, + ); + } let dot_radius = diameter / 9.0; let dot_spacing = diameter * 0.35; + let dots_x = rect.right - diameter / 2.0; let dots = [ - (camera, -microphone, Rgba::from_rgb(48, 209, 88)), - (microphone, camera, Rgba::from_rgb(255, 159, 10)), + ( + indicators.camera, + -indicators.microphone, + Rgba::from_rgb(48, 209, 88), + ), + ( + indicators.microphone, + indicators.camera, + Rgba::from_rgb(255, 159, 10), + ), ]; for (visibility, offset, color) in dots { - let x = rect.center_x() + offset * 0.5 * dot_spacing; + let x = dots_x + offset * 0.5 * dot_spacing; painter.fill_circle( Point::new(x, rect.center_y()), dot_radius, - color.with_alpha_f(opacity * visibility), + color.with_alpha_f(opacity * visibility * visibility), ); } } diff --git a/src/ui/island/multitask/activities/mod.rs b/src/ui/island/multitask/activities/mod.rs index ff19f8cc..e601ffbd 100644 --- a/src/ui/island/multitask/activities/mod.rs +++ b/src/ui/island/multitask/activities/mod.rs @@ -1,26 +1,56 @@ mod device; -pub(crate) use device::DeviceIndicators; +pub(crate) use device::StatusIndicators; use winisland_render::{Painter, Rect}; #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum Activity { - Devices { camera: f32, microphone: f32 }, + Status { + camera: f32, + microphone: f32, + timer: f32, + shared: f32, + timer_progress: f32, + timer_paused: bool, + }, } impl Activity { pub(crate) fn width(self, height: f32, _scale: f32) -> f32 { match self { - Self::Devices { .. } => device::width(height), + Self::Status { shared, .. } => device::width(height, shared), + } + } + + pub(crate) fn has_timer(self) -> bool { + match self { + Self::Status { timer, .. } => timer > 0.5, } } pub(crate) fn draw(self, painter: Painter<'_>, rect: Rect, scale: f32, opacity: f32) { match self { - Self::Devices { camera, microphone } => { - device::draw(painter, rect, scale, opacity, camera, microphone) - } + Self::Status { + camera, + microphone, + timer, + timer_progress, + timer_paused, + .. + } => device::draw( + painter, + rect, + scale, + opacity, + device::Indicators { + camera, + microphone, + timer, + timer_progress, + timer_paused, + }, + ), } } } diff --git a/src/ui/rolling_time.rs b/src/ui/rolling_time.rs index ac25dc71..894a173f 100644 --- a/src/ui/rolling_time.rs +++ b/src/ui/rolling_time.rs @@ -1,7 +1,7 @@ use std::time::{Duration, Instant}; use winisland_render::text::FontManager; -use winisland_render::{Painter, Point, Rect, Rgba, Vec2}; +use winisland_render::{FontWeight, Painter, Point, Rect, Rgba, Vec2}; use crate::ui::widget::expanded::draw_widget_text_centered; @@ -52,7 +52,9 @@ impl RollingTime { } pub(crate) fn measure_width(text: &str, size: f32) -> f32 { - let digit_width = FontManager::global().measure_str("0", size, true).width(); + let digit_width = FontManager::global() + .measure_str("0", size, FontWeight::SEMIBOLD) + .width(); text.chars() .map(|character| character_width(character, size, digit_width)) .sum() @@ -130,7 +132,9 @@ impl RollingTime { .min(0.1); self.last_frame = Some(now); self.advance(text, now); - let digit_width = FontManager::global().measure_str("0", size, true).width(); + let digit_width = FontManager::global() + .measure_str("0", size, FontWeight::SEMIBOLD) + .width(); let advance = |character| character_width(character, size, digit_width); let chars = &self.digits[..self.digit_count]; let total: f32 = chars.iter().map(|character| advance(*character)).sum(); @@ -180,7 +184,7 @@ impl RollingTime { size, ), size, - true, + FontWeight::SEMIBOLD, color.with_alpha_f(opacity(color) * (1.0 - amount)), ); } @@ -195,7 +199,7 @@ impl RollingTime { change.previous.encode_utf8(&mut buffer), cell.offset(Vec2::new(0.0, -travel * amount)), size, - true, + FontWeight::SEMIBOLD, color.with_alpha_f(opacity(color) * (1.0 - amount)), ); draw_widget_text_centered( @@ -203,7 +207,7 @@ impl RollingTime { character.encode_utf8(&mut buffer), cell.offset(Vec2::new(0.0, travel * (1.0 - amount))), size, - true, + FontWeight::SEMIBOLD, color.with_alpha_f(opacity(color) * amount), ); } else { @@ -212,7 +216,7 @@ impl RollingTime { character.encode_utf8(&mut buffer), cell, size, - true, + FontWeight::SEMIBOLD, color, ); } @@ -227,7 +231,7 @@ fn character_width(character: char, size: f32, digit_width: f32) -> f32 { digit_width } else { FontManager::global() - .measure_str(&character.to_string(), size, true) + .measure_str(&character.to_string(), size, FontWeight::SEMIBOLD) .width() .max(size * 0.25) } diff --git a/src/ui/widget/compact/resource_usage.rs b/src/ui/widget/compact/resource_usage.rs index 60c89816..ab5119c8 100644 --- a/src/ui/widget/compact/resource_usage.rs +++ b/src/ui/widget/compact/resource_usage.rs @@ -5,7 +5,7 @@ use crate::ui::widget::resource_usage::{ }; use winisland_core::config::{ResourceMetricConfig, ResourceMetricStyle}; use winisland_render::text::{DrawTextCachedParams, FontManager}; -use winisland_render::{Angle, FontStyle, Painter, Point, Radius, Rect, Rgba, StrokeCap}; +use winisland_render::{Angle, FontWeight, Painter, Point, Radius, Rect, Rgba, StrokeCap}; const RING_LABEL_GAP: f32 = 4.0; @@ -93,7 +93,7 @@ fn draw_bar( let left = rect.left + inset; let width = (rect.width() - inset * 2.0).max(0.0); let baseline = rect.center_y() + scale; - let label_size = (6.5 * scale).min(rect.width() * 0.22).max(4.5); + let label_size = (7.0 * scale).min(rect.width() * 0.22).max(4.5); let mut value_size = (8.5 * scale).min(rect.width() * 0.28).max(5.5); let track_h = (2.0 * scale).max(1.5); let track = Rect::from_xywh(left, baseline + 4.5 * scale, width, track_h); @@ -111,9 +111,9 @@ fn draw_bar( ); } let fonts = FontManager::global(); - let label_w = fonts.measure_text_cached(label, label_size, FontStyle::bold()); + let label_w = fonts.measure_text_cached(label, label_size, FontWeight::SEMIBOLD); let available_value_width = (width - label_w - 3.0 * scale).max(f32::EPSILON); - let natural_value_width = fonts.measure_text_cached(text, value_size, FontStyle::bold()); + let natural_value_width = fonts.measure_text_cached(text, value_size, FontWeight::SEMIBOLD); if natural_value_width > available_value_width { value_size *= available_value_width / natural_value_width; } @@ -123,18 +123,18 @@ fn draw_bar( x: left, y: baseline, size: label_size, - bold: true, + weight: FontWeight::SEMIBOLD, color: alpha_color(accent, (alpha as f32 * 0.78) as u8), blur: None, }); - let value_w = fonts.measure_text_cached(text, value_size, FontStyle::bold()); + let value_w = fonts.measure_text_cached(text, value_size, FontWeight::SEMIBOLD); fonts.draw_text_cached(DrawTextCachedParams { painter, text, x: rect.right - inset - value_w, y: baseline, size: value_size, - bold: true, + weight: FontWeight::SEMIBOLD, color: Rgba::from_argb(alpha, 255, 255, 255), blur: None, }); @@ -155,7 +155,7 @@ fn draw_ring( let accent = usage_color(metric_color(config.color), value); let fonts = FontManager::global(); let label_size = (6.0 * scale).max(4.5); - let label_w = fonts.measure_text_cached(label, label_size, FontStyle::bold()); + let label_w = fonts.measure_text_cached(label, label_size, FontWeight::SEMIBOLD); let label_gap = RING_LABEL_GAP * scale; let outer_diameter = (rect.height() * 0.72) .min(rect.width() - label_w - label_gap) @@ -189,12 +189,12 @@ fn draw_ring( StrokeCap::Round, ); } - let mut value_size = (diameter * 0.22).max(4.0); + let mut value_size = (diameter * 0.3).max(4.0); let max_value_width = diameter * 0.78; - let mut value_w = fonts.measure_text_cached(text, value_size, FontStyle::bold()); + let mut value_w = fonts.measure_text_cached(text, value_size, FontWeight::SEMIBOLD); if value_w > max_value_width { value_size = (value_size * max_value_width / value_w).max(3.2); - value_w = fonts.measure_text_cached(text, value_size, FontStyle::bold()); + value_w = fonts.measure_text_cached(text, value_size, FontWeight::SEMIBOLD); } fonts.draw_text_cached(DrawTextCachedParams { painter, @@ -202,7 +202,7 @@ fn draw_ring( x: center.x - value_w / 2.0, y: center.y + value_size * 0.32, size: value_size, - bold: true, + weight: FontWeight::SEMIBOLD, color: Rgba::from_argb(alpha, 255, 255, 255), blur: None, }); @@ -212,7 +212,7 @@ fn draw_ring( x: group_left, y: rect.center_y() + label_size * 0.34, size: label_size, - bold: true, + weight: FontWeight::SEMIBOLD, color: alpha_color(accent, (alpha as f32 * 0.8) as u8), blur: None, }); @@ -237,9 +237,9 @@ fn draw_rates( let row_width = |value: &str, unit: &str, value_size: f32, unit_size: f32| { arrow_size + arrow_gap - + fonts.measure_text_cached(value, value_size, FontStyle::bold()) + + fonts.measure_text_cached(value, value_size, FontWeight::SEMIBOLD) + unit_gap - + fonts.measure_text_cached(unit, unit_size, FontStyle::normal()) + + fonts.measure_text_cached(unit, unit_size, FontWeight::MEDIUM) }; let widest = row_width(upload.value, upload.unit, value_size, unit_size).max(row_width( download.value, @@ -274,18 +274,18 @@ fn draw_rates( x: value_x, y: baseline, size: value_size, - bold: true, + weight: FontWeight::SEMIBOLD, color: Rgba::from_argb(alpha, 255, 255, 255), blur: None, }); - let value_w = fonts.measure_text_cached(rate.value, value_size, FontStyle::bold()); + let value_w = fonts.measure_text_cached(rate.value, value_size, FontWeight::SEMIBOLD); fonts.draw_text_cached(DrawTextCachedParams { painter, text: rate.unit, x: value_x + value_w + unit_gap, y: baseline, size: unit_size, - bold: false, + weight: FontWeight::MEDIUM, color: Rgba::from_argb((alpha as f32 * 0.6) as u8, 255, 255, 255), blur: None, }); diff --git a/src/ui/widget/compact/time.rs b/src/ui/widget/compact/time.rs index 456e91b2..425bba56 100644 --- a/src/ui/widget/compact/time.rs +++ b/src/ui/widget/compact/time.rs @@ -1,23 +1,20 @@ use crate::ui::widget::time_text::with_current_time_text; use winisland_render::text::{DrawTextCachedParams, FontManager}; -use winisland_render::{Painter, Rect, Rgba}; +use winisland_render::{FontWeight, Painter, Rect, Rgba}; pub(super) fn draw(painter: Painter<'_>, rect: Rect, scale: f32, alpha: u8) { let size = (rect.height() * 0.43).clamp(10.0 * scale, 12.0 * scale); let color = Rgba::from_argb(alpha, 255, 255, 255); with_current_time_text(|text| { - let text_width = FontManager::global().measure_text_cached( - text, - size, - winisland_render::FontStyle::bold(), - ); + let text_width = + FontManager::global().measure_text_cached(text, size, FontWeight::SEMIBOLD); FontManager::global().draw_text_cached(DrawTextCachedParams { painter, text, x: rect.center_x() - text_width / 2.0, y: rect.center_y() + size * 0.36, size, - bold: true, + weight: FontWeight::SEMIBOLD, color, blur: None, }); diff --git a/src/ui/widget/expanded/calendar.rs b/src/ui/widget/expanded/calendar.rs index 39c72edd..010ca5b6 100644 --- a/src/ui/widget/expanded/calendar.rs +++ b/src/ui/widget/expanded/calendar.rs @@ -1,7 +1,7 @@ use super::{draw_widget_rounded_background, draw_widget_text_centered}; use std::cell::RefCell; use winisland_core::i18n::tr; -use winisland_render::{Painter, Rect, Rgba}; +use winisland_render::{FontWeight, Painter, Rect, Rgba}; struct CalendarText { year: u16, @@ -99,7 +99,7 @@ pub fn draw_calendar_widget( &cache.month_text, Rect::from_xywh(x, y + h * 0.08, w, h * 0.18), (h * 0.14).clamp(9.0 * scale, 15.0 * scale), - true, + FontWeight::SEMIBOLD, month_color, ); @@ -109,7 +109,7 @@ pub fn draw_calendar_widget( &cache.day_text, Rect::from_xywh(x, y + h * 0.27, w, h * 0.48), day_size, - true, + FontWeight::SEMIBOLD, day_color, ); @@ -118,7 +118,7 @@ pub fn draw_calendar_widget( &cache.weekday_text, Rect::from_xywh(x, y + h * 0.78, w, h * 0.14), (h * 0.12).clamp(8.0 * scale, 12.0 * scale), - false, + FontWeight::MEDIUM, weekday_color, ); }); diff --git a/src/ui/widget/expanded/mod.rs b/src/ui/widget/expanded/mod.rs index c1b484e9..fb63f42c 100644 --- a/src/ui/widget/expanded/mod.rs +++ b/src/ui/widget/expanded/mod.rs @@ -6,7 +6,7 @@ pub mod time; use crate::utils::shape::expanded_island_radius; use winisland_core::config::{WIDGET_GRID_COLS, WIDGET_GRID_ROWS, WidgetKind, widget_footprint}; use winisland_render::text::FontManager; -use winisland_render::{Painter, Path, Point, Rect, Rgba, StrokeCap, StrokeJoin}; +use winisland_render::{FontWeight, Painter, Path, Point, Rect, Rgba, StrokeCap, StrokeJoin}; #[derive(Debug, Clone, Copy)] pub struct WidgetGridLayout { @@ -121,10 +121,10 @@ pub(crate) fn draw_widget_text_centered( text: &str, bounds: Rect, size: f32, - bold: bool, + weight: FontWeight, color: Rgba, ) { - let glyph_bounds = FontManager::global().measure_str(text, size, bold); + let glyph_bounds = FontManager::global().measure_str(text, size, weight); let text_x = bounds.left + (bounds.width() - glyph_bounds.width()) / 2.0 - glyph_bounds.left; let baseline_y = bounds.top + (bounds.height() - glyph_bounds.height()) / 2.0 - glyph_bounds.top; @@ -133,7 +133,7 @@ pub(crate) fn draw_widget_text_centered( text, Point::new(text_x, baseline_y), size, - bold, + weight, color, ); } diff --git a/src/ui/widget/expanded/resource_usage.rs b/src/ui/widget/expanded/resource_usage.rs index 82b57182..4e8c735b 100644 --- a/src/ui/widget/expanded/resource_usage.rs +++ b/src/ui/widget/expanded/resource_usage.rs @@ -7,7 +7,7 @@ use winisland_core::config::{ ResourceMetricConfig, ResourceMetricStyle, default_resource_metrics, resource_widget_span, }; use winisland_render::text::{DrawTextCachedParams, FontManager}; -use winisland_render::{Angle, FontStyle, Painter, Point, Radius, Rect, Rgba, StrokeCap}; +use winisland_render::{Angle, FontWeight, Painter, Point, Radius, Rect, Rgba, StrokeCap}; const CELL_GAP: f32 = 3.0; @@ -149,8 +149,8 @@ fn draw_bar( ); } let fonts = FontManager::global(); - let label_width = fonts.measure_text_cached(label, font_size, FontStyle::bold()); - let measured_value_width = fonts.measure_text_cached(text, value_size, FontStyle::bold()); + let label_width = fonts.measure_text_cached(label, font_size, FontWeight::SEMIBOLD); + let measured_value_width = fonts.measure_text_cached(text, value_size, FontWeight::SEMIBOLD); let available = (right - left - 2.0 * scale).max(1.0); let fit = (available / (label_width + measured_value_width).max(1.0)).min(1.0); font_size = (font_size * fit).max(3.8 * scale); @@ -161,18 +161,18 @@ fn draw_bar( x: left, y: baseline, size: font_size, - bold: true, + weight: FontWeight::SEMIBOLD, color: alpha_color(accent, (alpha as f32 * 0.82) as u8), blur: None, }); - let value_w = fonts.measure_text_cached(text, value_size, FontStyle::bold()); + let value_w = fonts.measure_text_cached(text, value_size, FontWeight::SEMIBOLD); fonts.draw_text_cached(DrawTextCachedParams { painter, text, x: right - value_w, y: baseline, size: value_size, - bold: true, + weight: FontWeight::SEMIBOLD, color: alpha_color(text_color, alpha), blur: None, }); @@ -197,7 +197,7 @@ fn draw_ring( let label_gap = 3.0 * scale; let mut label_size = (bounds.height() * 0.16).clamp(4.0 * scale, 9.0 * scale); let label_space = (bounds.width() - inset * 2.0).max(1.0); - let measured_label = fonts.measure_text_cached(label, label_size, FontStyle::bold()); + let measured_label = fonts.measure_text_cached(label, label_size, FontWeight::SEMIBOLD); if measured_label > label_space { label_size = (label_size * label_space / measured_label).max(3.5 * scale); } @@ -229,12 +229,12 @@ fn draw_ring( StrokeCap::Round, ); } - let mut value_size = (diameter * 0.22).clamp(4.0 * scale, 9.0 * scale); + let mut value_size = (diameter * 0.26).clamp(4.0 * scale, 10.0 * scale); let max_value_width = diameter * 0.78; - let mut value_width = fonts.measure_text_cached(text, value_size, FontStyle::bold()); + let mut value_width = fonts.measure_text_cached(text, value_size, FontWeight::SEMIBOLD); if value_width > max_value_width { value_size = (value_size * max_value_width / value_width).max(3.2 * scale); - value_width = fonts.measure_text_cached(text, value_size, FontStyle::bold()); + value_width = fonts.measure_text_cached(text, value_size, FontWeight::SEMIBOLD); } fonts.draw_text_cached(DrawTextCachedParams { painter, @@ -242,18 +242,18 @@ fn draw_ring( x: center.x - value_width / 2.0, y: center.y + value_size * 0.32, size: value_size, - bold: true, + weight: FontWeight::SEMIBOLD, color: alpha_color(text_color, alpha), blur: None, }); - let label_width = fonts.measure_text_cached(label, label_size, FontStyle::bold()); + let label_width = fonts.measure_text_cached(label, label_size, FontWeight::SEMIBOLD); fonts.draw_text_cached(DrawTextCachedParams { painter, text: label, x: center.x - label_width / 2.0, y: ring.bottom + label_gap + label_size * 0.8, size: label_size, - bold: true, + weight: FontWeight::SEMIBOLD, color: alpha_color(accent, (alpha as f32 * 0.84) as u8), blur: None, }); @@ -280,9 +280,9 @@ fn draw_rates( let row_width = |rate: RateUsage<'_>, value_size: f32, unit_size: f32| { value_size * 0.8 + arrow_gap - + fonts.measure_text_cached(rate.value, value_size, FontStyle::bold()) + + fonts.measure_text_cached(rate.value, value_size, FontWeight::SEMIBOLD) + unit_gap - + fonts.measure_text_cached(rate.unit, unit_size, FontStyle::normal()) + + fonts.measure_text_cached(rate.unit, unit_size, FontWeight::MEDIUM) }; let widest = row_width(upload, value_size, unit_size).max(row_width(download, value_size, unit_size)); @@ -314,18 +314,18 @@ fn draw_rates( x: value_x, y: baseline, size: value_size, - bold: true, + weight: FontWeight::SEMIBOLD, color: alpha_color(text_color, alpha), blur: None, }); - let value_w = fonts.measure_text_cached(rate.value, value_size, FontStyle::bold()); + let value_w = fonts.measure_text_cached(rate.value, value_size, FontWeight::SEMIBOLD); fonts.draw_text_cached(DrawTextCachedParams { painter, text: rate.unit, x: value_x + value_w + unit_gap, y: baseline, size: unit_size, - bold: false, + weight: FontWeight::MEDIUM, color: alpha_color(text_color, (alpha as f32 * 0.58) as u8), blur: None, }); diff --git a/src/ui/widget/expanded/time.rs b/src/ui/widget/expanded/time.rs index ede2406a..9b62a095 100644 --- a/src/ui/widget/expanded/time.rs +++ b/src/ui/widget/expanded/time.rs @@ -1,6 +1,6 @@ use super::{draw_widget_rounded_background, draw_widget_text_centered}; use crate::ui::widget::time_text::with_current_time_text; -use winisland_render::{Painter, Rect, Rgba}; +use winisland_render::{FontWeight, Painter, Rect, Rgba}; #[allow(clippy::too_many_arguments)] pub fn draw_time_widget( @@ -24,7 +24,7 @@ pub fn draw_time_widget( text, Rect::from_xywh(x, y, w, h), size, - true, + FontWeight::SEMIBOLD, color, ); }); diff --git a/src/utils/color.rs b/src/utils/color.rs index eb377235..e54a84b9 100644 --- a/src/utils/color.rs +++ b/src/utils/color.rs @@ -4,30 +4,12 @@ pub(crate) fn settings_color(color: Rgba) -> Rgba { color } -pub const COLOR_CARD_HIGHLIGHT: Rgba = Rgba::from_rgb(72, 72, 74); -pub const COLOR_ACCENT: Rgba = Rgba::from_rgb(10, 132, 255); -pub const COLOR_TEXT_PRI: Rgba = Rgba::from_rgb(245, 245, 247); -pub const COLOR_TEXT_SEC: Rgba = Rgba::from_rgb(174, 174, 178); -pub const COLOR_DANGER: Rgba = Rgba::from_rgb(230, 55, 45); -pub const COLOR_DISABLED: Rgba = Rgba::from_rgb(99, 99, 102); - -pub const COLOR_WIN_BG: Rgba = Rgba::from_rgb(28, 28, 30); -pub const COLOR_SIDEBAR_BG: Rgba = Rgba::from_rgb(36, 36, 38); -pub const COLOR_GROUP_BG: Rgba = Rgba::from_rgb(44, 44, 46); -pub const COLOR_TOGGLE_ON: Rgba = Rgba::from_rgb(48, 209, 88); -pub const COLOR_TOGGLE_OFF: Rgba = Rgba::from_rgb(99, 99, 102); - -pub fn color_sidebar_hover() -> Rgba { - Rgba::from_argb(20, 255, 255, 255) -} - -pub fn color_separator() -> Rgba { - Rgba::from_argb(26, 255, 255, 255) -} - pub struct SettingsTheme { pub win_bg: Rgba, pub sidebar_bg: Rgba, + pub sidebar_glass: Rgba, + pub sidebar_border: Rgba, + pub sidebar_shadow: Rgba, pub group_bg: Rgba, pub card_highlight: Rgba, pub text_pri: Rgba, @@ -46,6 +28,10 @@ pub struct SettingsTheme { pub popup_border: Rgba, pub popup_shadow: Rgba, pub popup_separator: Rgba, + pub field_bg: Rgba, + pub button_bg: Rgba, + pub button_border: Rgba, + pub button_shadow: Rgba, pub control_bg: Rgba, pub control_hover: Rgba, pub control_disabled: Rgba, @@ -57,64 +43,78 @@ pub struct SettingsTheme { pub fn dark_settings_theme() -> SettingsTheme { SettingsTheme { - win_bg: COLOR_WIN_BG, - sidebar_bg: COLOR_SIDEBAR_BG, - group_bg: COLOR_GROUP_BG, - card_highlight: COLOR_CARD_HIGHLIGHT, - text_pri: COLOR_TEXT_PRI, - text_sec: COLOR_TEXT_SEC, - disabled: COLOR_DISABLED, - accent: COLOR_ACCENT, - danger: COLOR_DANGER, - toggle_on: COLOR_TOGGLE_ON, - toggle_off: COLOR_TOGGLE_OFF, - selection_bg: COLOR_ACCENT, - selection_text: COLOR_TEXT_PRI, + win_bg: Rgba::from_rgb(30, 30, 32), + sidebar_bg: Rgba::from_rgb(40, 40, 43), + sidebar_glass: Rgba::from_argb(230, 40, 40, 43), + sidebar_border: Rgba::from_argb(26, 255, 255, 255), + sidebar_shadow: Rgba::from_argb(110, 0, 0, 0), + group_bg: Rgba::from_rgb(40, 40, 42), + card_highlight: Rgba::from_rgb(70, 70, 74), + text_pri: Rgba::from_rgb(242, 242, 247), + text_sec: Rgba::from_rgb(152, 152, 158), + disabled: Rgba::from_rgb(96, 96, 100), + accent: Rgba::from_rgb(10, 132, 255), + danger: Rgba::from_rgb(255, 69, 58), + toggle_on: Rgba::from_rgb(10, 132, 255), + toggle_off: Rgba::from_rgb(72, 72, 76), + selection_bg: Rgba::from_rgb(10, 106, 230), + selection_text: Rgba::WHITE, - sidebar_hover: color_sidebar_hover(), - separator: color_separator(), - popup_bg: Rgba::from_rgb(50, 50, 52), - popup_border: Rgba::from_argb(40, 255, 255, 255), - popup_shadow: Rgba::from_argb(60, 0, 0, 0), - popup_separator: Rgba::from_argb(30, 255, 255, 255), - control_bg: Rgba::from_rgb(58, 58, 60), - control_hover: Rgba::from_rgb(72, 72, 74), - control_disabled: Rgba::from_rgb(48, 48, 50), - control_border: Rgba::from_argb(36, 255, 255, 255), - group_border: Rgba::from_argb(24, 255, 255, 255), - shadow: Rgba::from_argb(45, 0, 0, 0), - scrollbar: Rgba::from_argb(60, 255, 255, 255), + sidebar_hover: Rgba::from_argb(18, 255, 255, 255), + separator: Rgba::from_argb(20, 255, 255, 255), + popup_bg: Rgba::from_rgb(48, 48, 51), + popup_border: Rgba::from_argb(36, 255, 255, 255), + popup_shadow: Rgba::from_argb(80, 0, 0, 0), + popup_separator: Rgba::from_argb(24, 255, 255, 255), + field_bg: Rgba::from_argb(26, 255, 255, 255), + button_bg: Rgba::from_rgb(62, 62, 66), + button_border: Rgba::from_argb(22, 255, 255, 255), + button_shadow: Rgba::from_argb(70, 0, 0, 0), + control_bg: Rgba::from_rgb(56, 56, 60), + control_hover: Rgba::from_rgb(70, 70, 74), + control_disabled: Rgba::from_rgb(46, 46, 49), + control_border: Rgba::from_argb(22, 255, 255, 255), + group_border: Rgba::from_argb(10, 255, 255, 255), + shadow: Rgba::from_argb(0, 0, 0, 0), + scrollbar: Rgba::from_argb(70, 255, 255, 255), } } pub fn light_settings_theme() -> SettingsTheme { SettingsTheme { - win_bg: Rgba::from_rgb(244, 245, 247), - sidebar_bg: Rgba::from_rgb(250, 250, 252), - group_bg: Rgba::from_rgb(255, 255, 255), - card_highlight: Rgba::from_rgb(239, 242, 247), - text_pri: Rgba::from_rgb(31, 34, 40), - text_sec: Rgba::from_rgb(91, 98, 110), - disabled: Rgba::from_rgb(146, 151, 161), - accent: Rgba::from_rgb(0, 103, 192), - danger: Rgba::from_rgb(196, 43, 28), - toggle_on: Rgba::from_rgb(0, 120, 212), - toggle_off: Rgba::from_rgb(176, 181, 190), - selection_bg: Rgba::from_rgb(224, 238, 250), - selection_text: Rgba::from_rgb(0, 85, 153), + win_bg: Rgba::from_rgb(255, 255, 255), + sidebar_bg: Rgba::from_rgb(242, 242, 245), + sidebar_glass: Rgba::from_argb(244, 244, 244, 247), + sidebar_border: Rgba::from_argb(150, 255, 255, 255), + sidebar_shadow: Rgba::from_argb(40, 0, 0, 0), + group_bg: Rgba::from_rgb(246, 246, 247), + card_highlight: Rgba::from_rgb(220, 220, 225), + text_pri: Rgba::from_rgb(29, 29, 31), + text_sec: Rgba::from_rgb(110, 110, 115), + disabled: Rgba::from_rgb(170, 170, 175), + accent: Rgba::from_rgb(0, 118, 246), + danger: Rgba::from_rgb(215, 40, 32), + toggle_on: Rgba::from_rgb(0, 118, 246), + toggle_off: Rgba::from_rgb(220, 220, 224), + selection_bg: Rgba::from_rgb(0, 100, 225), + selection_text: Rgba::WHITE, - sidebar_hover: Rgba::from_argb(14, 20, 32, 48), - separator: Rgba::from_argb(18, 24, 32, 44), + sidebar_hover: Rgba::from_argb(14, 0, 0, 0), + separator: Rgba::from_argb(18, 0, 0, 0), popup_bg: Rgba::from_rgb(255, 255, 255), - popup_border: Rgba::from_argb(24, 20, 28, 40), - popup_shadow: Rgba::from_argb(20, 20, 28, 40), - popup_separator: Rgba::from_argb(12, 20, 28, 40), - control_bg: Rgba::from_rgb(247, 248, 250), - control_hover: Rgba::from_rgb(235, 239, 244), - control_disabled: Rgba::from_rgb(241, 242, 245), - control_border: Rgba::from_argb(24, 20, 28, 40), - group_border: Rgba::from_argb(14, 20, 28, 40), - shadow: Rgba::from_argb(20, 20, 28, 40), - scrollbar: Rgba::from_argb(52, 52, 58, 68), + popup_border: Rgba::from_argb(24, 0, 0, 0), + popup_shadow: Rgba::from_argb(28, 0, 0, 0), + popup_separator: Rgba::from_argb(12, 0, 0, 0), + field_bg: Rgba::from_argb(20, 0, 0, 0), + button_bg: Rgba::from_rgb(255, 255, 255), + button_border: Rgba::from_argb(22, 0, 0, 0), + button_shadow: Rgba::from_argb(26, 0, 0, 0), + control_bg: Rgba::from_rgb(228, 228, 230), + control_hover: Rgba::from_rgb(218, 218, 222), + control_disabled: Rgba::from_rgb(238, 238, 240), + control_border: Rgba::from_argb(16, 0, 0, 0), + group_border: Rgba::from_argb(8, 0, 0, 0), + shadow: Rgba::from_argb(0, 0, 0, 0), + scrollbar: Rgba::from_argb(90, 0, 0, 0), } } diff --git a/src/utils/fonts.rs b/src/utils/fonts.rs new file mode 100644 index 00000000..8beae6e8 --- /dev/null +++ b/src/utils/fonts.rs @@ -0,0 +1,114 @@ +use winisland_core::config::AppConfig; +use winisland_render::text::{FontManager, FontSelection}; + +pub(crate) const RECOMMENDED_FONT_URL: &str = "https://github.com/ACT-02/PingFangUI-VF"; + +const REGION_SUFFIXES: [&str; 7] = ["SC", "TC", "HK", "MO", "JA", "JP", "KR"]; + +struct Region { + suffixes: &'static [&'static str], + locale: &'static str, +} + +const SIMPLIFIED: Region = Region { + suffixes: &["SC"], + locale: "zh-CN", +}; + +fn current_region() -> Region { + let language = winisland_core::i18n::current_lang(); + let locale = if language.starts_with("zh") { + language + } else { + crate::platform::shell().locale() + }; + let mut subtags = locale.split(['-', '_']); + let primary = subtags.next().unwrap_or_default().to_ascii_lowercase(); + let subtags: Vec<&str> = subtags.collect(); + let has = |tag: &str| { + subtags + .iter() + .any(|subtag| subtag.eq_ignore_ascii_case(tag)) + }; + match primary.as_str() { + "ja" => Region { + suffixes: &["JA", "JP"], + locale: "ja-JP", + }, + "ko" => Region { + suffixes: &["KR"], + locale: "ko-KR", + }, + "zh" if has("HK") => Region { + suffixes: &["HK", "TC"], + locale: "zh-HK", + }, + "zh" if has("MO") => Region { + suffixes: &["MO", "HK", "TC"], + locale: "zh-MO", + }, + "zh" if has("TW") || has("Hant") => Region { + suffixes: &["TC"], + locale: "zh-TW", + }, + _ => SIMPLIFIED, + } +} + +fn split_region(family: &str) -> Option<&str> { + let (base, suffix) = family.rsplit_once(' ')?; + REGION_SUFFIXES.contains(&suffix).then_some(base) +} + +/// Lists installed families, folding regional variants such as `PingFang UI SC` and +/// `PingFang UI TC` into one `PingFang UI` entry that follows the interface language. +pub(crate) fn family_choices(families: &[String]) -> Vec { + let mut choices: Vec = Vec::new(); + for family in families { + let choice = split_region(family) + .filter(|base| { + !families.iter().any(|other| other == base) + && families + .iter() + .filter(|other| split_region(other) == Some(base)) + .count() + > 1 + }) + .unwrap_or(family); + if !choices.iter().any(|existing| existing == choice) { + choices.push(choice.to_string()); + } + } + choices +} + +/// Resolves a folded family to the regional variant matching the interface language. +pub(crate) fn resolve_family(family: &str) -> String { + let fonts = FontManager::global(); + if fonts.has_family(family) { + return family.to_string(); + } + current_region() + .suffixes + .iter() + .chain(SIMPLIFIED.suffixes) + .chain(®ION_SUFFIXES) + .map(|suffix| format!("{family} {suffix}")) + .find(|variant| fonts.has_family(variant)) + .unwrap_or_else(|| family.to_string()) +} + +pub(crate) fn apply(config: &AppConfig) { + let weight_offset = match config.font_weight.as_str() { + "light" => -100, + "bold" => 100, + _ => 0, + }; + let family = config.font_family.as_deref().map(resolve_family); + FontManager::global().configure(FontSelection { + family: family.as_deref(), + file: config.custom_font_path.as_deref(), + weight_offset, + locale: current_region().locale, + }); +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index a17c21ba..b1b57e3a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,6 +2,7 @@ pub mod backdrop; pub mod blur; pub mod color; pub(crate) mod cover; +pub(crate) mod fonts; pub mod logger; pub mod mouse; pub(crate) mod recorder; diff --git a/src/utils/scroll.rs b/src/utils/scroll.rs index bf33a82e..5ef118bc 100644 --- a/src/utils/scroll.rs +++ b/src/utils/scroll.rs @@ -88,7 +88,7 @@ impl ScrollText { x: x - self.offset, y, size, - bold: style.weight().value() >= 700, + weight: style.weight(), color, blur, }, @@ -103,7 +103,7 @@ impl ScrollText { x: next_x, y, size, - bold: style.weight().value() >= 700, + weight: style.weight(), color, blur, }, @@ -120,7 +120,7 @@ impl ScrollText { x, y, size, - bold: style.weight().value() >= 700, + weight: style.weight(), color, blur, }, diff --git a/src/utils/settings_ui/chrome.rs b/src/utils/settings_ui/chrome.rs new file mode 100644 index 00000000..07544348 --- /dev/null +++ b/src/utils/settings_ui/chrome.rs @@ -0,0 +1,368 @@ +use winisland_render::FontWeight; +use winisland_render::text::FontManager; +use winisland_render::{ + BlurSpec, FontStyle, Image, ImageOptions, Painter, Path, Point, Radius, Rect, Rgba, Sampling, + StrokeCap, StrokeJoin, Vec2, +}; + +use crate::utils::color::SettingsTheme; + +use super::ellipsize_text; +use super::text_baseline; + +pub(crate) const SIDEBAR_INSET: f32 = 8.0; +pub(crate) const WINDOW_CORNER: f32 = 22.0; +pub(crate) const SIDEBAR_PANEL_RADIUS: f32 = WINDOW_CORNER - SIDEBAR_INSET; +pub(crate) const SIDEBAR_ICON: f32 = 20.0; +pub(crate) const SEARCH_TOP: f32 = 42.0; +pub(crate) const SEARCH_H: f32 = 28.0; +pub(crate) const TRAFFIC_LIGHT_Y: f32 = 23.0; +pub(crate) const TRAFFIC_LIGHT_X: [f32; 3] = [24.0, 44.0, 64.0]; +pub(crate) const TRAFFIC_LIGHT_RADIUS: f32 = 6.0; + +pub(crate) fn sidebar_panel(sidebar_w: f32, win_h: f32) -> Rect { + Rect::from_xywh( + SIDEBAR_INSET, + SIDEBAR_INSET, + sidebar_w - SIDEBAR_INSET * 2.0, + win_h - SIDEBAR_INSET * 2.0, + ) +} + +pub(crate) fn search_rect(sidebar_w: f32, pad: f32) -> Rect { + Rect::from_xywh(pad, SEARCH_TOP, sidebar_w - pad * 2.0, SEARCH_H) +} + +pub(crate) fn traffic_light_centers() -> [(f32, f32); 3] { + TRAFFIC_LIGHT_X.map(|x| (x, TRAFFIC_LIGHT_Y)) +} + +/// Fills the opaque window base around the floating sidebar and draws the sidebar panel, leaving +/// the panel translucent when `glass` is true so the composition backdrop shows through. +pub(crate) fn draw_window_base( + painter: Painter<'_>, + theme: &SettingsTheme, + window: Rect, + panel: Rect, + glass: bool, +) { + let panel_path = Path::continuous_rounded_rect(panel, SIDEBAR_PANEL_RADIUS); + let saved = painter.save(); + painter.clip_path_difference(&panel_path); + painter.fill_rect(window, theme.win_bg); + painter.fill_path_blurred( + &Path::continuous_rounded_rect(panel.offset(Vec2::new(0.0, 1.5)), SIDEBAR_PANEL_RADIUS), + theme.sidebar_shadow, + BlurSpec::uniform(5.0), + ); + painter.restore_to(saved); + painter.fill_path( + &panel_path, + if glass { + theme.sidebar_glass + } else { + theme.sidebar_bg + }, + ); + painter.stroke_path( + &Path::continuous_rounded_rect(panel.inset(0.5), SIDEBAR_PANEL_RADIUS - 0.5), + 1.0, + theme.sidebar_border, + StrokeCap::Butt, + StrokeJoin::Round, + ); +} + +/// Clips drawing to the window's continuous corner shape. +pub(crate) fn clip_window(painter: Painter<'_>, window: Rect) { + painter.clip_path(&Path::continuous_rounded_rect(window, WINDOW_CORNER)); +} + +pub(crate) fn stroke_window(painter: Painter<'_>, window: Rect, color: Rgba) { + painter.stroke_path( + &Path::continuous_rounded_rect(window.inset(0.5), WINDOW_CORNER - 0.5), + 1.0, + color, + StrokeCap::Butt, + StrokeJoin::Round, + ); +} + +/// Corner radius for the circular composition backdrop so it stays inside the continuous +/// panel corner while the opaque base covers anything beyond it. +pub(crate) const BACKDROP_CORNER: f32 = SIDEBAR_PANEL_RADIUS * 0.6; + +pub(crate) fn draw_traffic_lights( + painter: Painter<'_>, + is_light: bool, + focused: bool, + hovered: bool, + text_sec: Rgba, +) { + let inactive = if is_light { + Rgba::from_rgb(200, 200, 204) + } else { + Rgba::from_rgb(78, 78, 82) + }; + let fills = if focused { + [ + Rgba::from_rgb(255, 95, 87), + Rgba::from_rgb(254, 188, 46), + inactive, + ] + } else { + [inactive; 3] + }; + let border = Rgba::from_argb(if is_light { 30 } else { 50 }, 0, 0, 0); + for ((x, y), fill) in traffic_light_centers().into_iter().zip(fills) { + let center = Point::new(x, y); + painter.fill_circle(center, TRAFFIC_LIGHT_RADIUS, fill); + painter.stroke_circle(center, TRAFFIC_LIGHT_RADIUS - 0.25, 0.5, border); + } + if !hovered { + return; + } + let marks = [ + if focused { + Rgba::from_rgb(120, 10, 6) + } else { + text_sec + }, + if focused { + Rgba::from_rgb(140, 90, 0) + } else { + text_sec + }, + ]; + let (close_x, y) = (TRAFFIC_LIGHT_X[0], TRAFFIC_LIGHT_Y); + for direction in [-1.0, 1.0] { + painter.stroke_line( + Point::new(close_x - 2.5, y - 2.5 * direction), + Point::new(close_x + 2.5, y + 2.5 * direction), + 1.1, + marks[0], + StrokeCap::Round, + ); + } + let minimize_x = TRAFFIC_LIGHT_X[1]; + painter.stroke_line( + Point::new(minimize_x - 3.5, y), + Point::new(minimize_x + 3.5, y), + 1.1, + marks[1], + StrokeCap::Round, + ); +} + +pub(crate) fn traffic_light_at(x: f32, y: f32) -> Option { + traffic_light_centers() + .iter() + .position(|&(cx, cy)| (x - cx).powi(2) + (y - cy).powi(2) <= 64.0) +} + +pub(crate) fn traffic_lights_hovered(x: f32, y: f32) -> bool { + (TRAFFIC_LIGHT_X[0] - 10.0..=TRAFFIC_LIGHT_X[2] + 10.0).contains(&x) + && (TRAFFIC_LIGHT_Y - 10.0..=TRAFFIC_LIGHT_Y + 10.0).contains(&y) +} + +pub(crate) struct SidebarRow<'a> { + pub(crate) rect: Rect, + pub(crate) icon: Option<&'a Image>, + pub(crate) label: &'a str, + pub(crate) selected: bool, + pub(crate) focused: bool, + pub(crate) hover: f32, +} + +pub(crate) fn draw_sidebar_row(painter: Painter<'_>, theme: &SettingsTheme, row: SidebarRow<'_>) { + let radius = Radius::uniform(8.0); + let text_color = if row.selected { + if row.focused { + painter.fill_round_rect(row.rect, radius, theme.selection_bg); + theme.selection_text + } else { + painter.fill_round_rect(row.rect, radius, theme.card_highlight); + theme.text_pri + } + } else { + if row.hover > 0.005 { + let base = theme.sidebar_hover; + painter.fill_round_rect( + row.rect, + radius, + base.with_alpha_f(f32::from(base.a()) / 255.0 * row.hover), + ); + } + theme.text_pri + }; + let icon_rect = Rect::from_xywh( + row.rect.left + 8.0, + row.rect.center_y() - SIDEBAR_ICON / 2.0, + SIDEBAR_ICON, + SIDEBAR_ICON, + ); + if let Some(icon) = row.icon { + draw_icon_shadow(painter, icon_rect, 1.0); + painter.draw_image( + icon, + icon_rect, + &ImageOptions::default() + .with_sampling(Sampling::LinearLinear) + .with_anti_alias(false), + ); + } + let text_left = icon_rect.right + 9.0; + let label = ellipsize_text( + FontManager::global(), + row.label, + 13.0, + FontStyle::normal(), + row.rect.right - text_left - 8.0, + ); + FontManager::global().draw_str( + painter, + &label, + Point::new(text_left, text_baseline(row.rect.center_y(), 13.0)), + 13.0, + FontWeight::REGULAR, + text_color, + ); +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn draw_search_field( + painter: Painter<'_>, + theme: &SettingsTheme, + rect: Rect, + glyph: Option<&Image>, + query: &str, + composition: &str, + focused: bool, + caret: bool, +) { + let radius = Radius::uniform(rect.height() / 2.0); + painter.fill_round_rect(rect, radius, theme.field_bg); + if focused { + painter.stroke_round_rect( + rect.inset(-1.0), + Radius::uniform(rect.height() / 2.0 + 1.0), + 2.0, + theme.accent.with_alpha_f(0.45), + ); + } + let icon_center = Point::new(rect.left + 15.0, rect.center_y()); + if let Some(glyph) = glyph { + let size = 13.0; + painter.draw_image( + glyph, + Rect::from_xywh( + icon_center.x - size / 2.0, + icon_center.y - size / 2.0, + size, + size, + ), + &ImageOptions::default() + .with_sampling(Sampling::LinearLinear) + .with_alpha_f(0.8), + ); + } + let text_left = rect.left + 28.0; + let baseline = text_baseline(rect.center_y(), 13.0); + let fonts = FontManager::global(); + let typed = format!("{query}{composition}"); + if typed.is_empty() { + fonts.draw_str( + painter, + &winisland_core::i18n::tr("search_placeholder"), + Point::new(text_left, baseline), + 13.0, + FontWeight::REGULAR, + theme.text_sec, + ); + } else { + let text = ellipsize_text( + fonts, + &typed, + 13.0, + FontStyle::normal(), + rect.right - text_left - 26.0, + ); + fonts.draw_str( + painter, + &text, + Point::new(text_left, baseline), + 13.0, + FontWeight::REGULAR, + theme.text_pri, + ); + if !composition.is_empty() { + let start = text_left + fonts.measure_text_cached(query, 13.0, FontStyle::normal()); + let end = (start + fonts.measure_text_cached(composition, 13.0, FontStyle::normal())) + .min(rect.right - 26.0); + painter.stroke_line( + Point::new(start, baseline + 2.5), + Point::new(end, baseline + 2.5), + 1.0, + theme.text_pri, + StrokeCap::Butt, + ); + } + } + if caret { + let width = fonts.measure_text_cached(&typed, 13.0, FontStyle::normal()); + let x = (text_left + width + 1.0).min(rect.right - 26.0); + painter.stroke_line( + Point::new(x, rect.center_y() - 7.0), + Point::new(x, rect.center_y() + 7.0), + 1.2, + theme.accent, + StrokeCap::Butt, + ); + } + if !query.is_empty() { + let center = search_clear_center(rect); + painter.fill_circle(center, 7.0, theme.text_sec.with_alpha_f(0.55)); + for direction in [-1.0, 1.0] { + painter.stroke_line( + Point::new(center.x - 2.5, center.y - 2.5 * direction), + Point::new(center.x + 2.5, center.y + 2.5 * direction), + 1.3, + theme.win_bg, + StrokeCap::Round, + ); + } + } +} + +pub(crate) fn search_clear_center(rect: Rect) -> Point { + Point::new(rect.right - 14.0, rect.center_y()) +} + +/// Draws a chevron pointing left or right, used by the navigation capsule. +pub(crate) fn draw_chevron(painter: Painter<'_>, center: Point, left: bool, color: Rgba) { + let dx = if left { 2.5 } else { -2.5 }; + if let Some(path) = Path::from_svg(&format!( + "M {} {} L {} {} L {} {}", + center.x + dx, + center.y - 5.5, + center.x - dx, + center.y, + center.x + dx, + center.y + 5.5, + )) { + painter.stroke_path(&path, 1.8, color, StrokeCap::Round, StrokeJoin::Round); + } +} + +/// Draws a soft drop shadow under an app-style icon tile occupying `rect`. +pub(crate) fn draw_icon_shadow(painter: Painter<'_>, rect: Rect, alpha: f32) { + let size = rect.width().min(rect.height()); + painter.fill_path_blurred( + &Path::continuous_rounded_rect( + rect.offset(Vec2::new(0.0, size * 0.05)).inset(size * 0.03), + size * 0.2, + ), + Rgba::from_argb((72.0 * alpha.clamp(0.0, 1.0)) as u8, 0, 0, 0), + BlurSpec::uniform(size * 0.05 + 0.5), + ); +} diff --git a/src/utils/settings_ui/input.rs b/src/utils/settings_ui/input.rs index 2cb474a6..70b6b7f3 100644 --- a/src/utils/settings_ui/input.rs +++ b/src/utils/settings_ui/input.rs @@ -1,7 +1,6 @@ use super::items::{ - CONTENT_PADDING, GROUP_INNER_PAD, POPUP_BTN_H, POPUP_BTN_W, ROW_HEIGHT, STEPPER_BTN_SIZE, - STEPPER_GAP, STEPPER_VALUE_W, SettingsItem, TOGGLE_H, TOGGLE_W, picker_button_rects, - trailing_control_rect, + CONTENT_PADDING, GROUP_INNER_PAD, POPUP_BTN_H, POPUP_BTN_W, ROW_HEIGHT, SettingsItem, TOGGLE_H, + TOGGLE_W, picker_button_rects, stepper_rects, trailing_control_rect, }; use crate::ui::widget::compact::widget_width; use crate::ui::widget::expanded::{WidgetGridLayout, widget_corner_radius, widget_grid_layout}; @@ -37,8 +36,6 @@ pub enum ClickResult { StepperDec(usize), StepperInc(usize), StepperValue(usize), - FontSelect(usize), - FontReset(usize), CenterLink(usize), SourceButton(usize), RowButton(usize), @@ -88,8 +85,6 @@ impl ClickResult { | ClickResult::StepperDec(index) | ClickResult::StepperInc(index) | ClickResult::StepperValue(index) - | ClickResult::FontSelect(index) - | ClickResult::FontReset(index) | ClickResult::CenterLink(index) | ClickResult::SourceButton(index) | ClickResult::RowButton(index) @@ -601,18 +596,14 @@ pub fn hit_test(items: &[SettingsItem], mx: f32, my: f32, start_y: f32, width: f for (idx, item) in items.iter().enumerate() { match item { SettingsItem::RowStepper { enabled, .. } if *enabled => { - let cy = y + ROW_HEIGHT / 2.0; - let btn_inc_x = CONTENT_PADDING + content_w - GROUP_INNER_PAD - STEPPER_BTN_SIZE; - let value_x = btn_inc_x - STEPPER_GAP - STEPPER_VALUE_W; - let btn_dec_x = value_x - STEPPER_GAP - STEPPER_BTN_SIZE; - let btn_y = cy - STEPPER_BTN_SIZE / 2.0; - if in_rect(mx, my, btn_dec_x, btn_y, STEPPER_BTN_SIZE, STEPPER_BTN_SIZE) { - return ClickResult::StepperDec(idx); - } - if in_rect(mx, my, btn_inc_x, btn_y, STEPPER_BTN_SIZE, STEPPER_BTN_SIZE) { + let (value, increment, decrement) = stepper_rects(y, ROW_HEIGHT, content_w); + if increment.inset(-2.0).contains(point) && my < increment.bottom { return ClickResult::StepperInc(idx); } - if in_rect(mx, my, value_x, btn_y, STEPPER_VALUE_W, STEPPER_BTN_SIZE) { + if decrement.inset(-2.0).contains(point) { + return ClickResult::StepperDec(idx); + } + if value.contains(point) { return ClickResult::StepperValue(idx); } } @@ -623,15 +614,6 @@ pub fn hit_test(items: &[SettingsItem], mx: f32, my: f32, start_y: f32, width: f return ClickResult::Switch(idx); } } - SettingsItem::RowFontPicker { reset_label, .. } => { - let (select, reset) = picker_button_rects(y, ROW_HEIGHT, content_w); - if select.contains(point) { - return ClickResult::FontSelect(idx); - } - if reset_label.is_some() && reset.contains(point) { - return ClickResult::FontReset(idx); - } - } SettingsItem::RowFolderPicker { clear_label, current_path, diff --git a/src/utils/settings_ui/items.rs b/src/utils/settings_ui/items.rs index 6bfaac8b..6d1f9d5e 100644 --- a/src/utils/settings_ui/items.rs +++ b/src/utils/settings_ui/items.rs @@ -3,25 +3,24 @@ use winisland_render::Rect; use winisland_render::Rgba; pub const CONTENT_PADDING: f32 = 24.0; -pub const ROW_HEIGHT: f32 = 48.0; +pub const ROW_HEIGHT: f32 = 40.0; pub const GROUP_RADIUS: f32 = 12.0; pub const GROUP_INNER_PAD: f32 = 16.0; -pub const SIDEBAR_PAD: f32 = 8.0; -pub const SIDEBAR_SEL_RADIUS: f32 = 7.0; +pub const SIDEBAR_PAD: f32 = 16.0; -pub const TOGGLE_W: f32 = 36.0; +pub const TOGGLE_W: f32 = 46.0; pub const TOGGLE_H: f32 = 20.0; -pub const TOGGLE_R: f32 = 10.0; -pub const TOGGLE_KNOB: f32 = 16.0; +pub const TOGGLE_KNOB_W: f32 = 28.0; pub const TOGGLE_INSET: f32 = 2.0; -pub const STEPPER_BTN_SIZE: f32 = 28.0; -pub const STEPPER_VALUE_W: f32 = 52.0; -pub const STEPPER_GAP: f32 = 0.0; +pub const STEPPER_BTN_SIZE: f32 = 24.0; +pub const STEPPER_VALUE_W: f32 = 58.0; +pub const STEPPER_GAP: f32 = 6.0; +pub const STEPPER_ARROW_W: f32 = 18.0; pub const POPUP_BTN_W: f32 = 112.0; -pub const POPUP_BTN_H: f32 = 28.0; -pub const POPUP_BTN_R: f32 = 7.0; +pub const POPUP_BTN_H: f32 = 24.0; +pub const POPUP_BTN_R: f32 = 6.0; pub const POPUP_ITEM_H: f32 = 32.0; pub const POPUP_MENU_R: f32 = 10.0; pub const POPUP_MENU_PAD: f32 = 5.0; @@ -43,6 +42,29 @@ pub fn trailing_control_rect( ) } +/// Returns the value field and the increment and decrement halves of a row stepper. +pub fn stepper_rects(y: f32, row_height: f32, content_width: f32) -> (Rect, Rect, Rect) { + let arrows = trailing_control_rect( + y, + row_height, + content_width, + STEPPER_ARROW_W, + STEPPER_BTN_SIZE, + ); + let value = Rect::from_xywh( + arrows.left - STEPPER_GAP - STEPPER_VALUE_W, + arrows.top, + STEPPER_VALUE_W, + STEPPER_BTN_SIZE, + ); + let half = STEPPER_BTN_SIZE / 2.0; + ( + value, + Rect::from_xywh(arrows.left, arrows.top, STEPPER_ARROW_W, half), + Rect::from_xywh(arrows.left, arrows.top + half, STEPPER_ARROW_W, half), + ) +} + pub fn picker_button_rects(y: f32, row_height: f32, content_width: f32) -> (Rect, Rect) { let primary = trailing_control_rect(y, row_height, content_width, PICKER_BTN_W, POPUP_BTN_H); let secondary = Rect::from_xywh( @@ -71,11 +93,6 @@ pub enum SettingsItem { on: bool, enabled: bool, }, - RowFontPicker { - label: String, - btn_label: String, - reset_label: Option, - }, RowFolderPicker { label: String, btn_label: String, @@ -129,7 +146,7 @@ pub enum SettingsItem { impl SettingsItem { pub fn height(&self) -> f32 { match self { - SettingsItem::SectionHeader { .. } => 36.0, + SettingsItem::SectionHeader { .. } => 34.0, SettingsItem::GroupStart => 0.0, SettingsItem::GroupEnd => 16.0, SettingsItem::CenterLink { .. } => 40.0, @@ -154,7 +171,6 @@ impl SettingsItem { self, SettingsItem::RowStepper { .. } | SettingsItem::RowSwitch { .. } - | SettingsItem::RowFontPicker { .. } | SettingsItem::RowFolderPicker { .. } | SettingsItem::RowSourceSelect { .. } | SettingsItem::RowButton { .. } diff --git a/src/utils/settings_ui/mod.rs b/src/utils/settings_ui/mod.rs index b48f4274..e36038bc 100644 --- a/src/utils/settings_ui/mod.rs +++ b/src/utils/settings_ui/mod.rs @@ -1,4 +1,5 @@ pub mod anim; +pub(crate) mod chrome; pub mod input; pub mod items; pub mod renderer; @@ -13,4 +14,6 @@ pub use input::{ widget_preview_hit_test, widget_source_span, }; pub use renderer::{ActiveStepperValue, DrawItemsParams, content_height, draw_items}; -pub(crate) use renderer::{SettingsPainter, ellipsize_text, settings_color}; +pub(crate) use renderer::{ + SettingsPainter, draw_switch, ellipsize_text, settings_color, text_baseline, +}; diff --git a/src/utils/settings_ui/renderer.rs b/src/utils/settings_ui/renderer.rs index 26586829..6061505f 100644 --- a/src/utils/settings_ui/renderer.rs +++ b/src/utils/settings_ui/renderer.rs @@ -17,7 +17,7 @@ use super::anim::SwitchAnimator; use super::items::SettingsItem; pub(crate) use crate::utils::color::settings_color; -pub(crate) use controls::{SettingsPainter, ellipsize_text}; +pub(crate) use controls::{SettingsPainter, draw_switch, ellipsize_text, text_baseline}; pub use items::{content_height, draw_items}; pub struct ActiveStepperValue<'a> { diff --git a/src/utils/settings_ui/renderer/controls.rs b/src/utils/settings_ui/renderer/controls.rs index 8a9358e2..6050bd48 100644 --- a/src/utils/settings_ui/renderer/controls.rs +++ b/src/utils/settings_ui/renderer/controls.rs @@ -1,28 +1,27 @@ use crate::utils::color::SettingsTheme; use crate::utils::color::settings_color; use winisland_render::FontStyle; +use winisland_render::FontWeight; use winisland_render::Painter; use winisland_render::Point; use winisland_render::Radius; use winisland_render::Rect; use winisland_render::Rgba; use winisland_render::StrokeCap; -use winisland_render::text::{DrawTextCachedParams, DrawTextInRectParams, FontManager}; +use winisland_render::text::{DrawTextCachedParams, FontManager}; use super::super::items::{ - CONTENT_PADDING, GROUP_INNER_PAD, POPUP_BTN_R, STEPPER_BTN_SIZE, TOGGLE_H, TOGGLE_INSET, - TOGGLE_KNOB, TOGGLE_R, TOGGLE_W, + CONTENT_PADDING, GROUP_INNER_PAD, POPUP_BTN_R, TOGGLE_H, TOGGLE_INSET, TOGGLE_KNOB_W, TOGGLE_W, }; pub(super) struct PillBtnParams<'a> { pub(super) painter: Painter<'a>, + pub(super) theme: &'a SettingsTheme, pub(super) rect: Rect, pub(super) label: &'a str, pub(super) text_color: Rgba, - pub(super) bg_color: Rgba, - pub(super) hover_bg_color: Rgba, - pub(super) border_color: Rgba, pub(super) hovered: bool, + pub(super) enabled: bool, } #[derive(Clone, Copy)] @@ -40,7 +39,7 @@ impl<'a> SettingsPainter<'a> { text: &str, position: (f32, f32), size: f32, - bold: bool, + weight: FontWeight, color: impl Into, ) { FontManager::global().draw_text_cached(DrawTextCachedParams { @@ -49,7 +48,7 @@ impl<'a> SettingsPainter<'a> { x: position.0, y: position.1, size, - bold, + weight, color: color.into(), blur: None, }); @@ -60,20 +59,15 @@ impl<'a> SettingsPainter<'a> { text: &str, position: (f32, f32), size: f32, - bold: bool, + weight: FontWeight, color: impl Into, ) { - let style = if bold { - FontStyle::bold() - } else { - FontStyle::normal() - }; - let width = FontManager::global().measure_text_cached(text, size, style); + let width = FontManager::global().measure_text_cached(text, size, weight); self.text( text, (position.0 - width / 2.0, position.1), size, - bold, + weight, color, ); } @@ -95,7 +89,7 @@ pub(super) fn draw_row_separator( ); } -pub(super) fn draw_switch( +pub(crate) fn draw_switch( painter: Painter<'_>, x: f32, y: f32, @@ -111,113 +105,239 @@ pub(super) fn draw_switch( let r = off_color.r() as f32 + (on_color.r() as f32 - off_color.r() as f32) * pos; let g = off_color.g() as f32 + (on_color.g() as f32 - off_color.g() as f32) * pos; let b = off_color.b() as f32 + (on_color.b() as f32 - off_color.b() as f32) * pos; + let track = Rect::from_xywh(x, y, TOGGLE_W, TOGGLE_H); painter.fill_round_rect( - Rect::from_xywh(x, y, TOGGLE_W, TOGGLE_H), - Radius::uniform(TOGGLE_R), + track, + Radius::uniform(TOGGLE_H / 2.0), Rgba::from_rgb(r as u8, g as u8, b as u8), ); - painter.stroke_round_rect( - Rect::from_xywh(x + 0.375, y + 0.375, TOGGLE_W - 0.75, TOGGLE_H - 0.75), - Radius::uniform(TOGGLE_R), - 0.75, + track.inset(0.25), + Radius::uniform(TOGGLE_H / 2.0 - 0.25), + 0.5, settings_color(theme.control_border), ); - let knob_x = x + TOGGLE_INSET + (pos * (TOGGLE_W - TOGGLE_KNOB - TOGGLE_INSET * 2.0)); - let knob_y = y + TOGGLE_INSET; - - painter.fill_round_rect( - Rect::from_xywh(knob_x, knob_y + 1.0, TOGGLE_KNOB, TOGGLE_KNOB), - Radius::uniform(TOGGLE_KNOB / 2.0), - Rgba::from_argb(40, 0, 0, 0), + let knob_h = TOGGLE_H - TOGGLE_INSET * 2.0; + let knob = Rect::from_xywh( + x + TOGGLE_INSET + pos * (TOGGLE_W - TOGGLE_KNOB_W - TOGGLE_INSET * 2.0), + y + TOGGLE_INSET, + TOGGLE_KNOB_W, + knob_h, ); - - painter.fill_round_rect( - Rect::from_xywh(knob_x, knob_y, TOGGLE_KNOB, TOGGLE_KNOB), - Radius::uniform(TOGGLE_KNOB / 2.0), - Rgba::WHITE, + painter.fill_path_blurred( + &winisland_render::Path::rounded_rect( + knob.offset(winisland_render::Vec2::new(0.0, 0.5)), + knob_h / 2.0, + ), + Rgba::from_argb(46, 0, 0, 0), + winisland_render::BlurSpec::uniform(0.8), ); + painter.fill_round_rect(knob, Radius::uniform(knob_h / 2.0), Rgba::WHITE); } -pub(super) fn draw_stepper_btn( +pub(super) fn draw_push_button( painter: Painter<'_>, - x: f32, - y: f32, - label: &str, - enabled: bool, theme: &SettingsTheme, + rect: Rect, hovered: bool, + enabled: bool, ) { - let fm = FontManager::global(); - if hovered && enabled { - painter.fill_round_rect( - Rect::from_xywh(x, y, STEPPER_BTN_SIZE, STEPPER_BTN_SIZE), - Radius::uniform(POPUP_BTN_R), - settings_color(theme.control_hover), + let radius = Radius::uniform(POPUP_BTN_R); + if enabled { + painter.fill_path_blurred( + &winisland_render::Path::rounded_rect( + rect.offset(winisland_render::Vec2::new(0.0, 0.5)), + POPUP_BTN_R, + ), + theme.button_shadow, + winisland_render::BlurSpec::uniform(1.0), ); } - let color = settings_color(if enabled { - theme.text_pri + painter.fill_round_rect( + rect, + radius, + if !enabled { + theme.control_disabled + } else if hovered { + theme.control_hover + } else { + theme.button_bg + }, + ); + painter.stroke_round_rect(rect.inset(0.25), radius, 0.5, theme.button_border); +} + +pub(super) fn draw_button_label(painter: Painter<'_>, rect: Rect, label: &str, color: Rgba) { + let fm = FontManager::global(); + let width = fm.measure_text_cached(label, 13.0, FontStyle::normal()); + let label = if width > rect.width() - 12.0 { + ellipsize_text(fm, label, 13.0, FontStyle::normal(), rect.width() - 12.0) } else { - theme.text_sec - }); - let bounds = fm.measure_str(label, 16.0, false); - let text_x = x + (STEPPER_BTN_SIZE - bounds.width()) / 2.0 - bounds.left; - let text_y = y + (STEPPER_BTN_SIZE - bounds.height()) / 2.0 - bounds.top; + label.to_string() + }; + let width = fm.measure_text_cached(&label, 13.0, FontStyle::normal()); fm.draw_str( painter, - label, - Point::new(text_x, text_y), - 16.0, - false, + &label, + Point::new( + rect.center_x() - width / 2.0, + text_baseline(rect.center_y(), 13.0), + ), + 13.0, + FontWeight::REGULAR, color, ); } pub(super) fn draw_pill_btn(params: PillBtnParams<'_>) { - let fm = FontManager::global(); - let painter = params.painter; - painter.fill_round_rect( + draw_push_button( + params.painter, + params.theme, params.rect, - Radius::uniform(POPUP_BTN_R), - if params.hovered { - params.hover_bg_color - } else { - params.bg_color - }, + params.hovered, + params.enabled, ); - painter.stroke_round_rect( - Rect::from_xywh( - params.rect.left + 0.375, - params.rect.top + 0.375, - params.rect.width() - 0.75, - params.rect.height() - 0.75, - ), - Radius::uniform(POPUP_BTN_R), - 0.75, - params.border_color, + draw_button_label(params.painter, params.rect, params.label, params.text_color); +} + +fn draw_small_chevron(painter: Painter<'_>, center: Point, up: bool, color: Rgba) { + let dy = if up { 1.5 } else { -1.5 }; + if let Some(path) = winisland_render::Path::from_svg(&format!( + "M {} {} L {} {} L {} {}", + center.x - 3.0, + center.y + dy, + center.x, + center.y - dy, + center.x + 3.0, + center.y + dy, + )) { + painter.stroke_path( + &path, + 1.4, + color, + StrokeCap::Round, + winisland_render::StrokeJoin::Round, + ); + } +} + +/// Draws the macOS style double chevron indicator used by pop-up buttons. +pub(super) fn draw_updown_indicator( + painter: Painter<'_>, + theme: &SettingsTheme, + rect: Rect, + active: bool, + hovered: bool, + enabled: bool, +) { + let fill = if active { + theme.accent + } else if hovered && enabled { + theme.control_hover + } else { + theme.control_bg + }; + painter.fill_round_rect(rect, Radius::uniform(5.0), fill); + let color = if active { + Rgba::WHITE + } else if enabled { + theme.text_pri + } else { + theme.disabled + }; + draw_small_chevron( + painter, + Point::new(rect.center_x(), rect.center_y() - 3.0), + true, + color, ); - fm.draw_text_in_rect(DrawTextInRectParams { + draw_small_chevron( painter, - text: params.label, - x: params.rect.left, - y: params.rect.top + 17.0, - w: params.rect.width(), - size: 12.0, - bold: false, - color: params.text_color, - blur: None, - }); + Point::new(rect.center_x(), rect.center_y() + 3.0), + false, + color, + ); +} + +pub(super) struct StepperParams<'a> { + pub(super) painter: Painter<'a>, + pub(super) theme: &'a SettingsTheme, + pub(super) value: Rect, + pub(super) increment: Rect, + pub(super) decrement: Rect, + pub(super) hover: Option<(f32, f32)>, + pub(super) enabled: bool, + pub(super) editing: bool, +} + +/// Draws the value field and the stacked arrow buttons of a stepper. +pub(super) fn draw_stepper(params: StepperParams<'_>) { + let StepperParams { + painter, + theme, + value, + increment, + decrement, + hover, + enabled, + editing, + } = params; + draw_push_button(painter, theme, value, false, enabled); + if editing { + painter.stroke_round_rect( + value.inset(-1.0), + Radius::uniform(POPUP_BTN_R + 1.0), + 2.0, + theme.accent.with_alpha_f(0.6), + ); + } + let arrows = Rect::from_ltrb( + increment.left, + increment.top, + increment.right, + decrement.bottom, + ); + draw_push_button(painter, theme, arrows, false, enabled); + let hovered = |rect: Rect| hover.is_some_and(|(x, y)| rect.contains(Point::new(x, y))); + for (rect, up) in [(increment, true), (decrement, false)] { + if enabled && hovered(rect) { + painter.save(); + painter.clip_rect(rect); + painter.fill_round_rect(arrows, Radius::uniform(POPUP_BTN_R), theme.sidebar_hover); + painter.restore(); + } + draw_small_chevron( + painter, + Point::new( + rect.center_x(), + rect.center_y() + if up { 0.5 } else { -0.5 }, + ), + up, + if enabled { + theme.text_pri + } else { + theme.disabled + }, + ); + } + painter.stroke_line( + Point::new(arrows.left + 3.0, arrows.center_y()), + Point::new(arrows.right - 3.0, arrows.center_y()), + 0.5, + theme.separator, + StrokeCap::Butt, + ); } pub(crate) fn ellipsize_text( fm: &FontManager, text: &str, size: f32, - style: FontStyle, + style: impl Into, max_width: f32, ) -> String { + let style = style.into(); if fm.measure_text_cached(text, size, style) <= max_width { return text.to_string(); } @@ -234,3 +354,9 @@ pub(crate) fn ellipsize_text( fitted.push_str(ellipsis); fitted } + +/// Returns the baseline that vertically centers digits and CJK text of `size` on `center_y`. +pub(crate) fn text_baseline(center_y: f32, size: f32) -> f32 { + let bounds = FontManager::global().measure_str("0", size, FontWeight::REGULAR); + center_y - bounds.height() / 2.0 - bounds.top +} diff --git a/src/utils/settings_ui/renderer/items.rs b/src/utils/settings_ui/renderer/items.rs index d9b51da4..0bafddc6 100644 --- a/src/utils/settings_ui/renderer/items.rs +++ b/src/utils/settings_ui/renderer/items.rs @@ -1,4 +1,5 @@ use winisland_render::FontStyle; +use winisland_render::FontWeight; use winisland_render::Image; use winisland_render::ImageOptions; use winisland_render::Painter; @@ -9,19 +10,18 @@ use winisland_render::Rgba; use winisland_render::Sampling; use winisland_render::StrokeCap; use winisland_render::StrokeJoin; -use winisland_render::text::{DrawTextInRectParams, FontManager}; +use winisland_render::text::FontManager; use crate::utils::color::SettingsTheme; use crate::utils::color::settings_color; use super::super::items::{ - CONTENT_PADDING, GROUP_INNER_PAD, GROUP_RADIUS, POPUP_BTN_H, POPUP_BTN_R, POPUP_BTN_W, - ROW_HEIGHT, STEPPER_BTN_SIZE, STEPPER_GAP, STEPPER_VALUE_W, SettingsItem, TOGGLE_H, TOGGLE_W, - picker_button_rects, trailing_control_rect, + CONTENT_PADDING, GROUP_INNER_PAD, GROUP_RADIUS, POPUP_BTN_H, POPUP_BTN_W, ROW_HEIGHT, + SettingsItem, TOGGLE_H, TOGGLE_W, picker_button_rects, stepper_rects, trailing_control_rect, }; use super::controls::{ - PillBtnParams, SettingsPainter, draw_pill_btn, draw_row_separator, draw_stepper_btn, - draw_switch, ellipsize_text, + PillBtnParams, SettingsPainter, StepperParams, draw_pill_btn, draw_row_separator, draw_stepper, + draw_switch, draw_updown_indicator, ellipsize_text, text_baseline, }; use super::widget_preview::{WidgetPreviewParams, draw_widget_preview}; use super::{ActiveStepperValue, DrawItemsParams}; @@ -66,11 +66,12 @@ fn draw_row_text(ctx: &ItemCtx, y: f32, height: f32, label: &str, color: Rgba) - let cy = y + height / 2.0; let visible = ctx.row_visible(y, height); if visible { - SettingsPainter::new(ctx.painter).text( + FontManager::global().draw_str( + ctx.painter, label, - (CONTENT_PADDING + GROUP_INNER_PAD, cy + 5.0), + Point::new(CONTENT_PADDING + GROUP_INNER_PAD, text_baseline(cy, 13.0)), 13.0, - false, + FontWeight::REGULAR, color, ); } @@ -88,85 +89,22 @@ fn draw_row_stepper( ) { let painter = ctx.painter; let theme = ctx.theme; - let content_w = ctx.content_w; let (cy, visible) = draw_row_text(ctx, y, ROW_HEIGHT, label, row_text_color(ctx, enabled)); - - let btn_inc_x = CONTENT_PADDING + content_w - GROUP_INNER_PAD - STEPPER_BTN_SIZE; - let value_x = btn_inc_x - STEPPER_GAP - STEPPER_VALUE_W; - let btn_dec_x = value_x - STEPPER_GAP - STEPPER_BTN_SIZE; - let btn_y = cy - STEPPER_BTN_SIZE / 2.0; + let (value_rect, increment, decrement) = stepper_rects(y, ROW_HEIGHT, ctx.content_w); if visible { - let control_x = btn_dec_x; - let control_w = STEPPER_BTN_SIZE * 2.0 + STEPPER_VALUE_W; - painter.fill_round_rect( - Rect::from_xywh(control_x, btn_y, control_w, STEPPER_BTN_SIZE), - Radius::uniform(POPUP_BTN_R), - settings_color(if enabled { - theme.control_bg - } else { - theme.control_disabled - }), - ); - painter.stroke_round_rect( - Rect::from_xywh( - control_x + 0.375, - btn_y + 0.375, - control_w - 0.75, - STEPPER_BTN_SIZE - 0.75, - ), - Radius::uniform(POPUP_BTN_R), - 0.75, - settings_color(theme.control_border), - ); - painter.stroke_line( - Point::new(value_x, btn_y + 4.0), - Point::new(value_x, btn_y + STEPPER_BTN_SIZE - 4.0), - 0.75, - settings_color(theme.separator), - StrokeCap::Butt, - ); - painter.stroke_line( - Point::new(btn_inc_x, btn_y + 4.0), - Point::new(btn_inc_x, btn_y + STEPPER_BTN_SIZE - 4.0), - 0.75, - settings_color(theme.separator), - StrokeCap::Butt, - ); - draw_stepper_btn( + let is_editing = active_stepper_value.as_ref().is_some_and(|input| { + (input.rect.left - value_rect.left).abs() < 0.5 + && (input.rect.top - value_rect.top).abs() < 0.5 + }); + draw_stepper(StepperParams { painter, - btn_dec_x, - btn_y, - "−", - enabled, theme, - ctx.hovered(Rect::from_xywh( - btn_dec_x, - btn_y, - STEPPER_BTN_SIZE, - STEPPER_BTN_SIZE, - )), - ); - draw_stepper_btn( - painter, - btn_inc_x, - btn_y, - "+", + value: value_rect, + increment, + decrement, + hover: ctx.hover_pos, enabled, - theme, - ctx.hovered(Rect::from_xywh( - btn_inc_x, - btn_y, - STEPPER_BTN_SIZE, - STEPPER_BTN_SIZE, - )), - ); - } - - let val_center = value_x + STEPPER_VALUE_W / 2.0; - if visible { - let fm = FontManager::global(); - let is_editing = active_stepper_value.as_ref().is_some_and(|input| { - (input.rect.left - value_x).abs() < 0.5 && (input.rect.top - btn_y).abs() < 0.5 + editing: is_editing, }); let display_value = active_stepper_value .as_ref() @@ -176,42 +114,27 @@ fn draw_row_stepper( let show_caret = active_stepper_value .as_ref() .is_some_and(|input| is_editing && input.show_caret); - if is_editing { - painter.fill_round_rect( - Rect::from_xywh(value_x, btn_y, STEPPER_VALUE_W, STEPPER_BTN_SIZE), - Radius::uniform(5.0), - settings_color(theme.card_highlight), - ); - painter.stroke_round_rect( - Rect::from_xywh( - value_x + 0.5, - btn_y + 0.5, - STEPPER_VALUE_W - 1.0, - STEPPER_BTN_SIZE - 1.0, - ), - Radius::uniform(4.5), - 1.0, - settings_color(theme.accent), - ); - } + let fm = FontManager::global(); let text_color = settings_color(if enabled { theme.text_pri } else { theme.text_sec }); - let val_w = fm.measure_text_cached(display_value, 13.0, FontStyle::normal()); - SettingsPainter::new(painter).text( + let value_w = fm.measure_text_cached(display_value, 13.0, FontStyle::normal()); + let value_x = value_rect.center_x() - value_w / 2.0; + fm.draw_str( + painter, display_value, - (val_center - val_w / 2.0, cy + 5.0), + Point::new(value_x, text_baseline(cy, 13.0)), 13.0, - false, + FontWeight::REGULAR, text_color, ); if show_caret { - let caret_x = val_center + val_w / 2.0 + 1.5; + let caret_x = value_x + value_w + 1.5; painter.stroke_line( - Point::new(caret_x, btn_y + 5.0), - Point::new(caret_x, btn_y + STEPPER_BTN_SIZE - 5.0), + Point::new(caret_x, value_rect.top + 5.0), + Point::new(caret_x, value_rect.bottom - 5.0), 1.0, settings_color(theme.accent), StrokeCap::Butt, @@ -243,48 +166,6 @@ fn draw_row_switch( advance_group_row(ctx, y + ROW_HEIGHT, groups, visible); } -fn draw_row_font_picker( - ctx: &ItemCtx, - y: f32, - label: &str, - btn_label: &str, - reset_label: &Option, - groups: &mut GroupRows, -) { - let painter = ctx.painter; - let theme = ctx.theme; - let content_w = ctx.content_w; - let (_, visible) = draw_row_text(ctx, y, ROW_HEIGHT, label, settings_color(theme.text_pri)); - if visible { - let (select, reset) = picker_button_rects(y, ROW_HEIGHT, content_w); - draw_pill_btn(PillBtnParams { - painter, - rect: select, - label: btn_label, - text_color: settings_color(theme.text_pri), - bg_color: settings_color(theme.card_highlight), - hover_bg_color: settings_color(theme.control_hover), - border_color: settings_color(theme.control_border), - hovered: ctx.hovered(select), - }); - - if let Some(rl) = reset_label { - draw_pill_btn(PillBtnParams { - painter, - rect: reset, - label: rl, - text_color: settings_color(theme.danger), - bg_color: settings_color(theme.card_highlight), - hover_bg_color: settings_color(theme.control_hover), - border_color: settings_color(theme.control_border), - hovered: ctx.hovered(reset), - }); - } - } - - advance_group_row(ctx, y + ROW_HEIGHT, groups, visible); -} - fn draw_row_folder_picker( ctx: &ItemCtx, y: f32, @@ -301,60 +182,56 @@ fn draw_row_folder_picker( let has_path = current_path.as_ref().is_some_and(|p| !p.is_empty()); let row_h = if has_path { 64.0 } else { ROW_HEIGHT }; let row_x = CONTENT_PADDING + GROUP_INNER_PAD; - let (cy, visible) = draw_row_text(ctx, y, row_h, label, row_text_color(ctx, enabled)); + let label_y = if has_path { y + 22.0 } else { y + row_h / 2.0 }; + let visible = ctx.row_visible(y, row_h); if visible { + fm.draw_str( + painter, + label, + Point::new(row_x, text_baseline(label_y, 13.0)), + 13.0, + FontWeight::REGULAR, + row_text_color(ctx, enabled), + ); if let Some(path) = current_path && !path.is_empty() { - let max_w = content_w - GROUP_INNER_PAD * 2.0 - 140.0; + let max_w = content_w - GROUP_INNER_PAD * 2.0 - 160.0; let display = ellipsize_text(fm, path, 11.0, FontStyle::normal(), max_w); - SettingsPainter::new(painter).text( + fm.draw_str( + painter, &display, - (row_x, cy + 17.0), + Point::new(row_x, text_baseline(y + 42.0, 11.0)), 11.0, - false, + FontWeight::REGULAR, settings_color(theme.text_sec), ); } - let label_color = settings_color(if enabled { - theme.text_pri - } else { - theme.text_sec - }); - let bg_color = settings_color(if enabled { - theme.card_highlight - } else { - theme.disabled - }); - let (select, clear) = picker_button_rects(y, row_h, content_w); draw_pill_btn(PillBtnParams { painter, + theme, rect: select, label: btn_label, - text_color: label_color, - bg_color, - hover_bg_color: settings_color(theme.control_hover), - border_color: settings_color(theme.control_border), + text_color: row_text_color(ctx, enabled), hovered: enabled && ctx.hovered(select), + enabled, }); - - if let Some(cl) = clear_label { + if let Some(clear_label) = clear_label { draw_pill_btn(PillBtnParams { painter, + theme, rect: clear, - label: cl, + label: clear_label, text_color: settings_color(if enabled { theme.danger } else { theme.text_sec }), - bg_color, - hover_bg_color: settings_color(theme.control_hover), - border_color: settings_color(theme.control_border), hovered: enabled && ctx.hovered(clear), + enabled, }); } } @@ -384,88 +261,29 @@ fn draw_row_source_select( .unwrap_or(""); let button = trailing_control_rect(y, ROW_HEIGHT, content_w, POPUP_BTN_W, POPUP_BTN_H); - let btn_x = button.left; - let btn_y = button.top; - let is_open = active_source_button.is_some_and(|button| { - (button.left - btn_x).abs() < 0.5 && (button.top - btn_y).abs() < 0.5 + let is_open = active_source_button.is_some_and(|open| { + (open.left - button.left).abs() < 0.5 && (open.top - button.top).abs() < 0.5 }); if visible { - painter.fill_round_rect( - button, - Radius::uniform(POPUP_BTN_R), - settings_color(if enabled { - if ctx.hovered(button) { - theme.control_hover - } else { - theme.control_bg - } - } else { - theme.control_disabled - }), - ); - painter.stroke_round_rect( - Rect::from_xywh( - btn_x + 0.5, - btn_y + 0.5, - POPUP_BTN_W - 1.0, - POPUP_BTN_H - 1.0, - ), - Radius::uniform(POPUP_BTN_R), - if is_open { 1.25 } else { 0.75 }, - settings_color(if is_open { - theme.accent - } else { - theme.control_border - }), - ); - - let text_w = POPUP_BTN_W - 22.0; - fm.draw_text_in_rect(DrawTextInRectParams { + let indicator = Rect::from_xywh(button.right - 18.0, cy - 10.0, 18.0, 20.0); + let hovered = ctx.hovered(button); + draw_updown_indicator(painter, theme, indicator, is_open, hovered, enabled); + let text_max = indicator.left - 8.0 - button.left; + let text = ellipsize_text(fm, selected_label, 13.0, FontStyle::normal(), text_max); + let text_w = fm.measure_text_cached(&text, 13.0, FontStyle::normal()); + fm.draw_str( painter, - text: selected_label, - x: btn_x + 4.0, - y: btn_y + 17.0, - w: text_w, - size: 13.0, - bold: false, - color: settings_color(if enabled { + &text, + Point::new(indicator.left - 8.0 - text_w, text_baseline(cy, 13.0)), + 13.0, + FontWeight::REGULAR, + settings_color(if enabled { theme.text_pri } else { theme.text_sec }), - blur: None, - }); - - let chev_cx = btn_x + POPUP_BTN_W - 12.0; - let chev_cy = cy; - let (top_y, bottom_y) = if is_open { - (chev_cy - 1.5, chev_cy + 1.5) - } else { - (chev_cy + 1.5, chev_cy - 1.5) - }; - let chev_svg = format!( - "M {} {} L {} {} L {} {}", - chev_cx - 3.0, - bottom_y, - chev_cx, - top_y, - chev_cx + 3.0, - bottom_y, ); - if let Some(chev_path) = winisland_render::Path::from_svg(&chev_svg) { - painter.stroke_path( - &chev_path, - 1.5, - settings_color(if enabled { - theme.text_sec - } else { - theme.disabled - }), - StrokeCap::Butt, - StrokeJoin::Miter, - ); - } } advance_group_row(ctx, y + ROW_HEIGHT, groups, visible); @@ -484,27 +302,15 @@ fn draw_row_button( let content_w = ctx.content_w; let (_, visible) = draw_row_text(ctx, y, ROW_HEIGHT, label, row_text_color(ctx, enabled)); if visible { - let label_color = settings_color(if enabled { - theme.text_pri - } else { - theme.text_sec - }); - let bg_color = settings_color(if enabled { - theme.control_bg - } else { - theme.control_disabled - }); - let button = trailing_control_rect(y, ROW_HEIGHT, content_w, POPUP_BTN_W, POPUP_BTN_H); draw_pill_btn(PillBtnParams { painter, + theme, rect: button, label: btn_label, - text_color: label_color, - bg_color, - hover_bg_color: settings_color(theme.control_hover), - border_color: settings_color(theme.control_border), + text_color: row_text_color(ctx, enabled), hovered: enabled && ctx.hovered(button), + enabled, }); } @@ -570,7 +376,13 @@ fn draw_row_app_item( }); let max_label_w = check_x - row_x - 8.0; let display = ellipsize_text(fm, label, 13.0, FontStyle::normal(), max_label_w); - SettingsPainter::new(painter).text(&display, (row_x, cy + 5.0), 13.0, false, text_color); + SettingsPainter::new(painter).text( + &display, + (row_x, cy + 5.0), + 13.0, + FontWeight::REGULAR, + text_color, + ); } advance_group_row(ctx, y + ROW_HEIGHT, groups, visible); @@ -615,7 +427,7 @@ fn draw_center_link(ctx: &ItemCtx, y: f32, height: f32, label: &str, color: Rgba label, (ctx.width / 2.0, y + 24.0), 13.0, - false, + FontWeight::REGULAR, color, ); } @@ -628,7 +440,7 @@ fn draw_center_text(ctx: &ItemCtx, y: f32, height: f32, text: &str, size: f32, c text, (ctx.width / 2.0, y + 22.0), size, - false, + FontWeight::REGULAR, color, ); } @@ -705,11 +517,12 @@ pub fn draw_items(params: DrawItemsParams<'_>) { SettingsItem::SectionHeader { label } => { let h = item.height(); if y + h >= visible_min_y && y <= visible_max_y { - SettingsPainter::new(painter).text( + FontManager::global().draw_str( + painter, label, - (CONTENT_PADDING + 4.0, y + 22.0), + Point::new(CONTENT_PADDING + 4.0, text_baseline(y + h - 13.0, 13.0)), 13.0, - true, + FontWeight::SEMIBOLD, settings_color(theme.text_pri), ); } @@ -724,26 +537,20 @@ pub fn draw_items(params: DrawItemsParams<'_>) { .filter(|item| item.is_row()) .count(); if y + total_h >= visible_min_y && y <= visible_max_y { - painter.fill_round_rect( - Rect::from_xywh(CONTENT_PADDING, y + 2.0, content_w, total_h), - Radius::uniform(GROUP_RADIUS), - settings_color(theme.shadow), - ); - painter.fill_round_rect( - Rect::from_xywh(CONTENT_PADDING, y, content_w, total_h), - Radius::uniform(GROUP_RADIUS), + let group = Rect::from_xywh(CONTENT_PADDING, y, content_w, total_h); + painter.fill_path( + &winisland_render::Path::continuous_rounded_rect(group, GROUP_RADIUS), settings_color(theme.group_bg), ); - painter.stroke_round_rect( - Rect::from_xywh( - CONTENT_PADDING + 0.375, - y + 0.375, - content_w - 0.75, - total_h - 0.75, + painter.stroke_path( + &winisland_render::Path::continuous_rounded_rect( + group.inset(0.375), + GROUP_RADIUS - 0.375, ), - Radius::uniform(GROUP_RADIUS), 0.75, settings_color(theme.group_border), + StrokeCap::Butt, + StrokeJoin::Round, ); } } @@ -773,13 +580,6 @@ pub fn draw_items(params: DrawItemsParams<'_>) { draw_row_switch(&ctx, y, label, *enabled, anims.get(switch_idx), &mut groups); switch_idx += 1; } - SettingsItem::RowFontPicker { - label, - btn_label, - reset_label, - } => { - draw_row_font_picker(&ctx, y, label, btn_label, reset_label, &mut groups); - } SettingsItem::RowFolderPicker { label, btn_label, diff --git a/src/utils/settings_ui/renderer/widget_preview.rs b/src/utils/settings_ui/renderer/widget_preview.rs index 6b383680..3a7c1b6d 100644 --- a/src/utils/settings_ui/renderer/widget_preview.rs +++ b/src/utils/settings_ui/renderer/widget_preview.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use std::collections::VecDeque; use winisland_render::{ - GradientStop, Image, ImageOptions, Painter, Path, Point, Radius, RasterSurface, Rect, Rgba, - Sampling, StrokeCap, StrokeJoin, TileMode, Vec2, + FontWeight, GradientStop, Image, ImageOptions, Painter, Path, Point, Radius, RasterSurface, + Rect, Rgba, Sampling, StrokeCap, StrokeJoin, TileMode, Vec2, }; use crate::ui::expanded::widget_view::draw_prepared_widget; @@ -90,10 +90,10 @@ fn draw_label( x: f32, y: f32, size: f32, - bold: bool, + weight: FontWeight, color: Rgba, ) { - SettingsPainter::new(painter).text(text, (x, y), size, bold, color); + SettingsPainter::new(painter).text(text, (x, y), size, weight, color); } fn draw_centered_label(painter: Painter<'_>, text: &str, rect: Rect, size: f32, color: Rgba) { @@ -101,7 +101,7 @@ fn draw_centered_label(painter: Painter<'_>, text: &str, rect: Rect, size: f32, text, (rect.center_x(), rect.center_y() + size * 0.35), size, - false, + FontWeight::REGULAR, color, ); } @@ -596,9 +596,9 @@ fn draw_preview_heading( keys: (&str, &str), theme: &SettingsTheme, ) { - for (key, offset, size, bold, color) in [ - (keys.0, 25.0, 13.0, true, theme.text_pri), - (keys.1, 44.0, 11.0, false, theme.text_sec), + for (key, offset, size, weight, color) in [ + (keys.0, 25.0, 13.0, FontWeight::SEMIBOLD, theme.text_pri), + (keys.1, 44.0, 11.0, FontWeight::REGULAR, theme.text_sec), ] { draw_label( painter, @@ -606,7 +606,7 @@ fn draw_preview_heading( panel_x + 16.0, panel_y + offset, size, - bold, + weight, settings_color(color), ); } diff --git a/src/window/app.rs b/src/window/app.rs index 7adfe335..5d1f4fd9 100644 --- a/src/window/app.rs +++ b/src/window/app.rs @@ -88,7 +88,9 @@ pub struct App { config: AppConfig, expanded: bool, multitask: Multitask, - device_indicators: crate::ui::island::multitask::activities::DeviceIndicators, + status_indicators: crate::ui::island::multitask::activities::StatusIndicators, + timer_orb: bool, + orb_collapse_from: Option, device_feed: Option>, device_monitor_retry: Cooldown, expanded_press_started_inside: bool, @@ -139,6 +141,10 @@ pub struct App { width_hiding_last_frame: bool, restoring_hide_width: bool, seek: SeekDrag, + audio_outputs: crate::core::audio_outputs::AudioOutputs, + audio_outputs_revision: u64, + audio_outputs_refreshed: Option, + volume_dragging: bool, is_fullscreen_suppressed: bool, attention_pulse_started: Option, is_cursor_suppressed: bool, @@ -194,8 +200,7 @@ impl Default for App { let last_config_modified = std::fs::metadata(get_config_path()) .and_then(|metadata| metadata.modified()) .ok(); - winisland_render::text::FontManager::global() - .set_custom_font_path(config.custom_font_path.as_deref()); + crate::utils::fonts::apply(&config); let plugin_mgr = PluginManager::default(); let plugin_host = match PluginHost::new(plugin_mgr.plugin_dir.clone(), 1) { Ok(host) => { @@ -228,7 +233,9 @@ impl Default for App { config: config.clone(), expanded: false, multitask: Multitask::new(), - device_indicators: Default::default(), + status_indicators: Default::default(), + timer_orb: false, + orb_collapse_from: None, device_feed: None, device_monitor_retry: Cooldown::ready(), expanded_press_started_inside: false, @@ -290,6 +297,10 @@ impl Default for App { width_hiding_last_frame: false, restoring_hide_width: false, seek: SeekDrag::default(), + audio_outputs: Default::default(), + audio_outputs_revision: u64::MAX, + audio_outputs_refreshed: None, + volume_dragging: false, is_fullscreen_suppressed: false, attention_pulse_started: None, is_cursor_suppressed: false, diff --git a/src/window/app/activities.rs b/src/window/app/activities.rs index 926706f2..5c5e85e0 100644 --- a/src/window/app/activities.rs +++ b/src/window/app/activities.rs @@ -38,10 +38,24 @@ impl App { let display = |timer: TimerContent| (timer.remaining_secs(), timer.total, timer.paused); changed |= self.timer_content.map(display) != timer.map(display); self.timer_content = timer; + let plugin_widths = crate::ui::plugin::compact_widths(self.plugin_host.as_deref()); + let shares_island = self + .activity_mgr + .current_excluding(TIMER_ACTIVITY) + .is_some() + || self + .config + .compact_widget_layout + .iter() + .any(|slot| slot.widget.is_some()) + || plugin_widths.0 + plugin_widths.1 > 0.0; + let timer_orb = timer.is_some() && shares_island; + changed |= self.timer_orb != timer_orb; + self.timer_orb = timer_orb; changed |= Self::sync_local_activity( &mut self.activity_mgr, TIMER_ACTIVITY, - timer.is_some(), + timer.is_some() && !timer_orb, Priority::Medium, true, timer.map_or(0.0, |timer| countdown_width(timer, self.config.base_width)), diff --git a/src/window/app/frame.rs b/src/window/app/frame.rs index cbea4769..064c810a 100644 --- a/src/window/app/frame.rs +++ b/src/window/app/frame.rs @@ -208,6 +208,7 @@ impl App { } self.update_seeking_input(&window, rel_x); + self.update_audio_controls(&window, rel_x, rel_y, offset_x, island_y); self.update_progress_hover(rel_x, rel_y, offset_x, island_y, music_active); self.update_pager_hover(&window, rel_x, rel_y, &layout, !interaction_suppressed, dt); self.update_hide_drag(&window, px, py, dt); @@ -244,6 +245,8 @@ impl App { || self.is_dragging || self.is_right_dragging || self.seek.active + || self.volume_dragging + || crate::ui::expanded::music_view::output_scroll_dragging() || self.compact_overlay.is_volume_dragging() || self.compact_overlay.is_brightness_dragging() || plugin_captured @@ -281,6 +284,11 @@ impl App { .map(rect_region), ); } + if !self.expanded + && let Some(orb) = self.timer_orb_rect() + { + regions.push(rect_region(orb)); + } if self.is_hidden() && self.config.hidden_width <= MIN_HIDDEN_WIDTH && self.springs.hide.value >= 0.999 @@ -771,6 +779,66 @@ impl App { } } + fn update_audio_controls( + &mut self, + window: &WindowRef, + rel_x: i32, + rel_y: i32, + offset_x: f64, + island_y: f64, + ) { + use crate::ui::expanded::music_view; + let revision = self.audio_outputs.revision(); + let devices = (revision != self.audio_outputs_revision).then(|| { + self.audio_outputs_revision = revision; + self.audio_outputs.devices() + }); + music_view::set_audio_state(devices, self.compact_overlay.volume_level()); + let on_music = self.expanded && self.page_focused(ExpandedPage::Music); + if on_music + && self + .audio_outputs_refreshed + .is_none_or(|refreshed| refreshed.elapsed() >= Duration::from_secs(3)) + { + self.audio_outputs_refreshed = Some(Instant::now()); + self.audio_outputs.refresh(); + } + let cx = rel_x as f32 - self.page_translation(ExpandedPage::Music); + music_view::set_audio_pointer( + on_music.then(|| winisland_render::Point::new(cx, rel_y as f32)), + ); + if self.volume_dragging { + if on_music && self.input_pressed() { + let level = music_view::volume_level_at( + offset_x as f32, + island_y as f32, + self.springs.w.value, + self.config.expanded_scale, + cx, + ); + self.compact_overlay.set_volume_level(level); + } else { + self.volume_dragging = false; + music_view::set_volume_dragging(false); + } + window.request_redraw(); + } + if music_view::output_scroll_dragging() { + if on_music && self.input_pressed() { + music_view::drag_output_scroll( + offset_x as f32, + island_y as f32, + self.springs.w.value, + self.config.expanded_scale, + rel_y as f32, + ); + } else { + music_view::end_output_scroll_drag(); + } + window.request_redraw(); + } + } + fn update_progress_hover( &self, rel_x: i32, @@ -1052,7 +1120,7 @@ impl App { self.springs .r .settle(target_r, SETTLE_PIXELS, SETTLE_PIXEL_VELOCITY); - self.springs.multitask_x.update_dt(0.0, 0.075, 0.78, dt); + self.springs.multitask_x.update_dt(0.0, 0.15, 0.71, dt); self.springs.multitask_x.settle( 0.0, 0.02 * self.config.compact_scale, @@ -1116,13 +1184,15 @@ impl App { || self.compact_overlay.is_timer_finished_animating() || (compact_timer.is_some() && crate::ui::compact::timer::countdown_is_animating()) || self.multitask.is_animating() - || self.device_indicators.is_animating() + || self.status_indicators.is_animating() || self .attention_pulse_started .is_some_and(|started| now.duration_since(started) < Duration::from_millis(1600)) || self.compact_overlay.is_volume_dragging() || self.compact_overlay.is_brightness_dragging() || self.lyrics.transition < 1.0 + || self.volume_dragging + || crate::ui::expanded::music_view::audio_controls_animating() || self.is_dragging || self.seek.active || self.is_right_dragging; diff --git a/src/window/app/input.rs b/src/window/app/input.rs index 2386f205..8d1e256f 100644 --- a/src/window/app/input.rs +++ b/src/window/app/input.rs @@ -21,6 +21,49 @@ pub(super) enum InputSource { } impl App { + fn handle_audio_controls_press(&mut self, ox: f32, oy: f32, w: f32, cx: f32, cy: f32) -> bool { + use crate::ui::expanded::music_view::{self, AudioPanel}; + let scale = self.config.expanded_scale; + let point = winisland_render::Point::new(cx, cy); + let panel = music_view::audio_panel(); + if panel == AudioPanel::Outputs { + if music_view::begin_output_scroll_drag(ox, oy, w, scale, point) { + return true; + } + if let Some(id) = music_view::output_row_at(ox, oy, w, scale, point) { + self.audio_outputs.select(&id); + music_view::close_audio_panel_soon(); + return true; + } + if music_view::output_list_rect(ox, oy, w, scale).contains(point) { + return true; + } + } + if music_view::output_button_rect(ox, oy, w, scale).contains(point) { + if panel != AudioPanel::Outputs { + self.audio_outputs.refresh(); + } + music_view::toggle_audio_panel(AudioPanel::Outputs); + return true; + } + if music_view::volume_button_rect(ox, oy, w, scale).contains(point) { + if self.compact_overlay.volume_level().is_some() { + music_view::toggle_audio_panel(AudioPanel::Volume); + } + return true; + } + if panel == AudioPanel::Volume + && music_view::volume_bar_rect(ox, oy, w, scale).contains(point) + { + let level = music_view::volume_level_at(ox, oy, w, scale, cx); + self.compact_overlay.set_volume_level(level); + music_view::set_volume_dragging(true); + self.volume_dragging = true; + return true; + } + false + } + pub(super) fn handle_input( &mut self, state: InputState, @@ -93,6 +136,22 @@ impl App { pub(super) fn handle_press(&mut self, rel_x: i32, rel_y: i32, layout: &IslandLayout) { crate::ui::expanded::timer_view::clear_selection(); + if !self.expanded + && self.timer_orb_rect().is_some_and(|orb| { + orb.contains(winisland_render::Point::new(rel_x as f32, rel_y as f32)) + }) + { + self.expand(); + if self.expanded_pages().contains(&ExpandedPage::Timer) { + self.current_page = ExpandedPage::Timer; + self.snap_to_current_page(); + } + self.idle_timer = Instant::now(); + if let Some(window) = &self.window { + window.request_redraw(); + } + return; + } let island_y = layout.island_y; let offset_x = layout.offset_x; let current_island_x = layout.current_island_x; @@ -246,6 +305,17 @@ impl App { && (self.plugin_media_source.is_some() || self.config.smtc_enabled); let cx = rel_x as f32 - self.page_translation(ExpandedPage::Music); let cy = rel_y as f32; + if music_on + && self.handle_audio_controls_press( + offset_x as f32, + island_y as f32, + w as f32, + cx, + cy, + ) + { + return; + } let (cover_x, cover_y, cover_w, cover_h) = get_cover_rect(offset_x as f32, island_y as f32, self.config.expanded_scale); if music_on diff --git a/src/window/app/layout.rs b/src/window/app/layout.rs index 4f8ede5f..20291326 100644 --- a/src/window/app/layout.rs +++ b/src/window/app/layout.rs @@ -37,11 +37,8 @@ impl App { } else { compact_height }; - let multitask_extent = if self.config.device_status_indicators { - crate::ui::island::multitask::side_extent(compact_scale, compact_lyric_height) - } else { - 0.0 - }; + let multitask_extent = + crate::ui::island::multitask::side_extent(compact_scale, compact_lyric_height); let width = compact_width .max(compact_overlay.width) .max(self.config.expanded_width * expanded_scale) @@ -414,7 +411,7 @@ impl App { FontManager::global().measure_text_cached( text, font_size, - winisland_render::FontStyle::normal(), + crate::ui::island::MINI_LYRIC_WEIGHT, ) / scale } diff --git a/src/window/app/multitask.rs b/src/window/app/multitask.rs index a55a91b1..bffbb72a 100644 --- a/src/window/app/multitask.rs +++ b/src/window/app/multitask.rs @@ -7,7 +7,32 @@ use crate::platform::WindowRef; use super::App; +const SETTLE_SPEED: f32 = 1.0; +const POP_AT_COLLAPSE: f32 = 0.5; + impl App { + pub(super) fn timer_orb_rect(&self) -> Option { + let frame = self + .multitask + .frame() + .filter(|frame| frame.task.content.has_timer() && frame.content_opacity > 0.5)?; + let layout = self.compute_island_layout(); + crate::ui::island::multitask::surface( + winisland_render::Rect::from_xywh( + layout.current_island_x as f32, + layout.current_island_y as f32, + self.springs.w.value, + self.springs.h.value, + ), + self.springs.r.value, + self.config.compact_scale, + self.compact_content_height(), + layout.secondary_left, + Some(frame), + ) + .secondary + } + pub(super) fn update_multitask(&mut self, window: &WindowRef, now: Instant) { if !self.config.device_status_indicators { self.device_feed = None; @@ -26,8 +51,12 @@ impl App { .device_feed .as_ref() .map_or(DeviceActivity::default(), |feed| feed.snapshot()); - let indicators_changed = self.device_indicators.update(activity, now); - let task = self.device_indicators.activity().map(|content| Task { + let indicators_changed = self.status_indicators.update( + activity, + self.timer_content.filter(|_| self.timer_orb), + now, + ); + let task = self.status_indicators.activity().map(|content| Task { id: TaskId(1), content, }); @@ -43,17 +72,31 @@ impl App { } else { Presentation::Compact }; - let compact_settled = !self.springs.expanded_target - && (self.springs.h.value - self.compact_content_height()).abs() - < 0.5 * self.config.compact_scale - && self.springs.w.velocity.abs() < 0.1 - && self.springs.h.velocity.abs() < 0.1 - && self.springs.r.velocity.abs() < 0.1; + let compact_h = self.compact_content_height(); + if self.springs.expanded_target { + self.orb_collapse_from = Some(self.springs.h.value); + } + let collapse_progress = self + .orb_collapse_from + .filter(|from| (from - compact_h).abs() > self.config.compact_scale) + .map(|from| (from - self.springs.h.value) / (from - compact_h)); + let settle_speed = SETTLE_SPEED * self.config.compact_scale; + let settled = !self.springs.expanded_target + && (self.springs.h.value - compact_h).abs() < 1.5 * self.config.compact_scale + && self.springs.w.velocity.abs() < settle_speed + && self.springs.h.velocity.abs() < settle_speed + && self.springs.r.velocity.abs() < settle_speed; + if settled { + self.orb_collapse_from = None; + } + let compact_settled = settled + || (!self.springs.expanded_target + && collapse_progress.is_some_and(|progress| progress >= POP_AT_COLLAPSE)); let separation_before = self.multitask.frame().map_or(0.0, |frame| frame.separation); let changed = self.multitask.update(now, presentation, compact_settled); let separation = self.multitask.frame().map_or(0.0, |frame| frame.separation); let impulse = if separation_before >= 0.7 && separation < 0.7 { - -1.3 + -1.8 } else { 0.0 }; diff --git a/src/window/app/pages.rs b/src/window/app/pages.rs index fe664668..f73e67f3 100644 --- a/src/window/app/pages.rs +++ b/src/window/app/pages.rs @@ -262,6 +262,41 @@ impl App { } } + fn handle_music_wheel( + &mut self, + delta: MouseWheelDelta, + rel_x: i32, + rel_y: i32, + layout: &IslandLayout, + ) -> bool { + use crate::ui::expanded::music_view::{self, AudioPanel}; + let (ox, oy) = (layout.offset_x as f32, layout.island_y as f32); + let (w, scale) = (self.springs.w.value, self.config.expanded_scale); + let point = Point::new( + rel_x as f32 - self.page_translation(ExpandedPage::Music), + rel_y as f32, + ); + let notches = match delta { + MouseWheelDelta::Lines { y, .. } => y, + MouseWheelDelta::Pixels { y, .. } => y as f32 / 40.0, + }; + let panel = music_view::audio_panel(); + if panel == AudioPanel::Outputs + && music_view::output_list_rect(ox, oy, w, scale).contains(point) + { + music_view::scroll_output_list(ox, oy, w, scale, notches * 32.0 * scale); + return true; + } + let over_volume = music_view::volume_button_rect(ox, oy, w, scale).contains(point) + || (panel == AudioPanel::Volume + && music_view::volume_bar_rect(ox, oy, w, scale).contains(point)); + if over_volume && let Some(level) = music_view::step_volume(notches) { + self.compact_overlay.set_volume_level(level); + return true; + } + false + } + pub(super) fn handle_mouse_wheel(&mut self, delta: MouseWheelDelta, px: i32, py: i32) -> bool { if !self.expanded { return false; @@ -281,6 +316,12 @@ impl App { if !over_island && !self.pager_contains(rel_x, rel_y, &layout) { return false; } + if over_island + && self.page_focused(ExpandedPage::Music) + && self.handle_music_wheel(delta, rel_x, rel_y, &layout) + { + return true; + } let dominant = |x: f32, y: f32| if x.abs() > y.abs() { x } else { -y }; let timer_target = self .page_focused(ExpandedPage::Timer) diff --git a/src/window/app/system.rs b/src/window/app/system.rs index ba67d070..e429aadf 100644 --- a/src/window/app/system.rs +++ b/src/window/app/system.rs @@ -610,7 +610,12 @@ impl App { let old_style = self.config.island_style.clone(); let old_mini_shape = self.config.mini_cover_shape.clone(); let old_expanded_shape = self.config.expanded_cover_shape.clone(); - let old_font = self.config.custom_font_path.clone(); + let old_font = ( + self.config.custom_font_path.clone(), + self.config.font_family.clone(), + self.config.font_weight.clone(), + self.config.language.clone(), + ); let old_smtc_enabled = self.config.smtc_enabled; let old_system_controls_overlay = self.config.system_controls_overlay; let old_position_x_offset = self.config.position_x_offset; @@ -666,9 +671,15 @@ impl App { crate::ui::expanded::music_view::clear_cover_cache(); } - if old_font != self.config.custom_font_path { - winisland_render::text::FontManager::global() - .set_custom_font_path(self.config.custom_font_path.as_deref()); + if old_font + != ( + self.config.custom_font_path.clone(), + self.config.font_family.clone(), + self.config.font_weight.clone(), + self.config.language.clone(), + ) + { + crate::utils::fonts::apply(&self.config); } let window_size = self.required_window_size(); diff --git a/src/window/guide.rs b/src/window/guide.rs index 6714d07d..d1b5c66e 100644 --- a/src/window/guide.rs +++ b/src/window/guide.rs @@ -6,6 +6,7 @@ use winisland_platform::{ CursorKind, InputState, Key, LogicalWindowSize, MouseButton, PlatformEvent, SettingsSpec, Theme, WindowId, }; +use winisland_render::FontWeight; use winisland_render::text::FontManager; use winisland_render::{ FontStyle, Image, ImageOptions, Painter, Point, Radius, Rect, Renderer, RendererTargetId, Rgba, @@ -14,17 +15,20 @@ use winisland_render::{ use crate::platform::{MonitorRef, WindowRef, window}; use crate::utils::color::{SettingsTheme, dark_settings_theme, light_settings_theme}; +use crate::utils::settings_ui::chrome::{ + SidebarRow, draw_sidebar_row, draw_traffic_lights, draw_window_base, sidebar_panel, + traffic_light_at, traffic_lights_hovered, +}; use crate::utils::settings_ui::items::{ CONTENT_PADDING, GROUP_INNER_PAD, GROUP_RADIUS, POPUP_BTN_H, POPUP_BTN_R, POPUP_BTN_W, - SIDEBAR_PAD, SIDEBAR_SEL_RADIUS, + SIDEBAR_PAD, }; +use crate::utils::settings_ui::text_baseline; use crate::utils::settings_ui::{SettingsPainter, ellipsize_text, settings_color}; -use crate::window::settings::{ - SETTINGS_HEADER_H, SIDEBAR_ROW_GAP, SIDEBAR_ROW_H, SIDEBAR_START_Y, SIDEBAR_W, - WINDOW_CONTROL_CENTERS, WINDOW_CONTROL_RADIUS, WINDOW_RADIUS, -}; +use crate::window::settings::{SETTINGS_HEADER_H, SIDEBAR_ROW_GAP, SIDEBAR_ROW_H, SIDEBAR_W}; -const WIN_W: f32 = 680.0; +const WIN_W: f32 = 720.0; +const GUIDE_TAB_TOP: f32 = 52.0; const WIN_H: f32 = 460.0; const GUIDE_TITLE: &str = "WinIsland Guide"; const FEATURE_ROW_H: f32 = 56.0; @@ -285,6 +289,7 @@ pub(crate) struct GuideApp { hover: Option, tab_hover: [f32; PAGES.len()], finished: bool, + backdrop: bool, } fn fade(color: Rgba, alpha: f32) -> Rgba { @@ -297,6 +302,7 @@ fn ease_out_cubic(t: f32) -> f32 { fn draw_icon(painter: Painter<'_>, icon: Icon, rect: Rect, alpha: f32) { if let Some(image) = icon.image() { + crate::utils::settings_ui::chrome::draw_icon_shadow(painter, rect, alpha); painter.draw_image( &image, rect, @@ -307,37 +313,10 @@ fn draw_icon(painter: Painter<'_>, icon: Icon, rect: Rect, alpha: f32) { } } -fn draw_window_control( - painter: Painter<'_>, - center: (f32, f32), - fill: Rgba, - border: Rgba, - highlighted: bool, -) { - painter.fill_circle( - Point::new(center.0, center.1 + 0.75), - WINDOW_CONTROL_RADIUS + 0.25, - Rgba::from_argb(38, 0, 0, 0), - ); - painter.fill_circle(Point::new(center.0, center.1), WINDOW_CONTROL_RADIUS, fill); - painter.stroke_circle( - Point::new(center.0, center.1), - WINDOW_CONTROL_RADIUS - 0.375, - 0.75, - border, - ); - if highlighted { - painter.fill_oval( - Rect::from_xywh(center.0 - 3.5, center.1 - 4.25, 7.0, 2.25), - Rgba::from_argb(36, 255, 255, 255), - ); - } -} - fn tab_rect(index: usize) -> Rect { Rect::from_xywh( SIDEBAR_PAD, - SIDEBAR_START_Y + index as f32 * (SIDEBAR_ROW_H + SIDEBAR_ROW_GAP), + GUIDE_TAB_TOP + index as f32 * (SIDEBAR_ROW_H + SIDEBAR_ROW_GAP), SIDEBAR_W - SIDEBAR_PAD * 2.0, SIDEBAR_ROW_H, ) @@ -380,7 +359,7 @@ fn draw_pill_btn( label, (rect.center_x(), rect.center_y() + 4.0), 12.0, - false, + FontWeight::REGULAR, text_color, ); } @@ -400,6 +379,7 @@ impl GuideApp { hover: None, tab_hover: [0.0; PAGES.len()], finished: false, + backdrop: false, } } @@ -417,6 +397,7 @@ impl GuideApp { }) .map_err(|error| error.to_string())?; self.window = Some(WindowRef(id)); + self.backdrop = window().start_window_backdrop(id).is_ok(); self.update_theme(); self.transition = Some((1.0, Instant::now())); self.recreate_renderer_target(renderer) @@ -498,16 +479,10 @@ impl GuideApp { fn hit_test(&self, x: f32, y: f32) -> Option { let point = Point::new(x, y); - let control_hit = |center: (f32, f32)| { - let dx = x - center.0; - let dy = y - center.1; - dx * dx + dy * dy <= (WINDOW_CONTROL_RADIUS + 2.0).powi(2) - }; - if control_hit(WINDOW_CONTROL_CENTERS[0]) { - return Some(Hit::Close); - } - if control_hit(WINDOW_CONTROL_CENTERS[1]) { - return Some(Hit::Minimize); + match traffic_light_at(x, y) { + Some(0) => return Some(Hit::Close), + Some(1) => return Some(Hit::Minimize), + _ => {} } if let Some(index) = (0..PAGES.len()).find(|index| tab_rect(*index).contains(point)) { return Some(Hit::Tab(index)); @@ -522,9 +497,7 @@ impl GuideApp { } fn controls_hovered(&self) -> bool { - self.focused - && (10.0..=70.0).contains(&self.mouse.0) - && (10.0..=30.0).contains(&self.mouse.1) + self.focused && traffic_lights_hovered(self.mouse.0, self.mouse.1) } fn go_to(&mut self, page: usize) { @@ -678,21 +651,36 @@ impl GuideApp { self.advance(); let scale = self.scale(); let theme = self.theme(); + let panel = sidebar_panel(SIDEBAR_W, WIN_H); + if self.backdrop + && let Some(window_ref) = self.window + { + self.backdrop = window().update_window_backdrop( + window_ref.id(), + [ + panel.left * scale, + panel.top * scale, + panel.width() * scale, + panel.height() * scale, + ], + crate::utils::settings_ui::chrome::BACKDROP_CORNER * scale, + ); + } + let backdrop = self.backdrop; let result = renderer.frame(target, |_, painter| { painter.scale(Vec2::new(scale, scale)); let win_rect = Rect::from_xywh(0.0, 0.0, WIN_W, WIN_H); painter.save(); - painter.clip_round_rect(win_rect, Radius::uniform(WINDOW_RADIUS)); - painter.fill_rect(win_rect, settings_color(theme.win_bg)); + crate::utils::settings_ui::chrome::clip_window(painter, win_rect); + draw_window_base(painter, &theme, win_rect, panel, backdrop); self.draw_sidebar(painter, &theme); self.draw_header(painter, &theme); self.draw_content(painter, &theme); self.draw_footer(painter, &theme); painter.restore(); - painter.stroke_round_rect( - Rect::from_xywh(0.5, 0.5, WIN_W - 1.0, WIN_H - 1.0), - Radius::uniform(WINDOW_RADIUS - 0.5), - 1.0, + crate::utils::settings_ui::chrome::stroke_window( + painter, + win_rect, settings_color(theme.separator), ); }); @@ -702,163 +690,61 @@ impl GuideApp { } fn draw_sidebar(&self, painter: Painter<'_>, theme: &SettingsTheme) { - painter.fill_rect( - Rect::from_xywh(0.0, 0.0, SIDEBAR_W, WIN_H), - settings_color(theme.sidebar_bg), - ); - - let inactive_fill = if self.is_light { - Rgba::from_rgb(184, 184, 188) - } else { - Rgba::from_rgb(82, 82, 86) - }; - let fills = if self.focused { - [ - Rgba::from_rgb(255, 95, 87), - Rgba::from_rgb(254, 188, 46), - Rgba::from_rgb(126, 126, 132), - ] - } else { - [inactive_fill; 3] - }; - let border = if self.is_light { - Rgba::from_argb(38, 0, 0, 0) - } else { - Rgba::from_argb(64, 0, 0, 0) - }; - for (center, fill) in WINDOW_CONTROL_CENTERS.into_iter().zip(fills) { - draw_window_control(painter, center, fill, border, self.focused); - } - if self.controls_hovered() { - let close_color = Rgba::from_rgb(78, 0, 2); - painter.stroke_line( - Point::new(17.5, 17.5), - Point::new(22.5, 22.5), - 1.1, - close_color, - StrokeCap::Round, - ); - painter.stroke_line( - Point::new(22.5, 17.5), - Point::new(17.5, 22.5), - 1.1, - close_color, - StrokeCap::Round, - ); - painter.stroke_line( - Point::new(36.5, 20.0), - Point::new(43.5, 20.0), - 1.1, - Rgba::from_rgb(92, 62, 0), - StrokeCap::Round, - ); - } - - painter.stroke_line( - Point::new(SIDEBAR_W, 0.0), - Point::new(SIDEBAR_W, WIN_H), - 0.5, - settings_color(theme.separator), - StrokeCap::Butt, + draw_traffic_lights( + painter, + self.is_light, + self.focused, + self.controls_hovered(), + theme.text_sec, ); - for (index, page) in PAGES.iter().enumerate() { - let row = tab_rect(index); - let text_color = if index == self.page { - painter.fill_round_rect( - row, - Radius::uniform(SIDEBAR_SEL_RADIUS), - settings_color(if self.focused { - theme.selection_bg - } else { - theme.card_highlight - }), - ); - if self.focused { - if self.is_light { - settings_color(theme.selection_text) - } else { - Rgba::WHITE - } - } else { - settings_color(theme.text_pri) - } - } else { - let hover = self.tab_hover[index]; - if hover > 0.005 { - let base = theme.sidebar_hover; - painter.fill_round_rect( - row, - Radius::uniform(SIDEBAR_SEL_RADIUS), - Rgba::from_argb( - (base.a() as f32 * hover) as u8, - base.r(), - base.g(), - base.b(), - ), - ); - } - settings_color(if self.hover == Some(Hit::Tab(index)) { - theme.text_pri - } else { - theme.text_sec - }) - }; - draw_icon( + let icon = page.icon.image(); + draw_sidebar_row( painter, - page.icon, - Rect::from_xywh(row.left + 7.0, row.top + 6.0, 22.0, 22.0), - 1.0, - ); - let label = ellipsize_text( - FontManager::global(), - &tr(page.tab), - 13.0, - FontStyle::normal(), - row.width() - 44.0, - ); - SettingsPainter::new(painter).text( - &label, - (row.left + 36.0, row.top + 22.0), - 13.0, - index == self.page, - text_color, + theme, + SidebarRow { + rect: tab_rect(index), + icon: icon.as_ref(), + label: &tr(page.tab), + selected: index == self.page, + focused: self.focused, + hover: self.tab_hover[index], + }, ); } } fn draw_header(&self, painter: Painter<'_>, theme: &SettingsTheme) { let title_x = SIDEBAR_W + CONTENT_PADDING; + let center_y = SETTINGS_HEADER_H / 2.0 + 2.0; let title = ellipsize_text( FontManager::global(), &tr(PAGES[self.page].title), - 17.0, - FontStyle::bold(), + 15.0, + FontWeight::SEMIBOLD, WIN_W - title_x - 70.0, ); - SettingsPainter::new(painter).text( + FontManager::global().draw_str( + painter, &title, - (title_x, 39.0), - 17.0, - true, + Point::new(title_x, text_baseline(center_y, 15.0)), + 15.0, + FontWeight::SEMIBOLD, settings_color(theme.text_pri), ); let progress = format!("{} / {}", self.page + 1, PAGES.len()); let width = FontManager::global().measure_text_cached(&progress, 12.0, FontStyle::normal()); - SettingsPainter::new(painter).text( + FontManager::global().draw_str( + painter, &progress, - (WIN_W - CONTENT_PADDING - width, 38.0), + Point::new( + WIN_W - CONTENT_PADDING - width, + text_baseline(center_y, 12.0), + ), 12.0, - false, + FontWeight::REGULAR, settings_color(theme.text_sec), ); - painter.stroke_line( - Point::new(SIDEBAR_W, SETTINGS_HEADER_H - 0.5), - Point::new(WIN_W, SETTINGS_HEADER_H - 0.5), - 0.5, - settings_color(theme.separator), - StrokeCap::Butt, - ); } fn draw_content(&self, painter: Painter<'_>, theme: &SettingsTheme) { @@ -908,32 +794,26 @@ impl GuideApp { &subtitle, (CONTENT_PADDING + 4.0, y + 22.0), 13.0, - false, + FontWeight::REGULAR, fade(settings_color(theme.text_sec), alpha), ); y += 36.0; let group_h = page.features.len() as f32 * FEATURE_ROW_H; - painter.fill_round_rect( - Rect::from_xywh(CONTENT_PADDING, y + 2.0, content_w, group_h), - Radius::uniform(GROUP_RADIUS), - fade(settings_color(theme.shadow), alpha), - ); - painter.fill_round_rect( - Rect::from_xywh(CONTENT_PADDING, y, content_w, group_h), - Radius::uniform(GROUP_RADIUS), + let group = Rect::from_xywh(CONTENT_PADDING, y, content_w, group_h); + painter.fill_path( + &winisland_render::Path::continuous_rounded_rect(group, GROUP_RADIUS), fade(settings_color(theme.group_bg), alpha), ); - painter.stroke_round_rect( - Rect::from_xywh( - CONTENT_PADDING + 0.375, - y + 0.375, - content_w - 0.75, - group_h - 0.75, + painter.stroke_path( + &winisland_render::Path::continuous_rounded_rect( + group.inset(0.375), + GROUP_RADIUS - 0.375, ), - Radius::uniform(GROUP_RADIUS), 0.75, fade(settings_color(theme.group_border), alpha), + StrokeCap::Butt, + winisland_render::StrokeJoin::Round, ); for (index, item) in page.features.iter().enumerate() { @@ -986,14 +866,14 @@ impl GuideApp { &title, (text_x, row_y + 25.0), 13.0, - false, + FontWeight::REGULAR, fade(settings_color(theme.text_pri), alpha), ); SettingsPainter::new(painter).text( &detail, (text_x, row_y + 41.0), 11.0, - false, + FontWeight::REGULAR, fade(settings_color(theme.text_sec), alpha), ); if index + 1 < page.features.len() { diff --git a/src/window/settings/input.rs b/src/window/settings/input.rs index abfacbac..e46dd2c1 100644 --- a/src/window/settings/input.rs +++ b/src/window/settings/input.rs @@ -1,19 +1,19 @@ use crate::utils::settings_ui::WidgetEditorMode; use crate::utils::settings_ui::hover_test; -use crate::utils::settings_ui::items::SIDEBAR_PAD; use winisland_platform::Key; use winisland_render::{Point, Rect}; use super::pages::PageInput; +use super::sidebar::SidebarEntry; use super::{ - NumberInput, NumberInputHandler, PAGE_NAV_GAP, PAGE_NAV_HEIGHT, PAGE_NAV_WIDTH, PAGE_NAV_X, - PAGE_NAV_Y, PLUGINS_PAGE_INDEX, POPUP_OPACITY_KEY, PageNavigation, SETTINGS_HEADER_H, - SIDEBAR_ROW_GAP, SIDEBAR_ROW_H, SIDEBAR_START_Y, SIDEBAR_W, SettingsApp, + BuiltinPage, MUSIC_PAGE_INDEX, NumberInput, NumberInputHandler, PAGE_NAV_GAP, PAGE_NAV_HEIGHT, + PAGE_NAV_WIDTH, PAGE_NAV_X, PAGE_NAV_Y, PLUGINS_PAGE_INDEX, POPUP_OPACITY_KEY, PageNavigation, + SETTINGS_HEADER_H, SIDEBAR_W, SettingsApp, }; impl SettingsApp { pub(crate) fn music_notice_button_hovered(&self) -> bool { - if self.active_page != 1 || !self.show_music_notice() { + if self.active_page != MUSIC_PAGE_INDEX || !self.show_music_notice() { return false; } let (x, y) = self.logical_mouse_pos; @@ -21,30 +21,63 @@ impl SettingsApp { .contains(Point::new(x - SIDEBAR_W, y + self.scroll_y)) } + pub(super) fn close_popup(&mut self, index: Option) { + let plugin_setting = self.pending_plugin_setting.is_some(); + let selection = self.popup.take().and_then(|popup| { + index + .and_then(|index| popup.values.get(index)) + .map(|value| (popup.on_select, value.clone())) + }); + self.anim.set_with_speed(POPUP_OPACITY_KEY, 0.0, 0.3); + if let Some((on_select, value)) = selection { + on_select(self, &value); + if !plugin_setting { + self.persist_settings_change(); + } + } else { + self.pending_plugin_setting = None; + self.request_redraw(); + } + } + + pub(super) fn handle_popup_key(&mut self, key: &Key, text: Option<&str>) -> bool { + let Some(popup) = self.popup.as_mut() else { + return false; + }; + popup.keyboard_anchor = Some(self.logical_mouse_pos); + match key { + Key::Escape => self.close_popup(None), + Key::Enter => { + let index = popup.hover_idx; + self.close_popup(index); + } + Key::ArrowDown => { + popup.move_highlight(1); + self.request_redraw(); + } + Key::ArrowUp => { + popup.move_highlight(-1); + self.request_redraw(); + } + _ => { + if let Some(text) = text.filter(|text| !text.chars().any(char::is_control)) + && popup.type_ahead(text) + { + self.request_redraw(); + } + } + } + true + } + pub(super) fn handle_click(&mut self) { let (mouse_x, mouse_y) = self.logical_mouse_pos; self.commit_number_input(); - if self.popup.is_some() { - let plugin_setting = self.pending_plugin_setting.is_some(); - let selection = self.popup.as_ref().and_then(|popup| { - popup - .hit_test_item(mouse_x, mouse_y) - .and_then(|index| popup.values.get(index)) - .map(|value| (popup.on_select, value.clone())) - }); - self.popup = None; - self.anim.set_with_speed(POPUP_OPACITY_KEY, 0.0, 0.3); - if let Some((on_select, value)) = selection { - on_select(self, &value); - if !plugin_setting { - self.persist_settings_change(); - } - } else { - self.pending_plugin_setting = None; - self.request_redraw(); - } + if let Some(popup) = &self.popup { + let index = popup.hit_test_item(mouse_x, mouse_y); + self.close_popup(index); return; } @@ -54,11 +87,10 @@ impl SettingsApp { } if mouse_x < SIDEBAR_W { - if let Some(page) = self.sidebar_page_at(mouse_x, mouse_y) { - self.visit_page(page); - } + self.handle_sidebar_click(mouse_x, mouse_y); return; } + self.focus_search(false); if self.handle_widget_mode_click(mouse_x, mouse_y) { return; @@ -77,20 +109,24 @@ impl SettingsApp { start_y: SETTINGS_HEADER_H, }; - match self.active_page { - 0 => self.handle_general_click(input), - 1 => self.handle_music_click(input), - 2 if self.widget_editor_mode == WidgetEditorMode::Pages => {} - 2 => { + match BuiltinPage::from_index(self.active_page) { + Some(BuiltinPage::General) => self.handle_general_click(input), + Some(BuiltinPage::Appearance) => self.handle_appearance_click(input), + Some(BuiltinPage::Layout) => self.handle_layout_click(input), + Some(BuiltinPage::Behavior) => self.handle_behavior_click(input), + Some(BuiltinPage::Alerts) => self.handle_alerts_click(input), + Some(BuiltinPage::Music) => self.handle_music_click(input), + Some(BuiltinPage::Widgets) if self.widget_editor_mode == WidgetEditorMode::Pages => {} + Some(BuiltinPage::Widgets) => { if self.handle_widget_click() && let Some(window) = &self.window { window.request_redraw(); } } - 3 => self.handle_plugin_click(), - 4 => self.handle_about_click(input), - _ => self.handle_plugin_settings_click(input), + Some(BuiltinPage::Plugins) => self.handle_plugin_click(), + Some(BuiltinPage::About) => self.handle_about_click(input), + None => self.handle_plugin_settings_click(input), } } @@ -149,7 +185,8 @@ impl SettingsApp { } if mouse_x < SIDEBAR_W { - return self.sidebar_page_at(mouse_x, mouse_y).is_some(); + return self.sidebar_entry_at(mouse_x, mouse_y).is_some() + || self.search_rect().contains(Point::new(mouse_x, mouse_y)); } let content_width = self.content_width(); @@ -176,14 +213,27 @@ impl SettingsApp { ) } - fn sidebar_page_at(&self, x: f32, y: f32) -> Option { - if !(SIDEBAR_PAD..=SIDEBAR_W - SIDEBAR_PAD).contains(&x) { - return None; + fn handle_sidebar_click(&mut self, x: f32, y: f32) { + let point = Point::new(x, y); + let search = self.search_rect(); + if search.contains(point) { + let clear = crate::utils::settings_ui::chrome::search_clear_center(search); + if self.search.active() && (x - clear.x).abs() <= 9.0 { + self.clear_search(); + } + self.focus_search(true); + return; + } + self.focus_search(false); + match self.sidebar_entry_at(x, y) { + Some(SidebarEntry::Page(page)) => self.visit_page(page), + Some(SidebarEntry::Result(index)) => { + if let Some(result) = self.search.results.get(index).cloned() { + self.open_search_result(&result); + } + } + None => {} } - (0..self.sidebar_page_count()).find(|&page| { - let row_y = SIDEBAR_START_Y + page as f32 * (SIDEBAR_ROW_H + SIDEBAR_ROW_GAP); - (row_y..=row_y + SIDEBAR_ROW_H).contains(&y) - }) } pub(super) fn page_navigation_at(mouse_x: f32, mouse_y: f32) -> Option { @@ -230,7 +280,7 @@ impl SettingsApp { true } - fn visit_page(&mut self, page: usize) { + pub(crate) fn visit_page(&mut self, page: usize) { if self.active_page == page { return; } @@ -239,7 +289,7 @@ impl SettingsApp { self.page_history.push(page); self.page_history_index = self.page_history.len() - 1; self.active_page = page; - if page != 3 { + if page != PLUGINS_PAGE_INDEX { self.selected_plugin_id = None; self.plugin_detail_closing = false; self.anim diff --git a/src/window/settings/items.rs b/src/window/settings/items.rs index b09ab743..025e0267 100644 --- a/src/window/settings/items.rs +++ b/src/window/settings/items.rs @@ -1,17 +1,21 @@ use crate::utils::settings_ui::content_height; use crate::utils::settings_ui::items::SettingsItem; -use super::{SETTINGS_HEADER_H, SettingsApp}; +use super::{BuiltinPage, SETTINGS_HEADER_H, SettingsApp}; impl SettingsApp { pub(crate) fn build_current_items(&self) -> Vec { - match self.active_page { - 0 => self.build_general_items(), - 1 => self.build_music_items(), - 2 => self.build_widget_items(), - 3 => self.build_plugin_items(), - 4 => self.build_about_items(), - _ => self.build_plugin_settings_items(), + match BuiltinPage::from_index(self.active_page) { + Some(BuiltinPage::General) => self.build_general_page().into_items(), + Some(BuiltinPage::Appearance) => self.build_appearance_page().into_items(), + Some(BuiltinPage::Layout) => self.build_layout_page().into_items(), + Some(BuiltinPage::Behavior) => self.build_behavior_page().into_items(), + Some(BuiltinPage::Alerts) => self.build_alerts_page().into_items(), + Some(BuiltinPage::Music) => self.build_music_items(), + Some(BuiltinPage::Widgets) => self.build_widget_items(), + Some(BuiltinPage::Plugins) => self.build_plugin_items(), + Some(BuiltinPage::About) => self.build_about_items(), + None => self.build_plugin_settings_items(), } } diff --git a/src/window/settings/mod.rs b/src/window/settings/mod.rs index 7a954aa3..6056bf45 100644 --- a/src/window/settings/mod.rs +++ b/src/window/settings/mod.rs @@ -1,7 +1,7 @@ use crate::platform::{MonitorRef, WindowRef, window}; use crate::plugin::inventory::InstalledPlugin; use crate::utils::color::{SettingsTheme, dark_settings_theme, light_settings_theme}; -use crate::utils::settings_ui::items::{POPUP_MENU_R, SIDEBAR_PAD, SettingsItem}; +use crate::utils::settings_ui::items::{POPUP_MENU_R, SettingsItem}; use crate::utils::settings_ui::{ SwitchAnimator, WidgetDropAnimation, WidgetEditorHover, WidgetEditorMode, WidgetEditorSlot, WidgetSource, @@ -28,31 +28,73 @@ pub mod pages; mod popup; pub mod renderer; mod resource_editor; +mod search; pub mod sidebar; pub(crate) use popup::PopupState; -pub(crate) const WIN_W: f32 = 760.0; +pub(crate) const WIN_W: f32 = 780.0; pub(crate) const WIN_H: f32 = 680.0; -pub(crate) const SIDEBAR_W: f32 = 184.0; -pub(crate) const SIDEBAR_ROW_H: f32 = 34.0; +pub(crate) const SIDEBAR_W: f32 = 216.0; +pub(crate) const SIDEBAR_ROW_H: f32 = 30.0; pub(crate) const SIDEBAR_ROW_GAP: f32 = 2.0; -pub(crate) const SIDEBAR_START_Y: f32 = 64.0; -pub(crate) const BUILTIN_SIDEBAR_PAGE_COUNT: usize = 5; -pub(crate) const GENERAL_PAGE_INDEX: usize = 0; -pub(crate) const WIDGETS_PAGE_INDEX: usize = 2; -pub(crate) const PLUGINS_PAGE_INDEX: usize = 3; -pub(crate) const PAGE_NAV_X: f32 = SIDEBAR_W + 18.0; -pub(crate) const PAGE_NAV_Y: f32 = 18.0; -pub(crate) const PAGE_NAV_WIDTH: f32 = 34.0; -pub(crate) const PAGE_NAV_HEIGHT: f32 = 28.0; -pub(crate) const PAGE_NAV_GAP: f32 = 2.0; -pub(crate) const SETTINGS_HEADER_H: f32 = 64.0; -pub(crate) const WINDOW_RADIUS: f32 = 16.0; -pub(crate) const WINDOW_CONTROL_CENTERS: [(f32, f32); 3] = - [(20.0, 20.0), (40.0, 20.0), (60.0, 20.0)]; -pub(crate) const WINDOW_CONTROL_RADIUS: f32 = 6.0; -const WINDOW_CONTROL_HIT_RADIUS: f32 = 8.0; +pub(crate) const SIDEBAR_START_Y: f32 = 84.0; +pub(crate) const BUILTIN_SIDEBAR_PAGE_COUNT: usize = BuiltinPage::ALL.len(); +pub(crate) const GENERAL_PAGE_INDEX: usize = BuiltinPage::General as usize; +pub(crate) const MUSIC_PAGE_INDEX: usize = BuiltinPage::Music as usize; +pub(crate) const WIDGETS_PAGE_INDEX: usize = BuiltinPage::Widgets as usize; +pub(crate) const PLUGINS_PAGE_INDEX: usize = BuiltinPage::Plugins as usize; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum BuiltinPage { + General, + Appearance, + Layout, + Behavior, + Alerts, + Music, + Widgets, + Plugins, + About, +} + +impl BuiltinPage { + pub(crate) const ALL: [Self; 9] = [ + Self::General, + Self::Appearance, + Self::Layout, + Self::Behavior, + Self::Alerts, + Self::Music, + Self::Widgets, + Self::Plugins, + Self::About, + ]; + + pub(crate) fn from_index(index: usize) -> Option { + Self::ALL.get(index).copied() + } + + pub(crate) fn title_key(self) -> &'static str { + match self { + Self::General => "tab_general", + Self::Appearance => "tab_appearance", + Self::Layout => "tab_layout", + Self::Behavior => "tab_behavior", + Self::Alerts => "tab_alerts", + Self::Music => "tab_music", + Self::Widgets => "tab_widgets", + Self::Plugins => "tab_plugins", + Self::About => "tab_about", + } + } +} +pub(crate) const PAGE_NAV_X: f32 = SIDEBAR_W + 8.0; +pub(crate) const PAGE_NAV_Y: f32 = 11.0; +pub(crate) const PAGE_NAV_WIDTH: f32 = 32.0; +pub(crate) const PAGE_NAV_HEIGHT: f32 = 32.0; +pub(crate) const PAGE_NAV_GAP: f32 = 0.0; +pub(crate) const SETTINGS_HEADER_H: f32 = 54.0; const SCROLLBAR_BOTTOM_INSET: f32 = 8.0; const SCROLLBAR_RIGHT_INSET: f32 = 5.0; const SCROLLBAR_THUMB_MIN_H: f32 = 32.0; @@ -61,8 +103,9 @@ const SCROLLBAR_W: f32 = 4.0; const SCROLLBAR_HIT_W: f32 = 16.0; const CURSOR_MOVE_THRESHOLD: f32 = 0.5; const MOUSE_WHEEL_LINE_HEIGHT: f32 = 40.0; -const SIDEBAR_TITLE_HEIGHT: f32 = 60.0; +const SIDEBAR_TITLE_HEIGHT: f32 = 78.0; const POPUP_CLOSE_SPEED: f32 = 0.3; +const SIDEBAR_HOVER_SLOTS: usize = 64; const WINDOW_CONTROL_CLOSE: usize = 0; const WINDOW_CONTROL_MINIMIZE: usize = 1; const WIDGET_HOVER_RATE: f32 = 18.0; @@ -78,15 +121,9 @@ fn animate_towards(value: &mut f32, target: f32, rate: f32, dt: f32) -> bool { (*value - previous).abs() > f32::EPSILON } -pub(crate) fn window_control_at(x: f32, y: f32) -> Option { - WINDOW_CONTROL_CENTERS.iter().position(|&(cx, cy)| { - (x - cx).powi(2) + (y - cy).powi(2) <= WINDOW_CONTROL_HIT_RADIUS.powi(2) - }) -} - -pub(crate) fn window_controls_hovered(x: f32, y: f32) -> bool { - (10.0..=70.0).contains(&x) && (10.0..=30.0).contains(&y) -} +pub(crate) use crate::utils::settings_ui::chrome::{ + traffic_light_at as window_control_at, traffic_lights_hovered as window_controls_hovered, +}; fn scroll_delta(delta: MouseWheelDelta) -> f32 { match delta { @@ -211,6 +248,7 @@ pub struct SettingsApp { pub(crate) sidebar_hover: i32, pub(crate) popup: Option, pub(crate) number_input: Option, + pub(crate) search: search::SearchState, pub(crate) is_light: bool, pub(crate) cached_items: Vec, pub(crate) items_dirty: bool, @@ -291,13 +329,9 @@ impl SettingsApp { } pub(crate) fn page_title(&self) -> String { - match self.active_page { - 0 => winisland_core::i18n::tr("tab_general"), - 1 => winisland_core::i18n::tr("tab_music"), - 2 => winisland_core::i18n::tr("tab_widgets"), - 3 => winisland_core::i18n::tr("tab_plugins"), - 4 => winisland_core::i18n::tr("tab_about"), - _ => self + match BuiltinPage::from_index(self.active_page) { + Some(page) => winisland_core::i18n::tr(page.title_key()), + None => self .active_plugin_settings_page() .map(|page| page.title.clone()) .unwrap_or_default(), @@ -349,6 +383,7 @@ impl SettingsApp { sidebar_hover: -1, popup: None, number_input: None, + search: search::SearchState::default(), is_light: false, cached_items: Vec::new(), items_dirty: true, @@ -628,10 +663,19 @@ impl SettingsApp { PlatformEvent::KeyInput { key, state: InputState::Pressed, + text, .. } => { - self.handle_pressed_key(&key); + if !self.handle_popup_key(&key, text.as_deref()) + && !self.handle_search_key(&key, text.as_deref()) + { + self.handle_pressed_key(&key); + } + } + PlatformEvent::ImeCommit { text, .. } => { + self.insert_search_text(&text); } + PlatformEvent::ImeComposition { text, .. } => self.set_search_composition(&text), PlatformEvent::CursorMoved { position, .. } => self.handle_cursor_moved(position), PlatformEvent::CursorLeft { .. } => self.handle_cursor_left(), PlatformEvent::DroppedFile { path, .. } @@ -820,12 +864,13 @@ impl SettingsApp { || (new_position.1 - self.last_hover_mouse_pos.1).abs() > CURSOR_MOVE_THRESHOLD; self.logical_mouse_pos = new_position; self.update_scroll_drag(new_position.1); + if let Some(popup) = self.popup.as_mut() + && popup.drag_scroll(new_position.1) + { + self.request_redraw(); + } - let mut redraw = (matches!( - self.active_page, - 1 | WIDGETS_PAGE_INDEX | PLUGINS_PAGE_INDEX - ) || self.active_plugin_settings_page().is_some()) - && mouse_moved; + let mut redraw = mouse_moved; let dots_hovered = self.focused && window_controls_hovered(new_position.0, new_position.1); if dots_hovered != self.dots_hovered { self.dots_hovered = dots_hovered; @@ -996,6 +1041,10 @@ impl SettingsApp { let Some(popup) = &mut self.popup else { return false; }; + if popup.keyboard_anchor == Some(self.logical_mouse_pos) { + return false; + } + popup.keyboard_anchor = None; let (mouse_x, mouse_y) = self.logical_mouse_pos; let hover_index = popup.hit_test_item(mouse_x, mouse_y); if hover_index == popup.hover_idx { @@ -1007,20 +1056,17 @@ impl SettingsApp { fn update_sidebar_hover(&mut self) -> bool { let (mouse_x, mouse_y) = self.logical_mouse_pos; - let hover_index = (mouse_x < SIDEBAR_W).then(|| { - (0..self.sidebar_page_count()).find(|index| { - let row_y = SIDEBAR_START_Y + *index as f32 * (SIDEBAR_ROW_H + SIDEBAR_ROW_GAP); - (row_y..=row_y + SIDEBAR_ROW_H).contains(&mouse_y) - && (SIDEBAR_PAD..=SIDEBAR_W - SIDEBAR_PAD).contains(&mouse_x) - }) - }); - let hover_index = hover_index.flatten(); + let point = winisland_render::Point::new(mouse_x, mouse_y); + let rows = self.sidebar_rows(); + let hover_index = (mouse_x < SIDEBAR_W) + .then(|| rows.iter().position(|(_, rect)| rect.contains(point))) + .flatten(); let sidebar_hover = hover_index.map_or(-1, |index| index as i32); if sidebar_hover == self.sidebar_hover { return false; } self.sidebar_hover = sidebar_hover; - for index in 0..self.sidebar_page_count() { + for index in 0..rows.len().max(SIDEBAR_HOVER_SLOTS) { self.anim.set( SIDEBAR_KEY_BASE + index as u64, if hover_index == Some(index) { 1.0 } else { 0.0 }, @@ -1030,7 +1076,7 @@ impl SettingsApp { } fn handle_cursor_left(&mut self) { - if self.active_page == 1 && self.show_music_notice() { + if self.active_page == MUSIC_PAGE_INDEX && self.show_music_notice() { self.logical_mouse_pos = (-1.0, -1.0); self.request_redraw(); } @@ -1054,6 +1100,19 @@ impl SettingsApp { if self.resource_editor_open { return; } + let (mouse_x, mouse_y) = self.logical_mouse_pos; + if let Some(popup) = self.popup.as_mut() + && popup.max_scroll() > 0.0 + && popup + .menu_rect() + .contains(winisland_render::Point::new(mouse_x, mouse_y)) + { + if popup.scroll_by(-scroll_delta(delta)) { + self.update_popup_hover(); + self.request_redraw(); + } + return; + } if self.popup.is_some() { self.popup = None; self.pending_plugin_setting = None; @@ -1063,7 +1122,6 @@ impl SettingsApp { return; } let delta = scroll_delta(delta); - let (mouse_x, _) = self.logical_mouse_pos; if self.active_page == PLUGINS_PAGE_INDEX && self.plugin_detail_contains(mouse_x) { self.plugin_detail_scroll = (self.plugin_detail_scroll - delta).clamp(0.0, self.plugin_detail_max_scroll); @@ -1078,6 +1136,12 @@ impl SettingsApp { fn handle_left_mouse_pressed(&mut self) { let (mouse_x, mouse_y) = self.logical_mouse_pos; + if let Some(popup) = self.popup.as_mut() + && popup.begin_scroll_drag(mouse_x, mouse_y) + { + self.request_redraw(); + return; + } if self.resource_editor_open { self.handle_click(); return; @@ -1114,7 +1178,11 @@ impl SettingsApp { if self.resource_editor_open { return false; } - let in_sidebar_title = mouse_x < SIDEBAR_W && mouse_y < SIDEBAR_TITLE_HEIGHT; + let in_sidebar_title = mouse_x < SIDEBAR_W + && mouse_y < SIDEBAR_TITLE_HEIGHT + && !self + .search_rect() + .contains(winisland_render::Point::new(mouse_x, mouse_y)); let in_content_title = mouse_x >= SIDEBAR_W && mouse_y < SETTINGS_HEADER_H && Self::page_navigation_at(mouse_x, mouse_y).is_none() @@ -1123,6 +1191,10 @@ impl SettingsApp { } fn handle_left_mouse_released(&mut self) { + if self.popup.as_mut().is_some_and(PopupState::end_scroll_drag) { + self.request_redraw(); + return; + } if std::mem::take(&mut self.music_notice_pressed) { if self.music_notice_button_hovered() { self.config.music_notice_acknowledged = true; @@ -1204,7 +1276,8 @@ impl SettingsApp { let has_popup = self.popup.is_some(); let is_scrolling = (self.target_scroll_y - self.scroll_y).abs() > 0.1; let is_widget_dragging = self.widget_drag_active(); - let is_number_input_active = self.number_input.is_some(); + let is_number_input_active = + self.number_input.is_some() || self.search.focused || self.search.flash_active(); if !settings_frame_should_continue( has_anim, diff --git a/src/window/settings/page_order.rs b/src/window/settings/page_order.rs index a4653477..7afd6b80 100644 --- a/src/window/settings/page_order.rs +++ b/src/window/settings/page_order.rs @@ -2,6 +2,7 @@ use std::time::Instant; use winisland_core::config::ExpandedPageKind; use winisland_core::i18n::tr; +use winisland_render::FontWeight; use winisland_render::text::FontManager; use winisland_render::{Painter, Path, Point, Radius, Rect, Rgba, StrokeCap, StrokeJoin}; @@ -556,7 +557,7 @@ impl SettingsApp { let fonts = FontManager::global(); let name = tr(page_name_key(kind)); - let name_bounds = fonts.measure_str(&name, 14.0, false); + let name_bounds = fonts.measure_str(&name, 14.0, FontWeight::REGULAR); let text_x = check.right + 12.0; let baseline = row.center_y() - name_bounds.height() / 2.0 - name_bounds.top; let primary = settings_color(theme.text_pri); @@ -567,7 +568,7 @@ impl SettingsApp { &name, Point::new(text_x - name_bounds.left, baseline), 14.0, - false, + FontWeight::REGULAR, name_color, ); let position = (slot + 1).to_string(); @@ -576,7 +577,7 @@ impl SettingsApp { &position, Point::new(text_x + name_bounds.width() + 10.0, baseline), 12.0, - false, + FontWeight::REGULAR, secondary, ); @@ -642,7 +643,7 @@ impl SettingsApp { list.bottom + HINT_GAP + HINT_SIZE + index as f32 * HINT_LINE_HEIGHT, ), HINT_SIZE, - false, + FontWeight::REGULAR, settings_color(theme.text_sec), ); } diff --git a/src/window/settings/pages/general.rs b/src/window/settings/pages/general.rs index 04e8e9c9..ba2a3ada 100644 --- a/src/window/settings/pages/general.rs +++ b/src/window/settings/pages/general.rs @@ -1,41 +1,7 @@ +mod alerts; mod appearance; mod behavior; -mod effects; +mod layout; +mod system; -use crate::utils::settings_ui::items::SettingsItem; - -use super::{PageInput, SettingsApp}; - -impl SettingsApp { - pub(crate) fn build_general_items(&self) -> Vec { - let mut items = self.build_appearance_page().into_items(); - items.extend(self.build_effects_page().into_items()); - items.extend(self.build_behavior_page().into_items()); - items - } - - pub(crate) fn handle_general_click(&mut self, input: PageInput) { - let appearance = self.build_appearance_page(); - let effects = self.build_effects_page(); - let appearance_height = appearance - .items() - .iter() - .map(SettingsItem::height) - .sum::(); - let effects_height = effects - .items() - .iter() - .map(SettingsItem::height) - .sum::(); - - self.handle_appearance_click(input); - self.handle_effects_click(PageInput { - start_y: input.start_y + appearance_height, - ..input - }); - self.handle_behavior_click(PageInput { - start_y: input.start_y + appearance_height + effects_height, - ..input - }); - } -} +use super::SettingsApp; diff --git a/src/window/settings/pages/general/alerts.rs b/src/window/settings/pages/general/alerts.rs new file mode 100644 index 00000000..36c53a3d --- /dev/null +++ b/src/window/settings/pages/general/alerts.rs @@ -0,0 +1,46 @@ +use winisland_core::config::AppConfigField; +use winisland_core::i18n::tr; + +use super::super::{PageInput, SettingsPage}; +use super::SettingsApp; + +impl SettingsApp { + pub(crate) fn build_alerts_page(&self) -> SettingsPage<()> { + let caps = crate::platform::capabilities(); + let mut page = SettingsPage::new(); + page.section(tr("section_notifications")); + page.group_start(); + page.setting( + &self.config, + AppConfigField::NotificationDisplay, + caps.toast_events, + ); + if !caps.toast_events { + page.row_label(tr("platform_unavailable")); + } + page.group_end(); + + page.section(tr("section_system_alerts")); + page.group_start(); + let overlay_available = + (caps.input_hooks && caps.volume_control) || caps.brightness_control; + page.setting( + &self.config, + AppConfigField::SystemControlsOverlay, + overlay_available, + ); + page.setting(&self.config, AppConfigField::DeviceStatusIndicators, true); + page.setting(&self.config, AppConfigField::ClipboardLinkPrompt, true); + if !overlay_available { + page.row_label(tr("platform_unavailable")); + } + page.group_end(); + page + } + + pub(crate) fn handle_alerts_click(&mut self, input: PageInput) { + let page = self.build_alerts_page(); + let result = input.hit_test(&page); + self.handle_setting_click(&page, &result, input); + } +} diff --git a/src/window/settings/pages/general/appearance.rs b/src/window/settings/pages/general/appearance.rs index bb519187..d54d25cd 100644 --- a/src/window/settings/pages/general/appearance.rs +++ b/src/window/settings/pages/general/appearance.rs @@ -1,77 +1,232 @@ use crate::utils::settings_ui::ClickResult; use crate::window::settings::PopupState; use winisland_core::config::AppConfigField; -use winisland_core::i18n::tr; +use winisland_core::i18n::{tr, tr_args}; +use winisland_render::text::FontManager; use super::super::{PageInput, SettingsPage}; use super::SettingsApp; +const FONT_FILE_CHOICE: &str = "\u{1}file"; + #[derive(Clone, Copy)] -pub(super) enum AppearanceAction { - Monitor, +pub(crate) enum AppearanceAction { + IslandStyle, + FontFamily, + RecommendedFont, } impl SettingsApp { - pub(super) fn build_appearance_page(&self) -> SettingsPage { + fn font_family_label(&self) -> String { + if let Some(path) = &self.config.custom_font_path { + return FontManager::global() + .custom_family_name() + .unwrap_or_else(|| { + std::path::Path::new(path) + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.clone()) + }); + } + self.config + .font_family + .clone() + .unwrap_or_else(|| tr("font_system_default")) + } + + pub(crate) fn build_appearance_page(&self) -> SettingsPage { let mut page = SettingsPage::new(); - page.section(tr("section_appearance")); + page.section(tr("section_island_style")); page.group_start(); - page.setting(&self.config, AppConfigField::CompactScale, true); - page.setting(&self.config, AppConfigField::ExpandedScale, true); - page.setting(&self.config, AppConfigField::BaseWidth, true); - page.setting(&self.config, AppConfigField::BaseHeight, true); - page.setting(&self.config, AppConfigField::ExpandedWidth, true); - page.setting(&self.config, AppConfigField::ExpandedHeight, true); - page.setting(&self.config, AppConfigField::PositionXOffset, true); - page.setting(&self.config, AppConfigField::PositionYOffset, true); - page.setting(&self.config, AppConfigField::FontSize, true); + let host_backdrop = crate::platform::capabilities().host_backdrop; + let style = if !host_backdrop && self.config.island_style == "glass" { + "dynamic" + } else { + self.config.island_style.as_str() + }; + let mut styles = vec![(tr("style_default"), style == "default")]; + if host_backdrop { + styles.push((tr("style_glass"), style == "glass")); + } + styles.push((tr("style_dynamic"), style == "dynamic")); + styles.push((tr("style_liquid_glass"), style == "liquid")); + page.row_source( + tr("island_style"), + styles, + true, + AppearanceAction::IslandStyle, + ); + if style == "glass" { + page.setting(&self.config, AppConfigField::GlassBlur, true); + } + if style == "liquid" { + page.setting(&self.config, AppConfigField::LiquidGlassBlur, true); + page.setting(&self.config, AppConfigField::LiquidGlassRefraction, true); + page.setting(&self.config, AppConfigField::LiquidGlassDispersion, true); + page.setting(&self.config, AppConfigField::LiquidGlassTint, true); + page.setting(&self.config, AppConfigField::LiquidGlassSiri, true); + page.row_label(tr("liquid_glass_siri_note")); + page.setting(&self.config, AppConfigField::LiquidGlassCompatCapture, true); + page.row_label(tr(if self.config.liquid_glass_compat_capture { + "liquid_glass_compat_capture_note" + } else { + "style_liquid_note" + })); + } + if !host_backdrop { + page.row_label(tr("platform_unavailable")); + } + page.group_end(); - let monitors = Self::get_monitor_list(); - let selected_monitor = - (self.config.monitor_index as usize).min(monitors.len().saturating_sub(1)); + page.section(tr("section_font")); + page.group_start(); page.row_source( - tr("monitor"), - monitors - .into_iter() - .enumerate() - .map(|(index, name)| (name, index == selected_monitor)) - .collect(), + tr("font_family"), + vec![(self.font_family_label(), true)], + true, + AppearanceAction::FontFamily, + ); + if self.config.custom_font_path.is_none() + && let Some(family) = &self.config.font_family + { + let resolved = crate::utils::fonts::resolve_family(family); + if &resolved != family { + page.row_label(tr_args("font_region_note", &[&resolved])); + } + } + page.setting(&self.config, AppConfigField::FontWeight, true); + page.setting(&self.config, AppConfigField::FontSize, true); + page.group_end(); + page.group_start(); + page.row_button( + tr("font_recommended"), + tr("font_download"), true, - AppearanceAction::Monitor, + AppearanceAction::RecommendedFont, ); + page.row_label(tr("font_recommended_note")); + page.group_end(); + + page.section(tr("section_motion")); + page.group_start(); + page.setting(&self.config, AppConfigField::MotionBlur, true); page.group_end(); page } - pub(super) fn handle_appearance_click(&mut self, input: PageInput) { + pub(crate) fn handle_appearance_click(&mut self, input: PageInput) { let page = self.build_appearance_page(); let result = input.hit_test(&page); if self.handle_setting_click(&page, &result, input) { return; } - let (Some(AppearanceAction::Monitor), ClickResult::SourceButton(item_index)) = - (page.action(&result), result) - else { + let Some(action) = page.action(&result).copied() else { + return; + }; + + if let (AppearanceAction::RecommendedFont, ClickResult::RowButton(_)) = (action, &result) { + if let Err(error) = + crate::platform::shell().open_url(crate::utils::fonts::RECOMMENDED_FONT_URL) + { + log::warn!("Could not open font page: {error}"); + } + return; + } + + let ClickResult::SourceButton(item_index) = result else { return; }; let button_rect = input.popup_button_rect(&page, item_index, self.scroll_y); let (win_w, win_h) = self.logical_window_size(); - - let monitors = Self::get_monitor_list(); - let selected = (self.config.monitor_index as usize).min(monitors.len().saturating_sub(1)); - let values = (0..monitors.len()).map(|index| index.to_string()).collect(); - self.show_popup(PopupState::new( - select_monitor, - button_rect, - monitors, - values, - selected, - win_w, - win_h, - )); + let popup = match action { + AppearanceAction::IslandStyle => { + let host_backdrop = crate::platform::capabilities().host_backdrop; + let mut labels = vec![tr("style_default")]; + let mut values = vec!["default".to_string()]; + if host_backdrop { + labels.push(tr("style_glass")); + values.push("glass".to_string()); + } + labels.push(tr("style_dynamic")); + values.push("dynamic".to_string()); + labels.push(tr("style_liquid_glass")); + values.push("liquid".to_string()); + let selected = values + .iter() + .position(|value| value == &self.config.island_style) + .unwrap_or(values.len() - 1); + PopupState::new( + select_island_style, + button_rect, + labels, + values, + selected, + win_w, + win_h, + ) + } + AppearanceAction::FontFamily => { + let families = crate::utils::fonts::family_choices( + &FontManager::global().installed_families(), + ); + let selected = if self.config.custom_font_path.is_some() { + 1 + } else { + self.config + .font_family + .as_ref() + .and_then(|family| families.iter().position(|name| name == family)) + .map_or(0, |index| index + 2) + }; + let mut labels = vec![tr("font_system_default"), tr("font_from_file")]; + let mut values = vec![String::new(), FONT_FILE_CHOICE.to_string()]; + labels.extend(families.iter().cloned()); + values.extend(families); + PopupState::new( + select_font_family, + button_rect, + labels, + values, + selected, + win_w, + win_h, + ) + } + AppearanceAction::RecommendedFont => return, + }; + self.show_popup(popup); } } -fn select_monitor(app: &mut SettingsApp, value: &str) { - app.config.monitor_index = value.parse().unwrap_or(0); +fn select_island_style(app: &mut SettingsApp, value: &str) { + app.config.island_style = if value == "glass" && !crate::platform::capabilities().host_backdrop + { + "dynamic".to_string() + } else { + value.to_string() + }; +} + +fn select_font_family(app: &mut SettingsApp, value: &str) { + match value { + "" => { + app.config.font_family = None; + app.config.custom_font_path = None; + } + FONT_FILE_CHOICE => { + let Some(path) = rfd::FileDialog::new() + .add_filter("Fonts", &["ttf", "otf", "ttc", "otc"]) + .pick_file() + else { + return; + }; + app.config.custom_font_path = Some(path.to_string_lossy().into_owned()); + app.config.font_family = None; + } + family => { + app.config.font_family = Some(family.to_string()); + app.config.custom_font_path = None; + } + } + crate::utils::fonts::apply(&app.config); } diff --git a/src/window/settings/pages/general/behavior.rs b/src/window/settings/pages/general/behavior.rs index 8859b0fd..4e989f10 100644 --- a/src/window/settings/pages/general/behavior.rs +++ b/src/window/settings/pages/general/behavior.rs @@ -1,40 +1,20 @@ -use crate::utils::settings_ui::ClickResult; use crate::utils::settings_ui::items::SettingsItem; -use crate::window::settings::PopupState; -use winisland_core::config::{AppConfig, AppConfigField, MAX_HIDDEN_WIDTH}; -use winisland_core::i18n::{available_langs, current_lang, init_i18n, set_lang, tr}; -use winisland_render::text::FontManager; +use winisland_core::config::{AppConfigField, MAX_HIDDEN_WIDTH}; +use winisland_core::i18n::tr; use super::super::{PageInput, SettingsPage}; use super::SettingsApp; -#[derive(Clone, Copy)] -pub(super) enum BehaviorAction { - AutoStart, - Language, - CheckUpdatesNow, - ResetDefaults, - HideIsland, - Exit, -} - impl SettingsApp { - pub(super) fn build_behavior_page(&self) -> SettingsPage { - let caps = crate::platform::capabilities(); + pub(crate) fn build_behavior_page(&self) -> SettingsPage<()> { let mut page = SettingsPage::new(); - page.section(tr("section_behavior")); + page.section(tr("section_auto_hide")); page.group_start(); - page.row_switch( - tr("start_boot"), - self.config.auto_start, - caps.autostart, - BehaviorAction::AutoStart, - ); page.setting(&self.config, AppConfigField::AutoHide, true); - page.setting(&self.config, AppConfigField::FullscreenAutoHide, true); if self.config.auto_hide { page.setting(&self.config, AppConfigField::AutoHideDelay, true); } + page.setting(&self.config, AppConfigField::FullscreenAutoHide, true); page.push_setting( SettingsItem::RowStepper { label: tr("hidden_width"), @@ -47,149 +27,18 @@ impl SettingsApp { }, AppConfigField::HiddenWidth, ); - page.setting(&self.config, AppConfigField::RightClickDrag, true); - page.setting( - &self.config, - AppConfigField::NotificationDisplay, - caps.toast_events, - ); - page.setting( - &self.config, - AppConfigField::SystemControlsOverlay, - (caps.input_hooks && caps.volume_control) || caps.brightness_control, - ); - page.setting(&self.config, AppConfigField::ClipboardLinkPrompt, true); - page.setting(&self.config, AppConfigField::DeviceStatusIndicators, true); - if !caps.autostart || !caps.toast_events || !caps.input_hooks || !caps.brightness_control { - page.row_label(tr("platform_unavailable")); - } - if !caps.tray { - page.row_button( - tr("tray_hide"), - tr("tray_hide"), - true, - BehaviorAction::HideIsland, - ); - page.row_button(tr("tray_exit"), tr("tray_exit"), true, BehaviorAction::Exit); - } - - let language = current_lang(); - page.row_source( - tr("language"), - available_langs() - .iter() - .map(|entry| (entry.name.clone(), entry.code == language)) - .collect(), - true, - BehaviorAction::Language, - ); page.group_end(); - page.section(tr("section_updates")); + + page.section(tr("section_interaction")); page.group_start(); - page.setting(&self.config, AppConfigField::CheckForUpdates, true); - if self.config.check_for_updates { - page.setting(&self.config, AppConfigField::UpdateChannel, true); - page.setting(&self.config, AppConfigField::UpdateCheckInterval, true); - } - page.row_button( - tr("check_updates_manual"), - tr("update_check_btn"), - true, - BehaviorAction::CheckUpdatesNow, - ); + page.setting(&self.config, AppConfigField::RightClickDrag, true); page.group_end(); - page.spacer(10.0); - page.center_link( - tr("reset_defaults"), - self.theme().danger, - BehaviorAction::ResetDefaults, - ); page } - pub(super) fn handle_behavior_click(&mut self, input: PageInput) { + pub(crate) fn handle_behavior_click(&mut self, input: PageInput) { let page = self.build_behavior_page(); let result = input.hit_test(&page); - if self.handle_setting_click(&page, &result, input) { - return; - } - let Some(action) = page.action(&result).copied() else { - return; - }; - - let changed = match (action, &result) { - (BehaviorAction::AutoStart, ClickResult::Switch(_)) => { - let enabled = !self.config.auto_start; - match crate::platform::shell().set_autostart(enabled) { - Ok(()) => { - self.config.auto_start = enabled; - true - } - Err(error) => { - crate::platform::update_capabilities(|caps| caps.autostart = false); - log::warn!("Autostart is unavailable: {error}"); - false - } - } - } - (BehaviorAction::HideIsland, ClickResult::RowButton(_)) => { - self.plugin_request = - Some(crate::window::settings::PluginSettingsRequest::HideIsland); - false - } - (BehaviorAction::Exit, ClickResult::RowButton(_)) => { - self.plugin_request = Some(crate::window::settings::PluginSettingsRequest::Exit); - false - } - (BehaviorAction::ResetDefaults, ClickResult::CenterLink(_)) => { - self.config = AppConfig::default(); - init_i18n(&self.config.language); - crate::ui::widget::expanded::calendar::clear_calendar_text_cache(); - FontManager::global().set_custom_font_path(self.config.custom_font_path.as_deref()); - true - } - (BehaviorAction::CheckUpdatesNow, ClickResult::RowButton(_)) => { - crate::utils::updater::check_updates_manually(); - false - } - _ => false, - }; - if changed { - self.persist_settings_change(); - return; - } - - let ClickResult::SourceButton(item_index) = result else { - return; - }; - let button_rect = input.popup_button_rect(&page, item_index, self.scroll_y); - let (win_w, win_h) = self.logical_window_size(); - let popup = match action { - BehaviorAction::Language => { - let languages = available_langs(); - let current = current_lang(); - let selected = languages - .iter() - .position(|entry| entry.code == current) - .unwrap_or(0); - PopupState::new( - select_language, - button_rect, - languages.iter().map(|entry| entry.name.clone()).collect(), - languages.iter().map(|entry| entry.code.clone()).collect(), - selected, - win_w, - win_h, - ) - } - _ => return, - }; - self.show_popup(popup); + self.handle_setting_click(&page, &result, input); } } - -fn select_language(app: &mut SettingsApp, value: &str) { - app.config.language = value.to_string(); - set_lang(value); - crate::ui::widget::expanded::calendar::clear_calendar_text_cache(); -} diff --git a/src/window/settings/pages/general/effects.rs b/src/window/settings/pages/general/effects.rs deleted file mode 100644 index ee59b661..00000000 --- a/src/window/settings/pages/general/effects.rs +++ /dev/null @@ -1,150 +0,0 @@ -use crate::utils::settings_ui::ClickResult; -use crate::window::settings::PopupState; -use winisland_core::config::AppConfigField; -use winisland_core::i18n::tr; -use winisland_render::text::FontManager; - -use super::super::{PageInput, SettingsPage}; -use super::SettingsApp; - -#[derive(Clone, Copy)] -pub(super) enum EffectsAction { - IslandStyle, - CustomFont, -} - -impl SettingsApp { - pub(super) fn build_effects_page(&self) -> SettingsPage { - let mut page = SettingsPage::new(); - page.section(tr("section_effects")); - page.group_start(); - page.setting(&self.config, AppConfigField::SettingsTheme, true); - page.setting(&self.config, AppConfigField::MotionBlur, true); - page.group_end(); - page.group_start(); - let host_backdrop = crate::platform::capabilities().host_backdrop; - let style = if !host_backdrop && self.config.island_style == "glass" { - "dynamic" - } else { - self.config.island_style.as_str() - }; - let mut styles = vec![(tr("style_default"), style == "default")]; - if host_backdrop { - styles.push((tr("style_glass"), style == "glass")); - } - styles.push((tr("style_dynamic"), style == "dynamic")); - styles.push((tr("style_liquid_glass"), style == "liquid")); - page.row_source(tr("island_style"), styles, true, EffectsAction::IslandStyle); - if style == "glass" { - page.setting(&self.config, AppConfigField::GlassBlur, true); - } - if style == "liquid" { - page.setting(&self.config, AppConfigField::LiquidGlassBlur, true); - page.setting(&self.config, AppConfigField::LiquidGlassRefraction, true); - page.setting(&self.config, AppConfigField::LiquidGlassDispersion, true); - page.setting(&self.config, AppConfigField::LiquidGlassTint, true); - page.setting(&self.config, AppConfigField::LiquidGlassSiri, true); - page.row_label(tr("liquid_glass_siri_note")); - page.setting(&self.config, AppConfigField::LiquidGlassCompatCapture, true); - page.row_label(tr(if self.config.liquid_glass_compat_capture { - "liquid_glass_compat_capture_note" - } else { - "style_liquid_note" - })); - } - if !host_backdrop { - page.row_label(tr("platform_unavailable")); - } - page.row_font( - tr("custom_font"), - tr("font_select"), - self.config - .custom_font_path - .as_ref() - .map(|_| tr("font_reset")), - EffectsAction::CustomFont, - ); - page.group_end(); - page - } - - pub(super) fn handle_effects_click(&mut self, input: PageInput) { - let page = self.build_effects_page(); - let result = input.hit_test(&page); - if self.handle_setting_click(&page, &result, input) { - return; - } - let Some(action) = page.action(&result).copied() else { - return; - }; - - let changed = match (action, &result) { - (EffectsAction::CustomFont, ClickResult::FontSelect(_)) => { - let Some(path) = rfd::FileDialog::new() - .add_filter("Fonts", &["ttf", "otf"]) - .pick_file() - else { - return; - }; - self.config.custom_font_path = Some(path.to_string_lossy().into_owned()); - FontManager::global().set_custom_font_path(self.config.custom_font_path.as_deref()); - true - } - (EffectsAction::CustomFont, ClickResult::FontReset(_)) => { - self.config.custom_font_path = None; - FontManager::global().set_custom_font_path(None); - true - } - _ => false, - }; - if changed { - self.persist_settings_change(); - return; - } - - let ClickResult::SourceButton(item_index) = result else { - return; - }; - let button_rect = input.popup_button_rect(&page, item_index, self.scroll_y); - let (win_w, win_h) = self.logical_window_size(); - let popup = match action { - EffectsAction::IslandStyle => { - let host_backdrop = crate::platform::capabilities().host_backdrop; - let mut labels = vec![tr("style_default")]; - let mut values = vec!["default".to_string()]; - if host_backdrop { - labels.push(tr("style_glass")); - values.push("glass".to_string()); - } - labels.push(tr("style_dynamic")); - values.push("dynamic".to_string()); - labels.push(tr("style_liquid_glass")); - values.push("liquid".to_string()); - let selected = values - .iter() - .position(|value| value == &self.config.island_style) - .unwrap_or(values.len() - 1); - PopupState::new( - select_island_style, - button_rect, - labels, - values, - selected, - win_w, - win_h, - ) - } - _ => return, - }; - self.show_popup(popup); - } -} - -fn select_island_style(app: &mut SettingsApp, value: &str) { - app.config.island_style = if value == "glass" && !crate::platform::capabilities().host_backdrop - { - "dynamic".to_string() - } else { - value.to_string() - }; -} diff --git a/src/window/settings/pages/general/layout.rs b/src/window/settings/pages/general/layout.rs new file mode 100644 index 00000000..1d66b4d1 --- /dev/null +++ b/src/window/settings/pages/general/layout.rs @@ -0,0 +1,83 @@ +use crate::utils::settings_ui::ClickResult; +use crate::window::settings::PopupState; +use winisland_core::config::AppConfigField; +use winisland_core::i18n::tr; + +use super::super::{PageInput, SettingsPage}; +use super::SettingsApp; + +#[derive(Clone, Copy)] +pub(crate) enum LayoutAction { + Monitor, +} + +impl SettingsApp { + pub(crate) fn build_layout_page(&self) -> SettingsPage { + let mut page = SettingsPage::new(); + page.section(tr("section_position")); + page.group_start(); + let monitors = Self::get_monitor_list(); + let selected_monitor = + (self.config.monitor_index as usize).min(monitors.len().saturating_sub(1)); + page.row_source( + tr("monitor"), + monitors + .into_iter() + .enumerate() + .map(|(index, name)| (name, index == selected_monitor)) + .collect(), + true, + LayoutAction::Monitor, + ); + page.setting(&self.config, AppConfigField::PositionXOffset, true); + page.setting(&self.config, AppConfigField::PositionYOffset, true); + page.group_end(); + + page.section(tr("section_compact_size")); + page.group_start(); + page.setting(&self.config, AppConfigField::CompactScale, true); + page.setting(&self.config, AppConfigField::BaseWidth, true); + page.setting(&self.config, AppConfigField::BaseHeight, true); + page.group_end(); + + page.section(tr("section_expanded_size")); + page.group_start(); + page.setting(&self.config, AppConfigField::ExpandedScale, true); + page.setting(&self.config, AppConfigField::ExpandedWidth, true); + page.setting(&self.config, AppConfigField::ExpandedHeight, true); + page.group_end(); + page + } + + pub(crate) fn handle_layout_click(&mut self, input: PageInput) { + let page = self.build_layout_page(); + let result = input.hit_test(&page); + if self.handle_setting_click(&page, &result, input) { + return; + } + let (Some(LayoutAction::Monitor), ClickResult::SourceButton(item_index)) = + (page.action(&result), result) + else { + return; + }; + let button_rect = input.popup_button_rect(&page, item_index, self.scroll_y); + let (win_w, win_h) = self.logical_window_size(); + + let monitors = Self::get_monitor_list(); + let selected = (self.config.monitor_index as usize).min(monitors.len().saturating_sub(1)); + let values = (0..monitors.len()).map(|index| index.to_string()).collect(); + self.show_popup(PopupState::new( + select_monitor, + button_rect, + monitors, + values, + selected, + win_w, + win_h, + )); + } +} + +fn select_monitor(app: &mut SettingsApp, value: &str) { + app.config.monitor_index = value.parse().unwrap_or(0); +} diff --git a/src/window/settings/pages/general/system.rs b/src/window/settings/pages/general/system.rs new file mode 100644 index 00000000..16b45834 --- /dev/null +++ b/src/window/settings/pages/general/system.rs @@ -0,0 +1,160 @@ +use crate::utils::settings_ui::ClickResult; +use crate::window::settings::PopupState; +use winisland_core::config::{AppConfig, AppConfigField}; +use winisland_core::i18n::{available_langs, current_lang, init_i18n, set_lang, tr}; + +use super::super::{PageInput, SettingsPage}; +use super::SettingsApp; + +#[derive(Clone, Copy)] +pub(crate) enum GeneralAction { + AutoStart, + Language, + CheckUpdatesNow, + ResetDefaults, + HideIsland, + Exit, +} + +impl SettingsApp { + pub(crate) fn build_general_page(&self) -> SettingsPage { + let caps = crate::platform::capabilities(); + let mut page = SettingsPage::new(); + page.section(tr("section_system")); + page.group_start(); + page.row_switch( + tr("start_boot"), + self.config.auto_start, + caps.autostart, + GeneralAction::AutoStart, + ); + let language = current_lang(); + page.row_source( + tr("language"), + available_langs() + .iter() + .map(|entry| (entry.name.clone(), entry.code == language)) + .collect(), + true, + GeneralAction::Language, + ); + page.setting(&self.config, AppConfigField::SettingsTheme, true); + if !caps.autostart { + page.row_label(tr("platform_unavailable")); + } + if !caps.tray { + page.row_button( + tr("tray_hide"), + tr("tray_hide"), + true, + GeneralAction::HideIsland, + ); + page.row_button(tr("tray_exit"), tr("tray_exit"), true, GeneralAction::Exit); + } + page.group_end(); + + page.section(tr("section_updates")); + page.group_start(); + page.setting(&self.config, AppConfigField::CheckForUpdates, true); + if self.config.check_for_updates { + page.setting(&self.config, AppConfigField::UpdateChannel, true); + page.setting(&self.config, AppConfigField::UpdateCheckInterval, true); + } + page.row_button( + tr("check_updates_manual"), + tr("update_check_btn"), + true, + GeneralAction::CheckUpdatesNow, + ); + page.group_end(); + page.spacer(10.0); + page.center_link( + tr("reset_defaults"), + self.theme().danger, + GeneralAction::ResetDefaults, + ); + page + } + + pub(crate) fn handle_general_click(&mut self, input: PageInput) { + let page = self.build_general_page(); + let result = input.hit_test(&page); + if self.handle_setting_click(&page, &result, input) { + return; + } + let Some(action) = page.action(&result).copied() else { + return; + }; + + let changed = match (action, &result) { + (GeneralAction::AutoStart, ClickResult::Switch(_)) => { + let enabled = !self.config.auto_start; + match crate::platform::shell().set_autostart(enabled) { + Ok(()) => { + self.config.auto_start = enabled; + true + } + Err(error) => { + crate::platform::update_capabilities(|caps| caps.autostart = false); + log::warn!("Autostart is unavailable: {error}"); + false + } + } + } + (GeneralAction::HideIsland, ClickResult::RowButton(_)) => { + self.plugin_request = + Some(crate::window::settings::PluginSettingsRequest::HideIsland); + false + } + (GeneralAction::Exit, ClickResult::RowButton(_)) => { + self.plugin_request = Some(crate::window::settings::PluginSettingsRequest::Exit); + false + } + (GeneralAction::ResetDefaults, ClickResult::CenterLink(_)) => { + self.config = AppConfig::default(); + init_i18n(&self.config.language); + crate::ui::widget::expanded::calendar::clear_calendar_text_cache(); + crate::utils::fonts::apply(&self.config); + true + } + (GeneralAction::CheckUpdatesNow, ClickResult::RowButton(_)) => { + crate::utils::updater::check_updates_manually(); + false + } + _ => false, + }; + if changed { + self.persist_settings_change(); + return; + } + + let (GeneralAction::Language, ClickResult::SourceButton(item_index)) = (action, result) + else { + return; + }; + let button_rect = input.popup_button_rect(&page, item_index, self.scroll_y); + let (win_w, win_h) = self.logical_window_size(); + let languages = available_langs(); + let current = current_lang(); + let selected = languages + .iter() + .position(|entry| entry.code == current) + .unwrap_or(0); + self.show_popup(PopupState::new( + select_language, + button_rect, + languages.iter().map(|entry| entry.name.clone()).collect(), + languages.iter().map(|entry| entry.code.clone()).collect(), + selected, + win_w, + win_h, + )); + } +} + +fn select_language(app: &mut SettingsApp, value: &str) { + app.config.language = value.to_string(); + set_lang(value); + crate::ui::widget::expanded::calendar::clear_calendar_text_cache(); + crate::utils::fonts::apply(&app.config); +} diff --git a/src/window/settings/pages/mod.rs b/src/window/settings/pages/mod.rs index 8b51557e..51e57947 100644 --- a/src/window/settings/pages/mod.rs +++ b/src/window/settings/pages/mod.rs @@ -2,8 +2,8 @@ use winisland_core::config::AppConfigField; use winisland_render::{Image, Rect, Rgba}; use crate::utils::settings_ui::items::{ - CONTENT_PADDING, GROUP_INNER_PAD, POPUP_BTN_H, POPUP_BTN_W, ROW_HEIGHT, STEPPER_BTN_SIZE, - STEPPER_GAP, STEPPER_VALUE_W, SettingsItem, + CONTENT_PADDING, GROUP_INNER_PAD, POPUP_BTN_H, POPUP_BTN_W, ROW_HEIGHT, SettingsItem, + stepper_rects, }; use crate::utils::settings_ui::{ClickResult, hit_test}; @@ -141,23 +141,6 @@ impl SettingsPage { self.push(SettingsItem::Spacer { height }); } - pub(crate) fn row_font( - &mut self, - label: String, - btn_label: String, - reset_label: Option, - action: A, - ) { - self.push_action( - SettingsItem::RowFontPicker { - label, - btn_label, - reset_label, - }, - action, - ); - } - pub(crate) fn row_label(&mut self, label: String) { self.push(SettingsItem::RowLabel { label }); } @@ -265,16 +248,12 @@ impl PageInput { scroll_y: f32, ) -> Rect { let content_w = self.width - CONTENT_PADDING * 2.0; - let button_x = CONTENT_PADDING + content_w - GROUP_INNER_PAD - STEPPER_BTN_SIZE; - let value_x = button_x - STEPPER_GAP - STEPPER_VALUE_W; - let value_y = - self.row_top(page, item_index, scroll_y) + (ROW_HEIGHT - STEPPER_BTN_SIZE) / 2.0; - Rect::from_xywh( - SIDEBAR_W + value_x, - value_y, - STEPPER_VALUE_W, - STEPPER_BTN_SIZE, - ) + let (value, _, _) = stepper_rects( + self.row_top(page, item_index, scroll_y), + ROW_HEIGHT, + content_w, + ); + value.offset(winisland_render::Vec2::new(SIDEBAR_W, 0.0)) } } diff --git a/src/window/settings/pages/plugins.rs b/src/window/settings/pages/plugins.rs index 957b8e76..ca776d08 100644 --- a/src/window/settings/pages/plugins.rs +++ b/src/window/settings/pages/plugins.rs @@ -1,12 +1,13 @@ use std::cell::RefCell; use std::collections::HashMap; use winisland_render::FontStyle; +use winisland_render::FontWeight; use winisland_render::{Image, ImageOptions, Mipmapped, Painter, Point, Radius, Rect, Rgba, Vec2}; use crate::plugin::inventory::InstalledPlugin; use crate::utils::color::SettingsTheme; use crate::utils::color::settings_color; -use crate::utils::settings_ui::items::{CONTENT_PADDING, SettingsItem}; +use crate::utils::settings_ui::items::{CONTENT_PADDING, SettingsItem, TOGGLE_H, TOGGLE_W}; use crate::utils::settings_ui::{SettingsPainter, ellipsize_text}; use winisland_core::i18n::tr; use winisland_plugin_package::marketplace::MarketplacePlugin; @@ -289,7 +290,7 @@ impl SettingsApp { ); let label = restart.then(|| tr("plugin_restart_now")); let label_w = label.as_ref().map_or(0.0, |label| { - fm.measure_text_cached(label, 12.0, FontStyle::bold()) + fm.measure_text_cached(label, 12.0, FontWeight::MEDIUM) }); let message = ellipsize_text( fm, @@ -302,7 +303,7 @@ impl SettingsApp { &message, (status.left + 14.0, status.top + 29.0), 12.0, - false, + FontWeight::REGULAR, theme.text_pri, ); if let Some(label) = label { @@ -310,7 +311,7 @@ impl SettingsApp { &label, (status.right - label_w - 14.0, status.top + 29.0), 12.0, - true, + FontWeight::MEDIUM, theme.accent, ); } @@ -551,7 +552,7 @@ fn draw_plugin_tabs(painter: Painter<'_>, theme: &SettingsTheme, active: PluginP ); painter.fill_round_rect( background, - Radius::uniform(9.0), + Radius::uniform(8.0), settings_color(theme.control_bg), ); for tab in [PluginPageTab::Installed, PluginPageTab::Marketplace] { @@ -565,13 +566,13 @@ fn draw_plugin_tabs(painter: Painter<'_>, theme: &SettingsTheme, active: PluginP rect.height() - 4.0, ), Radius::uniform(7.0), - settings_color(theme.card_highlight), + settings_color(theme.accent), ); } let text_color = if tab == active { - theme.text_pri + theme.selection_text } else { - theme.text_sec + theme.text_pri }; SettingsPainter::new(painter).centered_text( &tr(match tab { @@ -580,7 +581,11 @@ fn draw_plugin_tabs(painter: Painter<'_>, theme: &SettingsTheme, active: PluginP }), (rect.center_x(), rect.top + 21.0), 12.0, - tab == active, + if tab == active { + FontWeight::MEDIUM + } else { + FontWeight::REGULAR + }, text_color, ); } @@ -606,7 +611,7 @@ fn draw_section_title(painter: Painter<'_>, theme: &SettingsTheme, title: String &title, (CONTENT_PADDING + 4.0, PLUGIN_LIST_TOP + 18.0), 13.0, - true, + FontWeight::SEMIBOLD, theme.text_pri, ); PLUGIN_LIST_TOP + 30.0 @@ -651,12 +656,12 @@ fn draw_card_text( ) { let fm = FontManager::global(); let available = (card.width() - reserved_width).max(20.0); - let name = ellipsize_text(fm, name, 14.0, FontStyle::bold(), available); + let name = ellipsize_text(fm, name, 14.0, FontWeight::SEMIBOLD, available); SettingsPainter::new(painter).text( &name, (card.left + 76.0, card.top + 29.0), 14.0, - true, + FontWeight::SEMIBOLD, theme.text_pri, ); let subtitle = ellipsize_text(fm, subtitle, 11.5, FontStyle::normal(), available); @@ -664,7 +669,7 @@ fn draw_card_text( &subtitle, (card.left + 76.0, card.top + 51.0), 11.5, - false, + FontWeight::REGULAR, theme.text_sec, ); } @@ -691,7 +696,7 @@ fn draw_marketplace_action( label, (rect.center_x(), rect.top + 19.0), 11.0, - true, + FontWeight::MEDIUM, text_color, ); } @@ -701,7 +706,7 @@ fn marketplace_action_rect(card: Rect, width: f32) -> Rect { } fn action_button_width(label: &str) -> f32 { - (FontManager::global().measure_text_cached(label, 11.0, FontStyle::bold()) + 24.0) + (FontManager::global().measure_text_cached(label, 11.0, FontWeight::MEDIUM) + 24.0) .clamp(64.0, 112.0) } @@ -721,7 +726,13 @@ fn draw_empty_state( detail: Option<&str>, ) { let text_painter = SettingsPainter::new(painter); - text_painter.centered_text(title, (width / 2.0, baseline), 13.0, false, theme.text_sec); + text_painter.centered_text( + title, + (width / 2.0, baseline), + 13.0, + FontWeight::REGULAR, + theme.text_sec, + ); if let Some(detail) = detail { let detail = ellipsize_text( FontManager::global(), @@ -735,7 +746,7 @@ fn draw_empty_state( &detail, (width / 2.0, baseline + 21.0), 10.5, - false, + FontWeight::REGULAR, detail_color, ); } @@ -752,7 +763,7 @@ fn draw_retry_button(painter: Painter<'_>, theme: &SettingsTheme, width: f32, y: &tr("plugin_marketplace_retry"), (rect.center_x(), rect.top + 18.0), 11.0, - true, + FontWeight::MEDIUM, theme.accent, ); } @@ -762,20 +773,13 @@ fn retry_button_rect(width: f32, y: f32) -> Rect { } fn draw_toggle(painter: Painter<'_>, theme: &SettingsTheme, enabled: bool, x: f32, y: f32) { - let fill = if enabled { - theme.toggle_on - } else { - theme.toggle_off - }; - painter.fill_round_rect( - Rect::from_xywh(x, y, 36.0, 20.0), - Radius::uniform(10.0), - settings_color(fill), - ); - painter.fill_circle( - Point::new(x + if enabled { 26.0 } else { 10.0 }, y + 10.0), - 8.0, - Rgba::WHITE, + crate::utils::settings_ui::draw_switch( + painter, + x + 37.0 - TOGGLE_W, + y + 10.0 - TOGGLE_H / 2.0, + f32::from(u8::from(enabled)), + true, + theme, ); } @@ -840,7 +844,7 @@ pub(super) fn draw_plugin_icon_data( &initial, (rect.center_x(), rect.center_y() + rect.height() * 0.16), rect.height() * 0.44, - true, + FontWeight::SEMIBOLD, Rgba::WHITE, ); } diff --git a/src/window/settings/pages/plugins/detail.rs b/src/window/settings/pages/plugins/detail.rs index 463b3e18..a01aab3f 100644 --- a/src/window/settings/pages/plugins/detail.rs +++ b/src/window/settings/pages/plugins/detail.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use winisland_render::FontWeight; use winisland_render::{Painter, Point, Radius, Rect, Rgba, Vec2}; use crate::plugin::inventory::InstalledPlugin; @@ -291,10 +292,16 @@ impl SettingsApp { fm, &plugin.name(), 17.0, - winisland_render::FontStyle::bold(), + FontWeight::SEMIBOLD, name_width.max(30.0), ); - SettingsPainter::new(painter).text(&name, (info_x, y + 22.0), 17.0, true, theme.text_pri); + SettingsPainter::new(painter).text( + &name, + (info_x, y + 22.0), + 17.0, + FontWeight::SEMIBOLD, + theme.text_pri, + ); let subtitle = ellipsize_text( fm, &format!("{} · v{}", plugin.author(), plugin.version()), @@ -306,7 +313,7 @@ impl SettingsApp { &subtitle, (info_x, y + 43.0), 11.5, - false, + FontWeight::REGULAR, theme.text_sec, ); @@ -324,7 +331,7 @@ impl SettingsApp { &tr("plugin_builtin"), (panel_x + DETAIL_PADDING, DETAIL_ACTION_Y + 18.0), 11.0, - true, + FontWeight::MEDIUM, theme.text_sec, ); } else { @@ -355,7 +362,7 @@ impl SettingsApp { &tr("plugin_open_github"), (button.center_x(), button.top + 18.0), 11.0, - true, + FontWeight::MEDIUM, settings_color(theme.accent), ); } @@ -386,7 +393,7 @@ impl SettingsApp { &reason, (warning.left + 10.0, warning.top + 25.0), 11.0, - false, + FontWeight::REGULAR, Rgba::from_rgb(255, 69, 58), ); content_y += 54.0; @@ -453,7 +460,7 @@ fn draw_detail_action( &label, (rect.center_x(), rect.top + 18.0), 11.0, - true, + FontWeight::MEDIUM, settings_color(text_color), ); } @@ -470,7 +477,7 @@ fn draw_uninstall_button(painter: Painter<'_>, panel_x: f32, has_github: bool, c }), (rect.center_x(), rect.top + 18.0), 11.0, - true, + FontWeight::MEDIUM, Rgba::from_rgb(255, 69, 58), ); } @@ -501,26 +508,22 @@ fn draw_panel_header(painter: Painter<'_>, theme: &SettingsTheme, panel_x: f32) &tr("plugin_details"), (panel_x + DETAIL_PADDING, 37.0), 15.0, - true, + FontWeight::SEMIBOLD, settings_color(theme.text_pri), ); } fn toggle_rect(panel_x: f32) -> Rect { Rect::from_xywh( - panel_x + DETAIL_W - DETAIL_PADDING - 42.0, + panel_x + DETAIL_W - DETAIL_PADDING - 48.0, DETAIL_HEADER_Y - 4.0, - 48.0, + 54.0, 32.0, ) } fn detail_action_rect(panel_x: f32, label: &str) -> Rect { - let width = (FontManager::global().measure_text_cached( - label, - 11.0, - winisland_render::FontStyle::bold(), - ) + 24.0) + let width = (FontManager::global().measure_text_cached(label, 11.0, FontWeight::MEDIUM) + 24.0) .clamp(64.0, 112.0); Rect::from_xywh( panel_x + DETAIL_PADDING + 116.0 + ACTION_GAP, diff --git a/src/window/settings/pages/plugins/markdown.rs b/src/window/settings/pages/plugins/markdown.rs index 6e511a80..902b3e00 100644 --- a/src/window/settings/pages/plugins/markdown.rs +++ b/src/window/settings/pages/plugins/markdown.rs @@ -5,7 +5,9 @@ use std::rc::Rc; use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use winisland_render::text::{DrawTextCachedParams, FontManager}; -use winisland_render::{FontStyle, Painter, Point, Radius, Rect, Rgba, StrokeCap, Vec2}; +use winisland_render::{ + FontStyle, FontWeight, Painter, Point, Radius, Rect, Rgba, StrokeCap, Vec2, +}; const BODY_SIZE: f32 = 12.0; const LINE_HEIGHT: f32 = 18.0; @@ -618,7 +620,7 @@ impl<'a> LayoutBuilder<'a> { let mut style = span.style.clone(); style.bold |= force_bold; let font_style = if style.bold { - FontStyle::bold() + FontStyle::from(FontWeight::SEMIBOLD) } else { FontStyle::normal() }; @@ -755,7 +757,7 @@ pub(crate) fn render(params: MarkdownRenderParams<'_>) -> MarkdownRenderResult { text, *size, if style.bold { - FontStyle::bold() + FontStyle::from(FontWeight::SEMIBOLD) } else { FontStyle::normal() }, @@ -893,7 +895,11 @@ fn draw_text( x: 0.0, y: 0.0, size, - bold: style.bold, + weight: if style.bold { + FontWeight::SEMIBOLD + } else { + FontWeight::REGULAR + }, color, blur: None, }); @@ -905,7 +911,11 @@ fn draw_text( x, y: baseline, size, - bold: style.bold, + weight: if style.bold { + FontWeight::SEMIBOLD + } else { + FontWeight::REGULAR + }, color, blur: None, }); diff --git a/src/window/settings/pages/schema.rs b/src/window/settings/pages/schema.rs index 01d2b4b1..d487b5ba 100644 --- a/src/window/settings/pages/schema.rs +++ b/src/window/settings/pages/schema.rs @@ -99,6 +99,7 @@ impl SettingsApp { } match field { AppConfigField::SettingsTheme => self.update_theme(), + AppConfigField::FontWeight => crate::utils::fonts::apply(&self.config), AppConfigField::LyricsMode if self.config.lyrics_mode == "lrc" => { self.config.show_lyrics = true; } diff --git a/src/window/settings/popup.rs b/src/window/settings/popup.rs index 8272fabb..9f83898b 100644 --- a/src/window/settings/popup.rs +++ b/src/window/settings/popup.rs @@ -1,3 +1,5 @@ +use std::time::{Duration, Instant}; + use winisland_render::Rect; use crate::utils::settings_ui::items::{POPUP_ITEM_H, POPUP_MENU_PAD}; @@ -7,6 +9,14 @@ use super::SettingsApp; pub(crate) type PopupSelectHandler = fn(&mut SettingsApp, &str); +const POPUP_MAX_VISIBLE_ITEMS: usize = 12; +const POPUP_MAX_TEXT_WIDTH: f32 = 260.0; +const TYPE_AHEAD_RESET: Duration = Duration::from_millis(900); +const POPUP_SCROLL_GUTTER: f32 = 12.0; +const POPUP_THUMB_WIDTH: f32 = 4.0; +const POPUP_THUMB_MIN: f32 = 24.0; +const POPUP_THUMB_INSET: f32 = 4.0; + pub(crate) struct PopupState { pub(crate) on_select: PopupSelectHandler, pub(crate) button_rect: Rect, @@ -15,6 +25,11 @@ pub(crate) struct PopupState { pub(crate) values: Vec, pub(crate) selected_idx: usize, pub(crate) hover_idx: Option, + pub(crate) scroll: f32, + pub(crate) keyboard_anchor: Option<(f32, f32)>, + pub(crate) scroll_drag: Option, + typed: String, + typed_at: Option, } impl PopupState { @@ -33,13 +48,24 @@ impl PopupState { .map(|option| { font_manager.measure_text_cached( option, - 12.0, + 13.0, winisland_render::FontStyle::normal(), ) }) - .fold(120.0_f32, f32::max); - let menu_width = content_width + 36.0; - let menu_height = options.len() as f32 * POPUP_ITEM_H + POPUP_MENU_PAD * 2.0; + .fold(120.0_f32, f32::max) + .min(POPUP_MAX_TEXT_WIDTH); + let max_items_height = (window_height - 20.0 - POPUP_MENU_PAD * 2.0).max(POPUP_ITEM_H); + let visible_items = options + .len() + .min(POPUP_MAX_VISIBLE_ITEMS) + .min((max_items_height / POPUP_ITEM_H).floor().max(1.0) as usize); + let gutter = if options.len() > visible_items { + POPUP_SCROLL_GUTTER + } else { + 0.0 + }; + let menu_width = content_width + 36.0 + gutter; + let menu_height = visible_items as f32 * POPUP_ITEM_H + POPUP_MENU_PAD * 2.0; let max_x = (window_width - menu_width - 10.0).max(0.0); let max_y = (window_height - menu_height - 10.0).max(0.0); let menu_x = (button_rect.right - menu_width).clamp(0.0, max_x); @@ -52,7 +78,7 @@ impl PopupState { below_y.min(max_y) }; - Self { + let mut popup = Self { on_select, button_rect, menu_rect: Rect::from_xywh(menu_x, menu_y, menu_width, menu_height), @@ -60,35 +86,194 @@ impl PopupState { values, selected_idx, hover_idx: None, - } + scroll: 0.0, + keyboard_anchor: None, + scroll_drag: None, + typed: String::new(), + typed_at: None, + }; + let centered = (selected_idx as f32 + 0.5) * POPUP_ITEM_H - popup.visible_height() / 2.0; + popup.scroll_by(centered); + popup } pub(crate) fn menu_rect(&self) -> Rect { self.menu_rect } + pub(crate) fn inner_rect(&self) -> Rect { + Rect::from_ltrb( + self.menu_rect.left, + self.menu_rect.top + POPUP_MENU_PAD, + self.menu_rect.right, + self.menu_rect.bottom - POPUP_MENU_PAD, + ) + } + + fn visible_height(&self) -> f32 { + self.menu_rect.height() - POPUP_MENU_PAD * 2.0 + } + + pub(crate) fn max_scroll(&self) -> f32 { + (self.options.len() as f32 * POPUP_ITEM_H - self.visible_height()).max(0.0) + } + + pub(crate) fn scroll_by(&mut self, delta: f32) -> bool { + let scroll = (self.scroll + delta).clamp(0.0, self.max_scroll()); + let changed = (scroll - self.scroll).abs() > f32::EPSILON; + self.scroll = scroll; + changed + } + + pub(crate) fn type_ahead(&mut self, text: &str) -> bool { + let now = Instant::now(); + if self + .typed_at + .is_none_or(|typed_at| now.duration_since(typed_at) > TYPE_AHEAD_RESET) + { + self.typed.clear(); + } + self.typed_at = Some(now); + self.typed.push_str(&text.to_lowercase()); + let Some(index) = self + .options + .iter() + .position(|option| option.to_lowercase().starts_with(&self.typed)) + else { + return false; + }; + self.highlight(index); + true + } + + pub(crate) fn move_highlight(&mut self, step: isize) { + let last = self.options.len().saturating_sub(1); + let index = match self.hover_idx { + Some(index) => index.saturating_add_signed(step).min(last), + None if step < 0 => last, + None => 0, + }; + self.highlight(index); + } + + fn highlight(&mut self, index: usize) { + self.hover_idx = Some(index); + let top = index as f32 * POPUP_ITEM_H; + let bottom = top + POPUP_ITEM_H; + if top < self.scroll { + self.scroll_by(top - self.scroll); + } else if bottom > self.scroll + self.visible_height() { + self.scroll_by(bottom - self.scroll - self.visible_height()); + } + } + + fn gutter(&self) -> f32 { + if self.max_scroll() > 0.0 { + POPUP_SCROLL_GUTTER + } else { + 0.0 + } + } + pub(crate) fn item_rect(&self, index: usize) -> Rect { - let y = self.menu_rect.top + POPUP_MENU_PAD + index as f32 * POPUP_ITEM_H; + let y = self.menu_rect.top + POPUP_MENU_PAD + index as f32 * POPUP_ITEM_H - self.scroll; Rect::from_xywh( self.menu_rect.left + POPUP_MENU_PAD, y, - self.menu_rect.width() - POPUP_MENU_PAD * 2.0, + self.menu_rect.width() - POPUP_MENU_PAD * 2.0 - self.gutter(), POPUP_ITEM_H, ) } + fn scroll_track(&self) -> Option { + (self.max_scroll() > 0.0).then(|| { + let inner = self.inner_rect(); + Rect::from_ltrb( + self.menu_rect.right - POPUP_MENU_PAD - POPUP_SCROLL_GUTTER, + inner.top, + self.menu_rect.right, + inner.bottom, + ) + }) + } + + pub(crate) fn thumb_rect(&self) -> Option { + let track = self.scroll_track()?; + let max_scroll = self.max_scroll(); + let travel_h = track.height() - POPUP_THUMB_INSET * 2.0; + let visible = self.visible_height(); + let height = (travel_h * visible / (visible + max_scroll)) + .max(POPUP_THUMB_MIN) + .min(travel_h); + let center_x = self.menu_rect.right - POPUP_MENU_PAD - POPUP_SCROLL_GUTTER / 2.0; + Some(Rect::from_xywh( + center_x - POPUP_THUMB_WIDTH / 2.0, + track.top + POPUP_THUMB_INSET + (travel_h - height) * self.scroll / max_scroll, + POPUP_THUMB_WIDTH, + height, + )) + } + + pub(crate) fn scrollbar_hovered(&self, mouse_x: f32, mouse_y: f32) -> bool { + self.scroll_track() + .is_some_and(|track| track.contains(winisland_render::Point::new(mouse_x, mouse_y))) + } + + /// Grabs the scroll thumb, or jumps it under the pointer when the track is pressed. + pub(crate) fn begin_scroll_drag(&mut self, mouse_x: f32, mouse_y: f32) -> bool { + let Some(thumb) = self + .thumb_rect() + .filter(|_| self.scrollbar_hovered(mouse_x, mouse_y)) + else { + return false; + }; + self.scroll_drag = Some(if mouse_y >= thumb.top && mouse_y <= thumb.bottom { + mouse_y - thumb.top + } else { + thumb.height() / 2.0 + }); + self.drag_scroll(mouse_y); + true + } + + pub(crate) fn drag_scroll(&mut self, mouse_y: f32) -> bool { + let (Some(grab), Some(track), Some(thumb)) = + (self.scroll_drag, self.scroll_track(), self.thumb_rect()) + else { + return false; + }; + let travel = track.height() - POPUP_THUMB_INSET * 2.0 - thumb.height(); + if travel <= 0.0 { + return false; + } + let ratio = ((mouse_y - grab - track.top - POPUP_THUMB_INSET) / travel).clamp(0.0, 1.0); + let scroll = ratio * self.max_scroll(); + let changed = (scroll - self.scroll).abs() > f32::EPSILON; + self.scroll = scroll; + changed + } + + pub(crate) fn end_scroll_drag(&mut self) -> bool { + self.scroll_drag.take().is_some() + } + + pub(crate) fn visible_items(&self) -> std::ops::Range { + let first = (self.scroll / POPUP_ITEM_H).floor() as usize; + let last = ((self.scroll + self.visible_height()) / POPUP_ITEM_H).ceil() as usize; + first.min(self.options.len())..last.min(self.options.len()) + } + pub(crate) fn hit_test_item(&self, mouse_x: f32, mouse_y: f32) -> Option { - let inner_top = self.menu_rect.top + POPUP_MENU_PAD; - let inner_bottom = self.menu_rect.bottom - POPUP_MENU_PAD; - if mouse_x < self.menu_rect.left - || mouse_x > self.menu_rect.right - || mouse_y < inner_top - || mouse_y > inner_bottom + let inner = self.inner_rect(); + if mouse_x < inner.left + || mouse_x > inner.right - self.gutter() + || mouse_y < inner.top + || mouse_y > inner.bottom { return None; } - let index = ((mouse_y - inner_top) / POPUP_ITEM_H).floor() as usize; + let index = ((mouse_y - inner.top + self.scroll) / POPUP_ITEM_H).floor() as usize; (index < self.options.len()).then_some(index) } } diff --git a/src/window/settings/renderer.rs b/src/window/settings/renderer.rs index 4aa76745..0b38a154 100644 --- a/src/window/settings/renderer.rs +++ b/src/window/settings/renderer.rs @@ -11,13 +11,16 @@ use winisland_core::i18n::tr; use winisland_plugin_host::draw::replay::PreparedFrame; use winisland_render::Renderer; use winisland_render::text::{DrawTextCachedParams, FontManager}; -use winisland_render::{Painter, Path, Point, Radius, Rect, Rgba, StrokeCap, StrokeJoin, Vec2}; +use winisland_render::{ + FontWeight, Painter, Path, Point, Radius, Rect, Rgba, StrokeCap, StrokeJoin, Vec2, +}; use super::{ PAGE_NAV_GAP, PAGE_NAV_HEIGHT, PAGE_NAV_WIDTH, PAGE_NAV_X, PAGE_NAV_Y, PLUGINS_PAGE_INDEX, POPUP_MENU_R, POPUP_OPACITY_KEY, SETTINGS_HEADER_H, SIDEBAR_W, SettingsApp, WIDGETS_PAGE_INDEX, - WINDOW_RADIUS, WidgetEditorMode, + WidgetEditorMode, }; +use crate::utils::settings_ui::chrome::{draw_chevron, draw_window_base, sidebar_panel}; impl SettingsApp { fn draw_music_notice(&self, painter: Painter<'_>, width: f32) { @@ -48,7 +51,7 @@ impl SettingsApp { x: 38.0, y: top + 32.0, size: 14.0, - bold: true, + weight: FontWeight::SEMIBOLD, color: text_color, blur: None, }); @@ -69,7 +72,7 @@ impl SettingsApp { x: 38.0, y, size: 12.0, - bold: false, + weight: FontWeight::REGULAR, color: text_color, blur: None, }); @@ -87,11 +90,10 @@ impl SettingsApp { painter, text: "我已知晓", x: button.center_x() - - font.measure_text_cached("我已知晓", 12.0, winisland_render::FontStyle::bold()) - / 2.0, + - font.measure_text_cached("我已知晓", 12.0, FontWeight::MEDIUM) / 2.0, y: button.top + 18.0, size: 12.0, - bold: true, + weight: FontWeight::MEDIUM, color: Rgba::WHITE, blur: None, }); @@ -158,12 +160,17 @@ impl SettingsApp { Some(target) => target, None => return, }; + let panel = sidebar_panel(SIDEBAR_W, win_h); if self.window_backdrop { self.window_backdrop = crate::platform::window().update_window_backdrop( window_id, - ((SIDEBAR_W + WINDOW_RADIUS) * scale).min(p_w as f32), - (win_h * scale).min(p_h as f32), - WINDOW_RADIUS * scale, + [ + panel.left * scale, + panel.top * scale, + panel.width() * scale, + (panel.height() * scale).min(p_h as f32 - panel.top * scale), + ], + crate::utils::settings_ui::chrome::BACKDROP_CORNER * scale, ); } let render_result = renderer.frame(target, |drawing_context, painter| { @@ -171,12 +178,8 @@ impl SettingsApp { let win_rect = Rect::from_xywh(0.0, 0.0, win_w, win_h); painter.save(); - painter.clip_round_rect(win_rect, Radius::uniform(WINDOW_RADIUS)); - let content_left = if self.window_backdrop { SIDEBAR_W } else { 0.0 }; - painter.fill_rect( - Rect::from_xywh(content_left, 0.0, win_w - content_left, win_h), - settings_color(theme.win_bg), - ); + crate::utils::settings_ui::chrome::clip_window(painter, win_rect); + draw_window_base(painter, &theme, win_rect, panel, self.window_backdrop); self.draw_sidebar(drawing_context, painter, &theme); self.draw_page_navigation(painter, &theme); @@ -251,7 +254,23 @@ impl SettingsApp { self.logical_mouse_pos.1 + self.scroll_y, )), }); - if self.active_page == 1 && self.show_music_notice() { + if let Some((top, height)) = self.search_flash_y() { + let strength = self.search.flash.map_or(0.0, |(_, _, started)| { + 1.0 - started.elapsed().as_secs_f32() + / super::search::FLASH_DURATION.as_secs_f32() + }); + painter.fill_round_rect( + Rect::from_xywh( + crate::utils::settings_ui::items::CONTENT_PADDING, + top, + content_w - crate::utils::settings_ui::items::CONTENT_PADDING * 2.0, + height, + ), + Radius::uniform(8.0), + theme.accent.with_alpha_f(0.16 * strength.clamp(0.0, 1.0)), + ); + } + if self.active_page == super::MUSIC_PAGE_INDEX && self.show_music_notice() { self.draw_music_notice(painter, content_w); } painter.restore(); @@ -274,12 +293,9 @@ impl SettingsApp { self.draw_popup(painter, &theme); painter.restore(); - let border_rect = Rect::from_xywh(0.5, 0.5, win_w - 1.0, win_h - 1.0); - let border_radius = WINDOW_RADIUS - 0.5; - painter.stroke_round_rect( - border_rect, - Radius::uniform(border_radius), - 1.0, + crate::utils::settings_ui::chrome::stroke_window( + painter, + win_rect, settings_color(theme.separator), ); }); @@ -391,122 +407,74 @@ impl SettingsApp { } fn draw_page_navigation(&self, painter: Painter<'_>, theme: &SettingsTheme) { - let back_center_x = PAGE_NAV_X + PAGE_NAV_WIDTH / 2.0; - let forward_center_x = back_center_x + PAGE_NAV_WIDTH + PAGE_NAV_GAP; - let center_y = PAGE_NAV_Y + PAGE_NAV_HEIGHT / 2.0; + let capsule = Rect::from_xywh( + PAGE_NAV_X, + PAGE_NAV_Y, + PAGE_NAV_WIDTH * 2.0 + PAGE_NAV_GAP, + PAGE_NAV_HEIGHT, + ); + let radius = Radius::uniform(PAGE_NAV_HEIGHT / 2.0); + painter.fill_path_blurred( + &Path::rounded_rect(capsule.offset(Vec2::new(0.0, 1.0)), PAGE_NAV_HEIGHT / 2.0), + theme.button_shadow, + winisland_render::BlurSpec::uniform(2.5), + ); + painter.fill_round_rect(capsule, radius, theme.button_bg); let (mouse_x, mouse_y) = self.logical_mouse_pos; - - for (x, enabled, is_back) in [ - (PAGE_NAV_X, self.can_navigate_back(), true), - ( - PAGE_NAV_X + PAGE_NAV_WIDTH + PAGE_NAV_GAP, - self.can_navigate_forward(), - false, - ), - ] { - let rect = Rect::from_xywh(x, PAGE_NAV_Y, PAGE_NAV_WIDTH, PAGE_NAV_HEIGHT); - let outer_radius = PAGE_NAV_HEIGHT / 2.0; - let inner_radius = 3.0; - let (outer_corner, inner_corner) = ( - Vec2::new(outer_radius, outer_radius), - Vec2::new(inner_radius, inner_radius), - ); - let (first, second) = if is_back { - (outer_corner, inner_corner) - } else { - (inner_corner, outer_corner) - }; - let radius = Radius { - top_left: first, - top_right: second, - bottom_right: second, - bottom_left: first, - }; - let hovered = enabled && rect.contains(Point::new(mouse_x, mouse_y)); - let fill = if !enabled { - theme.control_disabled - } else if hovered { - theme.control_hover - } else { - theme.control_bg - }; - painter.fill_round_rect(rect, radius, settings_color(fill)); - painter.stroke_round_rect(rect, radius, 0.75, settings_color(theme.control_border)); - } - - let back_color = if self.can_navigate_back() { - theme.text_pri - } else { - theme.disabled - }; - if let Some(path) = Path::from_svg(&format!( - "M {} {} L {} {} L {} {}", - back_center_x + 2.5, - center_y - 5.0, - back_center_x - 2.5, - center_y, - back_center_x + 2.5, - center_y + 5.0, - )) { - painter.stroke_path( - &path, - 1.8, - settings_color(back_color), - StrokeCap::Round, - StrokeJoin::Round, + for (index, enabled) in [self.can_navigate_back(), self.can_navigate_forward()] + .into_iter() + .enumerate() + { + let half = Rect::from_xywh( + PAGE_NAV_X + index as f32 * (PAGE_NAV_WIDTH + PAGE_NAV_GAP), + PAGE_NAV_Y, + PAGE_NAV_WIDTH, + PAGE_NAV_HEIGHT, ); - } - - let forward_color = if self.can_navigate_forward() { - theme.text_pri - } else { - theme.disabled - }; - if let Some(path) = Path::from_svg(&format!( - "M {} {} L {} {} L {} {}", - forward_center_x - 2.5, - center_y - 5.0, - forward_center_x + 2.5, - center_y, - forward_center_x - 2.5, - center_y + 5.0, - )) { - painter.stroke_path( - &path, - 1.8, - settings_color(forward_color), - StrokeCap::Round, - StrokeJoin::Round, + if enabled && half.contains(Point::new(mouse_x, mouse_y)) { + painter.save(); + painter.clip_rect(half); + painter.fill_round_rect(capsule, radius, theme.sidebar_hover); + painter.restore(); + } + draw_chevron( + painter, + Point::new( + half.center_x() + if index == 0 { 1.0 } else { -1.0 }, + half.center_y(), + ), + index == 0, + if enabled { + theme.text_pri + } else { + theme.disabled + }, ); } + painter.stroke_round_rect(capsule.inset(0.25), radius, 0.5, theme.button_border); } fn draw_page_header(&self, painter: Painter<'_>, theme: &SettingsTheme, win_w: f32) { let title = self.page_title(); - let title_x = PAGE_NAV_X + PAGE_NAV_WIDTH * 2.0 + PAGE_NAV_GAP + 14.0; + let title_x = PAGE_NAV_X + PAGE_NAV_WIDTH * 2.0 + PAGE_NAV_GAP + 12.0; let title = ellipsize_text( FontManager::global(), &title, - 17.0, - winisland_render::FontStyle::bold(), + 15.0, + FontWeight::SEMIBOLD, (win_w - title_x - 20.0).max(0.0), ); - let paint = settings_color(theme.separator); - SettingsPainter::new(painter).text( + FontManager::global().draw_str( + painter, &title, - (title_x, 39.0), - 17.0, - true, + Point::new( + title_x, + crate::utils::settings_ui::text_baseline(PAGE_NAV_Y + PAGE_NAV_HEIGHT / 2.0, 15.0), + ), + 15.0, + FontWeight::SEMIBOLD, settings_color(theme.text_pri), ); - - painter.stroke_line( - Point::new(SIDEBAR_W, SETTINGS_HEADER_H - 0.5), - Point::new(win_w, SETTINGS_HEADER_H - 0.5), - 0.5, - paint, - StrokeCap::Butt, - ); } fn draw_widget_mode_control(&self, painter: Painter<'_>, theme: &SettingsTheme) { @@ -514,28 +482,16 @@ impl SettingsApp { return; } let control = self.widget_mode_control_rect(); - painter.fill_round_rect( - control, - Radius::uniform(8.0), - settings_color(theme.control_bg), - ); - painter.stroke_round_rect( - control, - Radius::uniform(8.0), - 0.75, - settings_color(theme.control_border), - ); - + painter.fill_round_rect(control, Radius::uniform(8.0), theme.control_bg); let selected = self.widget_mode_segment_rect(self.widget_editor_mode); painter.fill_round_rect( - Rect::from_xywh( - selected.left + 2.0, - selected.top + 2.0, - selected.width() - 4.0, - selected.height() - 4.0, - ), + selected.inset(2.0), Radius::uniform(6.0), - settings_color(theme.card_highlight), + if self.focused { + theme.accent + } else { + theme.card_highlight + }, ); for (mode, label) in [ @@ -544,27 +500,28 @@ impl SettingsApp { (WidgetEditorMode::Pages, tr("widget_mode_pages")), ] { let rect = self.widget_mode_segment_rect(mode); - let hovered = self.focused - && rect.contains(Point::new( - self.logical_mouse_pos.0, - self.logical_mouse_pos.1, - )); - let color = if mode == self.widget_editor_mode || hovered { + let active = mode == self.widget_editor_mode; + let color = if active && self.focused { + theme.selection_text + } else { theme.text_pri + }; + let weight = if active { + FontWeight::MEDIUM } else { - theme.text_sec + FontWeight::REGULAR }; - let size = 11.5; - let width = FontManager::global().measure_text_cached( - &label, - size, - winisland_render::FontStyle::normal(), - ); - SettingsPainter::new(painter).text( + let size = 12.0; + let width = FontManager::global().measure_text_cached(&label, size, weight); + FontManager::global().draw_str( + painter, &label, - (rect.center_x() - width / 2.0, rect.center_y() + size * 0.35), + Point::new( + rect.center_x() - width / 2.0, + crate::utils::settings_ui::text_baseline(rect.center_y(), size), + ), size, - mode == self.widget_editor_mode, + weight, settings_color(color), ); } @@ -622,8 +579,17 @@ impl SettingsApp { ); let text_alpha = (255.0 * opacity) as u8; - for (i, opt_label) in popup.options.iter().enumerate() { + let save_count = painter.save(); + painter.clip_rect(popup.inner_rect()); + for i in popup.visible_items() { let item_rect = popup.item_rect(i); + let opt_label = ellipsize_text( + FontManager::global(), + &popup.options[i], + 13.0, + FontWeight::REGULAR, + item_rect.width() - 36.0, + ); if popup.hover_idx == Some(i) { let a = theme.selection_bg.a() as f32 * opacity; @@ -647,10 +613,13 @@ impl SettingsApp { let text_color = Rgba::from_argb(text_alpha, text_base.r(), text_base.g(), text_base.b()); SettingsPainter::new(painter).text( - opt_label, - (item_rect.left + 8.0, item_rect.top + 19.0), - 12.0, - false, + &opt_label, + ( + item_rect.left + 10.0, + crate::utils::settings_ui::text_baseline(item_rect.center_y(), 13.0), + ), + 13.0, + FontWeight::REGULAR, settings_color(text_color), ); @@ -702,5 +671,31 @@ impl SettingsApp { ); } } + painter.restore_to(save_count); + if let Some(thumb) = popup.thumb_rect() { + let (mouse_x, mouse_y) = self.logical_mouse_pos; + let active = popup.scroll_drag.is_some() || popup.scrollbar_hovered(mouse_x, mouse_y); + let thumb = if active { + Rect::from_xywh( + thumb.center_x() - thumb.width() * 0.75, + thumb.top, + thumb.width() * 1.5, + thumb.height(), + ) + } else { + thumb + }; + let emphasis = if active { 1.6 } else { 1.0 }; + painter.fill_round_rect( + thumb, + Radius::uniform(thumb.width() / 2.0), + settings_color(Rgba::from_argb( + (theme.scrollbar.a() as f32 * opacity * emphasis).min(255.0) as u8, + theme.scrollbar.r(), + theme.scrollbar.g(), + theme.scrollbar.b(), + )), + ); + } } } diff --git a/src/window/settings/resource_editor.rs b/src/window/settings/resource_editor.rs index 3c64e95e..9e47695d 100644 --- a/src/window/settings/resource_editor.rs +++ b/src/window/settings/resource_editor.rs @@ -14,7 +14,9 @@ use winisland_core::config::{ }; use winisland_core::i18n::tr; use winisland_render::text::{DrawTextCachedParams, FontManager}; -use winisland_render::{FontStyle, Painter, Path, Point, Radius, Rect, Rgba, StrokeCap, Vec2}; +use winisland_render::{ + FontStyle, FontWeight, Painter, Path, Point, Radius, Rect, Rgba, StrokeCap, Vec2, +}; use super::page_order::draw_checkbox; use super::{PopupState, SettingsApp}; @@ -1284,6 +1286,14 @@ fn rgb(value: u32) -> Rgba { Rgba::from_rgb((value >> 16) as u8, (value >> 8) as u8, value as u8) } +fn text_weight(bold: bool) -> FontWeight { + if bold { + FontWeight::SEMIBOLD + } else { + FontWeight::REGULAR + } +} + fn draw_text(painter: Painter<'_>, text: &str, x: f32, y: f32, size: f32, bold: bool, color: Rgba) { FontManager::global().draw_text_cached(DrawTextCachedParams { painter, @@ -1291,7 +1301,7 @@ fn draw_text(painter: Painter<'_>, text: &str, x: f32, y: f32, size: f32, bold: x, y, size, - bold, + weight: text_weight(bold), color, blur: None, }); @@ -1305,15 +1315,7 @@ fn draw_centered_text( bold: bool, color: Rgba, ) { - let width = FontManager::global().measure_text_cached( - text, - size, - if bold { - FontStyle::bold() - } else { - FontStyle::normal() - }, - ); + let width = FontManager::global().measure_text_cached(text, size, text_weight(bold)); draw_text( painter, text, diff --git a/src/window/settings/search.rs b/src/window/settings/search.rs new file mode 100644 index 00000000..9d2c7ada --- /dev/null +++ b/src/window/settings/search.rs @@ -0,0 +1,227 @@ +use std::time::{Duration, Instant}; + +use winisland_core::i18n::tr; +use winisland_platform::Key; + +use crate::utils::settings_ui::items::SettingsItem; + +use super::{BUILTIN_SIDEBAR_PAGE_COUNT, BuiltinPage, SETTINGS_HEADER_H, SettingsApp}; + +pub(crate) const FLASH_DURATION: Duration = Duration::from_millis(1600); +const MAX_RESULTS: usize = 40; + +#[derive(Clone)] +pub(crate) struct SearchResult { + pub(crate) page: usize, + pub(crate) item: Option, + pub(crate) label: String, +} + +#[derive(Default)] +pub(crate) struct SearchState { + pub(crate) query: String, + pub(crate) focused: bool, + pub(crate) composition: String, + pub(crate) results: Vec, + pub(crate) flash: Option<(usize, usize, Instant)>, +} + +impl SearchState { + pub(crate) fn active(&self) -> bool { + !self.query.trim().is_empty() + } + + pub(crate) fn flash_active(&self) -> bool { + self.flash + .is_some_and(|(_, _, started)| started.elapsed() < FLASH_DURATION) + } +} + +fn item_label(item: &SettingsItem) -> Option<&str> { + match item { + SettingsItem::SectionHeader { label } + | SettingsItem::RowStepper { label, .. } + | SettingsItem::RowSwitch { label, .. } + | SettingsItem::RowFolderPicker { label, .. } + | SettingsItem::RowSourceSelect { label, .. } + | SettingsItem::RowButton { label, .. } => Some(label), + _ => None, + } +} + +impl SettingsApp { + fn searchable_items(&self, page: BuiltinPage) -> Vec { + match page { + BuiltinPage::General => self.build_general_page().into_items(), + BuiltinPage::Appearance => self.build_appearance_page().into_items(), + BuiltinPage::Layout => self.build_layout_page().into_items(), + BuiltinPage::Behavior => self.build_behavior_page().into_items(), + BuiltinPage::Alerts => self.build_alerts_page().into_items(), + BuiltinPage::Music => self.build_music_items(), + BuiltinPage::Widgets | BuiltinPage::Plugins | BuiltinPage::About => Vec::new(), + } + } + + pub(crate) fn refresh_search_results(&mut self) { + let needle = self.search.query.trim().to_lowercase(); + let mut results = Vec::new(); + if !needle.is_empty() { + let matches = |text: &str| text.to_lowercase().contains(&needle); + for page in BuiltinPage::ALL { + let index = page as usize; + let title = tr(page.title_key()); + if matches(&title) { + results.push(SearchResult { + page: index, + item: None, + label: title, + }); + } + for (item_index, item) in self.searchable_items(page).iter().enumerate() { + if let Some(label) = item_label(item).filter(|label| matches(label)) { + results.push(SearchResult { + page: index, + item: Some(item_index), + label: label.to_string(), + }); + } + } + } + for (offset, page) in self.plugin_settings_pages.iter().enumerate() { + if matches(&page.title) { + results.push(SearchResult { + page: BUILTIN_SIDEBAR_PAGE_COUNT + offset, + item: None, + label: page.title.clone(), + }); + } + } + } + results.truncate(MAX_RESULTS); + self.search.results = results; + self.sidebar_hover = -1; + self.request_redraw(); + } + + pub(crate) fn focus_search(&mut self, focused: bool) { + if self.search.focused == focused { + return; + } + self.search.focused = focused; + self.search.composition.clear(); + self.sync_text_input(); + self.request_redraw(); + } + + pub(crate) fn sync_text_input(&self) { + let Some(window) = self.window.as_ref() else { + return; + }; + let area = self.search.focused.then(|| { + let scale = self.window_scale(); + let rect = self.search_rect(); + winisland_platform::Rect { + left: (rect.left * scale) as i32, + top: (rect.top * scale) as i32, + right: (rect.right * scale) as i32, + bottom: (rect.bottom * scale) as i32, + } + }); + crate::platform::window().set_text_input(window.id(), area); + } + + pub(crate) fn set_search_composition(&mut self, text: &str) { + if self.search.focused && self.search.composition != text { + self.search.composition = text.to_string(); + self.request_redraw(); + } + } + + pub(crate) fn clear_search(&mut self) { + self.search.query.clear(); + self.refresh_search_results(); + } + + pub(crate) fn insert_search_text(&mut self, text: &str) -> bool { + if !self.search.focused { + return false; + } + self.search.composition.clear(); + let text: String = text + .chars() + .filter(|character| !character.is_control()) + .collect(); + if text.is_empty() { + return true; + } + self.search.query.push_str(&text); + self.refresh_search_results(); + true + } + + /// Edits the focused search field; returns whether the key was consumed. + pub(crate) fn handle_search_key(&mut self, key: &Key, text: Option<&str>) -> bool { + if !self.search.focused { + return false; + } + match key { + Key::Backspace => { + self.search.query.pop(); + self.refresh_search_results(); + } + Key::Escape => { + if self.search.query.is_empty() { + self.focus_search(false); + } else { + self.clear_search(); + } + } + Key::Enter => { + if let Some(result) = self.search.results.first().cloned() { + self.open_search_result(&result); + } + } + _ => { + if let Some(text) = text { + self.insert_search_text(text); + } + } + } + true + } + + pub(crate) fn open_search_result(&mut self, result: &SearchResult) { + self.visit_page(result.page); + let Some(item) = result.item else { + return; + }; + self.ensure_items_cache(); + let offset: f32 = self + .cached_items + .iter() + .take(item) + .map(SettingsItem::height) + .sum(); + let target = (offset - 12.0).clamp(0.0, self.cached_max_scroll); + self.scroll_y = target; + self.target_scroll_y = target; + self.search.flash = Some((result.page, item, Instant::now())); + self.request_redraw(); + } + + pub(crate) fn search_flash_y(&self) -> Option<(f32, f32)> { + let (page, item, _) = self.search.flash?; + if page != self.active_page || !self.search.flash_active() { + return None; + } + let top = SETTINGS_HEADER_H + + self + .cached_items + .iter() + .take(item) + .map(SettingsItem::height) + .sum::(); + let height = self.cached_items.get(item).map(SettingsItem::height)?; + Some((top, height)) + } +} diff --git a/src/window/settings/sidebar.rs b/src/window/settings/sidebar.rs index 39691e85..64b8a706 100644 --- a/src/window/settings/sidebar.rs +++ b/src/window/settings/sidebar.rs @@ -1,36 +1,46 @@ use std::cell::RefCell; use std::collections::HashMap; -use winisland_render::FontStyle; -use winisland_render::{ - Image, ImageOptions, Mipmapped, Painter, Point, Radius, Rect, Rgba, Sampling, StrokeCap, -}; +use winisland_render::FontWeight; +use winisland_render::{Image, Mipmapped, Painter, Point, Rect, Rgba}; use crate::utils::color::SettingsTheme; -use crate::utils::settings_ui::items::{SIDEBAR_PAD, SIDEBAR_SEL_RADIUS}; -use crate::utils::settings_ui::{SettingsPainter, ellipsize_text, settings_color}; +use crate::utils::settings_ui::chrome::{ + SidebarRow, draw_search_field, draw_sidebar_row, draw_traffic_lights, search_rect, +}; +use crate::utils::settings_ui::items::SIDEBAR_PAD; +use crate::utils::settings_ui::text_baseline; use winisland_core::i18n::tr; use winisland_render::DrawingContext; use winisland_render::text::FontManager; use super::{ - SIDEBAR_KEY_BASE, SIDEBAR_ROW_GAP, SIDEBAR_ROW_H, SIDEBAR_START_Y, SIDEBAR_W, SettingsApp, - WINDOW_CONTROL_CENTERS, WINDOW_CONTROL_RADIUS, + BUILTIN_SIDEBAR_PAGE_COUNT, BuiltinPage, SIDEBAR_KEY_BASE, SIDEBAR_ROW_GAP, SIDEBAR_ROW_H, + SIDEBAR_START_Y, SIDEBAR_W, SettingsApp, }; -const SIDEBAR_ICON_BYTES: [&[u8]; 5] = [ +const SIDEBAR_GROUP_GAP: f32 = 12.0; +const SIDEBAR_ICON_BYTES: [&[u8]; BUILTIN_SIDEBAR_PAGE_COUNT] = [ include_bytes!("../../../resources/in_app/settings/settings.png"), + include_bytes!("../../../resources/in_app/settings/appearance.png"), + include_bytes!("../../../resources/in_app/settings/layout.png"), + include_bytes!("../../../resources/in_app/settings/behavior.png"), + include_bytes!("../../../resources/in_app/settings/alerts.png"), include_bytes!("../../../resources/in_app/settings/music.png"), include_bytes!("../../../resources/in_app/settings/widget.png"), include_bytes!("../../../resources/in_app/settings/plugin.png"), include_bytes!("../../../resources/in_app/settings/about.png"), ]; -const SIDEBAR_BLUR_TINT_LIGHT: f32 = 0.8; -const SIDEBAR_BLUR_TINT_DARK: f32 = 0.72; - thread_local! { - static SIDEBAR_ICONS: RefCell> = const { RefCell::new(None) }; + static SIDEBAR_ICONS: RefCell>> = const { RefCell::new(None) }; static PLUGIN_SETTINGS_ICONS: RefCell> = RefCell::new(HashMap::new()); + static SEARCH_GLYPH: RefCell>> = const { RefCell::new(None) }; +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum SidebarEntry { + Page(usize), + Result(usize), } fn load_sidebar_icon(drawing_context: &mut DrawingContext<'_>, bytes: &[u8]) -> Image { @@ -40,27 +50,32 @@ fn load_sidebar_icon(drawing_context: &mut DrawingContext<'_>, bytes: &[u8]) -> .expect("Failed to create mipmapped sidebar icon texture") } -fn draw_sidebar_icon( - drawing_context: &mut DrawingContext<'_>, - painter: Painter<'_>, - index: usize, - rect: Rect, -) { +fn sidebar_icon(drawing_context: &mut DrawingContext<'_>, index: usize) -> Option { SIDEBAR_ICONS.with(|cache| { let mut cache = cache.borrow_mut(); - if cache.is_none() { - *cache = - Some(SIDEBAR_ICON_BYTES.map(|bytes| load_sidebar_icon(drawing_context, bytes))); - } - let icons = cache.as_ref().expect("Sidebar icon cache was initialized"); - painter.draw_image( - &icons[index], - rect, - &ImageOptions::default() - .with_sampling(Sampling::LinearLinear) - .with_anti_alias(false), - ); - }); + let icons = cache.get_or_insert_with(|| { + SIDEBAR_ICON_BYTES + .iter() + .map(|bytes| load_sidebar_icon(drawing_context, bytes)) + .collect() + }); + icons.get(index).cloned() + }) +} + +fn search_glyph() -> Option { + SEARCH_GLYPH.with(|cache| { + cache + .borrow_mut() + .get_or_insert_with(|| { + crate::icons::load_tinted_glyph( + include_bytes!("../../../resources/in_app/icons/magnifyingglass.png"), + Rgba::from_rgb(128, 128, 134), + ) + .map(|(image, _)| image) + }) + .clone() + }) } pub(super) fn clear_sidebar_icon_cache() { @@ -73,248 +88,175 @@ pub(super) fn clear_plugin_settings_icon_cache() { PLUGIN_SETTINGS_ICONS.with(|cache| cache.borrow_mut().clear()); } -fn draw_plugin_settings_icon( +fn plugin_settings_icon( drawing_context: &mut DrawingContext<'_>, - painter: Painter<'_>, page: &winisland_core::plugin_settings::PluginSettingsPage, - rect: Rect, -) { +) -> Option { if page.icon.is_empty() { - draw_sidebar_icon(drawing_context, painter, 3, rect); - return; - } - let image = PLUGIN_SETTINGS_ICONS.with(|cache| { - if let Some(image) = cache.borrow().get(&page.resource_id) { - return Some(image.clone()); - } - let image = Image::from_encoded(&page.icon)?; - let image = drawing_context.prepare_image(image, Mipmapped::Yes)?; - cache.borrow_mut().insert(page.resource_id, image.clone()); - Some(image) - }); - if let Some(image) = image { - painter.draw_image( - &image, - rect, - &ImageOptions::default() - .with_sampling(Sampling::LinearLinear) - .with_anti_alias(false), - ); - } else { - draw_sidebar_icon(drawing_context, painter, 3, rect); + return sidebar_icon(drawing_context, BuiltinPage::Plugins as usize); } + PLUGIN_SETTINGS_ICONS + .with(|cache| { + if let Some(image) = cache.borrow().get(&page.resource_id) { + return Some(image.clone()); + } + let image = Image::from_encoded(&page.icon)?; + let image = drawing_context.prepare_image(image, Mipmapped::Yes)?; + cache.borrow_mut().insert(page.resource_id, image.clone()); + Some(image) + }) + .or_else(|| sidebar_icon(drawing_context, BuiltinPage::Plugins as usize)) } -fn draw_sidebar_row_background( - app: &SettingsApp, - painter: Painter<'_>, - theme: &SettingsTheme, - index: usize, -) -> Rgba { - let row_y = SIDEBAR_START_Y + index as f32 * (SIDEBAR_ROW_H + SIDEBAR_ROW_GAP); - let row_x = SIDEBAR_PAD; - let row_w = SIDEBAR_W - SIDEBAR_PAD * 2.0; - if app.active_page == index { - painter.fill_round_rect( - winisland_render::Rect::from_xywh(row_x, row_y, row_w, SIDEBAR_ROW_H), - Radius::uniform(SIDEBAR_SEL_RADIUS), - settings_color(if app.focused { - theme.selection_bg - } else { - theme.card_highlight - }), - ); - if app.focused { - if app.is_light { - settings_color(theme.selection_text) - } else { - Rgba::WHITE +impl SettingsApp { + pub(crate) fn search_rect(&self) -> Rect { + search_rect(SIDEBAR_W, SIDEBAR_PAD) + } + + /// Lays out the sidebar: grouped pages normally, or search results while searching. + pub(crate) fn sidebar_rows(&self) -> Vec<(SidebarEntry, Rect)> { + let row = + |y: f32| Rect::from_xywh(SIDEBAR_PAD, y, SIDEBAR_W - SIDEBAR_PAD * 2.0, SIDEBAR_ROW_H); + let pitch = SIDEBAR_ROW_H + SIDEBAR_ROW_GAP; + let mut y = SIDEBAR_START_Y; + let mut rows = Vec::new(); + if self.search.active() { + for index in 0..self.search.results.len() { + rows.push((SidebarEntry::Result(index), row(y))); + y += pitch; } - } else { - settings_color(theme.text_pri) + return rows; } - } else { - let hover = app.anim.get(SIDEBAR_KEY_BASE + index as u64); - if hover > 0.005 { - let base = theme.sidebar_hover; - painter.fill_round_rect( - winisland_render::Rect::from_xywh(row_x, row_y, row_w, SIDEBAR_ROW_H), - Radius::uniform(SIDEBAR_SEL_RADIUS), - Rgba::from_argb( - (base.a() as f32 * hover) as u8, - base.r(), - base.g(), - base.b(), - ), - ); + let plugin_pages: Vec = (0..self.plugin_settings_pages.len()) + .map(|offset| BUILTIN_SIDEBAR_PAGE_COUNT + offset) + .collect(); + let groups: [&[usize]; 4] = [ + &[ + BuiltinPage::General as usize, + BuiltinPage::Appearance as usize, + BuiltinPage::Layout as usize, + BuiltinPage::Behavior as usize, + BuiltinPage::Alerts as usize, + ], + &[ + BuiltinPage::Music as usize, + BuiltinPage::Widgets as usize, + BuiltinPage::Plugins as usize, + ], + &plugin_pages, + &[BuiltinPage::About as usize], + ]; + for group in groups.iter().filter(|group| !group.is_empty()) { + for page in group.iter() { + rows.push((SidebarEntry::Page(*page), row(y))); + y += pitch; + } + y += SIDEBAR_GROUP_GAP; } - settings_color(if app.sidebar_hover == index as i32 { - theme.text_pri - } else { - theme.text_sec - }) + rows } -} - -fn draw_window_control( - painter: Painter<'_>, - center: (f32, f32), - fill: Rgba, - border: Rgba, - highlighted: bool, -) { - painter.fill_circle( - Point::new(center.0, center.1 + 0.75), - WINDOW_CONTROL_RADIUS + 0.25, - Rgba::from_argb(38, 0, 0, 0), - ); - painter.fill_circle(Point::new(center.0, center.1), WINDOW_CONTROL_RADIUS, fill); + pub(crate) fn sidebar_entry_at(&self, x: f32, y: f32) -> Option { + let point = Point::new(x, y); + self.sidebar_rows() + .into_iter() + .find(|(_, rect)| rect.contains(point)) + .map(|(entry, _)| entry) + } - painter.stroke_circle( - Point::new(center.0, center.1), - WINDOW_CONTROL_RADIUS - 0.375, - 0.75, - border, - ); + fn sidebar_entry_icon( + &self, + drawing_context: &mut DrawingContext<'_>, + page: usize, + ) -> Option { + if page < BUILTIN_SIDEBAR_PAGE_COUNT { + sidebar_icon(drawing_context, page) + } else { + self.plugin_settings_pages + .get(page - BUILTIN_SIDEBAR_PAGE_COUNT) + .and_then(|plugin_page| plugin_settings_icon(drawing_context, plugin_page)) + } + } - if highlighted { - painter.fill_oval( - winisland_render::Rect::from_xywh(center.0 - 3.5, center.1 - 4.25, 7.0, 2.25), - Rgba::from_argb(36, 255, 255, 255), - ); + fn sidebar_page_label(&self, page: usize) -> String { + match BuiltinPage::from_index(page) { + Some(page) => tr(page.title_key()), + None => self + .plugin_settings_pages + .get(page - BUILTIN_SIDEBAR_PAGE_COUNT) + .map(|page| page.title.clone()) + .unwrap_or_default(), + } } -} -impl SettingsApp { pub(crate) fn draw_sidebar( &self, drawing_context: &mut DrawingContext<'_>, painter: Painter<'_>, theme: &SettingsTheme, ) { - let logical_height = self.logical_window_size().1; - let sidebar_bg = settings_color(theme.sidebar_bg); - painter.fill_rect( - winisland_render::Rect::from_xywh(0.0, 0.0, SIDEBAR_W, logical_height), - if self.window_backdrop { - sidebar_bg.with_alpha_f(if self.is_light { - SIDEBAR_BLUR_TINT_LIGHT - } else { - SIDEBAR_BLUR_TINT_DARK - }) - } else { - sidebar_bg - }, + draw_traffic_lights( + painter, + self.is_light, + self.focused, + self.dots_hovered, + theme.text_sec, ); - - let inactive_fill = if self.is_light { - Rgba::from_rgb(184, 184, 188) - } else { - Rgba::from_rgb(82, 82, 86) - }; - let fills = if self.focused { - [ - Rgba::from_rgb(255, 95, 87), - Rgba::from_rgb(254, 188, 46), - Rgba::from_rgb(126, 126, 132), - ] - } else { - [inactive_fill; 3] - }; - let border = if self.is_light { - Rgba::from_argb(38, 0, 0, 0) - } else { - Rgba::from_argb(64, 0, 0, 0) - }; - - for (center, fill) in WINDOW_CONTROL_CENTERS.into_iter().zip(fills) { - draw_window_control(painter, center, fill, border, self.focused); - } - - if self.dots_hovered { - let close_color = if self.focused { - Rgba::from_rgb(78, 0, 2) - } else { - settings_color(theme.text_sec) - }; - painter.stroke_line( - Point::new(17.5, 17.5), - Point::new(22.5, 22.5), - 1.1, - close_color, - StrokeCap::Round, - ); - painter.stroke_line( - Point::new(22.5, 17.5), - Point::new(17.5, 22.5), - 1.1, - close_color, - StrokeCap::Round, - ); - - let minimize_color = if self.focused { - Rgba::from_rgb(92, 62, 0) - } else { - settings_color(theme.text_sec) - }; - painter.stroke_line( - Point::new(36.5, 20.0), - Point::new(43.5, 20.0), - 1.1, - minimize_color, - StrokeCap::Round, - ); - } - - painter.stroke_line( - Point::new(SIDEBAR_W, 0.0), - Point::new(SIDEBAR_W, logical_height), - 0.5, - settings_color(theme.separator), - StrokeCap::Butt, + draw_search_field( + painter, + theme, + self.search_rect(), + search_glyph().as_ref(), + &self.search.query, + &self.search.composition, + self.search.focused, + self.search.focused && self.frame_count % 60 < 30, ); - let pages = [ - tr("tab_general"), - tr("tab_music"), - tr("tab_widgets"), - tr("tab_plugins"), - tr("tab_about"), - ]; - for (index, label) in pages.iter().enumerate() { - let row_y = SIDEBAR_START_Y + index as f32 * (SIDEBAR_ROW_H + SIDEBAR_ROW_GAP); - let text_color = draw_sidebar_row_background(self, painter, theme, index); - let icon_rect = Rect::from_xywh(SIDEBAR_PAD + 7.0, row_y + 6.0, 22.0, 22.0); - draw_sidebar_icon(drawing_context, painter, index, icon_rect); - SettingsPainter::new(painter).text( - label, - (SIDEBAR_PAD + 36.0, row_y + 22.0), - 13.0, - self.active_page == index, - text_color, + let rows = self.sidebar_rows(); + for (position, (entry, rect)) in rows.iter().enumerate() { + let (page, label, selected) = match *entry { + SidebarEntry::Page(page) => ( + page, + self.sidebar_page_label(page), + self.active_page == page, + ), + SidebarEntry::Result(index) => { + let result = &self.search.results[index]; + (result.page, result.label.clone(), false) + } + }; + let icon = self.sidebar_entry_icon(drawing_context, page); + draw_sidebar_row( + painter, + theme, + SidebarRow { + rect: *rect, + icon: icon.as_ref(), + label: &label, + selected, + focused: self.focused, + hover: self.anim.get(SIDEBAR_KEY_BASE + position as u64), + }, ); } - - for (offset, page) in self.plugin_settings_pages.iter().enumerate() { - let index = super::BUILTIN_SIDEBAR_PAGE_COUNT + offset; - let row_y = SIDEBAR_START_Y + index as f32 * (SIDEBAR_ROW_H + SIDEBAR_ROW_GAP); - let text_color = draw_sidebar_row_background(self, painter, theme, index); - let icon_rect = Rect::from_xywh(SIDEBAR_PAD + 7.0, row_y + 6.0, 22.0, 22.0); - draw_plugin_settings_icon(drawing_context, painter, page, icon_rect); - let label = ellipsize_text( - FontManager::global(), - &page.title, + if self.search.active() && rows.is_empty() { + let text = tr("search_no_results"); + let width = FontManager::global().measure_text_cached( + &text, 13.0, - FontStyle::normal(), - SIDEBAR_W - SIDEBAR_PAD * 2.0 - 40.0, + winisland_render::FontStyle::normal(), ); - SettingsPainter::new(painter).text( - &label, - (SIDEBAR_PAD + 36.0, row_y + 22.0), + FontManager::global().draw_str( + painter, + &text, + Point::new( + SIDEBAR_W / 2.0 - width / 2.0, + text_baseline(SIDEBAR_START_Y + 20.0, 13.0), + ), 13.0, - self.active_page == index, - text_color, + FontWeight::REGULAR, + theme.text_sec, ); } }