mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-23 14:14:47 +00:00
fix: keep bubbles and the panel on their own monitor and follow the Linux theme
- Place the details panel inside the work area of the bubble's monitor instead of the union of all monitors, so it flips above the bubble rather than landing over the taskbar or off-screen in mixed layouts. - Clamp bubbles into their monitor's work area after a resize and update the saved position of bubbles that already have one. - Snap to the taskbar only when it borders the bubble's work area, so the primary monitor's taskbar no longer pulls bubbles on other monitors. - Follow live theme changes on Linux through the desktop portal's SettingChanged signal, since tao's ThemeChanged never reaches a window.
This commit is contained in:
1 parent
452ba60ea5
commit
d1f48dfb2f
6 files changed
+302
-37
No files matched your search
+43
-32
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}");
|
||||
|
||||
Reference in new issue
Block a user