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
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
128 changes: 100 additions & 28 deletions src-tauri/src/menubar/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -83,7 +84,7 @@ struct Ui {
/// 按菜单项的 tag 找到它做什么
actions: Vec<Action>,
bar: Option<Bar>,
/// 挂着的流光遮罩(见 [`glow`])。没挂是 None
/// 挂着的遮罩(流光或者圆点呼吸,见 [`glow`])。没挂是 None
glow: Option<Glow>,
}

Expand Down Expand Up @@ -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();
}

Expand All @@ -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;
Expand All @@ -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<CALayer> {
/// 遮罩的底子:`x0..x1` 以外原样(不透明),返回整个遮罩和中间那一段,那一段怎么动
/// 由调用方往上挂
fn mask_around(bounds: NSRect, x0: f64, x1: f64) -> (Retained<CALayer>, Retained<CALayer>) {
let h = bounds.size.height;
let mask = CALayer::layer();
mask.setFrame(bounds);
Expand All @@ -291,6 +325,38 @@ fn glow_mask(bounds: NSRect, x0: f64, x1: f64) -> Retained<CALayer> {
}
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<CALayer> {
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<CALayer> {
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(),
Expand Down Expand Up @@ -322,7 +388,6 @@ fn glow_mask(bounds: NSRect, x0: f64, x1: f64) -> Retained<CALayer> {
band.addAnimation_forKey(&cycle, Some(&NSString::from_str("glow")));
}
track.addSublayer(&band);
mask.addSublayer(&track);
mask
}

Expand Down Expand Up @@ -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<f64>) -> Retained<NSImage> {
let show_mark = bar.style != Style::Numbers;
let show_numbers = bar.style != Style::Icon;
Expand All @@ -384,7 +452,11 @@ pub fn bar_image(bar: &Bar, phase: Option<f64>) -> Retained<NSImage> {
} 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;
Expand Down Expand Up @@ -429,7 +501,7 @@ pub fn bar_image(bar: &Bar, phase: Option<f64>) -> Retained<NSImage> {
};
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;
}
// 两行都右对齐:数字变长时向左伸,右边缘不动
Expand Down
13 changes: 6 additions & 7 deletions src-tauri/src/menubar/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -812,7 +812,7 @@ fn rows(s: &Snapshot) -> Vec<Row> {
.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),
});
Expand Down Expand Up @@ -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<u32>) -> 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/秒」
Expand Down
7 changes: 3 additions & 4 deletions src-tauri/src/menubar/model/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

/// **估算不能冒充实测,算不出钱的也不能当成零**:和界面上同一套记号,菜单里和悬停
Expand Down Expand Up @@ -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()))
);
}

Expand Down
Loading