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
118 changes: 112 additions & 6 deletions windows/codenotch/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;

/// The Mac's notch sizes, as multiples of the designed size: Small, Medium, Large.
Expand Down Expand Up @@ -39,12 +40,16 @@ pub struct Config {
/// Allow dragging + wheel resizing (tray toggle, off by default to prevent accidental drags)
#[serde(default)]
pub drag_enabled: bool,
/// Position of the notch along its edge: the window centre as a fraction of the monitor's height
/// (left/right edges) or width (top/bottom edges), 0 = top/left, 1 = bottom/right, default 0.5;
/// saved after a drag. Named `notch_y` from when the right edge was the only one, so an existing
/// config keeps its place.
#[serde(default = "default_notch_y")]
/// The one position every edge used to share, read once so `load` can hand it to the edge the
/// notch is on and never written again. `notch_along` replaces it.
#[serde(default = "default_notch_y", skip_serializing)]
pub notch_y: f64,
/// Where along each edge the notch sits: the window centre as a fraction of that edge's span in
/// the work area, 0 = top/left, 1 = bottom/right, 0.5 = centred (the default for an edge with
/// no entry). One per edge, as the Mac keeps one offset per edge: sliding it along the right
/// edge should not also move it on the top.
#[serde(default)]
pub notch_along: BTreeMap<String, f64>,
/// Which screen edge the notch is pinned to: "right" (the default), "left", "top" or "bottom".
#[serde(default = "default_notch_edge")]
pub notch_edge: String,
Expand Down Expand Up @@ -76,6 +81,12 @@ pub struct Config {
/// false = the pill is kept off the screen edge entirely; the tray icon is then the only way in
#[serde(default = "yes")]
pub notch_visible: bool,
/// true = the Mac's Show on hover: the notch rests as a small pill at the edge and opens when the
/// pointer reaches it. Only means anything while `notch_visible` is true. The Mac's default, and a
/// fresh install's; a config written before this existed keeps the always-open notch it had (see
/// `load`), so nobody's notch starts folding on an update.
#[serde(default = "yes")]
pub notch_on_hover: bool,
/// false = the tray icon is hidden. Refused while the notch is also hidden, because that would
/// leave the app running with no way to reach it.
#[serde(default = "yes")]
Expand Down Expand Up @@ -109,6 +120,40 @@ pub fn edge_or_right(value: &str) -> String {
pub fn edge_is_vertical(edge: &str) -> bool {
matches!(edge, "left" | "right")
}

/// Show on hover is the default for a fresh install, as on the Mac, but a config saved before the
/// setting existed was saved by a notch that was always open, and it stays that way: its owner never
/// chose a folding notch, so an update is not where they should meet one.
fn keep_open_on_upgrade(cfg: &mut Config, raw: Option<&str>) {
let saved_without_it = raw
.and_then(|t| serde_json::from_str::<serde_json::Value>(t).ok())
.is_some_and(|v| v.get("notch_on_hover").is_none());
if saved_without_it {
cfg.notch_on_hover = false;
}
}

/// Migration: the one position every edge used to share becomes the position for the edge the notch
/// was on, so an existing config keeps its place; the other edges start centred. `notch_y` is never
/// written back, so this runs once and a later `load` finds `notch_along` already filled in.
fn carry_shared_position(cfg: &mut Config) {
if cfg.notch_along.is_empty() && (cfg.notch_y - 0.5).abs() > f64::EPSILON {
let edge = edge_or_right(&cfg.notch_edge);
let along = cfg.notch_y;
cfg.set_along(&edge, along);
}
}

impl Config {
/// Where the notch sits along `edge`: centred until it has been slid somewhere on that edge.
pub fn along(&self, edge: &str) -> f64 {
self.notch_along.get(edge).copied().unwrap_or(0.5).clamp(0.0, 1.0)
}
pub fn set_along(&mut self, edge: &str, along: f64) {
self.notch_along.insert(edge.to_string(), along.clamp(0.0, 1.0));
}
}

fn default_scale() -> f64 {
1.0
}
Expand Down Expand Up @@ -151,6 +196,7 @@ impl Default for Config {
bar_w: None,
drag_enabled: false,
notch_y: default_notch_y(),
notch_along: BTreeMap::new(),
notch_edge: default_notch_edge(),
notch_monitor: None,
scale: default_scale(),
Expand All @@ -160,6 +206,7 @@ impl Default for Config {
antigravity_limit: default_antigravity_limit(),
antigravity_model: default_antigravity_model(),
notch_visible: true,
notch_on_hover: true,
tray_visible: true,
show_move_handle: true,
}
Expand All @@ -180,6 +227,7 @@ pub fn load() -> Config {
.as_deref()
.and_then(|t| serde_json::from_str(t).ok())
.unwrap_or_default();
keep_open_on_upgrade(&mut cfg, raw.as_deref());

// Migration: before slots existed the notch was a plain provider list, one ring each. That is
// exactly a list of slots, so nobody's choice is lost and nobody has to reconfigure anything.
Expand All @@ -191,6 +239,8 @@ pub fn load() -> Config {
.collect();
}

carry_shared_position(&mut cfg);

// Both hidden would leave the app unreachable: no pill, no tray icon, no way to open settings.
if !cfg.notch_visible && !cfg.tray_visible {
cfg.tray_visible = true;
Expand All @@ -214,7 +264,63 @@ pub fn save(cfg: &Config) {

#[cfg(test)]
mod tests {
use super::{snap_scale, weekly_ring_or_off};
use super::{carry_shared_position, keep_open_on_upgrade, snap_scale, weekly_ring_or_off, Config};

/// Show on hover is the Mac's default, so a fresh install gets it — but an update must not start
/// folding a notch whose owner has only ever known it open.
#[test]
fn only_a_fresh_install_starts_on_hover() {
let mut fresh = Config::default();
keep_open_on_upgrade(&mut fresh, None);
assert!(fresh.notch_on_hover, "no config file: the Mac's default");

let mut upgraded = Config::default();
keep_open_on_upgrade(&mut upgraded, Some(r#"{"notch_visible":true}"#));
assert!(!upgraded.notch_on_hover, "saved before the setting existed: stays open");

for chosen in [true, false] {
let mut c = Config { notch_on_hover: chosen, ..Default::default() };
keep_open_on_upgrade(&mut c, Some(&format!(r#"{{"notch_on_hover":{chosen}}}"#)));
assert_eq!(c.notch_on_hover, chosen, "a choice already made is kept");
}
}

/// The Mac keeps one offset per edge; sliding the notch along one must not move it on another.
#[test]
fn each_edge_keeps_its_own_place() {
let mut c = Config::default();
assert_eq!(c.along("right"), 0.5, "an edge never slid along is centred");
c.set_along("right", 0.2);
assert_eq!(c.along("right"), 0.2);
assert_eq!(c.along("top"), 0.5, "sliding it on the right left the top where it was");
c.set_along("top", 7.0);
assert_eq!(c.along("top"), 1.0, "and it can never be put past the end of an edge");
}

#[test]
fn the_shared_position_moves_to_the_edge_the_notch_was_on() {
let mut c = Config { notch_y: 0.3, notch_edge: "left".into(), ..Default::default() };
carry_shared_position(&mut c);
assert_eq!(c.along("left"), 0.3, "an existing config keeps its place");
assert_eq!(c.along("right"), 0.5, "the edges it was not on start centred");
// Once carried over, a later load leaves it alone even though notch_y still reads 0.3
c.set_along("left", 0.8);
carry_shared_position(&mut c);
assert_eq!(c.along("left"), 0.8);
// A centred config has nothing to carry, so nothing is written for it
let mut centred = Config::default();
carry_shared_position(&mut centred);
assert!(centred.notch_along.is_empty());
}

#[test]
fn the_shared_position_is_read_but_never_written_again() {
let mut v = serde_json::to_value(Config { notch_y: 0.3, ..Default::default() }).unwrap();
assert!(v.get("notch_y").is_none(), "{v}");
v["notch_y"] = serde_json::json!(0.3);
let back: Config = serde_json::from_value(v).unwrap();
assert_eq!(back.notch_y, 0.3);
}

#[test]
fn a_saved_scale_snaps_to_the_nearest_size() {
Expand Down
47 changes: 40 additions & 7 deletions windows/codenotch/src/dropzones.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! The four places the notch can land, shown while it is carried from the move handle.
//!
//! A transparent, click-through window over the monitor the notch is on, drawing the notch's own
//! A transparent, click-through window over whichever monitor the pointer is on, drawing the notch's own
//! outline at each edge — the shape differs per edge, and showing the real silhouette is what makes
//! the choice legible before it is made. The page is `ui/dropzones.html`; this side only says where
//! the window goes and which zone the pointer is nearest.
Expand Down Expand Up @@ -48,15 +48,13 @@ pub fn show(app: &AppHandle, screen: &crate::Screen, zones: &Zones) {
.always_on_top(true)
.skip_taskbar(true)
.focused(false)
// Never focus, like the notch: `relocate` shows it again on every screen crossed, and a
// shown window that can take focus takes it from whatever the user is working in
.focusable(false)
.resizable(false);
match builder.build() {
Ok(w) => {
// The builder's figures are logical, and Windows converts them with whichever monitor it
// decides the window belongs to — which is how a taskbar anywhere but the bottom left the
// overlay short of the work area's corner and hanging off the far edge by the same amount.
// The work area is physical and absolute, so it is pinned again here, as `place_notch` does.
let _ = w.set_position(tauri::PhysicalPosition::new(ax, ay));
let _ = w.set_size(tauri::PhysicalSize::new(aw.max(1) as u32, ah.max(1) as u32));
pin(&w, screen);
let _ = w.set_ignore_cursor_events(true);
// The page asks for the zones itself once it is listening; this covers the other order
let _ = w.emit_to(LABEL, "zones", zones);
Expand All @@ -69,6 +67,41 @@ pub fn show(app: &AppHandle, screen: &crate::Screen, zones: &Zones) {
}
}

/// Pins the overlay to `screen`'s work area in physical pixels, which are absolute across monitors.
/// The builder's figures are logical, and Windows converts them with whichever monitor it decides the
/// window belongs to — which is how a taskbar anywhere but the bottom once left the overlay short of
/// the work area's corner. Moved onto a monitor at another scale, Windows may also resize the window
/// for the new DPI after it has been set, so the size is checked and set once more, as `place_notch`
/// does for the notch.
fn pin(w: &tauri::WebviewWindow, screen: &crate::Screen) {
let (ax, ay, aw, ah) = screen.area();
let size = tauri::PhysicalSize::new(aw.max(1) as u32, ah.max(1) as u32);
let _ = w.set_position(tauri::PhysicalPosition::new(ax, ay));
let _ = w.set_size(size);
if w.outer_size().map(|s| s != size).unwrap_or(false) {
let _ = w.set_position(tauri::PhysicalPosition::new(ax, ay));
let _ = w.set_size(size);
}
}

/// Takes the overlay to another screen mid-carry, for a pointer that has crossed onto it.
///
/// Hidden while it goes. Arriving on a screen at another scale, Windows first resizes the window by
/// the ratio of the two, and `pin` then puts it right — both of which played out on screen as the
/// zones shrinking and growing again. The page is also still drawing the old screen's figures until
/// the new ones arrive, so it is given a couple of frames to redraw before it is shown.
pub fn relocate(app: &AppHandle, screen: &crate::Screen, zones: &Zones) {
let Some(w) = app.get_webview_window(LABEL) else {
return show(app, screen, zones);
};
*CURRENT.lock().unwrap() = Some(zones.clone());
let _ = w.hide();
pin(&w, screen);
let _ = w.emit_to(LABEL, "zones", zones);
std::thread::sleep(std::time::Duration::from_millis(40));
let _ = w.show();
}

pub fn retarget(app: &AppHandle, zones: &Zones) {
*CURRENT.lock().unwrap() = Some(zones.clone());
let _ = app.emit_to(LABEL, "zones", zones);
Expand Down
10 changes: 10 additions & 0 deletions windows/codenotch/src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,14 @@ pub fn tr(lang: &str, key: &str) -> &'static str {
("ru", "open_host") => "Открыть %@",
("uk", "open_host") => "Відкрити %@",
(_, "open_host") => "Open %@",
// The Mac's own catalog, but for Korean, which it does not have
("zh", "keep_open") => "保持展开",
("zh-Hant", "keep_open") => "保持展開",
("ja", "keep_open") => "開いたままにする",
("ko", "keep_open") => "열어 두기",
("ru", "keep_open") => "Оставить открытым",
("uk", "keep_open") => "Тримати відкритим",
(_, "keep_open") => "Keep open",
_ => "?",
}
}
Expand All @@ -221,6 +229,7 @@ mod tests {
("quit_app", "Выйти из Codenotch"),
("refresh_now", "Обновить сейчас"),
("open_host", "Открыть %@"),
("keep_open", "Оставить открытым"),
("quit", "Выйти"),
("install", "Установить хуки Claude Code"),
("uninstall", "Удалить хуки"),
Expand All @@ -245,6 +254,7 @@ mod tests {
}

const UKRAINIAN_KEYS: &[(&str, &str)] = &[
("keep_open", "Тримати відкритим"),
("open_data", "Відкрити теку даних (журнали / значки)"),
("install", "Встановити хуки Claude Code"),
("uninstall", "Видалити хуки"),
Expand Down
Loading