mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-24 04:15:09 +00:00
Merge branch 'worktree-wf_093aad93-ad0-3' into review-fixes
This commit is contained in:
commit
4f14efe953
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