From 3354a8d9ecd330b932c6f5d521e10efa7aed9757 Mon Sep 17 00:00:00 2001 From: Rajat Patra Date: Sat, 19 Sep 2026 17:11:59 +0530 Subject: [PATCH 1/5] =?UTF-8?q?Slide=20the=20notch=20along=20its=20edge=20?= =?UTF-8?q?with=20Alt+drag,=20as=20the=20Mac's=20=E2=8C=A5-drag=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain press on the pill plus 4 px of movement dragged the window anywhere, and letting go snapped it to the nearest edge of whichever monitor it was over, saving one position that every edge shared. Since #244 a click on a ring refreshes it, so a click that slipped moved the notch instead (#251); and since #246 the move handle exists to change edge, so the drag was doing its job too. The drag now needs Alt held, and without it every press on the pill is a click. It follows the pointer along the edge the notch is on and nowhere else, inside the work area, and never changes edge: the move handle and the Edge picker in Settings do that. It is the Mac's ⌥-drag (NotchWindowController.dragged), which only ever moves alongOffset. Each edge now remembers its own place, as the Mac keeps notchOffset per edge. A move to another edge lands where the notch was last left on that edge, centred if it has never been slid along it, and Recentre centres only the edge it is on. A carry used to land centred on purpose, because every edge shared one position and a carry dragged the old edge's fraction along with it; with a place per edge that reason has gone. The place is kept as a fraction of the edge rather than the Mac's points, so an existing notch_y becomes the place for the edge it was saved on without needing a screen to convert it, and a change of resolution keeps the notch in the same part of the edge. notch_y is read once and never written again. One thing is lost: dragging no longer takes the notch to another monitor. The Screen picker in Settings does, as on the Mac, and its caption no longer claims the drag can. The drag hint is rewritten in the Mac's own words, with its ru, zh-Hans, zh-Hant, ja and uk translations taken from Localizable.xcstrings and ⌥ read as Alt; the Mac has no Korean, so that sentence is new. The Ukrainian hint had been keyed to an older English sentence and never showed; it does now. --- windows/codenotch/src/config.rs | 79 ++++++++++++++++++-- windows/codenotch/src/main.rs | 113 ++++++++++++++--------------- windows/codenotch/ui/notch.html | 6 +- windows/codenotch/ui/settings.html | 26 +++---- 4 files changed, 146 insertions(+), 78 deletions(-) diff --git a/windows/codenotch/src/config.rs b/windows/codenotch/src/config.rs index 33b392e72..891f6b2d8 100644 --- a/windows/codenotch/src/config.rs +++ b/windows/codenotch/src/config.rs @@ -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. @@ -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, /// Which screen edge the notch is pinned to: "right" (the default), "left", "top" or "bottom". #[serde(default = "default_notch_edge")] pub notch_edge: String, @@ -109,6 +114,28 @@ pub fn edge_or_right(value: &str) -> String { pub fn edge_is_vertical(edge: &str) -> bool { matches!(edge, "left" | "right") } + +/// 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 } @@ -151,6 +178,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(), @@ -191,6 +219,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; @@ -214,7 +244,44 @@ pub fn save(cfg: &Config) { #[cfg(test)] mod tests { - use super::{snap_scale, weekly_ring_or_off}; + use super::{carry_shared_position, snap_scale, weekly_ring_or_off, Config}; + + /// 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() { diff --git a/windows/codenotch/src/main.rs b/windows/codenotch/src/main.rs index 345ee0d3a..051101ebc 100644 --- a/windows/codenotch/src/main.rs +++ b/windows/codenotch/src/main.rs @@ -116,9 +116,6 @@ impl Screen { (self.x, self.y, self.w, self.h) } } - fn contains(&self, x: i32, y: i32) -> bool { - x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h - } } /// Every attached monitor, primary first so a stale name always falls back to something sensible. @@ -178,6 +175,13 @@ fn edge_origin(s: &Screen, edge: &str, ww: i32, wh: i32, ratio: f64) -> (i32, i3 } } +/// `edge_origin` run backwards along one axis: where a window at `pos`, `len` long, has its centre, +/// as a fraction of the span from `start`. What a slide along the edge saves, so it lands exactly +/// where it was let go. +fn along_at(pos: i32, len: i32, start: i32, span: i32) -> f64 { + (((pos - start) as f64 + len as f64 / 2.0) / span.max(1) as f64).clamp(0.0, 1.0) +} + /// How far the taskbar (or anything else outside the work area) covers each side of a window at /// (x, y, ww, wh), in physical pixels: top, right, bottom, left. `edge_origin` keeps the pill itself /// out of the taskbar, so what is left here is the window's other three sides — an upright notch is @@ -244,11 +248,10 @@ pub fn place_notch(app: &AppHandle) { .outer_size() .map(|s| (s.width as i32, s.height as i32)) .unwrap_or((target.width as i32, target.height as i32)); - // The position along the edge comes from the config (it persists across a drag) let ratio = { let st = app.state::(); let c = st.cfg.lock().unwrap(); - c.notch_y.clamp(0.0, 1.0) + c.along(&edge) }; let (x, y) = edge_origin(&mon, &edge, ww, wh, ratio); let _ = w.set_position(tauri::PhysicalPosition::new(x, y)); @@ -326,21 +329,23 @@ pub fn reset_bar(app: &AppHandle) { { let st = app.state::(); let mut c = st.cfg.lock().unwrap(); - c.notch_y = 0.5; if stranded { c.notch_edge = "right".into(); c.notch_monitor = None; } + // Only the edge it is on: the others keep wherever they were left, as on the Mac + let edge = config::edge_or_right(&c.notch_edge); + c.set_along(&edge, 0.5); config::save(&c); } place_notch(app); } -/// Drag. The page calls this once after a press on the pill moves more than 4 px; from then on a -/// Rust thread follows the system cursor (WebView mousemove is unreliable once the window itself -/// starts moving). The window is free in both axes while the button is down; releasing it snaps the -/// notch to the nearest edge of whichever monitor it was dropped on, and that edge, that monitor and -/// the position along the edge are written back to the config. +/// Drag. The page calls this once after an Alt-press on the pill moves more than 4 px; from then on +/// a Rust thread follows the system cursor (WebView mousemove is unreliable once the window itself +/// starts moving). Only the axis along the notch's edge follows it: this slides the notch along the +/// edge it is on and never takes it to another, which is the move handle's job — the Mac's ⌥-drag +/// (`NotchWindowController.dragged`). Releasing it saves that place for that edge alone. static DRAGGING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); #[cfg(windows)] @@ -427,10 +432,9 @@ fn begin_move(app: AppHandle, depth: f64, length: f64) { { let st = app.state::(); let mut c = st.cfg.lock().unwrap(); + // It lands where it was last left on that edge — centred, like the zone it was + // offered, on an edge it has never been slid along c.notch_edge = target.clone(); - // Centred, because the zone that was shown is centred: it lands where it was offered, - // not at whatever fraction along it happened to sit on the edge it came from - c.notch_y = 0.5; config::save(&c); } place_notch(&app); @@ -453,18 +457,19 @@ fn drag_begin(app: AppHandle) { DRAGGING.store(false, std::sync::atomic::Ordering::SeqCst); return; }; - let all = screens(&app); - if all.is_empty() { + let Some(mon) = target_screen(&app) else { DRAGGING.store(false, std::sync::atomic::Ordering::SeqCst); return; - } + }; + let edge = { + let st = app.state::(); + let c = st.cfg.lock().unwrap(); + config::edge_or_right(&c.notch_edge) + }; + let vertical = config::edge_is_vertical(&edge); let (ww, wh) = (size.width as i32, size.height as i32); - // The whole desktop, so the window can be carried across monitors before it is dropped - let (vx0, vy0) = (all.iter().map(|s| s.x).min().unwrap(), all.iter().map(|s| s.y).min().unwrap()); - let (vx1, vy1) = ( - all.iter().map(|s| s.x + s.w).max().unwrap(), - all.iter().map(|s| s.y + s.h).max().unwrap(), - ); + // The span `edge_origin` places against, so it cannot be slid under the taskbar + let (ax, ay, aw, ah) = mon.area(); let (mut last_x, mut last_y) = (start_pos.x, start_pos.y); let mut moved = false; loop { @@ -472,8 +477,13 @@ fn drag_begin(app: AppHandle) { break; } if let Ok(cur) = app.cursor_position() { - let nx = ((start_pos.x as f64 + (cur.x - start_cur.x)).round() as i32).clamp(vx0, (vx1 - ww).max(vx0)); - let ny = ((start_pos.y as f64 + (cur.y - start_cur.y)).round() as i32).clamp(vy0, (vy1 - wh).max(vy0)); + let (nx, ny) = if vertical { + let y = (start_pos.y as f64 + (cur.y - start_cur.y)).round() as i32; + (start_pos.x, y.clamp(ay, (ay + ah - wh).max(ay))) + } else { + let x = (start_pos.x as f64 + (cur.x - start_cur.x)).round() as i32; + (x.clamp(ax, (ax + aw - ww).max(ax)), start_pos.y) + }; if nx != last_x || ny != last_y { last_x = nx; last_y = ny; @@ -484,41 +494,14 @@ fn drag_begin(app: AppHandle) { std::thread::sleep(std::time::Duration::from_millis(8)); } if moved { - // Dropped: the monitor under the window's centre owns it, and the nearest of that - // monitor's four edges is where it snaps back to. - let (cx, cy) = (last_x + ww / 2, last_y + wh / 2); - let mon = all - .iter() - .find(|s| s.contains(cx, cy)) - .cloned() - .unwrap_or_else(|| all[0].clone()); - let d = [ - ("left", (cx - mon.x).max(0)), - ("right", (mon.x + mon.w - cx).max(0)), - ("top", (cy - mon.y).max(0)), - ("bottom", (mon.y + mon.h - cy).max(0)), - ]; - let edge = d.iter().min_by_key(|(_, v)| *v).map(|(e, _)| *e).unwrap_or("right"); - // Measured against the work area, because that is the span `edge_origin` reads the ratio - // back against: on the monitor's, a drop next to the taskbar landed short of the pointer. - let (ax, ay, aw, ah) = mon.area(); - let ratio = if config::edge_is_vertical(edge) { - ((cy - ay) as f64 / ah.max(1) as f64).clamp(0.0, 1.0) - } else { - ((cx - ax) as f64 / aw.max(1) as f64).clamp(0.0, 1.0) - }; + let along = if vertical { along_at(last_y, wh, ay, ah) } else { along_at(last_x, ww, ax, aw) }; { let st = app.state::(); let mut c = st.cfg.lock().unwrap(); - c.notch_y = ratio; - c.notch_edge = edge.into(); - c.notch_monitor = mon.name.clone(); + c.set_along(&edge, along); config::save(&c); } - applog(&format!( - "notch drag: dropped at ({last_x},{last_y}) -> edge={edge} ratio={ratio:.3} monitor={:?}", - mon.name - )); + applog(&format!("notch slid along {edge} to {along:.3}")); place_notch(&app); } DRAGGING.store(false, std::sync::atomic::Ordering::SeqCst); @@ -1285,8 +1268,8 @@ fn get_notch_edge(app: AppHandle) -> String { config::edge_or_right(&c.notch_edge) } -/// Moving to another edge keeps the position along it, so the notch stays where the eye expects it: -/// a notch two thirds down the right-hand edge arrives two thirds along the top one. +/// Moving to another edge puts the notch where it was last left on that edge, or centred if it has +/// never been slid along it — each edge keeps its own place, as on the Mac. #[tauri::command] fn set_notch_edge(app: AppHandle, edge: String) -> String { let value = { @@ -1680,6 +1663,22 @@ mod tests { assert_eq!(work_insets(&s, 0, 700, 432, 624), [0, 0, 0, 72]); } + /// A slide along the edge saves `along_at` and the next placement reads it back through + /// `edge_origin`, so the two must be exact inverses or the notch jumps when it is let go. + #[test] + fn a_slid_notch_lands_where_it_was_let_go() { + let s = Screen { name: None, x: 0, y: 0, w: 3200, h: 2000, scale: 1.5, work: (0, 0, 3200, 1928) }; + for along in [0.2, 0.5, 0.73] { + let (_, y) = super::edge_origin(&s, "right", 432, 624, along); + assert!((super::along_at(y, 624, 0, 1928) - along).abs() < 1e-3, "right at {along}"); + let (x, _) = super::edge_origin(&s, "top", 624, 624, along); + assert!((super::along_at(x, 624, 0, 3200) - along).abs() < 1e-3, "top at {along}"); + } + // Pushed hard against an end, what it saves is the end it stopped at, not the pointer + let (_, y) = super::edge_origin(&s, "right", 432, 624, 0.0); + assert_eq!(super::edge_origin(&s, "right", 432, 624, super::along_at(y, 624, 0, 1928)).1, y); + } + /// A notch on the edge the taskbar is docked to used to sit under it. #[test] fn the_notch_is_placed_inside_the_work_area() { diff --git a/windows/codenotch/ui/notch.html b/windows/codenotch/ui/notch.html index cdddffee4..a710b933e 100644 --- a/windows/codenotch/ui/notch.html +++ b/windows/codenotch/ui/notch.html @@ -1027,9 +1027,11 @@ delete refreshing[id]; renderRing(); } -pill.addEventListener('mousedown',e=>{ if(e.button!==0)return; press={x:e.clientX,y:e.clientY,id:cellAt(e.clientX,e.clientY)||hoverId}; }); +// Alt+drag, the Mac's ⌥-drag. Without Alt a press on the pill is only ever a click — a ring +// refreshes (#244) — so one that slips can no longer carry the notch off (#251). +pill.addEventListener('mousedown',e=>{ if(e.button!==0)return; press={x:e.clientX,y:e.clientY,id:cellAt(e.clientX,e.clientY)||hoverId,alt:e.altKey}; }); document.addEventListener('mousemove',e=>{ - if(!press||dragging)return; + if(!press||dragging||!press.alt)return; if(Math.abs(e.clientY-press.y)>4||Math.abs(e.clientX-press.x)>4){ dragging=true; clearTimeout(hideTimer); hideCard(); invoke('drag_begin').catch(err=>{notice('drag_begin failed: '+err);dragging=false;}); diff --git a/windows/codenotch/ui/settings.html b/windows/codenotch/ui/settings.html index 76351396b..56e022bdd 100644 --- a/windows/codenotch/ui/settings.html +++ b/windows/codenotch/ui/settings.html @@ -160,7 +160,7 @@

Accounts

-
The pill can also be dragged: pick it up and drop it anywhere, and it snaps to the nearest edge of the screen you dropped it on. Recentre puts it back in the middle of the edge it is on, or on the main screen's right-hand edge if the screen it was on has been unplugged.
+
Hold Alt and drag the notch to slide it along its edge. Each edge remembers where you left it. Recentre puts it back in the middle of the edge it is on, or on the main screen's right-hand edge if the screen it was on has been unplugged.
App
@@ -255,7 +255,7 @@

Accounts

'The notch stays open with every reading visible.':'Выемка остаётся открытой, и все данные видны.', 'The black pill against the right-hand edge of the screen, with one ring per tool. Turn it off and the pill disappears; Codenotch keeps counting your usage either way.':'Чёрная панель у правого края экрана с отдельным кольцом для каждого инструмента. При выключении панель исчезает, но Codenotch продолжает считать использование.', 'Recentre':'По центру', - 'The pill can also be dragged: pick it up and drop it anywhere, and it snaps to the nearest edge of the screen you dropped it on. Recentre puts it back in the middle of the edge it is on, or on the main screen\'s right-hand edge if the screen it was on has been unplugged.':'Капсулу также можно перетаскивать: захватите её и отпустите в любом месте, и она прилипнет к ближайшему краю того экрана, на котором вы её оставили. Кнопка «По центру» возвращает её на середину текущего края или на правый край главного экрана, если монитор, на котором она находилась, был отключен.', + 'Hold Alt and drag the notch to slide it along its edge. Each edge remembers where you left it. Recentre puts it back in the middle of the edge it is on, or on the main screen\'s right-hand edge if the screen it was on has been unplugged.':'Удерживайте Alt и перетаскивайте панель вдоль края. Для каждого края запоминается последнее положение. Кнопка «По центру» возвращает её на середину текущего края или на правый край главного экрана, если монитор, на котором она находилась, был отключен.', 'The notch is back in the middle':'Панель возвращена в центр', 'App':'Приложение', 'App icon':'Значок приложения', 'System tray':'Системный трей', 'Neither':'Ни то ни другое', 'The small icon down by the clock. Right-clicking it opens this window, refreshes the readings, or quits Codenotch.':'Небольшой значок рядом с часами. Щелчок правой кнопкой открывает это окно, обновляет данные или завершает работу Codenotch.', @@ -310,7 +310,7 @@

Accounts

'The notch stays open with every reading visible.':'刘海保持展开,所有读数都看得见。', 'The black pill against the right-hand edge of the screen, with one ring per tool. Turn it off and the pill disappears; Codenotch keeps counting your usage either way.':'屏幕右侧边缘的黑色胶囊,每个工具对应一个圆环。关闭它后胶囊会消失;无论哪种情况,Codenotch 都会继续统计你的使用情况。', 'Recentre':'重新居中', - 'The pill can also be dragged: pick it up and drop it anywhere, and it snaps to the nearest edge of the screen you dropped it on. Recentre puts it back in the middle of the edge it is on, or on the main screen\'s right-hand edge if the screen it was on has been unplugged.':'悬浮窗也可以拖动:将其拖放到任何地方,它会自动吸附到你放下它时所在屏幕的最近边缘。“重新居中”操作会将其放回当前所在边缘的中间;如果它所在的屏幕已被断开连接,则会将其放回主屏幕的右侧边缘。', + 'Hold Alt and drag the notch to slide it along its edge. Each edge remembers where you left it. Recentre puts it back in the middle of the edge it is on, or on the main screen\'s right-hand edge if the screen it was on has been unplugged.':'按住 Alt 拖动刘海,沿边缘滑动。每条边都会记住你放的位置。“重新居中”操作会将其放回当前所在边缘的中间;如果它所在的屏幕已被断开连接,则会将其放回主屏幕的右侧边缘。', 'The notch is back in the middle':'刘海已回到中间', 'App':'应用', 'App icon':'应用图标', @@ -346,7 +346,7 @@

Accounts

'Stands upright on the right-hand edge, the side it was drawn for.':'竖立在屏幕右边缘,也就是它本来画的那一侧。', 'Lies flat along the top edge, with the card opening below it.':'横贴在屏幕上边缘,提示卡向下打开。', 'Lies flat along the bottom edge, with the card opening above it.':'横贴在屏幕下边缘,提示卡向上打开。', - 'Which screen the notch lives on. Dragging it onto another screen sets this too.':'刘海所在的屏幕。拖到另一块屏幕上也会改这里。', + 'Which screen the notch lives on.':'刘海所在的屏幕。', 'this screen cannot be remembered by name':'这块屏幕无法按名称记住' }; const ZH_HANT_STATIC = { @@ -389,7 +389,7 @@

Accounts

'The notch stays open with every reading visible.':'瀏海保持展開,所有讀數都看得見。', 'The black pill against the right-hand edge of the screen, with one ring per tool. Turn it off and the pill disappears; Codenotch keeps counting your usage either way.':'螢幕右側邊緣的黑色膠囊,每個工具對應一個圓環。關閉它後膠囊會消失;無論哪種情況,Codenotch 都會繼續統計你的使用情況。', 'Recentre':'重新置中', - 'The pill can also be dragged: pick it up and drop it anywhere, and it snaps to the nearest edge of the screen you dropped it on. Recentre puts it back in the middle of the edge it is on, or on the main screen\'s right-hand edge if the screen it was on has been unplugged.':'浮動視窗也可以拖動:將其拖放到任何地方,它會自動吸附到你放下它時所在螢幕的最近邊緣。「重新置中」會將它放回目前所在邊緣的中間;如果它所在的螢幕已拔除,則會放回主螢幕的右側邊緣。', + 'Hold Alt and drag the notch to slide it along its edge. Each edge remembers where you left it. Recentre puts it back in the middle of the edge it is on, or on the main screen\'s right-hand edge if the screen it was on has been unplugged.':'按住 Alt 拖動瀏海,沿邊緣滑動。每條邊都會記住你放的位置。「重新置中」會將它放回目前所在邊緣的中間;如果它所在的螢幕已拔除,則會放回主螢幕的右側邊緣。', 'The notch is back in the middle':'瀏海已回到中間', 'App':'App', 'App icon':'App 圖示', @@ -425,7 +425,7 @@

Accounts

'Stands upright on the right-hand edge, the side it was drawn for.':'豎立在螢幕右邊緣,也就是它本來畫的那一側。', 'Lies flat along the top edge, with the card opening below it.':'橫貼在螢幕上邊緣,提示卡向下開啟。', 'Lies flat along the bottom edge, with the card opening above it.':'橫貼在螢幕下邊緣,提示卡向上開啟。', - 'Which screen the notch lives on. Dragging it onto another screen sets this too.':'瀏海所在的螢幕。拖到另一塊螢幕上也會改這裡。', + 'Which screen the notch lives on.':'瀏海所在的螢幕。', 'this screen cannot be remembered by name':'這塊螢幕無法按名稱記住' }; const JA_STATIC = { @@ -470,7 +470,7 @@

Accounts

'The notch stays open with every reading visible.':'ノッチは開いたままで、すべての読み取りが見えます。', 'The black pill against the right-hand edge of the screen, with one ring per tool. Turn it off and the pill disappears; Codenotch keeps counting your usage either way.':'画面の右端にある黒いピルには、ツールごとに1つのリングが表示されます。オフにするとピルは消えますが、どちらにしても Codenotch は使用量のカウントを続けます。', 'Recentre':'中央に戻す', - 'The pill can also be dragged: pick it up and drop it anywhere, and it snaps to the nearest edge of the screen you dropped it on. Recentre puts it back in the middle of the edge it is on, or on the main screen\'s right-hand edge if the screen it was on has been unplugged.':'ピルはドラッグすることもできます。掴んで任意の場所にドロップすると、ドロップした画面の最も近い端にスナップします。「中央に戻す」を使うと、現在配置されている端の中央に戻ります。もし配置されていた画面が取り外されている場合は、メイン画面の右端に戻ります。', + 'Hold Alt and drag the notch to slide it along its edge. Each edge remembers where you left it. Recentre puts it back in the middle of the edge it is on, or on the main screen\'s right-hand edge if the screen it was on has been unplugged.':'Alt を押しながらノッチをドラッグすると、辺に沿って動かせます。位置は辺ごとに記憶されます。「中央に戻す」を使うと、現在配置されている端の中央に戻ります。もし配置されていた画面が取り外されている場合は、メイン画面の右端に戻ります。', 'The notch is back in the middle':'ノッチが中央に戻りました', 'App':'アプリ', 'App icon':'アプリアイコン', @@ -543,11 +543,11 @@

Accounts

'Stands upright on the right-hand edge, the side it was drawn for.':'화면 오른쪽 가장자리에 세로로 붙습니다. 원래 디자인된 위치입니다.', 'Lies flat along the top edge, with the card opening below it.':'화면 위쪽 가장자리에 가로로 눕고, 카드는 아래로 열립니다.', 'Lies flat along the bottom edge, with the card opening above it.':'화면 아래쪽 가장자리에 가로로 눕고, 카드는 위로 열립니다.', - 'Which screen the notch lives on. Dragging it onto another screen sets this too.':'노치를 표시할 화면입니다. 노치를 다른 화면으로 끌어다 놓아도 이 값이 바뀝니다.', + 'Which screen the notch lives on.':'노치를 표시할 화면입니다.', 'this screen cannot be remembered by name':'이 화면은 이름으로 기억할 수 없습니다', 'The black pill against the right-hand edge of the screen, with one ring per tool. Turn it off and the pill disappears; Codenotch keeps counting your usage either way.':'화면 오른쪽 가장자리에 있는 검은색 필로, 도구당 하나의 링이 있습니다. 끄면 필이 사라지지만, 어느 쪽이든 Codenotch는 사용량을 계속 계산합니다.', 'Recentre':'가운데로 되돌리기', - 'The pill can also be dragged: pick it up and drop it anywhere, and it snaps to the nearest edge of the screen you dropped it on. Recentre puts it back in the middle of the edge it is on, or on the main screen\'s right-hand edge if the screen it was on has been unplugged.':'필(pill)은 드래그할 수도 있습니다. 필을 잡아서 아무 곳에나 놓으면, 놓은 화면의 가장 가까운 가장자리에 스냅됩니다. \'가운데로 되돌리기\'를 사용하면 현재 위치한 가장자리의 중앙으로 돌아가며, 필이 있던 화면이 연결 해제된 경우에는 기본 화면의 오른쪽 가장자리로 돌아갑니다.', + 'Hold Alt and drag the notch to slide it along its edge. Each edge remembers where you left it. Recentre puts it back in the middle of the edge it is on, or on the main screen\'s right-hand edge if the screen it was on has been unplugged.':'Alt 키를 누른 채 노치를 드래그하면 가장자리를 따라 옮길 수 있습니다. 가장자리마다 마지막 위치를 기억합니다. \'가운데로 되돌리기\'를 사용하면 현재 위치한 가장자리의 중앙으로 돌아가며, 필이 있던 화면이 연결 해제된 경우에는 기본 화면의 오른쪽 가장자리로 돌아갑니다.', 'The notch is back in the middle':'노치가 중앙으로 돌아왔습니다', 'App':'앱', 'App icon':'앱 아이콘', @@ -620,9 +620,9 @@

Accounts

'Stands upright on the right-hand edge, the side it was drawn for.':'Стоїть вертикально біля правого краю — того боку, для якого його й малювали.', 'Lies flat along the top edge, with the card opening below it.':'Лежить уздовж верхнього краю, картка відкривається під ним.', 'Lies flat along the bottom edge, with the card opening above it.':'Лежить уздовж нижнього краю, картка відкривається над ним.', - 'Which screen the notch lives on. Dragging it onto another screen sets this too.':'На якому екрані живе виріз. Перетягування на інший екран теж змінює цей вибір.', + 'Which screen the notch lives on.':'На якому екрані живе виріз.', 'this screen cannot be remembered by name':'цей екран неможливо запамʼятати за назвою', - 'The pill can also be dragged: pick it up and drop it anywhere, and it snaps to the nearest edge of the screen you dropped it on. If it has ended up somewhere you cannot see it — off a screen you have unplugged, say — this puts it back in the middle of the main screen\'s right-hand edge.':'Панель можна й перетягувати: візьміть її та відпустіть будь-де — вона прилипне до найближчого краю того екрана, на який ви її кинули. Якщо вона опинилась там, де ви її не бачите (скажімо, за межами екрана, який ви відʼєднали), ця кнопка повертає її на середину правого краю основного екрана.', + 'Hold Alt and drag the notch to slide it along its edge. Each edge remembers where you left it. Recentre puts it back in the middle of the edge it is on, or on the main screen\'s right-hand edge if the screen it was on has been unplugged.':'Утримуйте Alt і перетягніть виріз, щоб посунути його вздовж краю. Кожен край памʼятає останнє положення. Кнопка «Центрувати» повертає його на середину поточного краю або на правий край основного екрана, якщо екран, на якому він був, відʼєднано.', 'The black pill against the right-hand edge of the screen, with one ring per tool. Turn it off and the pill disappears; Codenotch keeps counting your usage either way.':'Чорна панель біля правого краю екрана, по кільцю на кожен інструмент. Якщо вимкнути, панель зникне — але використання Codenotch рахує в будь-якому разі.', 'Recentre':'Центрувати', 'The pill can be dragged up and down the right-hand edge. If it has ended up somewhere you cannot see it — off the bottom of a screen you have unplugged, say — this drops it back into the middle of the edge.':'Панель можна перетягувати вгору й вниз уздовж правого краю. Якщо вона опинилась там, де ви її не бачите (скажімо, нижче за край екрана, який ви відʼєднали), ця кнопка повертає її на середину краю.', @@ -1100,7 +1100,7 @@

Accounts

sel.appendChild(o); } const who = monitors.find(m => m.current); - const text = 'Which screen the notch lives on. Dragging it onto another screen sets this too.'; + const text = 'Which screen the notch lives on.'; cap.textContent = ui(text, text) + (who && !who.id ? ' (' + ui('this screen cannot be remembered by name', 'this screen cannot be remembered by name') + ')' : ''); } document.getElementById('screen').addEventListener('change', e => { @@ -1233,7 +1233,7 @@

Accounts

window.addEventListener('focus', () => { loadLook(); refreshOptions(); - // A screen can be plugged in, and the pill dragged to another edge, while this window sits behind + // A screen can be plugged in, and the notch carried to another edge, while this window sits behind call('get_notch_edge', undefined, null).then(v => { if(typeof v === 'string') notchEdge = v; renderEdge(); }); refreshScreens(); }); From f14519ce57dd6452d7391d8aa7d5d2b3716d333b Mon Sep 17 00:00:00 2001 From: Rajat Patra Date: Sat, 19 Sep 2026 17:41:26 +0530 Subject: [PATCH 2/5] Carry the notch across monitors by its handle, taking the zones along MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The move handle only ever offered the edges of one monitor. begin_move took the notch's screen once, read every target against it, and on release wrote the edge alone, so a pointer carried onto a second monitor still picked an edge of the first, and the overlay stayed behind — drawn there at the wrong size, since its figures belonged to the other screen. The zones now follow the pointer. Whichever screen it is over, the overlay moves there, the edge is read against that screen's work area, and letting go writes the screen as well as the edge; the right edge of another screen is a move too, though the edge has the same name. Over the gap a shorter screen leaves beside a taller one, the carry keeps the screen it was last over rather than flicking between them. With the drag held to its own edge, this is the one gesture left that takes the notch to another monitor; the Screen picker in Settings is the other way. The Mac's handle is single-screen — beginMove takes currentScreen() once — so this goes past it rather than porting it. The overlay is pinned in physical pixels, and pinned again if Windows resizes it for a new scale after the move, as place_notch does for the notch. On a screen at another scale that resize and its correction would both play out as the zones shrinking and growing again, so the overlay is hidden for the crossing and shown a couple of frames after the new zones are sent. It is also made unfocusable, like the notch: shown again on every screen crossed, a window that could take focus would take it from whatever the user was working in. The silhouette is in logical pixels, so it keeps its size on a screen at another scale, the size the notch will land at. Screens are told apart by where they are rather than by name, which the platform is not obliged to report. --- windows/codenotch/src/dropzones.rs | 47 +++++++++++++++++--- windows/codenotch/src/main.rs | 70 +++++++++++++++++++++++++----- 2 files changed, 99 insertions(+), 18 deletions(-) diff --git a/windows/codenotch/src/dropzones.rs b/windows/codenotch/src/dropzones.rs index 0d4bfa6a8..f46b65c95 100644 --- a/windows/codenotch/src/dropzones.rs +++ b/windows/codenotch/src/dropzones.rs @@ -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. @@ -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); @@ -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); diff --git a/windows/codenotch/src/main.rs b/windows/codenotch/src/main.rs index 051101ebc..57d83c8a0 100644 --- a/windows/codenotch/src/main.rs +++ b/windows/codenotch/src/main.rs @@ -175,6 +175,18 @@ fn edge_origin(s: &Screen, edge: &str, ww: i32, wh: i32, ratio: f64) -> (i32, i3 } } +/// The screen the pointer is over, for a carry that can cross between them. None in the gap a +/// smaller screen leaves beside a larger one, where the carry stays on the screen it was last over. +fn screen_at(list: &[Screen], x: f64, y: f64) -> Option<&Screen> { + list.iter() + .find(|s| x >= s.x as f64 && x < (s.x + s.w) as f64 && y >= s.y as f64 && y < (s.y + s.h) as f64) +} + +/// By where it is rather than by name, which the platform is not obliged to report. +fn same_screen(a: &Screen, b: &Screen) -> bool { + (a.x, a.y, a.w, a.h) == (b.x, b.y, b.w, b.h) +} + /// `edge_origin` run backwards along one axis: where a window at `pos`, `len` long, has its centre, /// as a fraction of the span from `start`. What a slide along the edge saves, so it lands exactly /// where it was let go. @@ -391,7 +403,7 @@ fn begin_move(app: AppHandle, depth: f64, length: f64) { DRAGGING.store(false, std::sync::atomic::Ordering::SeqCst); let _ = app.emit("move_end", ()); }; - let Some(mon) = target_screen(&app) else { + let Some(start) = target_screen(&app) else { done(&app); return; }; @@ -405,18 +417,33 @@ fn begin_move(app: AppHandle, depth: f64, length: f64) { // Every figure here is the work area's, to match the overlay window and the notch itself: // the zone drawn on the taskbar's edge has to sit where the notch will, and the edge the // pointer picks has to be read against the same rectangle the zones are drawn in. - let (ax, ay, aw, ah) = mon.area(); - let mut zones = dropzones::Zones { - w: aw as f64 / mon.scale, - h: ah as f64 / mon.scale, - depth: depth * size, - length: length * size, - target: from.clone(), + let zones_on = |s: &Screen, target: &str| { + let (_, _, aw, ah) = s.area(); + dropzones::Zones { + w: aw as f64 / s.scale, + h: ah as f64 / s.scale, + depth: depth * size, + length: length * size, + target: target.to_string(), + } }; + let all = screens(&app); + let mut mon = start.clone(); + let mut zones = zones_on(&mon, &from); dropzones::show(&app, &mon, &zones); let mut target = from.clone(); while left_button_down() { if let Ok(cur) = app.cursor_position() { + // Crossing onto another screen takes the zones with it. The silhouette is in logical + // px, so it keeps its size on a screen at another scale, exactly as the notch will. + if let Some(s) = screen_at(&all, cur.x, cur.y) { + if !same_screen(s, &mon) { + mon = s.clone(); + zones = zones_on(&mon, &target); + dropzones::relocate(&app, &mon, &zones); + } + } + let (ax, ay, aw, ah) = mon.area(); let next = edge_at(cur.x - ax as f64, cur.y - ay as f64, aw as f64, ah as f64); if next != target { target = next.to_string(); @@ -427,14 +454,17 @@ fn begin_move(app: AppHandle, depth: f64, length: f64) { } std::thread::sleep(std::time::Duration::from_millis(16)); } - applog(&format!("notch carry: {from} -> {target}")); - if target != from { + // The right edge of another screen is a move too, though the edge has the same name + let crossed = !same_screen(&mon, &start); + applog(&format!("notch carry: {from} -> {target} on {:?}", mon.name)); + if target != from || crossed { { let st = app.state::(); let mut c = st.cfg.lock().unwrap(); // It lands where it was last left on that edge — centred, like the zone it was // offered, on an edge it has never been slid along c.notch_edge = target.clone(); + c.notch_monitor = mon.name.clone(); config::save(&c); } place_notch(&app); @@ -1724,10 +1754,28 @@ mod tests { // The corner diagonals are the boundaries: a step either side of one changes the answer assert_eq!(super::edge_at(690.0, 700.0, w, h), "left"); assert_eq!(super::edge_at(700.0, 690.0, w, h), "top"); - // Dragged onto another monitor: still answers the edge it left by + // A pointer off the screen — in the gap a smaller one leaves — still answers the nearest edge assert_eq!(super::edge_at(-200.0, 700.0, w, h), "left"); } + /// A carry follows the pointer from one screen to the next, and holds on to the last one while + /// the pointer crosses the gap a shorter screen leaves beside a taller one. + #[test] + fn a_carry_crosses_onto_whichever_screen_the_pointer_is_over() { + let main = Screen { name: Some("1".into()), x: 0, y: 0, w: 2560, h: 1600, scale: 1.25, work: (0, 0, 2560, 1552) }; + // An older monitor to the right, shorter, and sitting 200 px lower + let old = Screen { name: Some("2".into()), x: 2560, y: 200, w: 1920, h: 1080, scale: 1.0, work: (2560, 200, 1920, 1040) }; + let all = [main.clone(), old.clone()]; + assert_eq!(super::screen_at(&all, 100.0, 100.0).and_then(|s| s.name.clone()), main.name); + assert_eq!(super::screen_at(&all, 3000.0, 700.0).and_then(|s| s.name.clone()), old.name); + assert!(super::screen_at(&all, 3000.0, 100.0).is_none(), "above the shorter screen is on neither"); + assert!(super::screen_at(&all, 2560.0, 700.0).is_some(), "the shared border belongs to the right-hand one"); + assert!(super::same_screen(&main, &main.clone())); + assert!(!super::same_screen(&main, &old)); + // The same place with no name reported is still the same screen + assert!(super::same_screen(&Screen { name: None, ..old.clone() }, &old)); + } + /// The pill sits in the middle of the window, so half of it, a fillet and the settings orb's reach /// all have to fit between the centre and each end. #[test] From 96620a921366e12aca59eaa00a59994689dd852a Mon Sep 17 00:00:00 2001 From: Rajat Patra Date: Sat, 19 Sep 2026 17:50:59 +0530 Subject: [PATCH 3/5] Land the notch on a screen at another scale without the jump showing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arriving on a monitor at another scale, Windows resizes the notch by the ratio of the two scales before place_notch puts it right, and the page then re-zooms itself for the new pixel ratio a debounce later, which can bring one more zoom correction from report_dpr. All of it played out on screen: a notch carried to another monitor jumped sizes as it landed. Hiding the window for the landing does not help. A hidden WebView2 stops painting and throttles its timers, so the page only re-laid itself out once it was shown again, in plain view — however long the delay before showing it. So the window stays up and the page empties itself instead: the window is transparent, so an empty page is an invisible notch, while the WebView keeps doing its layout. Rust waits for the page to confirm it has painted empty before moving it, and reveals it on the first report_dpr from the end of a burst of resizes that needs no further correction — the layout has stopped changing — rather than after a guessed delay. A fallback shows it after 700 ms whatever happens, numbered so it can never reveal a later landing early. At the same scale nothing is resized on arrival, so nothing is hidden. Every other placement stays in sight as before. --- windows/codenotch/src/main.rs | 73 ++++++++++++++++++++++++++++++++- windows/codenotch/ui/notch.html | 17 ++++++-- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/windows/codenotch/src/main.rs b/windows/codenotch/src/main.rs index 57d83c8a0..17d220c3d 100644 --- a/windows/codenotch/src/main.rs +++ b/windows/codenotch/src/main.rs @@ -175,6 +175,60 @@ fn edge_origin(s: &Screen, edge: &str, ww: i32, wh: i32, ratio: f64) -> (i32, i3 } } +/// The landing whose pill is being kept out of sight, or 0. Numbered so a fallback timer from one +/// landing can never reveal the next one early. +static LANDING: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +static LANDING_SEQ: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +/// The landing the page has confirmed it has painted empty for. +static LANDING_HIDDEN: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +/// Longest a landing stays out of sight if the page never reports a settled layout. +const LANDING_FALLBACK_MS: u64 = 700; + +/// Places the notch on a screen at another scale without the change of scale showing. +/// +/// Arriving there, Windows resizes the window by the ratio of the two scales before `place_notch` +/// puts it right, and the page then re-zooms itself for the new pixel ratio a debounce later, which +/// can bring one more zoom correction from `report_dpr`. All of that played out on screen as the +/// notch jumping sizes as it landed. Hiding the window did not help: a hidden WebView2 stops painting +/// and throttles its timers, so the page only caught up once it was shown again, in plain view. +/// +/// So the window stays up and the page empties itself instead — the window is transparent, so an +/// empty page is an invisible notch — while the WebView keeps doing its layout. It is revealed when +/// the page reports a layout that has stopped changing (`report_dpr` with `settled`), not after a +/// guessed delay. At the same scale nothing is resized on arrival, so there is nothing to hide. +fn land_on_another_screen(app: &AppHandle, from_scale: f64, to_scale: f64) { + if (from_scale - to_scale).abs() < 0.01 { + return place_notch(app); + } + use std::sync::atomic::Ordering::SeqCst; + let gen = LANDING_SEQ.fetch_add(1, SeqCst) + 1; + LANDING.store(gen, SeqCst); + let _ = app.emit_to("notch", "notch_landing", ()); + // Moved before the page has painted itself empty, the jump would show after all + for _ in 0..40 { + if LANDING_HIDDEN.load(SeqCst) == gen { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + place_notch(app); + let app = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(LANDING_FALLBACK_MS)); + if LANDING.compare_exchange(gen, 0, SeqCst, SeqCst).is_ok() { + applog("notch landing: no settled layout reported, shown anyway"); + let _ = app.emit_to("notch", "notch_reveal", ()); + } + }); +} + +/// The page has painted itself empty for the landing in progress. +#[tauri::command] +fn notch_hidden() { + use std::sync::atomic::Ordering::SeqCst; + LANDING_HIDDEN.store(LANDING.load(SeqCst), SeqCst); +} + /// The screen the pointer is over, for a carry that can cross between them. None in the gap a /// smaller screen leaves beside a larger one, where the carry stays on the screen it was last over. fn screen_at(list: &[Screen], x: f64, y: f64) -> Option<&Screen> { @@ -467,7 +521,11 @@ fn begin_move(app: AppHandle, depth: f64, length: f64) { c.notch_monitor = mon.name.clone(); config::save(&c); } - place_notch(&app); + if crossed { + land_on_another_screen(&app, start.scale, mon.scale); + } else { + place_notch(&app); + } } done(&app); }); @@ -761,8 +819,11 @@ pub fn applog(line: &str) { /// the designed 340 and every coordinate conversion was off (the watchdog misfired and the card /// flashed away). Fix: the page reports its DPR, and when it differs from the primary monitor's /// scale, set_zoom pulls the effective DPR back to that scale, restoring the 340 px width. +/// +/// `settled` is true when the report comes at the end of a burst of resizes rather than partway +/// through one, which is what a landing on another screen waits for before it shows the notch. #[tauri::command] -fn report_dpr(app: AppHandle, dpr: f64, w: f64, h: f64) { +fn report_dpr(app: AppHandle, dpr: f64, w: f64, h: f64, settled: Option) { let Some(win) = app.get_webview_window("notch") else { return }; let want = target_screen(&app) .map(|s| s.scale) @@ -778,6 +839,7 @@ fn report_dpr(app: AppHandle, dpr: f64, w: f64, h: f64) { )); // Oscillation guard: at most three corrections per process (if the DPR does not follow the zoom, stop chasing it) static APPLIED: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let mut corrected = false; if (dpr - want).abs() > 0.02 && (target - *z).abs() > 0.01 && (0.25..=4.0).contains(&target) @@ -786,11 +848,17 @@ fn report_dpr(app: AppHandle, dpr: f64, w: f64, h: f64) { match win.set_zoom(target) { Ok(()) => { *z = target; + corrected = true; applog(&format!("dpr correction: set_zoom({target:.3}) ok")); } Err(e) => applog(&format!("dpr correction failed: {e}")), } } + // A correction resizes the page once more, and its own settled report follows; the landing is + // shown on the first settled report that needed none + if settled == Some(true) && !corrected && LANDING.swap(0, std::sync::atomic::Ordering::SeqCst) != 0 { + let _ = app.emit_to("notch", "notch_reveal", ()); + } } /// Slack around every hot rectangle: this is sampled on a timer, so a cursor arriving at the pill @@ -1563,6 +1631,7 @@ fn main() { notchmenu::show_notch_menu, set_hot, report_dpr, + notch_hidden, log_js, focus_session, dismiss_session, diff --git a/windows/codenotch/ui/notch.html b/windows/codenotch/ui/notch.html index a710b933e..b9e3b2e95 100644 --- a/windows/codenotch/ui/notch.html +++ b/windows/codenotch/ui/notch.html @@ -946,16 +946,25 @@ document.documentElement.style.zoom=(Math.abs(z-1)>0.02)?String(z):''; return z; } -function reportDpr(){ +// `settled` only from the end of a burst of resizes, which is what a landing waits for (land_on_another_screen) +function reportDpr(settled){ const z=fitZoom(); jslog(`dpr=${window.devicePixelRatio} inner=${innerWidth}x${innerHeight} cssZoom=${z.toFixed(3)}`); - callq('report_dpr',{dpr:window.devicePixelRatio||1,w:innerWidth,h:innerHeight}).catch(()=>{}); + callq('report_dpr',{dpr:window.devicePixelRatio||1,w:innerWidth,h:innerHeight,settled:settled===true}).catch(()=>{}); } // Not measured yet: the width to measure against depends on the edge, and only Rust knows it. The // ask is at the bottom of this file and answers in a round trip; measuring first would zoom a flat // window by 520/360 for that moment. -window.addEventListener('resize',()=>{clearTimeout(window._dprT);window._dprT=setTimeout(()=>{reportDpr();reportHot();},120);}); -matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`).addEventListener('change',reportDpr); +window.addEventListener('resize',()=>{clearTimeout(window._dprT);window._dprT=setTimeout(()=>{reportDpr(true);reportHot();},120);}); +matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`).addEventListener('change',()=>reportDpr()); +// A landing on a screen at another scale: empty the page, which leaves the transparent window +// invisible while it is resized and re-zoomed, and say so once that has painted — hiding the window +// itself would pause the page, and the re-zoom would then happen in plain view +listen('notch_landing',()=>{ + document.documentElement.style.visibility='hidden'; + requestAnimationFrame(()=>requestAnimationFrame(()=>invoke('notch_hidden').catch(()=>{}))); +}).catch(()=>{}); +listen('notch_reveal',()=>{ document.documentElement.style.visibility=''; }).catch(()=>{}); /* Collapse test: no trust in element-level mouseenter/mouseleave/e.target at all — pure geometry: is the cursor (clientX/Y) inside pill rect ∪ card rect ∪ their bounding box? From e780c8c79d96556b85e124200061c8ec7aff7f32 Mon Sep 17 00:00:00 2001 From: Rajat Patra Date: Sat, 19 Sep 2026 23:50:08 +0530 Subject: [PATCH 4/5] Fold the notch to a pill at the edge when not in use, as the Mac does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mac's Show setting has three modes and Windows had two. The one it was missing is the Mac's default, Show on hover: the notch rests as a small pill at the edge and opens the moment the pointer reaches it. Folded, the notch is the Mac's resting pill — NotchLayout.pillWidth by pillHeight, 10 × 79 here in the same unscaled measures the orb uses. The open pill keeps its whole layout and a clip-path draws it down to that, so everything measured off it (the card's anchor, the handles, which ring is under the pointer) stays true; a thin outline arrives with it, since a black pill on a black wallpaper is otherwise nothing. The rings leave by fading and sliding toward the edge one after another, NotchMotion's stagger, and the handles fade with them. It opens on contact and folds 0.45 s after the pointer has gone (foldGrace). What wakes it is the pill's length along the edge and its depth plus the Mac's pillHotZone, 34 px, into the screen — the pill is small, the place that opens it should not be. The pointer watchdog says when it crosses on or off, from the transition that already switches click-through, because once click-through is back on the page is sent nothing. It never folds mid-carry, mid-drag or under the right-click menu, all of which take the pointer off the notch on purpose. Keep open in the right-click menu is the Mac's own shortcut (onToggleKeepOpen): it switches between Always show and Show on hover, the same setting from another place, and Settings follows it if open. A fresh install starts on Show on hover, as on the Mac. A config saved before the setting existed keeps the always-open notch it had: its owner never chose a folding notch, and an update is not where they should meet one. Settings gains the third button with the Mac's wording and its ru, zh-Hans, zh-Hant, ja and uk translations; the Mac has no Korean, so those strings are new. --- windows/codenotch/src/config.rs | 41 +++++++++++++- windows/codenotch/src/i18n.rs | 10 ++++ windows/codenotch/src/main.rs | 46 +++++++++++++--- windows/codenotch/src/notchmenu.rs | 10 +++- windows/codenotch/ui/notch.html | 87 ++++++++++++++++++++++++++++-- windows/codenotch/ui/settings.html | 43 ++++++++++----- 6 files changed, 210 insertions(+), 27 deletions(-) diff --git a/windows/codenotch/src/config.rs b/windows/codenotch/src/config.rs index 891f6b2d8..1116b406e 100644 --- a/windows/codenotch/src/config.rs +++ b/windows/codenotch/src/config.rs @@ -81,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")] @@ -115,6 +121,18 @@ 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::(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. @@ -188,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, } @@ -208,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. @@ -244,7 +264,26 @@ pub fn save(cfg: &Config) { #[cfg(test)] mod tests { - use super::{carry_shared_position, snap_scale, weekly_ring_or_off, Config}; + 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] diff --git a/windows/codenotch/src/i18n.rs b/windows/codenotch/src/i18n.rs index d27d39bfd..0b9e726c4 100644 --- a/windows/codenotch/src/i18n.rs +++ b/windows/codenotch/src/i18n.rs @@ -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", _ => "?", } } @@ -221,6 +229,7 @@ mod tests { ("quit_app", "Выйти из Codenotch"), ("refresh_now", "Обновить сейчас"), ("open_host", "Открыть %@"), + ("keep_open", "Оставить открытым"), ("quit", "Выйти"), ("install", "Установить хуки Claude Code"), ("uninstall", "Удалить хуки"), @@ -245,6 +254,7 @@ mod tests { } const UKRAINIAN_KEYS: &[(&str, &str)] = &[ + ("keep_open", "Тримати відкритим"), ("open_data", "Відкрити теку даних (журнали / значки)"), ("install", "Встановити хуки Claude Code"), ("uninstall", "Видалити хуки"), diff --git a/windows/codenotch/src/main.rs b/windows/codenotch/src/main.rs index 17d220c3d..8b5974071 100644 --- a/windows/codenotch/src/main.rs +++ b/windows/codenotch/src/main.rs @@ -935,6 +935,9 @@ fn start_pointer_watchdog(app: AppHandle) { if click_through != Some(!inside) { set_click_through(&app, !inside); click_through = Some(!inside); + // Show on hover opens on this and folds a moment after it goes false. The page cannot + // tell on its own: once click-through is back on, it is sent nothing at all. + let _ = app.emit_to("notch", "notch_pointer", inside); applog(&format!( "click-through {} at cursor_rel=({lx:.0},{ly:.0}) rects={rects:?}", if inside { "off (cursor on the notch)" } else { "on (cursor elsewhere)" } @@ -1251,43 +1254,72 @@ fn get_app_icon() -> Option { // ---------------- what is on screen at all ---------------- -#[derive(serde::Serialize)] +#[derive(serde::Serialize, Clone)] struct UiFlags { notch_visible: bool, + notch_on_hover: bool, tray_visible: bool, } +fn ui_flags(c: &config::Config) -> UiFlags { + UiFlags { notch_visible: c.notch_visible, notch_on_hover: c.notch_on_hover, tray_visible: c.tray_visible } +} + #[tauri::command] fn get_ui_flags(app: AppHandle) -> UiFlags { let st = app.state::(); let c = st.cfg.lock().unwrap(); - UiFlags { notch_visible: c.notch_visible, tray_visible: c.tray_visible } + ui_flags(&c) } /// Hiding both would leave the app running with nothing to click, so the tray icon is kept /// whenever the notch is off. The answer says what was actually stored, so the settings window can -/// show the corrected state rather than a lie. +/// show the corrected state rather than a lie. Show on hover is not "off": the pill stays on screen. #[tauri::command] -fn set_ui_flags(app: AppHandle, notch_visible: bool, tray_visible: bool) -> UiFlags { +fn set_ui_flags(app: AppHandle, notch_visible: bool, tray_visible: bool, notch_on_hover: Option) -> UiFlags { let flags = { let st = app.state::(); let mut c = st.cfg.lock().unwrap(); c.notch_visible = notch_visible; + if let Some(on_hover) = notch_on_hover { + c.notch_on_hover = on_hover; + } c.tray_visible = if notch_visible { tray_visible } else { true }; config::save(&c); - UiFlags { notch_visible: c.notch_visible, tray_visible: c.tray_visible } + ui_flags(&c) }; apply_visibility(&app); flags } +/// The notch menu's Keep open: the Mac's own shortcut between Always show and Show on hover +/// (`onToggleKeepOpen` flips `notchVisibility`), so it is the same setting from another place. +pub fn toggle_keep_open(app: &AppHandle) { + { + let st = app.state::(); + let mut c = st.cfg.lock().unwrap(); + c.notch_on_hover = !c.notch_on_hover; + config::save(&c); + } + apply_visibility(app); +} + +pub fn keeps_open(app: &AppHandle) -> bool { + let st = app.state::(); + let c = st.cfg.lock().unwrap(); + !c.notch_on_hover +} + /// Puts the two switches into effect. pub fn apply_visibility(app: &AppHandle) { - let (notch, tray_on) = { + let (notch, tray_on, flags) = { let st = app.state::(); let c = st.cfg.lock().unwrap(); - (c.notch_visible, c.tray_visible) + (c.notch_visible, c.tray_visible, ui_flags(&c)) }; + // The page folds or stays open by these, and the Settings window redraws its Show row from them + // when Keep open changed them from the notch's own menu + let _ = app.emit("ui_flags", flags); if let Some(w) = app.get_webview_window("notch") { if notch { let _ = w.show(); diff --git a/windows/codenotch/src/notchmenu.rs b/windows/codenotch/src/notchmenu.rs index dcb826449..3ccbe511c 100644 --- a/windows/codenotch/src/notchmenu.rs +++ b/windows/codenotch/src/notchmenu.rs @@ -2,7 +2,7 @@ //! usage page: a left click used to open it, and a left click now refreshes the ring instead. use crate::i18n::tr; -use tauri::menu::{MenuBuilder, MenuItemBuilder}; +use tauri::menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder}; use tauri::{AppHandle, Manager, Window}; /// Tauri hands every menu event to every handler, the tray's included, so these ids carry their own @@ -37,10 +37,15 @@ pub fn show_notch_menu(window: Window, provider: Option) -> Result<(), S .map_err(err)?; menu = menu.item(&open); } + // Checked while the notch is always open; unticking it is Show on hover + let keep_open = CheckMenuItemBuilder::with_id(format!("{PREFIX}keep_open"), tr(&lang, "keep_open")) + .checked(crate::keeps_open(app)) + .build(app) + .map_err(err)?; let quit = MenuItemBuilder::with_id(format!("{PREFIX}quit"), tr(&lang, "quit_app")) .build(app) .map_err(err)?; - let menu = menu.separator().item(&quit).build().map_err(err)?; + let menu = menu.separator().item(&keep_open).separator().item(&quit).build().map_err(err)?; #[cfg(windows)] let before = foreground(); // Returns once the menu has closed @@ -83,6 +88,7 @@ fn handle(app: &AppHandle, id: &str) { } match item { "refresh" => crate::refresh_all(app), + "keep_open" => crate::toggle_keep_open(app), "quit" => app.exit(0), _ => {} } diff --git a/windows/codenotch/ui/notch.html b/windows/codenotch/ui/notch.html index b9e3b2e95..932516b96 100644 --- a/windows/codenotch/ui/notch.html +++ b/windows/codenotch/ui/notch.html @@ -152,6 +152,39 @@ body[data-edge="top"] #tail{clip-path:path('M0 32C9 32 13.68 13.44 18 0C22.32 13.44 27 32 36 32Z')} body[data-edge="bottom"] #tail{clip-path:path('M0 0C9 0 13.68 18.56 18 32C22.32 18.56 27 0 36 0Z')} + /* Show on hover, the Mac's .onHover: folded, the notch rests as a small pill at the edge. Its size is + the Mac's NotchLayout.pillWidth × pillHeight, 26 × 210 of its design px — 10 × 79 of this page's, + the same unscaled measures the orb and the fillets use. The open pill keeps its whole layout and is + clipped down to that, so everything measured off it (the card's anchor, the handles, which ring is + under the pointer) stays true; `#rest` draws the outline the clip takes away, which is the only + thing that shows a black pill against a black wallpaper. */ + #pill{--clip-open:inset(-40px -1px -40px -1px round 0); + --clip-rest:inset(calc(50% - 39.5px) 0 calc(50% - 39.5px) calc(100% - 10px) round 6px 0 0 6px); + clip-path:var(--clip-open);transition:clip-path .36s cubic-bezier(.32,.72,.24,1)} + body.folded #pill{clip-path:var(--clip-rest)} + body[data-edge="left"] #pill{--clip-rest:inset(calc(50% - 39.5px) calc(100% - 10px) calc(50% - 39.5px) 0 round 0 6px 6px 0)} + body[data-edge="top"] #pill,body[data-edge="bottom"] #pill{--clip-open:inset(-1px -40px -1px -40px round 0)} + body[data-edge="top"] #pill{--clip-rest:inset(0 calc(50% - 39.5px) calc(100% - 10px) calc(50% - 39.5px) round 0 0 6px 6px)} + body[data-edge="bottom"] #pill{--clip-rest:inset(calc(100% - 10px) calc(50% - 39.5px) 0 calc(50% - 39.5px) round 6px 6px 0 0)} + #rest{position:absolute;box-sizing:border-box;background:#000;border:1px solid #2e2e2e;pointer-events:none; + right:0;top:50%;translate:0 -50%;width:10px;height:79px;border-right:none;border-radius:6px 0 0 6px; + opacity:0;transition:opacity .16s ease} + body.folded #rest{opacity:1;transition:opacity .2s ease .18s} /* arrives as the clip does */ + body[data-edge="left"] #rest{right:auto;left:0;border-right:1px solid #2e2e2e;border-left:none;border-radius:0 6px 6px 0} + body[data-edge="top"] #rest,body[data-edge="bottom"] #rest{right:auto;left:50%;top:auto;translate:-50% 0;width:79px;height:10px;border:1px solid #2e2e2e} + body[data-edge="top"] #rest{top:0;border-top:none;border-radius:0 0 6px 6px} + body[data-edge="bottom"] #rest{bottom:0;border-bottom:none;border-radius:6px 6px 0 0} + /* The rings leave by fading and sliding a little toward the edge, one after another — the Mac's + NotchMotion.stagger, 28 of its design px (10 here), and no scaling: the clip does the concealing */ + .cell{transition:opacity .18s ease calc(var(--i,0) * 28ms),translate .3s cubic-bezier(.32,.72,.24,1) calc(var(--i,0) * 28ms)} + body.folded .cell{opacity:0;translate:10px 0} + body.folded[data-edge="left"] .cell{translate:-10px 0} + body.folded[data-edge="top"] .cell{translate:0 -10px} + body.folded[data-edge="bottom"] .cell{translate:0 10px} + .handle{transition:opacity .2s ease} + body.folded .handle{opacity:0;pointer-events:none} + @media (prefers-reduced-motion: reduce){#pill,#rest,body.folded #rest,.cell,.handle{transition:none}} + /* The two handles, one off each end of the pill, as on the Mac (SettingsOrb and MoveHandle): settings past the far fillet, move past the near one. At rest each is a quarter arc tucked into that fillet's pocket, concentric with it and a gap inside; under the pointer the same circle @@ -204,6 +237,7 @@
+