From c5fe4292762f5ef98a1f7f088914b27084ea71e3 Mon Sep 17 00:00:00 2001 From: lars-berger Date: Wed, 13 Nov 2024 18:50:50 +0800 Subject: [PATCH] feat: improvements to appbar implementation (#150) --- packages/client-api/src/config/dock-config.ts | 5 + packages/client-api/src/config/index.ts | 2 +- .../src/config/reserve-space-config.ts | 6 - .../client-api/src/config/widget-placement.ts | 4 +- packages/desktop/src/commands.rs | 5 +- packages/desktop/src/common/app_bar.rs | 72 ---- packages/desktop/src/common/length_value.rs | 9 + packages/desktop/src/common/macos/mod.rs | 3 + .../src/common/macos/window_ext_macos.rs | 27 ++ packages/desktop/src/common/mod.rs | 10 +- packages/desktop/src/common/window_ext.rs | 110 ------ .../desktop/src/common/windows/app_bar.rs | 116 ++++++ packages/desktop/src/common/windows/mod.rs | 5 + .../src/common/windows/window_ext_windows.rs | 63 +++ packages/desktop/src/config.rs | 40 +- packages/desktop/src/main.rs | 24 +- .../src/providers/disk/disk_provider.rs | 4 +- packages/desktop/src/widget_factory.rs | 370 ++++++++++++------ .../src/configs/WidgetConfigForm.tsx | 201 +++++++--- .../settings-ui/src/configs/WidgetConfigs.tsx | 1 + 20 files changed, 653 insertions(+), 424 deletions(-) create mode 100644 packages/client-api/src/config/dock-config.ts delete mode 100644 packages/client-api/src/config/reserve-space-config.ts delete mode 100644 packages/desktop/src/common/app_bar.rs create mode 100644 packages/desktop/src/common/macos/mod.rs create mode 100644 packages/desktop/src/common/macos/window_ext_macos.rs delete mode 100644 packages/desktop/src/common/window_ext.rs create mode 100644 packages/desktop/src/common/windows/app_bar.rs create mode 100644 packages/desktop/src/common/windows/mod.rs create mode 100644 packages/desktop/src/common/windows/window_ext_windows.rs diff --git a/packages/client-api/src/config/dock-config.ts b/packages/client-api/src/config/dock-config.ts new file mode 100644 index 00000000..fea07549 --- /dev/null +++ b/packages/client-api/src/config/dock-config.ts @@ -0,0 +1,5 @@ +export type DockConfig = { + enabled: boolean; + edge: 'top' | 'bottom' | 'left' | 'right' | null; + windowMargin: string; +}; diff --git a/packages/client-api/src/config/index.ts b/packages/client-api/src/config/index.ts index dda82387..08330b68 100644 --- a/packages/client-api/src/config/index.ts +++ b/packages/client-api/src/config/index.ts @@ -1,5 +1,5 @@ export * from './monitor-selection'; -export * from './reserve-space-config'; +export * from './dock-config'; export * from './widget-config'; export * from './widget-placement'; export * from './widget-preset'; diff --git a/packages/client-api/src/config/reserve-space-config.ts b/packages/client-api/src/config/reserve-space-config.ts deleted file mode 100644 index 9957df89..00000000 --- a/packages/client-api/src/config/reserve-space-config.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type ReserveSpaceConfig = { - enabled: boolean; - edge: 'top' | 'bottom' | 'left' | 'right' | null; - thickness: string | null; - offset: string | null; -}; diff --git a/packages/client-api/src/config/widget-placement.ts b/packages/client-api/src/config/widget-placement.ts index 630c0f1d..af7a90f3 100644 --- a/packages/client-api/src/config/widget-placement.ts +++ b/packages/client-api/src/config/widget-placement.ts @@ -1,5 +1,5 @@ import type { MonitorSelection } from './monitor-selection'; -import type { ReserveSpaceConfig } from './reserve-space-config'; +import type { DockConfig } from './dock-config'; export type WidgetPlacement = { anchor: @@ -15,5 +15,5 @@ export type WidgetPlacement = { width: string; height: string; monitorSelection: MonitorSelection; - reserveSpace: ReserveSpaceConfig; + dockToEdge: DockConfig; }; diff --git a/packages/desktop/src/commands.rs b/packages/desktop/src/commands.rs index fe439fb4..01ffb595 100644 --- a/packages/desktop/src/commands.rs +++ b/packages/desktop/src/commands.rs @@ -2,8 +2,11 @@ use std::{collections::HashMap, path::PathBuf, sync::Arc}; use tauri::{State, Window}; +#[cfg(target_os = "macos")] +use crate::common::macos::WindowExtMacOs; +#[cfg(target_os = "windows")] +use crate::common::windows::WindowExtWindows; use crate::{ - common::WindowExt, config::{Config, WidgetConfig, WidgetPlacement}, providers::{ProviderConfig, ProviderManager}, widget_factory::{WidgetFactory, WidgetOpenOptions, WidgetState}, diff --git a/packages/desktop/src/common/app_bar.rs b/packages/desktop/src/common/app_bar.rs deleted file mode 100644 index c0fc3e33..00000000 --- a/packages/desktop/src/common/app_bar.rs +++ /dev/null @@ -1,72 +0,0 @@ -use windows::Win32::{ - Foundation::{HWND, RECT}, - UI::{ - Shell::{ - SHAppBarMessage, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, ABE_TOP, ABM_NEW, - ABM_REMOVE, ABM_SETPOS, APPBARDATA, - }, - WindowsAndMessaging::WM_USER, - }, -}; - -use crate::config::WidgetEdge; - -pub fn create_app_bar( - hwnd: HWND, - left: i32, - top: i32, - right: i32, - bottom: i32, - edge: WidgetEdge, -) { - if hwnd.is_invalid() { - tracing::trace!("Invalid hwnd passed to create_app_bar"); - return; - } - - let rect = RECT { - left, - top, - right, - bottom, - }; - - let edge = match edge { - WidgetEdge::Left => ABE_LEFT, - WidgetEdge::Top => ABE_TOP, - WidgetEdge::Right => ABE_RIGHT, - WidgetEdge::Bottom => ABE_BOTTOM, - }; - - tracing::trace!( - "Registering app bar for {:?} with edge: {:?} and rect: {:?}", - hwnd, - edge, - rect - ); - - let mut data = APPBARDATA { - cbSize: std::mem::size_of::() as u32, - hWnd: hwnd, - uCallbackMessage: WM_USER + 0x01, - uEdge: edge, - rc: rect, - ..Default::default() - }; - - unsafe { SHAppBarMessage(ABM_NEW, &mut data) }; - - // have to call setpos after new for it to actually work - unsafe { SHAppBarMessage(ABM_SETPOS, &mut data) }; -} - -pub fn remove_app_bar(handle: isize) { - tracing::trace!("Removing app bar for {:?}", handle); - - let mut abd = APPBARDATA { - hWnd: HWND(handle as _), - ..Default::default() - }; - - unsafe { SHAppBarMessage(ABM_REMOVE, &mut abd) }; -} diff --git a/packages/desktop/src/common/length_value.rs b/packages/desktop/src/common/length_value.rs index 91245a7c..7dbf8c1c 100644 --- a/packages/desktop/src/common/length_value.rs +++ b/packages/desktop/src/common/length_value.rs @@ -96,3 +96,12 @@ impl<'de> Deserialize<'de> for LengthValue { LengthValue::from_str(&s).map_err(serde::de::Error::custom) } } + +impl Default for LengthValue { + fn default() -> Self { + Self { + amount: 0., + unit: LengthUnit::Pixel, + } + } +} diff --git a/packages/desktop/src/common/macos/mod.rs b/packages/desktop/src/common/macos/mod.rs new file mode 100644 index 00000000..d798ad98 --- /dev/null +++ b/packages/desktop/src/common/macos/mod.rs @@ -0,0 +1,3 @@ +mod window_ext_macos; + +pub use window_ext_macos::*; diff --git a/packages/desktop/src/common/macos/window_ext_macos.rs b/packages/desktop/src/common/macos/window_ext_macos.rs new file mode 100644 index 00000000..97b8477d --- /dev/null +++ b/packages/desktop/src/common/macos/window_ext_macos.rs @@ -0,0 +1,27 @@ +use anyhow::Context; +use cocoa::{ + appkit::{NSMainMenuWindowLevel, NSWindow}, + base::id, +}; +use tauri::{Runtime, Window}; + +pub trait WindowExtMacOs { + fn set_above_menu_bar(&self) -> anyhow::Result<()>; +} + +impl WindowExtMacOs for Window { + fn set_above_menu_bar(&self) -> anyhow::Result<()> { + let ns_win = + self.ns_window().context("Failed to get window handle.")? as id; + + unsafe { + ns_win.setLevel_( + ((NSMainMenuWindowLevel + 1) as u64) + .try_into() + .context("Failed to cast `NSMainMenuWindowLevel`.")?, + ); + } + + Ok(()) + } +} diff --git a/packages/desktop/src/common/mod.rs b/packages/desktop/src/common/mod.rs index dd885705..eb5cf80f 100644 --- a/packages/desktop/src/common/mod.rs +++ b/packages/desktop/src/common/mod.rs @@ -1,15 +1,13 @@ -#[cfg(target_os = "windows")] -mod app_bar; mod format_bytes; mod fs_util; mod length_value; +#[cfg(target_os = "macos")] +pub mod macos; mod path_ext; -mod window_ext; - #[cfg(target_os = "windows")] -pub use app_bar::remove_app_bar; +pub mod windows; + pub use format_bytes::*; pub use fs_util::*; pub use length_value::*; pub use path_ext::*; -pub use window_ext::*; diff --git a/packages/desktop/src/common/window_ext.rs b/packages/desktop/src/common/window_ext.rs deleted file mode 100644 index 26ac0d7d..00000000 --- a/packages/desktop/src/common/window_ext.rs +++ /dev/null @@ -1,110 +0,0 @@ -use anyhow::Context; -#[cfg(target_os = "macos")] -use cocoa::{ - appkit::{NSMainMenuWindowLevel, NSWindow}, - base::id, -}; -#[cfg(target_os = "windows")] -use tauri::{PhysicalPosition, PhysicalSize}; -use tauri::{Runtime, Window}; -#[cfg(target_os = "windows")] -use windows::Win32::UI::WindowsAndMessaging::{ - SetWindowLongPtrW, GWL_EXSTYLE, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW, -}; - -#[cfg(target_os = "windows")] -use crate::config::WidgetEdge; - -pub trait WindowExt { - #[cfg(target_os = "macos")] - fn set_above_menu_bar(&self) -> anyhow::Result<()>; - - #[cfg(target_os = "windows")] - fn set_tool_window(&self, enable: bool) -> anyhow::Result<()>; - - #[cfg(target_os = "windows")] - fn allocate_app_bar( - &self, - size: PhysicalSize, - position: PhysicalPosition, - edge: WidgetEdge, - ) -> anyhow::Result<()>; - - #[cfg(target_os = "windows")] - fn deallocate_app_bar(&self) -> anyhow::Result<()>; -} - -impl WindowExt for Window { - #[cfg(target_os = "macos")] - fn set_above_menu_bar(&self) -> anyhow::Result<()> { - let ns_win = - self.ns_window().context("Failed to get window handle.")? as id; - - unsafe { - ns_win.setLevel_( - ((NSMainMenuWindowLevel + 1) as u64) - .try_into() - .context("Failed to cast `NSMainMenuWindowLevel`.")?, - ); - } - - Ok(()) - } - - #[cfg(target_os = "windows")] - fn set_tool_window(&self, enable: bool) -> anyhow::Result<()> { - let handle = self.hwnd().context("Failed to get window handle.")?; - - // Normally one would add the `WS_EX_TOOLWINDOW` style and remove the - // `WS_EX_APPWINDOW` style to hide a window from the taskbar. Oddly - // enough, this was causing the `WS_EX_APPWINDOW` style to be - // preserved unless fully overwriting the extended window style. - unsafe { - match enable { - true => SetWindowLongPtrW( - handle, - GWL_EXSTYLE, - WS_EX_TOOLWINDOW.0 as isize, - ), - false => SetWindowLongPtrW( - handle, - GWL_EXSTYLE, - WS_EX_APPWINDOW.0 as isize, - ), - } - }; - - Ok(()) - } - - #[cfg(target_os = "windows")] - fn allocate_app_bar( - &self, - size: PhysicalSize, - position: PhysicalPosition, - edge: WidgetEdge, - ) -> anyhow::Result<()> { - use super::app_bar; - - let handle = self.hwnd().context("Failed to get window handle.")?; - - let left = position.x; - let top = position.y; - let right = position.x + size.width; - let bottom = position.y + size.height; - - app_bar::create_app_bar(handle, left, top, right, bottom, edge); - - Ok(()) - } - - #[cfg(target_os = "windows")] - fn deallocate_app_bar(&self) -> anyhow::Result<()> { - use super::app_bar; - - let handle = self.hwnd().context("Failed to get window handle.")?; - app_bar::remove_app_bar(handle.0 as _); - - Ok(()) - } -} diff --git a/packages/desktop/src/common/windows/app_bar.rs b/packages/desktop/src/common/windows/app_bar.rs new file mode 100644 index 00000000..f456dd38 --- /dev/null +++ b/packages/desktop/src/common/windows/app_bar.rs @@ -0,0 +1,116 @@ +use anyhow::bail; +use tauri::{PhysicalPosition, PhysicalSize}; +use tracing::info; +use windows::Win32::{ + Foundation::{HWND, RECT}, + UI::Shell::{ + SHAppBarMessage, ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, ABE_TOP, ABM_NEW, + ABM_QUERYPOS, ABM_REMOVE, ABM_SETPOS, APPBARDATA, + }, +}; + +use crate::config::DockEdge; + +pub fn create_app_bar( + window_handle: isize, + size: PhysicalSize, + position: PhysicalPosition, + edge: DockEdge, +) -> anyhow::Result<(PhysicalSize, PhysicalPosition)> { + let rect = RECT { + left: position.x, + top: position.y, + right: position.x + size.width, + bottom: position.y + size.height, + }; + + info!("Creating app bar with initial rect: {:?}", rect); + + let mut data = APPBARDATA { + cbSize: std::mem::size_of::() as u32, + hWnd: HWND(window_handle as _), + uCallbackMessage: 0, + uEdge: match edge { + DockEdge::Left => ABE_LEFT, + DockEdge::Top => ABE_TOP, + DockEdge::Right => ABE_RIGHT, + DockEdge::Bottom => ABE_BOTTOM, + }, + rc: rect.clone(), + ..Default::default() + }; + + if unsafe { SHAppBarMessage(ABM_NEW, &mut data) } == 0 { + bail!("Failed to register new app bar."); + } + + // TODO: Ideally we should respond to incoming `ABN_POSCHANGED` messages. + // Not responding to these messages causes space to be continually + // reserved on hot-reloads in development. This is blocked by #11650. + // Ref: https://github.com/tauri-apps/tauri/issues/11650. + + // Query to get the adjusted position. This only adjusts the edges of the + // rect that have an appbar on them. + // e.g. { left: 0, top: 0, right: 1920, bottom: 40 } + // -> { left: 0, top: 80, right: 1920, bottom: 40 } (top edge adjusted) + if unsafe { SHAppBarMessage(ABM_QUERYPOS, &mut data) } == 0 { + bail!("Failed to query for app bar position."); + } + + let adjusted_position = PhysicalPosition::new(data.rc.left, data.rc.top); + + let width_delta = match edge { + DockEdge::Left => rect.right - data.rc.right, + DockEdge::Right => rect.left - data.rc.left, + _ => (rect.right - data.rc.right) - (rect.left - data.rc.left), + }; + + let height_delta = match edge { + DockEdge::Top => rect.bottom - data.rc.bottom, + DockEdge::Bottom => rect.top - data.rc.top, + _ => (rect.bottom - data.rc.bottom) - (rect.top - data.rc.top), + }; + + // Size has changed if the edge that is not being docked has been + // adjusted by ABM_QUERYPOS. For example, if the top edge is docked, then + // diffs in the left and right edges are the size changes. + let adjusted_size = PhysicalSize::new( + size.width - width_delta, + size.height - height_delta, + ); + + data.rc = RECT { + left: adjusted_position.x, + top: adjusted_position.y, + right: adjusted_position.x + size.width, + bottom: adjusted_position.y + size.height, + }; + + // Set position for it to actually reserve the size and position. + if unsafe { SHAppBarMessage(ABM_SETPOS, &mut data) } == 0 { + bail!("Failed to set app bar position."); + } + + info!("Successfully registered appbar with rect: {:?}", data.rc); + + Ok((adjusted_size, adjusted_position)) +} + +/// Deallocate the app bar for given window handle. +/// +/// Note that this does not error if handle is invalid. +pub fn remove_app_bar(handle: isize) -> anyhow::Result<()> { + info!("Removing app bar for {:?}.", handle); + + let mut abd = APPBARDATA { + cbSize: std::mem::size_of::() as u32, + hWnd: HWND(handle as _), + uCallbackMessage: 0, + ..Default::default() + }; + + match unsafe { SHAppBarMessage(ABM_REMOVE, &mut abd) } { + 0 => bail!("Failed to remove app bar."), + _ => Ok(()), + } +} diff --git a/packages/desktop/src/common/windows/mod.rs b/packages/desktop/src/common/windows/mod.rs new file mode 100644 index 00000000..26e2af99 --- /dev/null +++ b/packages/desktop/src/common/windows/mod.rs @@ -0,0 +1,5 @@ +mod app_bar; +mod window_ext_windows; + +pub use app_bar::*; +pub use window_ext_windows::*; diff --git a/packages/desktop/src/common/windows/window_ext_windows.rs b/packages/desktop/src/common/windows/window_ext_windows.rs new file mode 100644 index 00000000..f74dc865 --- /dev/null +++ b/packages/desktop/src/common/windows/window_ext_windows.rs @@ -0,0 +1,63 @@ +use anyhow::Context; +use tauri::{PhysicalPosition, PhysicalSize, Runtime, Window}; +use windows::Win32::UI::WindowsAndMessaging::{ + SetWindowLongPtrW, GWL_EXSTYLE, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW, +}; + +use super::app_bar; +use crate::config::DockEdge; + +pub trait WindowExtWindows { + fn set_tool_window(&self, enable: bool) -> anyhow::Result<()>; + + fn allocate_app_bar( + &self, + size: PhysicalSize, + position: PhysicalPosition, + edge: DockEdge, + ) -> anyhow::Result<(PhysicalSize, PhysicalPosition)>; + + fn deallocate_app_bar(&self) -> anyhow::Result<()>; +} + +impl WindowExtWindows for Window { + fn set_tool_window(&self, enable: bool) -> anyhow::Result<()> { + let handle = self.hwnd().context("Failed to get window handle.")?; + + // Normally one would add the `WS_EX_TOOLWINDOW` style and remove the + // `WS_EX_APPWINDOW` style to hide a window from the taskbar. Oddly + // enough, this was causing the `WS_EX_APPWINDOW` style to be + // preserved unless fully overwriting the extended window style. + unsafe { + match enable { + true => SetWindowLongPtrW( + handle, + GWL_EXSTYLE, + WS_EX_TOOLWINDOW.0 as isize, + ), + false => SetWindowLongPtrW( + handle, + GWL_EXSTYLE, + WS_EX_APPWINDOW.0 as isize, + ), + } + }; + + Ok(()) + } + + fn allocate_app_bar( + &self, + size: PhysicalSize, + position: PhysicalPosition, + edge: DockEdge, + ) -> anyhow::Result<(PhysicalSize, PhysicalPosition)> { + let handle = self.hwnd().context("Failed to get window handle.")?; + app_bar::create_app_bar(handle.0 as _, size, position, edge) + } + + fn deallocate_app_bar(&self) -> anyhow::Result<()> { + let handle = self.hwnd().context("Failed to get window handle.")?; + app_bar::remove_app_bar(handle.0 as _) + } +} diff --git a/packages/desktop/src/config.rs b/packages/desktop/src/config.rs index 7d175fd3..02e8c35e 100644 --- a/packages/desktop/src/config.rs +++ b/packages/desktop/src/config.rs @@ -137,7 +137,7 @@ pub struct WidgetPlacement { /// How to reserve space for the widget. #[serde(default)] - pub reserve_space: ReserveSpaceConfig, + pub dock_to_edge: DockConfig, } #[derive( @@ -167,40 +167,33 @@ pub enum MonitorSelection { Name(String), } -fn no() -> bool { - false -} - #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)] #[serde(rename_all = "camelCase")] -pub struct ReserveSpaceConfig { - /// Whether to reserve space for the widget. - #[serde(default = "no")] +pub struct DockConfig { + /// Whether to dock the widget to the monitor edge and reserve screen + /// space for it. + #[serde(default = "default_bool::")] pub enabled: bool, - /// Edge to reserve space on. - pub edge: Option, + /// Edge to dock the widget to. + pub edge: Option, - /// Thickness of the reserved space. - pub thickness: Option, - - /// Offset from the edge. - pub offset: Option, + /// Margin to reserve after the widget window. Can be positive or + /// negative. + #[serde(default)] + pub window_margin: LengthValue, } -#[derive( - Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Default, -)] +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] -pub enum WidgetEdge { - #[default] +pub enum DockEdge { Top, Bottom, Left, Right, } -impl WidgetEdge { +impl DockEdge { pub fn is_horizontal(&self) -> bool { matches!(self, Self::Top | Self::Bottom) } @@ -637,6 +630,11 @@ fn is_app_installed(app_name: &str) -> bool { } } +/// Helper function for setting a default value for a boolean field. +const fn default_bool() -> bool { + V +} + /// Helper function for setting the default value for a /// `WidgetPreset::name` field. fn default_preset_name() -> String { diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index ee7452df..c5f4be96 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -16,6 +16,8 @@ use tracing::{error, info, level_filters::LevelFilter}; use tracing_subscriber::EnvFilter; use widget_factory::WidgetOpenOptions; +#[cfg(target_os = "windows")] +use crate::common::windows::WindowExtWindows; use crate::{ cli::{Cli, CliCommand, QueryArgs}, config::Config, @@ -97,21 +99,13 @@ async fn main() -> anyhow::Result<()> { // Keep the message loop running even if all windows are closed. api.prevent_exit(); } else { - // Deallocate app bar space on windows + // Deallocate any appbars on Windows. #[cfg(target_os = "windows")] - task::block_in_place(|| { - block_on(async move { - let widget_factory = app.state::>(); - - for id in widget_factory.states().await.keys() { - if let Some(window) = app.get_webview_window(id) { - if let Ok(hwnd) = window.hwnd() { - common::remove_app_bar(hwnd.0 as _); - } - } - } - }) - }); + { + for (_, window) in app.webview_windows() { + let _ = window.as_ref().window().deallocate_app_bar(); + } + } } } }); @@ -299,7 +293,7 @@ async fn open_widgets_by_cli_command( MonitorType::Primary => MonitorSelection::Primary, MonitorType::Secondary => MonitorSelection::Secondary, }, - reserve_space: Default::default(), + dock_to_edge: Default::default(), }), ) .await diff --git a/packages/desktop/src/providers/disk/disk_provider.rs b/packages/desktop/src/providers/disk/disk_provider.rs index 97b4890b..a6248a03 100644 --- a/packages/desktop/src/providers/disk/disk_provider.rs +++ b/packages/desktop/src/providers/disk/disk_provider.rs @@ -1,7 +1,7 @@ -use std::{any::Any, sync::Arc}; +use std::sync::Arc; use serde::{Deserialize, Serialize}; -use sysinfo::{Disk, Disks}; +use sysinfo::Disks; use tokio::sync::Mutex; use crate::{ diff --git a/packages/desktop/src/widget_factory.rs b/packages/desktop/src/widget_factory.rs index eb4594bb..3b7c66aa 100644 --- a/packages/desktop/src/widget_factory.rs +++ b/packages/desktop/src/widget_factory.rs @@ -1,5 +1,6 @@ use std::{ collections::HashMap, + i32, path::PathBuf, sync::{ atomic::{AtomicU32, Ordering}, @@ -19,12 +20,17 @@ use tokio::{ }; use tracing::{error, info}; +#[cfg(target_os = "macos")] +use crate::common::macos::WindowExtMacOs; #[cfg(target_os = "windows")] -use crate::config::WidgetEdge; +use crate::common::windows::{remove_app_bar, WindowExtWindows}; use crate::{ - common::{PathExt, WindowExt}, - config::{AnchorPoint, Config, WidgetConfig, WidgetPlacement}, - monitor_state::MonitorState, + common::PathExt, + config::{ + AnchorPoint, Config, DockConfig, DockEdge, WidgetConfig, + WidgetPlacement, + }, + monitor_state::{Monitor, MonitorState}, }; /// Manages the creation of Zebar widgets. @@ -66,6 +72,11 @@ pub struct WidgetState { /// Used as the Tauri window label. pub id: String, + /// Handle to the underlying Tauri window. + /// + /// This is only available on Windows. + pub window_handle: Option, + /// User-defined config for the widget. pub config: WidgetConfig, @@ -86,13 +97,55 @@ pub enum WidgetOpenOptions { Preset(String), } -struct Coordinates { +struct WidgetCoordinates { size: PhysicalSize, position: PhysicalPosition, - monitor_size: PhysicalSize, - scale_factor: f32, - anchor: AnchorPoint, offset: PhysicalPosition, + anchor: AnchorPoint, + monitor: Monitor, +} + +impl WidgetCoordinates { + /// Gets which monitor edge (top, bottom, left, right) the widget is + /// closest to. + /// + /// This is determined by dividing the monitor into four triangular + /// quadrants (forming an "X") and checking which quadrant contains the + /// widget's center point. + fn closest_edge(&self) -> DockEdge { + let widget_center = PhysicalPosition::new( + self.position.x + (self.size.width / 2), + self.position.y + (self.size.height / 2), + ); + + let monitor_center = PhysicalPosition::new( + self.monitor.x + (self.monitor.width as i32 / 2), + self.monitor.y + (self.monitor.height as i32 / 2), + ); + + // Get relative position from monitor center. + let delta_x = widget_center.x - monitor_center.x; + let delta_y = widget_center.y - monitor_center.y; + + match delta_x.abs() > delta_y.abs() { + // Widget is in left or right triangle. + true => { + if delta_x > 0 { + DockEdge::Right + } else { + DockEdge::Left + } + } + // Widget is in top or bottom triangle. + false => { + if delta_y > 0 { + DockEdge::Bottom + } else { + DockEdge::Top + } + } + } + } } impl WidgetFactory { @@ -174,15 +227,6 @@ impl WidgetFactory { }; for coordinates in self.widget_coordinates(placement).await { - let Coordinates { - size, - position, - monitor_size, - scale_factor, - anchor, - offset, - } = coordinates; - let new_count = self.widget_count.fetch_add(1, Ordering::Relaxed) + 1; @@ -227,6 +271,17 @@ impl WidgetFactory { .resizable(widget_config.resizable) .build()?; + let mut size = coordinates.size; + let mut position = coordinates.position; + + if placement.dock_to_edge.enabled { + (size, position) = self.dock_to_edge( + &window, + &placement.dock_to_edge, + &coordinates, + )?; + } + info!("Positioning widget to {:?} {:?}", size, position); let _ = window.set_size(size); let _ = window.set_position(position); @@ -239,8 +294,22 @@ impl WidgetFactory { let _ = window.set_position(position); } + let window_handle = { + #[cfg(target_os = "windows")] + { + let handle = + window.hwnd().context("Failed to get window handle.")?; + + Some(handle.0 as isize) + } + + #[cfg(not(target_os = "windows"))] + None + }; + let state = WidgetState { id: widget_id.clone(), + window_handle, config: widget_config.clone(), config_path: config_path.clone(), html_path: html_path.clone(), @@ -252,104 +321,13 @@ impl WidgetFactory { serde_json::to_string(&state)? )); + // On Windows, Tauri's `skip_taskbar` option isn't 100% reliable, + // so we also set the window as a tool window. #[cfg(target_os = "windows")] - { - let window = window.as_ref().window(); - - // On Windows, Tauri's `skip_taskbar` option isn't 100% reliable, - // so we also set the window as a tool window. - let _ = window.set_tool_window(!widget_config.shown_in_taskbar); - - // Reserve space for the app bar if enabled. - if placement.reserve_space.enabled { - let edge = if let Some(edge) = placement.reserve_space.edge { - edge - } else { - // default to whichever edge the widget appears to be on - let widget_horizontal = size.width > size.height; - - match (anchor, widget_horizontal) { - (AnchorPoint::Center, true) => WidgetEdge::Top, - (AnchorPoint::Center, false) => WidgetEdge::Left, - (AnchorPoint::TopCenter, _) => WidgetEdge::Top, - (AnchorPoint::CenterLeft, _) => WidgetEdge::Left, - (AnchorPoint::CenterRight, _) => WidgetEdge::Right, - (AnchorPoint::BottomCenter, _) => WidgetEdge::Bottom, - (AnchorPoint::TopLeft, true) => WidgetEdge::Top, - (AnchorPoint::TopLeft, false) => WidgetEdge::Left, - (AnchorPoint::TopRight, true) => WidgetEdge::Top, - (AnchorPoint::TopRight, false) => WidgetEdge::Right, - (AnchorPoint::BottomLeft, true) => WidgetEdge::Bottom, - (AnchorPoint::BottomLeft, false) => WidgetEdge::Left, - (AnchorPoint::BottomRight, true) => WidgetEdge::Bottom, - (AnchorPoint::BottomRight, false) => WidgetEdge::Right, - } - }; - - // total height of monitor perpendicular to the edge - let total_height = if edge.is_horizontal() { - monitor_size.height - } else { - monitor_size.width - }; - - let thickness = - if let Some(thickness) = &placement.reserve_space.thickness { - thickness.to_px_scaled(total_height, scale_factor) - } else { - // default to whichever dimension of widget is smaller - // if this is not desired the user should specify a thickness - // anyway - if size.width > size.height { - size.height - } else { - size.width - } - }; - - let offset = - if let Some(offset) = &placement.reserve_space.offset { - offset.to_px_scaled(total_height, scale_factor) - } else { - // default to widget position offset - match edge { - WidgetEdge::Top => offset.y, - WidgetEdge::Bottom => -offset.y, - WidgetEdge::Left => offset.x, - WidgetEdge::Right => -offset.x, - } - }; - - let reserve_size = if edge.is_horizontal() { - PhysicalSize::new(monitor_size.width, thickness) - } else { - PhysicalSize::new(thickness, monitor_size.height) - }; - - let reserve_position = match edge { - WidgetEdge::Top => PhysicalPosition::new(0, offset), - WidgetEdge::Bottom => PhysicalPosition::new( - 0, - monitor_size.height - thickness - offset, - ), - WidgetEdge::Left => PhysicalPosition::new(offset, 0), - WidgetEdge::Right => PhysicalPosition::new( - monitor_size.width - thickness - offset, - 0, - ), - }; - - tracing::info!( - "Reserving app bar space on {:?}: {:?} {:?}", - edge, - reserve_size, - reserve_position - ); - - let _ = - window.allocate_app_bar(reserve_size, reserve_position, edge); - } - } + let _ = window + .as_ref() + .window() + .set_tool_window(!widget_config.shown_in_taskbar); // On MacOS, we need to set the window as above the menu bar for it // to truly be always on top. @@ -372,6 +350,130 @@ impl WidgetFactory { Ok(()) } + /// Dock the widget window to a given edge. This might result in the + /// window being resized or repositioned (e.g. if a window is already + /// docked to the given edge). + /// + /// Returns the new window size and position. + fn dock_to_edge( + &self, + window: &tauri::WebviewWindow, + dock_config: &DockConfig, + coords: &WidgetCoordinates, + ) -> anyhow::Result<(PhysicalSize, PhysicalPosition)> { + #[cfg(not(target_os = "windows"))] + { + return Ok((coords.size, coords.position)); + } + + #[cfg(target_os = "windows")] + { + // Disallow docking with a centered anchor point. Doesn't make sense. + if coords.anchor == AnchorPoint::Center { + return Ok((coords.size, coords.position)); + } + + let edge = dock_config.edge.unwrap_or_else(|| coords.closest_edge()); + + // Offset from the monitor edge to the window. + let offset = match edge { + DockEdge::Top => coords.offset.y, + DockEdge::Bottom => -coords.offset.y, + DockEdge::Left => coords.offset.x, + DockEdge::Right => -coords.offset.x, + }; + + // Length of the window perpendicular to the monitor edge. + let window_length = if edge.is_horizontal() { + coords.size.height + } else { + coords.size.width + }; + + // Margin to reserve *after* the window. Can be negative, but should + // not be smaller than the size of the window. + let window_margin = dock_config + .window_margin + .to_px_scaled(window_length as i32, coords.monitor.scale_factor) + .clamp(-coords.size.height, i32::MAX); + + let monitor_length = if edge.is_horizontal() { + coords.monitor.height + } else { + coords.monitor.width + }; + + // Prevent the reserved amount from exceeding 50% of the monitor + // size. This maximum is arbitrary but should be sufficient for + // most cases. + let reserved_length = (offset + window_length + window_margin) + .clamp(0, monitor_length as i32 / 2); + + let reserve_size = if edge.is_horizontal() { + PhysicalSize::new(coords.monitor.width as i32, reserved_length) + } else { + PhysicalSize::new(reserved_length, coords.monitor.height as i32) + }; + + let reserve_position = match edge { + DockEdge::Top | DockEdge::Left => { + PhysicalPosition::new(coords.monitor.x, coords.monitor.y) + } + DockEdge::Bottom => PhysicalPosition::new( + coords.monitor.x, + coords.monitor.y + coords.monitor.height as i32 + - reserved_length, + ), + DockEdge::Right => PhysicalPosition::new( + coords.monitor.x + coords.monitor.width as i32 - reserved_length, + coords.monitor.y, + ), + }; + + let (allocated_size, allocated_position) = window + .as_ref() + .window() + .allocate_app_bar(reserve_size, reserve_position, edge)?; + + // Adjust the size to account for the window margin. + let final_size = if edge.is_horizontal() { + PhysicalSize::new( + allocated_size.width, + allocated_size.height.saturating_sub(window_margin.abs()), + ) + } else { + PhysicalSize::new( + allocated_size.width.saturating_sub(window_margin.abs()), + allocated_size.height, + ) + }; + + // Adjust position if we're docked to bottom or right edge to account + // for the size reduction. + let final_position = match edge { + DockEdge::Bottom => PhysicalPosition::new( + allocated_position.x, + allocated_position.y + + (allocated_size.height - final_size.height), + ), + DockEdge::Right => PhysicalPosition::new( + allocated_position.x + (allocated_size.width - final_size.width), + allocated_position.y, + ), + _ => allocated_position, + }; + + tracing::info!( + "Docked widget to edge '{:?}' with size {:?} and position {:?}.", + edge, + final_size, + final_position + ); + + Ok((final_size, final_position)) + } + } + /// Opens presets that are configured to be launched on startup. pub async fn startup(&self) -> anyhow::Result<()> { let startup_configs = self.config.startup_configs().await; @@ -408,25 +510,27 @@ impl WidgetFactory { let widget_states = self.widget_states.clone(); let close_tx = self.close_tx.clone(); - #[cfg(target_os = "windows")] - let window_handle = - window.hwnd().context("Failed to get window handle.")?.0 as _; - window.on_window_event(move |event| { if let WindowEvent::Destroyed = event { let widget_states = widget_states.clone(); let close_tx = close_tx.clone(); let widget_id = widget_id.clone(); - // Ensure appbar space is deallocated on close. - #[cfg(target_os = "windows")] - crate::common::remove_app_bar(window_handle); - task::spawn(async move { let mut widget_states = widget_states.lock().await; // Remove the widget state. - let _ = widget_states.remove(&widget_id); + let state = widget_states.remove(&widget_id); + + // Ensure appbar space is deallocated on close. + #[cfg(target_os = "windows")] + { + if let Some(window_handle) = + state.and_then(|state| state.window_handle) + { + let _ = remove_app_bar(window_handle); + } + } // Broadcast the close event. if let Err(err) = close_tx.send(widget_id) { @@ -443,7 +547,7 @@ impl WidgetFactory { async fn widget_coordinates( &self, placement: &WidgetPlacement, - ) -> Vec { + ) -> Vec { let mut coordinates = vec![]; let monitors = self @@ -513,13 +617,12 @@ impl WidgetFactory { let window_position = PhysicalPosition::new(anchor_x + offset_x, anchor_y + offset_y); - coordinates.push(Coordinates { + coordinates.push(WidgetCoordinates { size: window_size, position: window_position, - monitor_size: PhysicalSize::new(monitor_width, monitor_height), - scale_factor: monitor.scale_factor, - anchor: placement.anchor, offset: PhysicalPosition::new(offset_x, offset_y), + monitor: monitor.clone(), + anchor: placement.anchor, }); } @@ -602,10 +705,19 @@ impl WidgetFactory { widget_ids .iter() .filter_map(|id| widget_states.remove(id)) + .inspect(|widget_state| { + // Need to clean up any appbars prior to restarting. + #[cfg(target_os = "windows")] + { + if let Some(window_handle) = widget_state.window_handle { + let _ = remove_app_bar(window_handle); + } + } + }) .collect::>() }; - for widget_state in changed_states { + for widget_state in &changed_states { info!( "Relaunching widget #{} from {}", widget_state.id, diff --git a/packages/settings-ui/src/configs/WidgetConfigForm.tsx b/packages/settings-ui/src/configs/WidgetConfigForm.tsx index 05a549c5..0e07d6ff 100644 --- a/packages/settings-ui/src/configs/WidgetConfigForm.tsx +++ b/packages/settings-ui/src/configs/WidgetConfigForm.tsx @@ -11,24 +11,30 @@ import { Tooltip, TooltipTrigger, } from '@glzr/components'; -import { IconTrash } from '@tabler/icons-solidjs'; +import { IconAlertTriangle, IconTrash } from '@tabler/icons-solidjs'; import { createForm, Field } from 'smorf'; -import { createEffect, on } from 'solid-js'; +import { batch, createEffect, on, Show } from 'solid-js'; import { WidgetConfig } from 'zebar'; export interface WidgetConfigFormProps { config: WidgetConfig; + configPath: string; onChange: (config: WidgetConfig) => void; } export function WidgetConfigForm(props: WidgetConfigFormProps) { const configForm = createForm(props.config); - // Update the form when the incoming config changes. + // Update the form when the config is different. createEffect( on( - () => props.config, - config => configForm.setValue(config), + () => props.configPath, + () => { + configForm.unsetDirty(); + configForm.unsetTouched(); + configForm.setValue(props.config); + }, + { defer: true }, ), ); @@ -57,11 +63,10 @@ export function WidgetConfigForm(props: WidgetConfigFormProps) { monitorSelection: { type: 'all', }, - reserveSpace: { + dockToEdge: { enabled: false, edge: null, - thickness: null, - offset: null, + windowMargin: '0px', }, }, ]); @@ -73,6 +78,27 @@ export function WidgetConfigForm(props: WidgetConfigFormProps) { ); } + function anchorToEdges( + anchor: string, + ): ('top' | 'left' | 'right' | 'bottom')[] { + switch (anchor) { + case 'top_left': + return ['top', 'left']; + case 'top_center': + return ['top']; + case 'top_right': + return ['top', 'right']; + case 'center': + return []; + case 'bottom_left': + return ['bottom', 'left']; + case 'bottom_center': + return ['bottom']; + case 'bottom_right': + return ['bottom', 'right']; + } + } + return (
@@ -216,6 +242,23 @@ export function WidgetConfigForm(props: WidgetConfigFormProps) { ] as const } {...inputProps()} + onChange={(value: any) => { + batch(() => { + inputProps().onChange(value); + + // Dock edges depend on the anchor point. Change + // to first valid edge for given anchor point. + if ( + configForm.value.presets[index].dockToEdge + .edge !== null + ) { + configForm.setFieldValue( + `presets.${index}.dockToEdge.edge`, + anchorToEdges(value)[0] ?? null, + ); + } + }); + }} /> )} @@ -289,71 +332,111 @@ export function WidgetConfigForm(props: WidgetConfigFormProps) {
-

Reserve space

- -
+
{inputProps => ( )} - - {inputProps => ( - + ( + + )} /> - )} - + + Dock to edge has no effect with a centered anchor + point. + + + +
- {/* TODO: Change to px/percent input. */} - - {inputProps => ( - - )} - + {configForm.value.presets[index].dockToEdge.enabled && + configForm.value.presets[index].anchor !== 'center' && ( + <> + + {inputProps => ( + <> + inputProps().onBlur()} + onChange={enabled => + inputProps().onChange( + enabled + ? null + : anchorToEdges( + configForm.value.presets[index] + .anchor, + )[0] ?? null, + ) + } + value={inputProps().value === null} + /> - {/* TODO: Change to px/percent input. */} - - {inputProps => ( - - )} - -
+ + + anchorToEdges( + configForm.value.presets[index].anchor, + ).includes(opt.value), + )} + {...inputProps()} + /> + + + )} + + + {/* TODO: Change to px/percent input. */} + + {inputProps => ( + + )} + + + )} ))} diff --git a/packages/settings-ui/src/configs/WidgetConfigs.tsx b/packages/settings-ui/src/configs/WidgetConfigs.tsx index f15b954e..c8b617cf 100644 --- a/packages/settings-ui/src/configs/WidgetConfigs.tsx +++ b/packages/settings-ui/src/configs/WidgetConfigs.tsx @@ -183,6 +183,7 @@ export function WidgetConfigs() { onConfigChange(selectedConfigPath(), config) }