Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 2 additions & 37 deletions apps/desktop-tauri/src-tauri/src/commands/surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ pub fn dismiss_tray_panel(app: tauri::AppHandle) -> Result<(), String> {
crate::shell::flyout_window::hide(&app)
}

/// Arm the gesture blur guard before a resize-grip drag or drag-reorder
/// gesture starts its Win32/OLE modal loop, so the transient
/// `Focused(false)` that loop produces doesn't auto-hide the flyout.
#[tauri::command]
pub fn begin_flyout_gesture(app: tauri::AppHandle) -> Result<(), String> {
let state = app
Expand Down Expand Up @@ -78,9 +75,8 @@ pub async fn open_flyout_window(app: tauri::AppHandle) -> Result<(), String> {
}

/// Reveal the flyout window after the frontend's first layout pass. Called by
/// `useTrayPanelLayout` once content has been measured/auto-fit (or the
/// remembered fixed size re-applied), so Windows never shows a pre-measure
/// blank/backing frame.
/// `useTrayPanelLayout` once content has been measured and auto-fit, so
/// Windows never shows a pre-measure blank/backing frame.
///
/// No-ops when the flyout window doesn't exist or no one-shot reveal is pending.
/// The window takes focus only as far as the pending reveal's activation
Expand Down Expand Up @@ -116,37 +112,6 @@ pub fn close_settings_window(
crate::shell::settings_window::dismiss(&app, &window)
}

/// Persist a user-chosen size (logical px) for the "Pop Out Dashboard" flyout
/// window. Only the size is stored (via a size-only `StoredSize` entry — no
/// fabricated `x`/`y`); the position is tracked separately, only once the
/// user drags the flyout. The frontend calls this on genuine user drag-resizes, not on its own
/// auto-fit resizes, so auto-fit sizes never freeze the panel.
#[tauri::command]
pub fn set_flyout_size(width: f64, height: f64) -> Result<(), String> {
let width = (width.round() as i64).clamp(1, i64::from(u32::MAX)) as u32;
let height = (height.round() as i64).clamp(1, i64::from(u32::MAX)) as u32;
crate::shell::flyout_window::save_stored_size(width, height);
Ok(())
}

/// Forget where the user dragged the flyout and anchor it to the tray again
/// (double-click on the panel's move handle).
#[tauri::command]
pub fn reset_flyout_position(app: tauri::AppHandle) -> Result<(), String> {
crate::shell::flyout_window::reset_position(&app)
}

/// Return the remembered flyout size, if the user has manually resized it.
/// The frontend uses this to decide whether to auto-fit (no stored size) or
/// honor the user's size (stored) on open. Transparently migrates a
/// pre-existing size stored under the legacy `SurfaceMode::TrayPanel`
/// shared-window geometry key (from before the flyout became its own
/// window), so upgrading users don't lose their remembered size.
#[tauri::command]
pub fn flyout_stored_size() -> Result<Option<(u32, u32)>, String> {
Ok(crate::shell::flyout_window::stored_size())
}

#[tauri::command]
pub fn get_current_surface_state(
state: tauri::State<'_, Mutex<AppState>>,
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,39 @@ pub fn get_work_area_rect(app: tauri::AppHandle) -> Result<WorkAreaRect, String>

// ── Misc UX ────────────────────────────────────────────────────────────

#[tauri::command]
pub fn get_system_accent_color() -> Option<String> {
system_accent_abgr().map(abgr_to_hex)
}

#[cfg(windows)]
fn system_accent_abgr() -> Option<u32> {
use winreg::RegKey;
use winreg::enums::HKEY_CURRENT_USER;

let hkcu = RegKey::predef(HKEY_CURRENT_USER);
[
(
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Accent",
"AccentColorMenu",
),
(r"Software\Microsoft\Windows\DWM", "AccentColor"),
]
.into_iter()
.find_map(|(path, name)| hkcu.open_subkey(path).ok()?.get_value::<u32, _>(name).ok())
}

#[cfg(not(windows))]
fn system_accent_abgr() -> Option<u32> {
None
}

/// Windows stores accent colors as 0xAABBGGRR.
fn abgr_to_hex(value: u32) -> String {
let [r, g, b, _] = value.to_le_bytes();
format!("#{r:02x}{g:02x}{b:02x}")
}

#[tauri::command]
pub fn play_notification_sound(
event: codexbar::sound::NotificationSoundEvent,
Expand Down Expand Up @@ -425,6 +458,12 @@ fn copilot_label_matches_login(account: &TokenAccount, login: Option<&str>) -> b
mod tests {
use super::*;

#[test]
fn accent_registry_dword_reads_as_abgr() {
assert_eq!(abgr_to_hex(0xffd4_7800), "#0078d4");
assert_eq!(abgr_to_hex(0xff00_b9ff), "#ffb900");
}

#[test]
fn dashboard_url_resolves_from_codex_provider_metadata() {
assert_eq!(
Expand Down
206 changes: 14 additions & 192 deletions apps/desktop-tauri/src-tauri/src/geometry_store.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
//! Persistent window-geometry store for the Tauri desktop shell.
//!
//! Remembers position (and size where applicable) for detached user surfaces:
//! PopOut and Settings.
//!
//! The flyout window is anchored to the tray until the user drags it; only
//! then is its position stored (`shell::flyout_placement`). Its SIZE is
//! remembered via the size-only [`StoredSize`] entries below, kept separate
//! from [`StoredGeometry`] so a resize of a tray-anchored flyout never carries
//! fabricated `x`/`y` coordinates.
//! Remembers position (and size where applicable) for detached user surfaces.

use std::fs;
use std::path::PathBuf;
Expand Down Expand Up @@ -35,29 +28,13 @@ pub struct StoredGeometry {
pub height: Option<u32>,
}

/// A size-only persisted entry, for windows that are always re-anchored (never
/// remember position) so storing `x`/`y` would be fabricated data. Used by the
/// detached flyout window, which is anchored above the tray on every open —
/// only its user-chosen width/height is meaningful to remember.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredSize {
pub width: u32,
pub height: u32,
}

/// All persisted geometries keyed by surface mode string (`settings`, ...).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GeometryFile {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub entries: std::collections::BTreeMap<String, StoredGeometry>,
/// Size-only entries (no position), keyed by an arbitrary label (e.g. the
/// `"flyout"` window). Kept in a separate map — rather than reusing
/// `entries` with a fabricated `x: 0, y: 0` — so the on-disk shape can't
/// be misread as a remembered position.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub size_entries: std::collections::BTreeMap<String, StoredSize>,
}

fn geometry_path() -> Option<PathBuf> {
Expand All @@ -70,16 +47,9 @@ fn geometry_path() -> Option<PathBuf> {
/// Surface modes eligible for geometry persistence.
///
/// - `Hidden`: never remembered.
/// - `TrayPanel`: remembered for SIZE — the "Pop Out Dashboard" flyout is always
/// re-anchored above the tray icon (its position comes from
/// `default_surface_position`, which ignores the stored x/y), but its
/// width/height persist so a user resize sticks across opens.
/// - `PopOut` / `Settings`: user-movable, position + size remembered.
pub fn should_remember(mode: SurfaceMode) -> bool {
matches!(
mode,
SurfaceMode::TrayPanel | SurfaceMode::PopOut | SurfaceMode::Settings
)
matches!(mode, SurfaceMode::PopOut | SurfaceMode::Settings)
}

fn load_file() -> GeometryFile {
Expand Down Expand Up @@ -154,66 +124,6 @@ pub fn save_entry(key: &str, geometry: StoredGeometry) {
}
}

/// Forget the geometry stored under an arbitrary key. No-op (and no write)
/// when nothing is stored there.
pub fn remove_entry(key: &str) {
let mut file = load_file();
if file.entries.remove(key).is_none() {
return;
}
if let Err(err) = save_file(&file) {
tracing::warn!(target: "codexbar::geometry", %err, "failed to persist geometry");
}
}

/// Legacy key the flyout size was stored under before it became a dedicated
/// window: the old `SurfaceMode::TrayPanel` shared-window geometry entry.
const LEGACY_FLYOUT_SIZE_KEY: &str = "trayPanel";

/// Resolve a size-only entry from an in-memory [`GeometryFile`]: prefer the
/// new `size_entries` map, falling back to migrating a pre-existing
/// `"trayPanel"` [`StoredGeometry`] width/height (from before the flyout was
/// split into its own window) so upgrading users keep their remembered size
/// instead of it silently resetting to the default. Pure/side-effect-free so
/// it can be unit-tested without touching disk; [`load_size`] is the
/// disk-backed wrapper that also persists the migrated value.
fn resolve_size(file: &GeometryFile, key: &str) -> Option<StoredSize> {
if let Some(size) = file.size_entries.get(key).copied() {
return Some(size);
}
if key != LEGACY_FLYOUT_SIZE_KEY {
let legacy = file.entries.get(LEGACY_FLYOUT_SIZE_KEY)?;
let (width, height) = (legacy.width?, legacy.height?);
return Some(StoredSize { width, height });
}
None
}

/// Look up a remembered size-only entry (e.g. the flyout window's
/// user-chosen width/height). See [`resolve_size`] for the migration
/// fallback; a migrated value is re-persisted under the new key so this
/// lookup path is only taken once (the legacy entry is left in place —
/// harmless, since nothing reads `entries["trayPanel"]` as a position
/// anymore).
pub fn load_size(key: &str) -> Option<StoredSize> {
let file = load_file();
let size = resolve_size(&file, key)?;
if !file.size_entries.contains_key(key) {
save_size(key, size);
}
Some(size)
}

/// Persist a size-only entry under an arbitrary key.
pub fn save_size(key: &str, size: StoredSize) {
let mut file = load_file();
file.version = GEOMETRY_VERSION;
file.size_entries.insert(key.to_string(), size);
if let Err(err) = save_file(&file) {
tracing::warn!(target: "codexbar::geometry", %err, "failed to persist size");
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -225,9 +135,18 @@ mod tests {
}

#[test]
fn tray_panel_is_remembered_for_size() {
// The flyout persists its size (position is always re-anchored).
assert!(should_remember(SurfaceMode::TrayPanel));
fn tray_panel_is_not_remembered() {
assert!(!should_remember(SurfaceMode::TrayPanel));
}

#[test]
fn old_flyout_size_entries_are_dropped_on_the_next_save() {
let json = r#"{"version":1,"entries":{"settings":{"x":10,"y":20}},"size_entries":{"flyoutLogical":{"width":700,"height":700}}}"#;
let file: GeometryFile = serde_json::from_str(json).unwrap();
assert_eq!(
serde_json::to_string(&file).unwrap(),
r#"{"version":1,"entries":{"settings":{"x":10,"y":20}}}"#
);
}

#[test]
Expand Down Expand Up @@ -309,101 +228,4 @@ mod tests {
assert_eq!(entry.width, None);
assert_eq!(entry.height, None);
}

#[test]
fn stored_size_round_trips_without_position_fields() {
let mut f = GeometryFile::default();
f.size_entries.insert(
"flyout".into(),
StoredSize {
width: 400,
height: 820,
},
);
let json = serde_json::to_string(&f).unwrap();
// The size-only entry must never carry x/y — that's the whole point
// of keeping it out of `entries: BTreeMap<String, StoredGeometry>`.
assert!(!json.contains("\"x\""));
assert!(!json.contains("\"y\""));
let parsed: GeometryFile = serde_json::from_str(&json).unwrap();
let entry = parsed.size_entries.get("flyout").unwrap();
assert_eq!(entry.width, 400);
assert_eq!(entry.height, 820);
}

#[test]
fn resolve_size_prefers_new_key_over_legacy() {
let mut file = GeometryFile::default();
file.size_entries.insert(
"flyout".into(),
StoredSize {
width: 500,
height: 900,
},
);
file.entries.insert(
LEGACY_FLYOUT_SIZE_KEY.into(),
StoredGeometry {
x: 0,
y: 0,
width: Some(640),
height: Some(720),
},
);

let resolved = resolve_size(&file, "flyout").expect("size present");
assert_eq!(resolved.width, 500);
assert_eq!(resolved.height, 900);
}

#[test]
fn resolve_size_migrates_legacy_tray_panel_geometry_when_no_new_entry() {
// Simulates an upgrading user: pre-refactor size lived under the
// `SurfaceMode::TrayPanel` shared-window geometry key.
let mut file = GeometryFile::default();
file.entries.insert(
LEGACY_FLYOUT_SIZE_KEY.into(),
StoredGeometry {
x: 0,
y: 0,
width: Some(640),
height: Some(720),
},
);

let resolved = resolve_size(&file, "flyout").expect("legacy size migrates");
assert_eq!(resolved.width, 640);
assert_eq!(resolved.height, 720);
}

#[test]
fn resolve_size_ignores_legacy_entry_missing_width_or_height() {
let mut file = GeometryFile::default();
file.entries.insert(
LEGACY_FLYOUT_SIZE_KEY.into(),
StoredGeometry {
x: 0,
y: 0,
width: Some(640),
height: None,
},
);

assert!(resolve_size(&file, "flyout").is_none());
}

#[test]
fn resolve_size_returns_none_when_nothing_stored() {
let file = GeometryFile::default();
assert!(resolve_size(&file, "flyout").is_none());
}

#[test]
fn resolve_size_for_legacy_key_itself_does_not_self_migrate() {
// Looking up the legacy key directly should only consult
// `size_entries` (the `key != LEGACY_FLYOUT_SIZE_KEY` guard) — no
// infinite fallback to itself.
let file = GeometryFile::default();
assert!(resolve_size(&file, LEGACY_FLYOUT_SIZE_KEY).is_none());
}
}
9 changes: 1 addition & 8 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,6 @@ fn main() {
commands::open_settings_window,
commands::open_flyout_window,
commands::close_settings_window,
commands::set_flyout_size,
commands::flyout_stored_size,
commands::reset_flyout_position,
commands::get_current_surface_state,
commands::refresh_providers,
commands::refresh_providers_if_stale,
Expand Down Expand Up @@ -304,6 +301,7 @@ fn main() {
commands::register_global_shortcut,
commands::unregister_global_shortcut,
commands::get_work_area_rect,
commands::get_system_accent_color,
commands::play_notification_sound,
commands::open_external_url,
commands::reanchor_tray_panel,
Expand Down Expand Up @@ -401,11 +399,6 @@ fn main() {
{
return;
}
// Gesture guard: ignore blur while a resize-grip drag or
// HTML5 drag-reorder is running its Win32/OLE modal loop.
// Windows produces a spurious Focused(false) the instant
// such a loop starts even though the user never left the
// window; see AppState::begin_gesture_blur_guard.
if let Some(st) = window.app_handle().try_state::<Mutex<AppState>>()
&& st
.lock()
Expand Down
Loading