From 1eb343c43acd4dbe29d557f1999e49edfd7eb7c5 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:52:44 +0800 Subject: [PATCH] Menu bar: 0 t/s instead of a dash; the numbers-only dot breathes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The speed line reads 0 t/s both when nothing is running and when running requests have not produced a token yet; it no longer shows "— t/s". In the numbers-only style the in-flight dot now breathes: a Core Animation mask on the dot fades it between full and 25% opacity with ease-in-out, 1 s each way, sharing the glow's begin time. Like the glow it is taken off while the menu is open and stays solid when Reduce Motion is on. Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.toml | 2 +- src-tauri/src/menubar/macos.rs | 128 +++++++++++++++++++++------ src-tauri/src/menubar/model.rs | 13 ++- src-tauri/src/menubar/model/tests.rs | 7 +- 4 files changed, 110 insertions(+), 40 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 71148fec..2738c4f1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -132,7 +132,7 @@ objc2-user-notifications = "0.3" # 菜单栏自己持有 NSStatusItem 和 NSMenu(见 `menubar/macos.rs`)。和 Tauri 用的是同一版 objc2-app-kit = "0.3" # 菜单栏上的流光是图层遮罩上的动画(见 `menubar/macos.rs` 的 `glow`) -objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "objc2-core-graphics", "CABase", "CALayer", "CAAnimation", "CAMediaTiming", "CATransaction"] } +objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "objc2-core-graphics", "CABase", "CALayer", "CAAnimation", "CAMediaTiming", "CAMediaTimingFunction", "CATransaction"] } # 菜单开着时主线程在事件跟踪模式,更新要走 GCD 的主队列才执行得到 dispatch2 = "0.3" # 更新包是 DMG,而更新器插件只装 `.app.tar.gz`:挂上映像,把里面的 `.app` diff --git a/src-tauri/src/menubar/macos.rs b/src-tauri/src/menubar/macos.rs index 3c44abb5..038df7d0 100644 --- a/src-tauri/src/menubar/macos.rs +++ b/src-tauri/src/menubar/macos.rs @@ -37,7 +37,8 @@ use objc2_foundation::{ NSRect, NSSize, NSString, }; use objc2_quartz_core::{ - CAAnimationGroup, CABasicAnimation, CACurrentMediaTime, CALayer, CAMediaTiming, CATransaction, + CAAnimationGroup, CABasicAnimation, CACurrentMediaTime, CALayer, CAMediaTiming, + CAMediaTimingFunction, CATransaction, kCAMediaTimingFunctionEaseInEaseOut, }; use super::model::{Action, Bar, Row, Style, SubItem}; @@ -83,7 +84,7 @@ struct Ui { /// 按菜单项的 tag 找到它做什么 actions: Vec, bar: Option, - /// 挂着的流光遮罩(见 [`glow`])。没挂是 None + /// 挂着的遮罩(流光或者圆点呼吸,见 [`glow`])。没挂是 None glow: Option, } @@ -203,14 +204,27 @@ fn draw_bar(mtm: MainThreadMarker, item: &NSStatusItem, bar: &Bar) { const GLOW_CYCLE: f64 = 1.8; const SWEEP: f64 = 1.3; -/// 菜单栏上现在挂着的流光遮罩,和它是照什么尺寸搭的(按钮宽高、流光那一段的左右) +/// 圆点呼吸:[`BREATHE`] 秒从全亮渐暗到 [`BREATHE_LOW`],再同样慢慢亮回来 +const BREATHE: f64 = 1.0; +const BREATHE_LOW: f32 = 0.25; + +/// 有请求在跑时菜单栏那一块怎么动:有标识就是标识上走流光,只显示数字时是数字 +/// 左边的圆点呼吸 +#[derive(Clone, Copy, PartialEq)] +enum Motion { + Sweep, + Breathe, +} + +/// 菜单栏上现在挂着的遮罩,和它是照什么搭的(哪一种动法;按钮宽高、动的那一段的左右) struct Glow { + motion: Motion, key: [f64; 4], } thread_local! { - /// 流光从哪一刻起算(Core Animation 的时钟)。遮罩换了尺寸重搭时接着这一刻走, - /// 亮带不从头来 + /// 流光、呼吸从哪一刻起算(Core Animation 的时钟)。遮罩换了尺寸重搭时接着这一刻 + /// 走,不从头来 static GLOW_EPOCH: f64 = CACurrentMediaTime(); } @@ -226,14 +240,14 @@ fn sync_glow(mtm: MainThreadMarker) { }); } -/// 有请求在跑时给菜单栏按钮的图层挂上流光遮罩,没有了就摘掉。**只走在标识上**: -/// 只显示数字时不走流光,数字左边画一个圆点(见 [`bar_image`])。**动画交给 Core -/// Animation**,在系统的渲染进程里走:每帧重画按钮的话(换图或者标脏),图层重新显示、 -/// 和系统的菜单栏场景同步一回,一秒二十帧就占好几个百分点的 CPU;挂一次遮罩之后这边 -/// 什么都不用做。 +/// 有请求在跑时给菜单栏按钮的图层挂上遮罩,没有了就摘掉。**流光只走在标识上**: +/// 只显示数字时不走流光,数字左边画一个圆点(见 [`bar_image`]),遮罩让它呼吸。 +/// **动画交给 Core Animation**,在系统的渲染进程里走:每帧重画按钮的话(换图或者 +/// 标脏),图层重新显示、和系统的菜单栏场景同步一回,一秒二十帧就占好几个百分点的 +/// CPU;挂一次遮罩之后这边什么都不用做。 /// /// **菜单开着时摘掉**:旧系统上菜单栏的高亮底色是按钮自己画的,遮罩会把它也压暗。 -/// 系统设了减弱动态效果就不动,亮带停在正中间 +/// 系统设了减弱动态效果就不动:亮带停在正中间,圆点常亮 fn glow(mtm: MainThreadMarker, ui: &mut Ui) { let Some(button) = ui.item.as_ref().and_then(|item| item.button(mtm)) else { ui.glow = None; @@ -242,43 +256,63 @@ fn glow(mtm: MainThreadMarker, ui: &mut Ui) { let want = ui .bar .as_ref() - .is_some_and(|b| b.busy && b.style != Style::Numbers) - && !OPEN.with(Cell::get); + .filter(|b| b.busy && !OPEN.with(Cell::get)) + .map(|b| { + if b.style == Style::Numbers { + Motion::Breathe + } else { + Motion::Sweep + } + }); let layer = { button.setWantsLayer(true); button.layer() }; let Some(layer) = layer else { return }; - if !want { + let Some(motion) = want else { if ui.glow.take().is_some() { unsafe { layer.setMask(None) }; } return; - } + }; let bounds = button.bounds(); let image = match (button.cell(), button.image()) { (Some(cell), Some(_)) => cell.imageRectForBounds(bounds), _ => return, }; + // 圆点那一段左右各多留 1pt,抗锯齿的边也跟着呼吸;右边离数字还有 1pt + let (x0, x1) = match motion { + Motion::Sweep => (EDGE, EDGE + MARK_W), + Motion::Breathe => (EDGE - 1.0, EDGE + DOT + 1.0), + }; let key = [ bounds.size.width, bounds.size.height, - image.origin.x + EDGE, - image.origin.x + EDGE + MARK_W, + image.origin.x + x0, + image.origin.x + x1, ]; - if ui.glow.as_ref().is_some_and(|g| g.key == key) { + if ui + .glow + .as_ref() + .is_some_and(|g| g.motion == motion && g.key == key) + { return; } + let mask = match motion { + Motion::Sweep => glow_mask(bounds, key[2], key[3]), + Motion::Breathe => breathe_mask(bounds, key[2], key[3]), + }; CATransaction::begin(); CATransaction::setDisableActions(true); // 新建的图层,没有父图层,也不是别的图层的遮罩 - unsafe { layer.setMask(Some(&glow_mask(bounds, key[2], key[3]))) }; + unsafe { layer.setMask(Some(&mask)) }; CATransaction::commit(); - ui.glow = Some(Glow { key }); + ui.glow = Some(Glow { motion, key }); } -/// 流光遮罩:`x0..x1` 那一段压到 [`GLOW_BASE`]、上面一条亮带来回扫,别处原样(不透明) -fn glow_mask(bounds: NSRect, x0: f64, x1: f64) -> Retained { +/// 遮罩的底子:`x0..x1` 以外原样(不透明),返回整个遮罩和中间那一段,那一段怎么动 +/// 由调用方往上挂 +fn mask_around(bounds: NSRect, x0: f64, x1: f64) -> (Retained, Retained) { let h = bounds.size.height; let mask = CALayer::layer(); mask.setFrame(bounds); @@ -291,6 +325,38 @@ fn glow_mask(bounds: NSRect, x0: f64, x1: f64) -> Retained { } let track = CALayer::layer(); track.setFrame(rect(x0, 0.0, x1 - x0, h)); + mask.addSublayer(&track); + (mask, track) +} + +/// 圆点呼吸的遮罩:`x0..x1` 那一段的透明度在 1 和 [`BREATHE_LOW`] 之间缓进缓出地来回 +fn breathe_mask(bounds: NSRect, x0: f64, x1: f64) -> Retained { + let (mask, track) = mask_around(bounds, x0, x1); + track.setBackgroundColor(Some(&NSColor::blackColor().CGColor())); + if !NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion() { + let fade = CABasicAnimation::animationWithKeyPath(Some(&NSString::from_str("opacity"))); + let from = NSNumber::new_f32(1.0); + let to = NSNumber::new_f32(BREATHE_LOW); + unsafe { + fade.setFromValue(Some(&from)); + fade.setToValue(Some(&to)); + fade.setTimingFunction(Some(&CAMediaTimingFunction::functionWithName( + kCAMediaTimingFunctionEaseInEaseOut, + ))); + } + fade.setDuration(BREATHE); + fade.setAutoreverses(true); + fade.setRepeatCount(f32::INFINITY); + fade.setBeginTime(GLOW_EPOCH.with(|t| *t)); + track.addAnimation_forKey(&fade, Some(&NSString::from_str("breathe"))); + } + mask +} + +/// 流光遮罩:`x0..x1` 那一段压到 [`GLOW_BASE`]、上面一条亮带来回扫,别处原样(不透明) +fn glow_mask(bounds: NSRect, x0: f64, x1: f64) -> Retained { + let h = bounds.size.height; + let (mask, track) = mask_around(bounds, x0, x1); track.setMasksToBounds(true); track.setBackgroundColor(Some( &NSColor::colorWithWhite_alpha(0.0, GLOW_BASE).CGColor(), @@ -322,7 +388,6 @@ fn glow_mask(bounds: NSRect, x0: f64, x1: f64) -> Retained { band.addAnimation_forKey(&cycle, Some(&NSString::from_str("glow"))); } track.addSublayer(&band); - mask.addSublayer(&track); mask } @@ -355,15 +420,18 @@ const MARK_H: f64 = 15.0; const MARK_W: f64 = MARK_H * 22.0 / 20.0; const GAP: f64 = 5.0; const EDGE: f64 = 3.0; +/// 只显示数字时有请求在跑,数字左边的圆点:直径,和离数字多远 +const DOT: f64 = 5.0; +const DOT_GAP: f64 = 2.0; /// 画成一张图。**正常时是模板图**:系统按菜单栏的深浅自动着色;网关不在的时候要 /// 画红色角标,只能关掉模板,用跟着外观走的系统颜色自己画。**额度不改这一块的颜色** /// (见 `Tone`):紧张、用完都在菜单里的那一行说。 /// /// 有请求在跑时标识上走流光:整个标识压暗,一条亮带从左往右扫过。**数字上不走**: -/// 只显示数字时在数字左边画一个圆点。菜单栏上的流光是图层遮罩(见 [`glow`]), -/// 图本身照常画,`phase` 给 None;预览和截图没有图层,给 `phase`(扫到哪儿,0 刚进来、 -/// 1 刚出去)就把那一帧画进图里 +/// 只显示数字时在数字左边画一个圆点。菜单栏上的流光和圆点的呼吸是图层遮罩 +/// (见 [`glow`]),图本身照常画,`phase` 给 None;预览和截图没有图层,给 `phase` +/// (扫到哪儿,0 刚进来、1 刚出去)就把流光的那一帧画进图里,圆点照常全亮 pub fn bar_image(bar: &Bar, phase: Option) -> Retained { let show_mark = bar.style != Style::Numbers; let show_numbers = bar.style != Style::Icon; @@ -384,7 +452,11 @@ pub fn bar_image(bar: &Bar, phase: Option) -> Retained { } else { 0.0 }; - let dot_w = if !show_mark && bar.busy { 7.0 } else { 0.0 }; + let dot_w = if !show_mark && bar.busy { + DOT + DOT_GAP + } else { + 0.0 + }; let mut width = EDGE * 2.0 + dot_w + numbers_w; if show_mark { width += MARK_W; @@ -429,7 +501,7 @@ pub fn bar_image(bar: &Bar, phase: Option) -> Retained { }; let color = color.colorWithAlphaComponent(alpha); if dot_w > 0.0 { - fill_oval(x, height / 2.0 - 2.5, 5.0, &color); + fill_oval(x, (height - DOT) / 2.0, DOT, &color); x += dot_w; } // 两行都右对齐:数字变长时向左伸,右边缘不动 diff --git a/src-tauri/src/menubar/model.rs b/src-tauri/src/menubar/model.rs index f08cbcad..103e8716 100644 --- a/src-tauri/src/menubar/model.rs +++ b/src-tauri/src/menubar/model.rs @@ -571,10 +571,10 @@ fn bar(s: &Snapshot, style: Style) -> Bar { style, numbers: if running { // 费用不上菜单栏(用户说金额没什么用),在点开的菜单和悬停提示里。速度是此刻的: - // 没有在跑的请求写破折号,不是 0 t/s;在跑却都还没开始生成的是 0 t/s + // 没有在跑的请求、在跑却都还没开始生成,都是 0 t/s,不写破折号 s.today .as_ref() - .map(|t| (tokens_short(t.tokens), rate_short(s.rate))) + .map(|t| (tokens_short(t.tokens), rate_short(s.rate.unwrap_or(0)))) } else { // 不在运行时,上一次的数字已经不代表现在:画破折号,不画一个凝固的旧值 None @@ -812,7 +812,7 @@ fn rows(s: &Snapshot) -> Vec { .unwrap_or(&l.key) .to_string(), model: l.model.clone(), - rate: l.rate.map(|r| rate_short(Some(r))), + rate: l.rate.map(rate_short), elapsed: elapsed(s.now_ms.saturating_sub(l.started_ms)), action: Action::OpenRequest(l.id), }); @@ -1212,11 +1212,10 @@ pub fn tokens_short(n: i64) -> String { } } -/// 菜单栏上的生成速度:`42 t/s`、`2.2k t/s`,数字和 token 数同一套写法。没有速度是 `— t/s`。 +/// 菜单栏上的生成速度:`0 t/s`、`42 t/s`、`2.2k t/s`,数字和 token 数同一套写法。 /// 菜单里「进行中」每一行右边的速度也是这个写法:那一行要放下应用、模型和时长 -pub fn rate_short(rate: Option) -> String { - let n = rate.map_or_else(|| "—".to_string(), |r| tokens_short(r.into())); - format!("{n} t/s") +pub fn rate_short(rate: u32) -> String { + format!("{} t/s", tokens_short(rate.into())) } /// 菜单和悬停提示里的生成速度:「42 token/秒」 diff --git a/src-tauri/src/menubar/model/tests.rs b/src-tauri/src/menubar/model/tests.rs index a9d1926d..0cc9c21a 100644 --- a/src-tauri/src/menubar/model/tests.rs +++ b/src-tauri/src/menubar/model/tests.rs @@ -361,12 +361,11 @@ fn the_bar_shows_todays_tokens_over_the_generation_speed() { }); s.rate = Some(2_215); assert_eq!(build(&s, Style::Full).0.numbers.unwrap().1, "2.2k t/s"); - // 有请求在跑、却都还没开始生成:0,不是破折号 + // 有请求在跑、却都还没开始生成,和没有在跑的请求一样:0,不写破折号 s.rate = Some(0); assert_eq!(build(&s, Style::Full).0.numbers.unwrap().1, "0 t/s"); - // 没有在跑的请求:没有速度,不是 0 s.rate = None; - assert_eq!(build(&s, Style::Full).0.numbers.unwrap().1, "— t/s"); + assert_eq!(build(&s, Style::Full).0.numbers.unwrap().1, "0 t/s"); } /// **估算不能冒充实测,算不出钱的也不能当成零**:和界面上同一套记号,菜单里和悬停 @@ -443,7 +442,7 @@ fn an_unknown_day_is_two_dashes_and_a_quiet_day_is_zero() { s.today = Some(Today::default()); assert_eq!( build(&s, Style::Full).0.numbers, - Some(("0".into(), "— t/s".into())) + Some(("0".into(), "0 t/s".into())) ); }