Merge branch 'worktree-wf_093aad93-ad0-3' into review-fixes

This commit is contained in:
tiennm99 committed 2026-10-23 03:41:26 +07:00
commit 4f14efe953
6 files changed
+302 -37

No files matched your search

+43 -32
View File
@@ -183,30 +183,42 @@ fn screens(app: &AppHandle) -> Vec<Screen> {
/// The primary screen, else the first one, else a 1920x1080 stand-in.
fn primary_screen(app: &AppHandle, screens: &[Screen]) -> Screen {
primary_monitor_screen(app, screens).unwrap_or(FALLBACK)
}
/// The primary screen, else the first one; `None` without any monitor.
fn primary_monitor_screen(app: &AppHandle, screens: &[Screen]) -> Option<Screen> {
match app.primary_monitor() {
Ok(Some(monitor)) => Screen::of(&monitor),
Ok(None) => screens.first().copied().unwrap_or(FALLBACK),
Ok(Some(monitor)) => Some(Screen::of(&monitor)),
Ok(None) => screens.first().copied(),
Err(err) => {
log::warn!("primary monitor unavailable: {err}");
screens.first().copied().unwrap_or(FALLBACK)
screens.first().copied()
}
}
}
/// The screen a window is on (Win32 picks the nearest), else the primary.
/// The screen a window is on (Win32 picks the nearest), else the primary,
/// else a 1920x1080 stand-in.
fn screen_of(window: &WebviewWindow) -> Screen {
monitor_screen_of(window).unwrap_or(FALLBACK)
}
/// The screen a window is on, else the primary; `None` without any monitor.
fn monitor_screen_of(window: &WebviewWindow) -> Option<Screen> {
match window.current_monitor() {
Ok(Some(monitor)) => Screen::of(&monitor),
Ok(None) => {
let app = window.app_handle();
primary_screen(app, &screens(app))
}
Err(err) => {
log::warn!("monitor of {} unavailable: {err}", window.label());
let app = window.app_handle();
primary_screen(app, &screens(app))
}
Ok(Some(monitor)) => return Some(Screen::of(&monitor)),
Ok(None) => {}
Err(err) => log::warn!("monitor of {} unavailable: {err}", window.label()),
}
let app = window.app_handle();
primary_monitor_screen(app, &screens(app))
}
/// Work area of the monitor `window` is on, else of the primary; `None`
/// when no monitor can be found.
pub(crate) fn work_area_of(window: &WebviewWindow) -> Option<Rect> {
monitor_screen_of(window).map(|s| s.work)
}
fn outer_rect(window: &WebviewWindow) -> Option<Rect> {
@@ -527,20 +539,9 @@ pub fn on_window_event(window: &Window, event: &WindowEvent) {
}
});
}
// tao reports "no preference" and an unreachable portal as light, so
// the portal is read again and only a stated preference applies.
#[cfg(target_os = "linux")]
WindowEvent::ThemeChanged(_) => {
let app = window.app_handle().clone();
defer("bubble-theme", move || {
let Some(is_dark) = platform::linux::portal_prefers_dark() else {
return;
};
if let Some(runtime) = runtime_of(&app) {
runtime.set_dark(is_dark);
}
});
}
// On Linux tao sends ThemeChanged with a placeholder window id that
// tauri drops, so it never arrives here; `platform::linux_theme`
// follows the portal's SettingChanged signal instead.
_ => {}
}
}
@@ -663,20 +664,30 @@ fn reclamp(app: &AppHandle, provider: ProviderId) {
}
/// Resize every bubble to `size_logical` around its own centre, at its own
/// DPI. Nothing snaps and no position is saved.
pub fn resize_all(app: &AppHandle, size_logical: i32) {
/// DPI, kept inside the work area of the monitor it was on. Nothing snaps
/// and nothing is saved here; returns where each moved bubble now is, empty
/// where windows cannot be positioned.
pub fn resize_all(app: &AppHandle, size_logical: i32) -> Vec<(ProviderId, Point)> {
let placeable = platform::positioning_supported();
for (_, window) in windows(app) {
let mut moved = Vec::new();
for (provider, window) in windows(app) {
let Some(rect) = outer_rect(&window) else {
continue;
};
// The monitor before the resize: a bubble grown past an edge may
// overlap a neighbour more than its own monitor afterwards.
let work = screen_of(&window).work;
let (w, h) = geom::bubble_size_px(size_logical, dpi_of(&window));
let (cx, cy) = ((rect.l + rect.r) / 2, (rect.t + rect.b) / 2);
set_size(&window, (w, h));
if placeable {
set_position(&window, Point::new(cx - w / 2, cy - h / 2));
let centred = Rect::from_xywh(cx - w / 2, cy - h / 2, w, h);
let pos = geom::clamp_into_work_area(centred, work, provider);
set_position(&window, pos);
moved.push((provider, pos));
}
}
moved
}
/// Move every bubble to its default spot on the primary screen. The
+78 -1
View File
@@ -324,6 +324,9 @@ fn snap_alongside_taskbar(
gap: i32,
) {
let Fit { wa, w, h, zone } = *fit;
if !taskbar_borders_work_area(edge, tb, wa) {
return;
}
// Only snap on the taskbar's docked edge. The bubble docks against the
// inner face of the taskbar with a 4-px gap so it visually leans on it.
match edge {
@@ -354,6 +357,21 @@ fn snap_alongside_taskbar(
}
}
/// Whether the taskbar sits on this work area's `edge`: its inner face is
/// that work-area edge and it overlaps the work area along that edge. A
/// taskbar on another monitor (the platform reports only the primary's)
/// fails this, so it never pulls a bubble dropped on a secondary monitor.
fn taskbar_borders_work_area(edge: Edge, tb: &Rect, wa: &Rect) -> bool {
let overlaps_x = tb.l < wa.r && tb.r > wa.l;
let overlaps_y = tb.t < wa.b && tb.b > wa.t;
match edge {
Edge::Bottom => tb.t == wa.b && overlaps_x,
Edge::Top => tb.b == wa.t && overlaps_x,
Edge::Left => tb.r == wa.l && overlaps_y,
Edge::Right => tb.l == wa.r && overlaps_y,
}
}
fn align_with_peer(peers: &[Rect], ny: &mut i32, tolerance: i32) {
if let Some(peer) = peers.iter().find(|p| (*ny - p.t).abs() <= tolerance) {
*ny = peer.t;
@@ -362,7 +380,7 @@ fn align_with_peer(peers: &[Rect], ny: &mut i32, tolerance: i32) {
/// Place a `panel_w`×`panel_h` panel below `anchor` (the bubble's outer
/// rect), flipping above when it would clip, and keep it inside `bounds`
/// (the virtual-screen union of all monitors, whose origin can be negative).
/// (the work area of the bubble's monitor, whose origin can be negative).
pub fn place_near(anchor: Rect, panel_size: (i32, i32), bounds: Rect) -> Point {
let (panel_w, panel_h) = panel_size;
let mut x = anchor.l;
@@ -662,6 +680,29 @@ mod tests {
assert_eq!(snap(&i), Point::new(800, 900));
}
#[test]
fn taskbar_of_another_monitor_never_snaps() {
// The platform reports the primary's bottom taskbar; the bubble is
// dropped on a secondary monitor right of the primary, 10 px above
// where that taskbar's snap would put it and far from its own edges.
let primary_taskbar = Rect::new(0, 1040, 1920, 1080);
let rect = Rect::from_xywh(2800, 1040 - 4 - 66 - 10, 200, 66);
for secondary_wa in [
Rect::new(1920, 0, 3840, 1080),
// Same bottom as the primary's work area, still no overlap.
Rect::new(1920, 0, 3840, 1040),
] {
let i = SnapInput {
rect,
work_area: secondary_wa,
dpi: 96,
taskbar: Some((Edge::Bottom, primary_taskbar)),
peers: Vec::new(),
};
assert_eq!(snap(&i), Point::new(rect.l, rect.t), "{secondary_wa:?}");
}
}
#[test]
fn peer_alignment_takes_first_peer_within_tolerance() {
let mut i = input(Rect::from_xywh(800, 500, 200, 66));
@@ -741,6 +782,42 @@ mod tests {
assert_eq!(place_near(b, (280, 400), tall_bounds), Point::new(0, -190));
}
#[test]
fn place_near_flips_above_on_the_shorter_monitor_of_a_mixed_layout() {
// A 1920x1080 laptop (40 px taskbar) left of a top-aligned
// 2560x1440 monitor. Near the laptop's bottom the panel flips above
// within the laptop's work area, though the union of both monitors
// would leave room below.
let laptop_work = Rect::new(0, 0, 1920, 1040);
let bubble = Rect::from_xywh(1500, 940, 200, 66);
assert_eq!(
place_near(bubble, (280, 120), laptop_work),
Point::new(1500, 940 - 120 - 8)
);
let union = Rect::new(0, 0, 4480, 1440);
assert_eq!(
place_near(bubble, (280, 120), union),
Point::new(1500, 940 + 66 + 8)
);
}
#[test]
fn place_near_flips_against_the_work_area_not_the_monitor() {
// The panel fits below inside the 1080-tall monitor but not inside
// its work area, which ends at the taskbar's top at 1040.
let monitor = Rect::new(0, 0, 1920, 1080);
let work = Rect::new(0, 0, 1920, 1040);
let bubble = Rect::from_xywh(100, 880, 200, 66);
assert_eq!(
place_near(bubble, (280, 120), monitor),
Point::new(100, 954)
);
assert_eq!(
place_near(bubble, (280, 120), work),
Point::new(100, 880 - 120 - 8)
);
}
#[test]
fn position_probe_against_monitors() {
let monitors = [Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 3840, 1080)];
+9 -2
View File
@@ -19,6 +19,7 @@ use tauri::{
WebviewWindowBuilder, Window, WindowEvent,
};
use crate::bubble;
use crate::core::geom::{self, Point, Rect};
use crate::core::payload::PanelPayload;
use crate::platform;
@@ -245,7 +246,8 @@ fn show_for(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) {
}
/// Size the panel for the bubble's DPI and place it next to the bubble,
/// inside the union of all monitors. Without a bubble it stays where it is.
/// inside the work area of the bubble's monitor. Without a bubble it stays
/// where it is.
fn place_near_bubble(app: &AppHandle, window: &WebviewWindow, provider: ProviderId) {
let Some(bubble) = app.get_webview_window(&runtime::bubble_label(provider)) else {
log::warn!("no bubble for {}; panel not moved", provider.slug());
@@ -283,7 +285,12 @@ fn place_near_bubble(app: &AppHandle, window: &WebviewWindow, provider: Provider
i32::try_from(size.width).unwrap_or(i32::MAX),
i32::try_from(size.height).unwrap_or(i32::MAX),
);
let bounds = virtual_screen(&runtime::monitor_rects(app)).unwrap_or(anchor);
// The bubble's own work area keeps the panel off the taskbar and off
// the gaps of a mixed-size layout; the union of all monitors and then
// the bubble itself stand in only when no monitor is found.
let bounds = bubble::work_area_of(&bubble)
.or_else(|| virtual_screen(&runtime::monitor_rects(app)))
.unwrap_or(anchor);
let target = geom::place_near(anchor, panel_size, bounds);
// Move first so a DPI change from crossing monitors resizes the window
// before the explicit size, then place it again at the final size.
+157
View File
@@ -0,0 +1,157 @@
//! Live theme switching on Linux.
//!
//! tao reports a desktop theme change with a placeholder window id that
//! tauri drops, so no window ever sees `ThemeChanged`. Instead one
//! background thread listens on the session bus for the desktop portal's
//! `SettingChanged` signal and applies a stated `color-scheme` preference.
use std::sync::Arc;
use std::time::Duration;
use dbus::arg::{ArgType, RefArg, Variant};
use dbus::blocking::Connection;
use dbus::message::MatchRule;
use tauri::{AppHandle, Manager};
use crate::platform::linux::color_scheme_is_dark;
use crate::runtime::Runtime;
const SETTINGS_INTERFACE: &str = "org.freedesktop.portal.Settings";
const APPEARANCE_NAMESPACE: &str = "org.freedesktop.appearance";
const COLOR_SCHEME_KEY: &str = "color-scheme";
/// How long one wait for bus traffic blocks before it is started again.
const PROCESS_TIMEOUT: Duration = Duration::from_secs(60);
/// Start the listener thread. It runs for the life of the process; without
/// a session bus it logs once and ends, leaving the startup theme in place.
pub fn spawn(app: &AppHandle) {
let app = app.clone();
let spawned = std::thread::Builder::new()
.name("theme-listener".into())
.spawn(move || listen(&app));
if let Err(err) = spawned {
log::error!("theme listener thread could not start: {err}");
}
}
fn listen(app: &AppHandle) {
let conn = match Connection::new_session() {
Ok(conn) => conn,
Err(err) => {
log::warn!("no session bus; the theme will not follow the desktop: {err}");
return;
}
};
let rule = MatchRule::new_signal(SETTINGS_INTERFACE, "SettingChanged");
let handle = app.clone();
let added = conn.add_match(
rule,
move |(namespace, key, value): (String, String, Variant<Box<dyn RefArg>>),
_: &Connection,
_: &dbus::Message| {
let scheme = color_scheme_of(&namespace, &key, &value);
if let Some(is_dark) = color_scheme_is_dark(scheme) {
apply(&handle, is_dark);
}
true
},
);
if let Err(err) = added {
log::warn!(
"portal settings signal not watched; the theme will not follow the desktop: {err}"
);
return;
}
loop {
if let Err(err) = conn.process(PROCESS_TIMEOUT) {
log::warn!("theme listener stopped: {err}");
return;
}
}
}
/// Record the new theme on the main thread, like every other UI change.
fn apply(app: &AppHandle, is_dark: bool) {
let handle = app.clone();
let queued = app.run_on_main_thread(move || {
if let Some(runtime) = handle.try_state::<Arc<Runtime>>() {
runtime.set_dark(is_dark);
}
});
if let Err(err) = queued {
log::warn!("theme change could not reach the main thread: {err}");
}
}
/// The `color-scheme` value carried by one `SettingChanged` signal, or
/// `None` for any other setting or a value that is not a `u32`. Variants
/// are unwrapped however deeply the portal nests them.
fn color_scheme_of(namespace: &str, key: &str, value: &dyn RefArg) -> Option<u32> {
if namespace != APPEARANCE_NAMESPACE || key != COLOR_SCHEME_KEY {
return None;
}
unwrap_u32(value)
}
fn unwrap_u32(value: &dyn RefArg) -> Option<u32> {
match value.arg_type() {
ArgType::UInt32 => value.as_u64().and_then(|v| u32::try_from(v).ok()),
ArgType::Variant => value.as_iter()?.next().and_then(unwrap_u32),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn boxed(value: impl RefArg + 'static) -> Variant<Box<dyn RefArg>> {
Variant(Box::new(value))
}
#[test]
fn color_scheme_is_read_from_its_own_setting_only() {
let dark = boxed(1u32);
assert_eq!(
color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &dark),
Some(1)
);
assert_eq!(
color_scheme_of(APPEARANCE_NAMESPACE, "accent-color", &dark),
None
);
assert_eq!(
color_scheme_of("org.gnome.desktop.interface", COLOR_SCHEME_KEY, &dark),
None
);
}
#[test]
fn nested_variants_unwrap_to_the_scheme() {
let nested = boxed(Variant(2u32));
assert_eq!(
color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &nested),
Some(2)
);
let bare: Box<dyn RefArg> = Box::new(0u32);
assert_eq!(
color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &bare),
Some(0)
);
}
#[test]
fn a_value_of_another_type_is_ignored() {
let text = boxed("prefer-dark".to_string());
assert_eq!(
color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &text),
None
);
let signed = boxed(1i32);
assert_eq!(
color_scheme_of(APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY, &signed),
None
);
}
}
+2
View File
@@ -5,6 +5,8 @@ use std::time::{Duration, Instant};
#[cfg(target_os = "linux")]
pub mod linux;
#[cfg(target_os = "linux")]
pub mod linux_theme;
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(windows)]
+13 -2
View File
@@ -515,7 +515,9 @@ impl Runtime {
}
/// Clamp and store the bubble size, then resize every bubble around its
/// centre (no snap, no position save); a no-op when it does not change.
/// centre, kept inside its work area (no snap); a no-op when it does not
/// change. A bubble with a saved position has it updated to where the
/// resize put it; one still at its default spot stays unsaved.
pub fn set_bubble_size(self: &Arc<Self>, size_logical: i32) {
let size = size_logical.clamp(MIN_BUBBLE_SIZE, MAX_BUBBLE_SIZE);
let changed = self.with_state(|s| s.settings.bubble_size_logical != size);
@@ -523,7 +525,12 @@ impl Runtime {
return;
}
self.update_settings(|s| s.bubble_size_logical = size);
bubble::resize_all(&self.ui.app, size);
for (provider, pos) in bubble::resize_all(&self.ui.app, size) {
let saved = self.with_state(|s| s.settings.bubble_positions.get(provider).is_some());
if saved {
self.save_bubble_position(provider, (pos.x, pos.y));
}
}
self.propagate();
}
@@ -986,6 +993,10 @@ fn setup(app: &mut tauri::App, cli: &CliArgs) -> Result<(), Box<dyn Error>> {
scheduler::spawn(config, rx, Arc::clone(&runtime))?;
runtime.send(Message::PollNow);
// tao's theme change never reaches a window on Linux; follow the portal.
#[cfg(target_os = "linux")]
platform::linux_theme::spawn(app.handle());
// Without the thread the bubbles just stay up over fullscreen apps.
if let Err(err) = fullscreen::spawn(Arc::clone(&runtime)) {
log::error!("fullscreen check thread could not start: {err}");