Skip to content

Commit ff07998

Browse files
committed
feat: 支持通过长句跳过
1 parent 1393d00 commit ff07998

10 files changed

Lines changed: 254 additions & 23 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ dirs = "5.0"
3333

3434
# macOS-specific dependencies
3535
[target.'cfg(target_os = "macos")'.dependencies]
36+
block2 = "0.6"
3637
objc2 = "0.6.3"
3738
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
3839
"std",
@@ -42,6 +43,7 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = [
4243
"NSCell",
4344
"NSColor",
4445
"NSControl",
46+
"NSEvent",
4547
"NSFont",
4648
"NSGraphics",
4749
"NSMenu",
@@ -55,6 +57,7 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = [
5557
"NSTextField",
5658
"NSView",
5759
"NSWindow",
60+
"block2",
5861
"objc2-core-foundation",
5962
] }
6063
objc2-foundation = { version = "0.3.2", default-features = false, features = [

src/macos/delegate.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,12 @@ use objc2_app_kit::{NSApplication, NSApplicationDelegate, NSMenu, NSMenuDelegate
1010
use objc2_foundation::{NSNotification, NSObjectProtocol, NSTimer};
1111

1212
use super::state::{Phase, with_state, with_state_ref};
13-
use super::timer::{schedule_phase, start_break_now, transition_on_timer};
13+
use super::timer::{schedule_phase, skip_break as skip_break_phase, start_break_now, transition_on_timer};
1414
use super::ui::{
15-
close_countdown_window, finish_countdown, open_settings_dialog, refresh_header_title,
16-
refresh_menu_info, refresh_status_title, set_rest_now_enabled, setup_status_item,
17-
show_about_dialog, update_countdown,
15+
finish_countdown, open_settings_dialog, refresh_header_title, refresh_menu_info,
16+
refresh_status_title, set_rest_now_enabled, setup_status_item, show_about_dialog,
17+
update_countdown,
1818
};
19-
use super::utils::play_sound;
2019

2120
define_class!(
2221
#[unsafe(super(NSObject))]
@@ -94,6 +93,20 @@ define_class!(
9493
return;
9594
}
9695

96+
// 隐藏跳过:仅在倒计时窗口激活时处理,且不增加非休息时开销
97+
let should_skip = with_state(|state| {
98+
if state.countdown_skip_requested {
99+
state.countdown_skip_requested = false;
100+
true
101+
} else {
102+
false
103+
}
104+
});
105+
if should_skip {
106+
skip_break_phase(self);
107+
return;
108+
}
109+
97110
if !update_countdown() {
98111
// 倒计时结束
99112
finish_countdown();
@@ -102,9 +115,8 @@ define_class!(
102115

103116
#[unsafe(method(skipBreak:))]
104117
fn skip_break(&self, _sender: Option<&AnyObject>) {
105-
// 用户点击跳过休息按钮
106-
close_countdown_window();
107-
play_sound("Tink");
118+
// 用户点击跳过休息按钮(或隐藏短语触发)
119+
skip_break_phase(self);
108120
}
109121
}
110122
);

src/macos/state.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::cell::RefCell;
66
use std::time::{Instant, SystemTime};
77

88
use objc2::rc::Retained;
9+
use objc2::runtime::AnyObject;
910
use objc2_app_kit::{NSMenuItem, NSStatusItem, NSTextField, NSWindow};
1011
use objc2_foundation::NSTimer;
1112

@@ -43,6 +44,11 @@ pub struct AppState {
4344
pub countdown_label: Option<Retained<NSTextField>>,
4445
pub countdown_timer: Option<Retained<NSTimer>>,
4546
pub countdown_end_time: Option<Instant>,
47+
// Hidden skip phrase state (only used during breaks)
48+
pub countdown_key_monitor: Option<Retained<AnyObject>>,
49+
pub countdown_skip_smart_idx: usize,
50+
pub countdown_skip_ascii_idx: usize,
51+
pub countdown_skip_requested: bool,
4652
}
4753

4854
impl AppState {
@@ -62,6 +68,10 @@ impl AppState {
6268
countdown_label: None,
6369
countdown_timer: None,
6470
countdown_end_time: None,
71+
countdown_key_monitor: None,
72+
countdown_skip_smart_idx: 0,
73+
countdown_skip_ascii_idx: 0,
74+
countdown_skip_requested: false,
6575
}
6676
}
6777
}

src/macos/timer.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,15 @@ pub fn start_break_now(delegate: &RestGapDelegate) {
100100
schedule_phase(delegate, Phase::Breaking);
101101
}
102102
}
103+
104+
/// 跳过当前休息(仅在 Breaking 阶段生效)
105+
pub fn skip_break(delegate: &RestGapDelegate) {
106+
let should_skip = with_state(|state| state.phase == Phase::Breaking);
107+
if !should_skip {
108+
return;
109+
}
110+
111+
// 提前结束休息:关闭倒计时窗口并回到工作阶段
112+
finish_countdown();
113+
schedule_phase(delegate, Phase::Working);
114+
}

src/macos/ui/countdown.rs

Lines changed: 125 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,111 @@
11
//! 倒计时窗口模块
22
3+
use std::ptr;
34
use std::time::{Duration, Instant};
45

5-
use objc2::{MainThreadOnly, sel};
6+
use block2::global_block;
7+
use objc2::{MainThreadOnly, define_class, msg_send, sel};
68
use objc2_app_kit::{
7-
NSBackingStoreType, NSColor, NSFont, NSScreen, NSStatusWindowLevel, NSTextField, NSWindow,
8-
NSWindowCollectionBehavior, NSWindowStyleMask,
9+
NSEvent, NSEventMask, NSApplication, NSBackingStoreType, NSColor, NSFont, NSScreen,
10+
NSStatusWindowLevel, NSTextField, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask,
911
};
1012
use objc2_core_foundation::CGFloat;
11-
use objc2_foundation::{NSPoint, NSRect, NSSize, NSString, NSTimer};
13+
use objc2_foundation::{NSPoint, NSRect, NSSize, NSString, NSTimer, NSObjectProtocol};
1214

1315
use super::super::constants::APP_NAME_ZH;
1416
use super::super::delegate::RestGapDelegate;
1517
use super::super::state::with_state;
1618
use super::super::utils::{format_countdown, play_sound};
1719
use super::status_bar::target_anyobject;
1820

21+
const SKIP_PHRASE_SMART: &str = "i don’t care about my health.";
22+
const SKIP_PHRASE_ASCII: &str = "i don't care about my health.";
23+
24+
define_class!(
25+
#[unsafe(super(NSWindow))]
26+
#[thread_kind = MainThreadOnly]
27+
pub struct CountdownWindow;
28+
29+
unsafe impl NSObjectProtocol for CountdownWindow {}
30+
31+
impl CountdownWindow {
32+
#[unsafe(method(canBecomeKeyWindow))]
33+
fn can_become_key_window(&self) -> bool {
34+
true
35+
}
36+
37+
#[unsafe(method(canBecomeMainWindow))]
38+
fn can_become_main_window(&self) -> bool {
39+
true
40+
}
41+
}
42+
);
43+
44+
fn advance_phrase_idx(idx: &mut usize, phrase: &str, ch: char) -> bool {
45+
let mut buf = [0u8; 4];
46+
let ch_bytes = ch.encode_utf8(&mut buf).as_bytes();
47+
let phrase_bytes = phrase.as_bytes();
48+
49+
if *idx + ch_bytes.len() <= phrase_bytes.len()
50+
&& &phrase_bytes[*idx..(*idx + ch_bytes.len())] == ch_bytes
51+
{
52+
*idx += ch_bytes.len();
53+
return *idx == phrase_bytes.len();
54+
}
55+
56+
// 朴素回退:短语以 'I' 开头且几乎不重叠,足够可靠。
57+
*idx = 0;
58+
if ch_bytes.len() <= phrase_bytes.len() && &phrase_bytes[..ch_bytes.len()] == ch_bytes {
59+
*idx = ch_bytes.len();
60+
return *idx == phrase_bytes.len();
61+
}
62+
false
63+
}
64+
65+
fn on_countdown_keydown(event: &NSEvent) {
66+
let Some(text) = event
67+
.charactersIgnoringModifiers()
68+
.map(|s| s.to_string())
69+
else {
70+
return;
71+
};
72+
73+
with_state(|state| {
74+
if state.countdown_window.is_none() {
75+
return;
76+
}
77+
if state.countdown_skip_requested {
78+
return;
79+
}
80+
81+
for ch in text.chars() {
82+
let ch = if ch.is_ascii() {
83+
ch.to_ascii_lowercase()
84+
} else {
85+
ch
86+
};
87+
let smart_done =
88+
advance_phrase_idx(&mut state.countdown_skip_smart_idx, SKIP_PHRASE_SMART, ch);
89+
let ascii_done =
90+
advance_phrase_idx(&mut state.countdown_skip_ascii_idx, SKIP_PHRASE_ASCII, ch);
91+
if smart_done || ascii_done {
92+
state.countdown_skip_requested = true;
93+
state.countdown_skip_smart_idx = 0;
94+
state.countdown_skip_ascii_idx = 0;
95+
break;
96+
}
97+
}
98+
});
99+
}
100+
101+
global_block! {
102+
static COUNTDOWN_KEY_MONITOR = |event: core::ptr::NonNull<NSEvent>| -> *mut NSEvent {
103+
let event_ref: &NSEvent = unsafe { event.as_ref() };
104+
on_countdown_keydown(event_ref);
105+
ptr::null_mut()
106+
};
107+
}
108+
19109
/// 显示倒计时窗口
20110
#[allow(clippy::too_many_lines)]
21111
pub fn show_countdown_window(delegate: &RestGapDelegate, seconds: u64, play_start_sound: bool) {
@@ -42,15 +132,10 @@ pub fn show_countdown_window(delegate: &RestGapDelegate, seconds: u64, play_star
42132

43133
// 创建窗口 - 使用 Borderless 样式隐藏标题栏
44134
let style = NSWindowStyleMask::Borderless;
45-
let window = unsafe {
46-
NSWindow::initWithContentRect_styleMask_backing_defer(
47-
NSWindow::alloc(mtm),
48-
frame,
49-
style,
50-
NSBackingStoreType::Buffered,
51-
false,
52-
)
135+
let window: objc2::rc::Retained<CountdownWindow> = unsafe {
136+
msg_send![CountdownWindow::alloc(mtm), initWithContentRect: frame styleMask: style backing: NSBackingStoreType::Buffered defer: false]
53137
};
138+
let window: objc2::rc::Retained<NSWindow> = window.into_super();
54139

55140
// 设置窗口属性
56141
window.setLevel(NSStatusWindowLevel); // 使用状态窗口级别,确保在最前
@@ -134,9 +219,28 @@ pub fn show_countdown_window(delegate: &RestGapDelegate, seconds: u64, play_star
134219
content_view.addSubview(&hint_label);
135220
}
136221

222+
// 让倒计时窗口能获取键盘事件(Borderless 默认不可成为 key window)
223+
NSApplication::sharedApplication(mtm).activateIgnoringOtherApps(true);
224+
225+
// 先标记窗口存在,避免用户刚弹窗就开始输入时被忽略
226+
with_state(|state| {
227+
state.countdown_window = Some(window.clone());
228+
state.countdown_skip_smart_idx = 0;
229+
state.countdown_skip_ascii_idx = 0;
230+
state.countdown_skip_requested = false;
231+
});
232+
137233
// 显示窗口
138234
window.makeKeyAndOrderFront(None);
139235

236+
// 仅在倒计时窗口存在时安装本地键盘监听器,避免非休息时任何额外开销。
237+
let key_monitor = unsafe {
238+
NSEvent::addLocalMonitorForEventsMatchingMask_handler(
239+
NSEventMask::KeyDown,
240+
&COUNTDOWN_KEY_MONITOR,
241+
)
242+
};
243+
140244
// 设置结束时间
141245
let end_time = Instant::now() + Duration::from_secs(seconds);
142246

@@ -154,10 +258,10 @@ pub fn show_countdown_window(delegate: &RestGapDelegate, seconds: u64, play_star
154258

155259
// 保存状态
156260
with_state(|state| {
157-
state.countdown_window = Some(window);
158261
state.countdown_label = Some(countdown_label);
159262
state.countdown_timer = Some(timer);
160263
state.countdown_end_time = Some(end_time);
264+
state.countdown_key_monitor = key_monitor;
161265
});
162266
}
163267

@@ -188,6 +292,11 @@ pub fn update_countdown() -> bool {
188292
/// 关闭倒计时窗口
189293
pub fn close_countdown_window() {
190294
with_state(|state| {
295+
if let Some(monitor) = state.countdown_key_monitor.take() {
296+
// Safety: The monitor handle is created by NSEvent::addLocalMonitor...
297+
unsafe { NSEvent::removeMonitor(&monitor) };
298+
}
299+
191300
// 先使定时器无效,防止后续触发
192301
if let Some(timer) = state.countdown_timer.take() {
193302
timer.invalidate();
@@ -198,6 +307,9 @@ pub fn close_countdown_window() {
198307
}
199308
state.countdown_label = None;
200309
state.countdown_end_time = None;
310+
state.countdown_skip_smart_idx = 0;
311+
state.countdown_skip_ascii_idx = 0;
312+
state.countdown_skip_requested = false;
201313
});
202314
}
203315

src/macos/ui/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ pub mod dialogs;
77
pub mod status_bar;
88

99
pub use countdown::{
10-
close_countdown_window, finish_countdown, show_countdown_window, update_countdown,
10+
finish_countdown, show_countdown_window, update_countdown,
1111
};
1212
pub use dialogs::{open_settings_dialog, show_about_dialog};
1313
pub use status_bar::{

src/windows/state.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ pub struct AppState {
4040
pub countdown_hwnd: Option<HWND>,
4141
pub countdown_timer_id: Option<usize>,
4242
pub countdown_end_time: Option<Instant>,
43+
// Hidden skip phrase state (only used during breaks)
44+
pub countdown_skip_smart_idx: usize,
45+
pub countdown_skip_ascii_idx: usize,
4346
}
4447

4548
impl AppState {
@@ -56,6 +59,8 @@ impl AppState {
5659
countdown_hwnd: None,
5760
countdown_timer_id: None,
5861
countdown_end_time: None,
62+
countdown_skip_smart_idx: 0,
63+
countdown_skip_ascii_idx: 0,
5964
}
6065
}
6166
}

src/windows/timer.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,15 @@ pub fn start_break_now() {
9494
schedule_phase(Phase::Breaking);
9595
}
9696
}
97+
98+
/// 跳过当前休息(仅在 Breaking 阶段生效)
99+
pub fn skip_break() {
100+
let should_skip = with_state(|state| state.phase == Phase::Breaking);
101+
if !should_skip {
102+
return;
103+
}
104+
105+
// 提前结束休息:关闭倒计时窗口并回到工作阶段
106+
finish_countdown();
107+
schedule_phase(Phase::Working);
108+
}

0 commit comments

Comments
 (0)