From 4b34a89a660ef33b330de1abb69c2c4055171366 Mon Sep 17 00:00:00 2001 From: letteryzzm Date: Sun, 5 Apr 2026 16:54:58 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(macOS):=20=E6=94=AF=E6=8C=81=E5=85=A8?= =?UTF-8?q?=E5=B1=8F=E5=BA=94=E7=94=A8=E8=A6=86=E7=9B=96=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=92=8C=E8=B7=A8=E6=98=BE=E7=A4=BA=E5=99=A8=E6=8B=96=E6=8B=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过运行时 object_setClass 将窗口提升为 NSPanel 子类,启用 NonactivatingPanel style mask,这是 macOS 10.14 以来唯一能让 窗口浮在全屏应用之上的方式。同时设置 CanJoinAllSpaces + FullScreenAuxiliary + Stationary collectionBehavior 和 NSScreenSaverWindowLevel 窗口层级。拖拽范围改为所有显示器的 联合包围盒以支持跨屏拖拽。 Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 4 +- src-tauri/Cargo.lock | 3 +- src-tauri/Cargo.toml | 3 +- src-tauri/src/lib.rs | 17 ++++-- src-tauri/src/macos_spaces.rs | 99 +++++++++++++++++++++++++++++------ src-tauri/src/windows.rs | 26 +++++++++ src-tauri/tauri.conf.json | 6 ++- 7 files changed, 134 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index a37a940..443537f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clyde", - "version": "0.1.1", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clyde", - "version": "0.1.1", + "version": "0.1.4", "license": "AGPL-3.0-only", "dependencies": { "@tauri-apps/api": "^2" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 02a039c..c70db86 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -525,7 +525,7 @@ dependencies = [ [[package]] name = "clyde" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "axum", @@ -534,6 +534,7 @@ dependencies = [ "core-graphics", "dirs 5.0.1", "nix", + "objc2", "objc2-app-kit", "objc2-foundation", "open", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 48d3e93..ba1df21 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,7 +38,8 @@ nix = { version = "0.29", features = ["signal"] } [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6" -objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSWindow", "NSWorkspace"] } +objc2 = "0.6" +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSWindow", "NSPanel", "NSWorkspace"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSNotification", "NSOperation", "NSString", "NSThread", "block2"] } core-foundation = "0.10.1" core-graphics = "0.25.0" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c917c46..68bd87b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -378,11 +378,15 @@ fn drag_move(app: AppHandle, drag: tauri::State, x: f64, y: f64) { height: pet_h, }; - // Clamp to screen bounds: keep at least 30px of the pet visible - if let Some(monitor) = windows::monitor_for_bounds(&app, &probe_bounds) + // Clamp to the union of all monitors so the pet can be dragged across displays + // but still keeps at least 30px visible on some screen. + const MIN_VISIBLE: i32 = 30; + if let Some(union_rect) = windows::union_of_all_monitors(&app) { + (new_x, new_y) = + windows::clamp_window_to_monitor(new_x, new_y, pet_w, pet_h, &union_rect, MIN_VISIBLE); + } else if let Some(monitor) = windows::monitor_for_bounds(&app, &probe_bounds) .or_else(|| windows::current_monitor_for_pet(&app)) { - const MIN_VISIBLE: i32 = 30; (new_x, new_y) = windows::clamp_window_to_monitor(new_x, new_y, pet_w, pet_h, &monitor, MIN_VISIBLE); } @@ -1554,6 +1558,9 @@ fn setup_pet_window(app: &AppHandle, prefs: &prefs::Prefs) -> Option() .map(|prefs| { diff --git a/src-tauri/src/macos_spaces.rs b/src-tauri/src/macos_spaces.rs index c5baeb2..3f7d6e8 100644 --- a/src-tauri/src/macos_spaces.rs +++ b/src-tauri/src/macos_spaces.rs @@ -2,7 +2,7 @@ use block2::RcBlock; #[cfg(target_os = "macos")] use objc2_app_kit::{ - NSWindow, NSWindowCollectionBehavior, NSWorkspace, + NSWindow, NSWindowCollectionBehavior, NSWindowLevel, NSWindowStyleMask, NSWorkspace, NSWorkspaceActiveSpaceDidChangeNotification, }; #[cfg(target_os = "macos")] @@ -10,15 +10,79 @@ use core::ptr::NonNull; #[cfg(target_os = "macos")] use objc2_foundation::{NSNotification, NSThread}; -/// Applies Space-follow behavior to a window. +/// NSScreenSaverWindowLevel (1000) — the same level Electron uses for +/// `setAlwaysOnTop(true, 'screen-saver')`. +#[cfg(target_os = "macos")] +const OVERLAY_WINDOW_LEVEL: NSWindowLevel = 1000; + +/// Desired collection behavior: visible on all Spaces (including fullscreen +/// Spaces and other monitors), acts as a fullscreen auxiliary window, and +/// stays stationary during Space-switch animations. +#[cfg(target_os = "macos")] +fn overlay_behavior(current: NSWindowCollectionBehavior) -> NSWindowCollectionBehavior { + (current + | NSWindowCollectionBehavior::CanJoinAllSpaces + | NSWindowCollectionBehavior::FullScreenAuxiliary + | NSWindowCollectionBehavior::Stationary) + & !NSWindowCollectionBehavior::MoveToActiveSpace +} + +/// Promotes an NSWindow to an NSPanel subclass at runtime using +/// `object_setClass`. The custom subclass overrides `styleMask` to include +/// `NonactivatingPanel`, which is the *only* way since macOS 10.14 to float +/// above fullscreen apps (plain NSWindow rejects this style mask bit). +/// +/// The subclass is registered once and cached for the process lifetime. +#[cfg(target_os = "macos")] +unsafe fn promote_to_panel(ns_window: &NSWindow) { + use objc2::runtime::{AnyClass, AnyObject, ClassBuilder, Sel}; + use std::sync::Once; + + // The name for our dynamic subclass. + static REGISTER: Once = Once::new(); + static mut PANEL_CLASS: *const AnyClass = std::ptr::null(); + + REGISTER.call_once(|| { + // Create a subclass of NSPanel (which is itself an NSWindow subclass). + // Using NSPanel as superclass instead of NSWindow means the runtime + // won't reject the NonactivatingPanel style mask. + let superclass = AnyClass::get(c"NSPanel").expect("NSPanel class not found"); + let mut builder = ClassBuilder::new(c"ClydeOverlayPanel", superclass) + .expect("failed to create ClydeOverlayPanel class"); + + // Override -styleMask to always include NonactivatingPanel. + // Use raw pointer to avoid lifetime issues with add_method. + extern "C" fn override_style_mask(this: *mut AnyObject, _sel: Sel) -> usize { + let superclass = AnyClass::get(c"NSPanel").unwrap(); + let super_mask: usize = unsafe { + objc2::msg_send![super(unsafe { &*this }, superclass), styleMask] + }; + super_mask | (1 << 7) // NonactivatingPanel + } + + unsafe { + builder.add_method( + objc2::sel!(styleMask), + override_style_mask as extern "C" fn(*mut AnyObject, Sel) -> usize, + ); + } + + let cls = builder.register(); + unsafe { PANEL_CLASS = cls as *const AnyClass; } + }); + + let cls = unsafe { &*PANEL_CLASS }; + let obj = ns_window as *const NSWindow as *mut AnyObject; + objc2::ffi::object_setClass(obj, cls as *const AnyClass); +} + +/// Applies overlay behavior to a window so it is visible on all Spaces +/// (including fullscreen apps) and on every connected display. /// -/// Safety note (macOS/AppKit): `NSWindow` calls must run on the AppKit main -/// thread. This helper checks `NSThread::isMainThread` and no-ops when called -/// off-main-thread, because this API shape cannot hop threads or surface errors. +/// This promotes the NSWindow to an NSPanel subclass (via runtime class swap), +/// sets the collection behavior for all-spaces + fullscreen-auxiliary, and +/// raises the window level to screen-saver. /// -/// On macOS, this enables `NSWindowCollectionBehaviorMoveToActiveSpace` and -/// clears `NSWindowCollectionBehaviorCanJoinAllSpaces` so the single window -/// follows the currently active Space instead of appearing on all Spaces. /// On non-macOS platforms, this is a no-op. pub fn apply_space_follow(window: &tauri::WebviewWindow) { #[cfg(target_os = "macos")] @@ -27,13 +91,16 @@ pub fn apply_space_follow(window: &tauri::WebviewWindow) { return; }; + // 1. Promote to NSPanel subclass so NonactivatingPanel works. + promote_to_panel(ns_window); + + // 2. Set collection behavior for all Spaces + fullscreen auxiliary. let current_behavior = ns_window.collectionBehavior(); - let updated_behavior = (current_behavior | NSWindowCollectionBehavior::MoveToActiveSpace) - & !NSWindowCollectionBehavior::CanJoinAllSpaces; + let updated_behavior = overlay_behavior(current_behavior); + ns_window.setCollectionBehavior(updated_behavior); - if updated_behavior != current_behavior { - ns_window.setCollectionBehavior(updated_behavior); - } + // 3. Set window level high enough to float above everything. + ns_window.setLevel(OVERLAY_WINDOW_LEVEL); } #[cfg(not(target_os = "macos"))] @@ -44,6 +111,7 @@ pub fn apply_space_follow(window: &tauri::WebviewWindow) { /// Refreshes an existing window after a Space switch without activating app focus. /// +/// Re-applies overlay behavior and brings the window to the front if needed. /// This should run on AppKit main thread. #[allow(dead_code)] pub fn refresh_space_follow(window: &tauri::WebviewWindow) { @@ -54,12 +122,13 @@ pub fn refresh_space_follow(window: &tauri::WebviewWindow) { }; let current_behavior = ns_window.collectionBehavior(); - let updated_behavior = (current_behavior | NSWindowCollectionBehavior::MoveToActiveSpace) - & !NSWindowCollectionBehavior::CanJoinAllSpaces; + let updated_behavior = overlay_behavior(current_behavior); if updated_behavior != current_behavior { ns_window.setCollectionBehavior(updated_behavior); } + ns_window.setLevel(OVERLAY_WINDOW_LEVEL); + if !ns_window.isOnActiveSpace() { ns_window.orderFrontRegardless(); } diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 31b442c..1f208b6 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -166,6 +166,32 @@ pub fn available_monitor_areas(app: &AppHandle) -> Option> { ) } +/// Returns a single `MonitorArea` that is the bounding box of all connected +/// monitors. This allows the pet to be dragged freely across displays. +pub fn union_of_all_monitors(app: &AppHandle) -> Option { + let monitors = available_monitor_areas(app)?; + if monitors.is_empty() { + return None; + } + let mut min_x = i32::MAX; + let mut min_y = i32::MAX; + let mut max_x = i32::MIN; + let mut max_y = i32::MIN; + for m in &monitors { + min_x = min_x.min(m.x); + min_y = min_y.min(m.y); + max_x = max_x.max(m.x + m.width as i32); + max_y = max_y.max(m.y + m.height as i32); + } + Some(MonitorArea { + key: String::from("union"), + x: min_x, + y: min_y, + width: (max_x - min_x) as u32, + height: (max_y - min_y) as u32, + }) +} + pub fn current_monitor_for_pet(app: &AppHandle) -> Option { if let Some(pet) = app.get_webview_window("pet") { if let Ok(Some(monitor)) = pet.current_monitor() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 378773d..e89d171 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,10 +19,11 @@ "decorations": false, "transparent": true, "shadow": false, - "alwaysOnTop": true, + "alwaysOnTop": false, "skipTaskbar": true, "resizable": false, "visible": false, + "visibleOnAllWorkspaces": true, "url": "src/windows/pet/index.html" }, { @@ -33,10 +34,11 @@ "decorations": false, "transparent": true, "shadow": false, - "alwaysOnTop": true, + "alwaysOnTop": false, "skipTaskbar": true, "resizable": false, "visible": false, + "visibleOnAllWorkspaces": true, "url": "src/windows/hit/index.html" } ] From 61e5f3b5b7de02d12ec41fd7f9131217abbd3056 Mon Sep 17 00:00:00 2001 From: letteryzzm Date: Sun, 5 Apr 2026 16:57:08 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(macOS):=20=E9=9A=90=E8=97=8F=20Dock=20?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=EF=BC=8C=E4=BD=BF=E7=94=A8=E6=89=98=E7=9B=98?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E6=8E=A7=E5=88=B6=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置 ActivationPolicy::Accessory 隐藏 Dock 上的应用图标, 桌面宠物通过系统托盘图标进行交互和管理。 Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 68bd87b..197810b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1722,6 +1722,10 @@ pub fn run() { focus::focus_terminal_for_session, ]) .setup(move |app| { + // Hide Dock icon — the app is controlled via the tray icon instead. + #[cfg(target_os = "macos")] + app.set_activation_policy(tauri::ActivationPolicy::Accessory); + let prefs = prefs::load(app.handle()); *shared_prefs.lock_or_recover() = prefs.clone(); sync_autostart_pref(prefs.auto_start_with_claude); From d2c95b9d78abc605e436a68dd40126873bd5b047 Mon Sep 17 00:00:00 2001 From: letteryzzm Date: Mon, 6 Apr 2026 10:01:11 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(macOS):=20=E4=BF=AE=E5=A4=8D=E8=B7=A8?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=99=A8=E6=8B=96=E6=8B=BD=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 CoreGraphics CGEvent 获取全局鼠标坐标,替代前端 screenX * devicePixelRatio 的方式,避免跨不同 DPI 显示器 拖拽时坐标缩放不一致的问题。同时在 hit 窗口添加 setPointerCapture,防止快速拖拽时 pointer 事件丢失。 Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/lib.rs | 37 ++++++++++++++++++++++++++++++------- src/windows/hit/App.svelte | 8 +++++++- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 197810b..00d17cc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -303,17 +303,34 @@ pub(crate) fn toggle_auto_dnd_meetings_pref(app: &AppHandle) { } +/// Returns the global mouse cursor position using CoreGraphics (macOS) or +/// falls back to the frontend-supplied coordinates on other platforms. +/// CG coordinates use a top-left origin that is DPI-independent, avoiding +/// the scale-factor mismatch that occurs when dragging across monitors with +/// different DPI settings. +#[cfg(target_os = "macos")] +fn native_cursor_position() -> Option<(f64, f64)> { + use core_graphics::event::CGEvent; + use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; + let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState).ok()?; + let event = CGEvent::new(source).ok()?; + let pt = event.location(); + Some((pt.x, pt.y)) +} + #[tauri::command] fn drag_start(app: AppHandle, drag: tauri::State, x: f64, y: f64) { emit_snap_preview(&app, None); let mut d = drag.lock_or_recover(); - // Always set active so drag_end runs (for click detection, sync_hit, etc.). - // If pet bounds are unavailable (rare, e.g. during animation), use last known or 0,0. d.active = true; d.dragging = false; - d.start_mouse_x = x; - d.start_mouse_y = y; - // Window position in physical pixels to match mouse coords (toPhys in frontend) + // On macOS, use native CG cursor position to avoid DPI mismatch across monitors. + #[cfg(target_os = "macos")] + let (mx, my) = native_cursor_position().unwrap_or((x, y)); + #[cfg(not(target_os = "macos"))] + let (mx, my) = (x, y); + d.start_mouse_x = mx; + d.start_mouse_y = my; if let Some(pet) = app.get_webview_window("pet") { if let Ok(pos) = pet.outer_position() { d.start_win_x = pos.x; @@ -347,8 +364,14 @@ fn drag_move(app: AppHandle, drag: tauri::State, x: f64, y: f64) { return; } - let dx = x - smx; - let dy = y - smy; + // On macOS, use native CG cursor position to avoid DPI mismatch across monitors. + #[cfg(target_os = "macos")] + let (cx, cy) = native_cursor_position().unwrap_or((x, y)); + #[cfg(not(target_os = "macos"))] + let (cx, cy) = (x, y); + + let dx = cx - smx; + let dy = cy - smy; // Don't start moving until the mouse has moved past the drag threshold if !dragging { diff --git a/src/windows/hit/App.svelte b/src/windows/hit/App.svelte index 45d70ff..8b3d68d 100644 --- a/src/windows/hit/App.svelte +++ b/src/windows/hit/App.svelte @@ -33,6 +33,10 @@ startY = toPhys(e.screenY); if (positionLocked) return; + // Capture pointer so we keep receiving events even when the cursor + // leaves the hit window (e.g. fast cross-monitor drags). + (e.target as HTMLElement).setPointerCapture(e.pointerId); + invoke('drag_start', { x: startX, y: startY }); clickCount++; @@ -72,6 +76,7 @@ function onPointerUp(e: PointerEvent) { if (!pointerActive) return; if (activePointerId !== null && e.pointerId !== activePointerId) return; + (e.target as HTMLElement).releasePointerCapture(e.pointerId); pointerActive = false; activePointerId = null; snapSide = null; @@ -81,8 +86,9 @@ } } - function onPointerCancel() { + function onPointerCancel(e: PointerEvent) { if (!pointerActive) return; + try { (e.target as HTMLElement).releasePointerCapture(e.pointerId); } catch {} pointerActive = false; activePointerId = null; snapSide = null; From 33edfd052ceed5819a565ad53079ff30447373bf Mon Sep 17 00:00:00 2001 From: letteryzzm Date: Mon, 6 Apr 2026 17:52:49 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在宠物窗口左上角始终显示最多3条任务,右键菜单「编辑任务」 打开编辑面板,支持添加、编辑、删除任务。任务存储在 ~/.clyde/tasks.json,通过 Tauri events 实时同步到宠物窗口。 同时添加二次开发交接文档 INTEGRATION.md。 Co-Authored-By: Claude Opus 4.6 (1M context) --- INTEGRATION.md | 233 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 57 +++++++ src-tauri/src/tasks.rs | 128 ++++++++++++++++ src/windows/pet/App.svelte | 59 ++++++++ src/windows/tasks/App.svelte | 283 +++++++++++++++++++++++++++++++++++ src/windows/tasks/index.html | 16 ++ src/windows/tasks/main.ts | 4 + vite.config.ts | 1 + 8 files changed, 781 insertions(+) create mode 100644 INTEGRATION.md create mode 100644 src-tauri/src/tasks.rs create mode 100644 src/windows/tasks/App.svelte create mode 100644 src/windows/tasks/index.html create mode 100644 src/windows/tasks/main.ts diff --git a/INTEGRATION.md b/INTEGRATION.md new file mode 100644 index 0000000..b8db976 --- /dev/null +++ b/INTEGRATION.md @@ -0,0 +1,233 @@ +# Clyde 桌面宠物 — 二次开发交接文档 + +## 项目概述 + +Clyde 是一个基于 Tauri 2(Rust + Svelte 5)的桌面宠物应用,通过 SVG 动画展示不同状态。本文档说明如何将其接入外部应用(如语音聊天)作为视觉效果展示。 + +--- + +## 架构总览 + +``` +外部应用(语音聊天等) + │ + │ POST http://127.0.0.1:23333/state + │ { "state": "thinking", "session_id": "voice-chat-001" } + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Clyde HTTP Server (Axum, 127.0.0.1:23333) │ +│ └─ state_machine.rs: 多会话状态优先级管理 │ +│ └─ 最高优先级状态 → 发射前端事件 │ +├─────────────────────────────────────────────┤ +│ Pet Window (Svelte + SVG) │ +│ └─ 收到 state-change 事件 → 切换 SVG 动画 │ +└─────────────────────────────────────────────┘ +``` + +**核心接入方式:向本地 HTTP server POST 状态即可驱动宠物动画,无需修改 Clyde 源码。** + +--- + +## HTTP API 接口 + +### 健康检查 + +``` +GET http://127.0.0.1:23333/state +→ {"ok": true, "app": "clyde-on-desk"} +``` + +### 设置状态(核心接口) + +``` +POST http://127.0.0.1:23333/state +Content-Type: application/json + +{ + "state": "thinking", // 必填 — 动画状态名 + "session_id": "voice-chat-001", // 可选 — 默认 "default",建议填自定义 ID + "event": "UserSpeaking", // 可选 — 事件名,用于日志 + "agent_id": "my-voice-app", // 可选 — 默认 "claude-code" + "source_pid": 12345, // 可选 — 用于 attention 状态自动聚焦终端 + "cwd": "/path/to/project" // 可选 — 工作目录 +} +``` + +**最简调用:** +```bash +curl -X POST http://127.0.0.1:23333/state \ + -H "Content-Type: application/json" \ + -d '{"state": "thinking", "session_id": "voice-chat"}' +``` + +### 结束会话 + +```json +{ + "state": "idle", + "session_id": "voice-chat-001", + "event": "SessionEnd" +} +``` + +发送 `event: "SessionEnd"` 会完全移除该会话。 + +--- + +## 可用状态列表 + +| 状态名 | 优先级 | 视觉效果 | SVG 文件 | 类型 | +|--------|--------|---------|----------|------| +| `error` | 8 | 感叹号/错误表情 | clyde-error.svg | 一次性 | +| `notification` | 7 | 通知表情 | clyde-notification.svg | 一次性 | +| `sweeping` | 6 | 扫地动画 | clyde-working-sweeping.svg | 一次性 | +| `attention` | 5 | 开心跳跃 | clyde-happy.svg | 一次性 | +| `carrying` | 4 | 搬运动画 | clyde-working-carrying.svg | 一次性 | +| `juggling` | 4 | 杂耍/指挥 | clyde-working-juggling.svg | 持续 | +| `working` | 3 | 打字/建造 | clyde-working-typing.svg | 持续 | +| `thinking` | 2 | 托腮思考 | clyde-working-thinking.svg | 持续 | +| `idle` | 1 | 待机跟随眼球 | clyde-idle-follow.svg | 持续 | +| `sleeping` | 0 | 睡觉 | clyde-sleeping.svg | 持续 | + +### 状态类型说明 + +- **一次性(Oneshot)**:播放动画后自动恢复到之前的状态(如 `attention` 跳一下就回 `idle`) +- **持续(Persistent)**:保持该状态直到收到新的状态更新 +- **优先级**:多个会话同时存在时,最高优先级的状态显示 + +### 语音聊天建议映射 + +| 语音聊天事件 | 建议状态 | +|-------------|---------| +| 用户开始说话 | `thinking` | +| AI 正在处理/生成回复 | `working` | +| AI 正在说话 | `working` 或 `juggling` | +| AI 回复完成 | `attention` | +| 出错 | `error` | +| 空闲等待 | `idle` | +| 收到新消息 | `notification` | + +--- + +## 会话管理 + +- 每个 `session_id` 是独立的会话,互不干扰 +- 会话 **10 分钟** 无更新自动清除 +- `working`/`thinking` 状态 **5 分钟** 无更新自动降级为 `idle` +- 多会话并存时,优先级最高的状态显示 +- DND(勿扰)模式下状态更新会被跳过(除 `SessionEnd`) + +--- + +## 端口发现 + +Clyde 启动后写入运行时配置文件: + +``` +~/.clyde/runtime.json +→ {"app": "clyde-on-desk", "port": 23333} +``` + +默认端口 `23333`,如果被占用会尝试 `23334-23339`。外部应用应该: +1. 先读 `~/.clyde/runtime.json` 获取端口 +2. 读取失败则尝试 23333-23339 范围 +3. 用 `GET /state` 验证是否为 Clyde server + +--- + +## 关键源码位置 + +| 文件 | 用途 | +|------|------| +| `src-tauri/src/http_server.rs` | HTTP server,所有 API 端点 | +| `src-tauri/src/state_machine.rs` | 多会话状态管理、优先级、SVG 映射 | +| `src-tauri/src/macos_spaces.rs` | macOS 全屏覆盖 + NSPanel 提升 | +| `src-tauri/src/windows.rs` | 多显示器坐标、拖拽范围 | +| `src-tauri/src/lib.rs` | 拖拽逻辑、窗口初始化 | +| `src/windows/pet/App.svelte` | 前端 SVG 渲染、眼球跟随 | +| `src/windows/hit/App.svelte` | 不可见交互层(拖拽/点击) | +| `assets/svg/` | 所有 SVG 动画文件(35 个) | +| `hooks/` | Claude Code hook 脚本(参考实现) | + +--- + +## 二次开发改动记录 + +### 本次改动(feat/fullscreen-overlay 分支) + +1. **全屏覆盖** — 通过 `object_setClass` 将 NSWindow 提升为 NSPanel 子类,启用 `NonactivatingPanel` style mask,配合 `CanJoinAllSpaces + FullScreenAuxiliary + Stationary` collectionBehavior 和 `NSScreenSaverWindowLevel(1000)` + +2. **跨显示器拖拽** — 拖拽 clamp 改为所有显示器联合包围盒;Rust 端用 CoreGraphics `CGEvent` 获取全局鼠标坐标,绕过 Tauri 的 DPI 缩放 bug + +3. **隐藏 Dock 图标** — `set_activation_policy(Accessory)` + +--- + +## 快速接入示例(Python) + +```python +import requests +import json + +CLYDE_URL = "http://127.0.0.1:23333/state" +SESSION = "voice-chat-session" + +def set_pet_state(state: str): + """设置宠物状态""" + requests.post(CLYDE_URL, json={ + "state": state, + "session_id": SESSION, + "agent_id": "voice-chat" + }, timeout=0.5) + +# 使用示例 +set_pet_state("thinking") # 用户在说话 +set_pet_state("working") # AI 在处理 +set_pet_state("attention") # 处理完成(跳一下) +set_pet_state("idle") # 回到待机 + +# 结束会话 +requests.post(CLYDE_URL, json={ + "state": "idle", + "session_id": SESSION, + "event": "SessionEnd" +}, timeout=0.5) +``` + +--- + +## 快速接入示例(Node.js) + +```javascript +const CLYDE_URL = 'http://127.0.0.1:23333/state'; +const SESSION = 'voice-chat-session'; + +async function setPetState(state) { + await fetch(CLYDE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + state, + session_id: SESSION, + agent_id: 'voice-chat' + }) + }); +} + +// 使用 +await setPetState('thinking'); +await setPetState('working'); +await setPetState('attention'); +await setPetState('idle'); +``` + +--- + +## 注意事项 + +1. **Clyde 必须先启动** — 外部应用调用前确认 `GET /state` 返回 200 +2. **超时设短** — POST 请求建议 500ms 超时,避免阻塞主流程 +3. **状态不要刷太快** — 建议至少间隔 100ms,否则动画来不及播放 +4. **一次性状态自动恢复** — `attention`/`error` 等播完会自动回到之前状态,不需要手动切回 +5. **session_id 要唯一** — 避免和 Claude Code 的会话冲突,建议用自己的前缀如 `voice-chat-xxx` diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 00d17cc..5342450 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod macos_spaces; mod prefs; mod session_meta; mod state_machine; +mod tasks; mod tick; mod tray; mod util; @@ -804,6 +805,11 @@ fn show_context_menu( } } + // Tasks editor + if let Ok(edit_tasks) = MenuItem::with_id(&app, "ctx-edit-tasks", "📋 编辑任务", true, None::<&str>) { + items.push(Box::new(edit_tasks)); + } + // Hide / About / Quit if let Ok(sep) = PredefinedMenuItem::separator(&app) { items.push(Box::new(sep)); @@ -1530,6 +1536,9 @@ fn handle_context_menu_event(app: &AppHandle, state: &SharedState, id: &str) { let _ = open::that("https://github.com/QingJ01/Clyde"); } "quit" => app.exit(0), + "edit-tasks" => { + show_tasks_editor(app); + } _ => {} } if refresh_tray { @@ -1542,6 +1551,47 @@ fn handle_context_menu_event(app: &AppHandle, state: &SharedState, id: &str) { } } +fn show_tasks_editor(app: &AppHandle) { + use tauri::WebviewWindowBuilder; + // If already open, just focus it + if let Some(win) = app.get_webview_window("tasks") { + let _ = win.show(); + let _ = win.set_focus(); + return; + } + // Position near the pet window + let (x, y) = if let Some(pet) = app.get_webview_window("pet") { + if let Ok(pos) = pet.outer_position() { + (pos.x + 20, pos.y + 20) + } else { + (200, 200) + } + } else { + (200, 200) + }; + + if let Ok(win) = WebviewWindowBuilder::new( + app, + "tasks", + tauri::WebviewUrl::App("src/windows/tasks/index.html".into()), + ) + .title("Tasks") + .inner_size(240.0, 260.0) + .position(x as f64, y as f64) + .decorations(false) + .transparent(false) + .shadow(true) + .always_on_top(true) + .resizable(false) + .skip_taskbar(true) + .build() + { + // Use a dark, opaque background so macOS receives mouse events. + // (Fully transparent windows on macOS don't receive clicks.) + let _ = win.set_background_color(Some(tauri::window::Color(30, 30, 30, 255))); + } +} + fn setup_pet_window(app: &AppHandle, prefs: &prefs::Prefs) -> Option { let Some(pet) = app.get_webview_window("pet") else { eprintln!("Clyde: pet window not found!"); @@ -1743,6 +1793,13 @@ pub fn run() { permission::bubble_height_measured, permission::dismiss_bubble, focus::focus_terminal_for_session, + tasks::get_tasks, + tasks::set_tasks, + tasks::update_task, + tasks::add_task, + tasks::remove_task, + tasks::reorder_tasks, + tasks::close_tasks_editor, ]) .setup(move |app| { // Hide Dock icon — the app is controlled via the tray icon instead. diff --git a/src-tauri/src/tasks.rs b/src-tauri/src/tasks.rs new file mode 100644 index 0000000..ce7d896 --- /dev/null +++ b/src-tauri/src/tasks.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +const MAX_TASKS: usize = 5; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Task { + pub id: String, + pub text: String, + pub order: usize, +} + +fn tasks_path() -> Option { + dirs::home_dir().map(|h| h.join(".clyde").join("tasks.json")) +} + +pub fn load_tasks() -> Vec { + let Some(path) = tasks_path() else { + return Vec::new(); + }; + let Ok(data) = std::fs::read_to_string(&path) else { + return Vec::new(); + }; + serde_json::from_str(&data).unwrap_or_default() +} + +pub fn save_tasks(tasks: &[Task]) { + let Some(path) = tasks_path() else { return }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(tasks) { + let _ = std::fs::write(path, json); + } +} + +#[tauri::command] +pub fn close_tasks_editor(app: AppHandle) { + if let Some(win) = app.get_webview_window("tasks") { + let _ = win.destroy(); + } +} + +/// Emit task list to the pet window frontend. +pub fn emit_tasks(app: &AppHandle) { + let tasks = get_tasks(); + let _ = tauri::Emitter::emit(app, "tasks-changed", tasks); +} + +#[tauri::command] +pub fn get_tasks() -> Vec { + let mut tasks = load_tasks(); + tasks.sort_by_key(|t| t.order); + tasks +} + +#[tauri::command] +pub fn set_tasks(app: AppHandle, tasks: Vec) { + let mut tasks = tasks; + tasks.truncate(MAX_TASKS); + for (i, t) in tasks.iter_mut().enumerate() { + t.order = i; + if t.id.is_empty() { + t.id = uuid::Uuid::new_v4().to_string()[..8].to_string(); + } + } + save_tasks(&tasks); + emit_tasks(&app); +} + +#[tauri::command] +pub fn update_task(app: AppHandle, id: String, text: String) { + let mut tasks = load_tasks(); + if let Some(task) = tasks.iter_mut().find(|t| t.id == id) { + task.text = text; + save_tasks(&tasks); + emit_tasks(&app); + } +} + +#[tauri::command] +pub fn add_task(app: AppHandle, text: String) { + let mut tasks = load_tasks(); + if tasks.len() >= MAX_TASKS { + return; + } + let order = tasks.len(); + tasks.push(Task { + id: uuid::Uuid::new_v4().to_string()[..8].to_string(), + text, + order, + }); + save_tasks(&tasks); + emit_tasks(&app); +} + +#[tauri::command] +pub fn remove_task(app: AppHandle, id: String) { + let mut tasks = load_tasks(); + tasks.retain(|t| t.id != id); + for (i, t) in tasks.iter_mut().enumerate() { + t.order = i; + } + save_tasks(&tasks); + emit_tasks(&app); +} + +#[tauri::command] +pub fn reorder_tasks(app: AppHandle, ids: Vec) { + let tasks = load_tasks(); + let mut reordered = Vec::new(); + for (i, id) in ids.iter().enumerate() { + if let Some(mut t) = tasks.iter().find(|t| &t.id == id).cloned() { + t.order = i; + reordered.push(t); + } + } + for t in &tasks { + if !ids.contains(&t.id) { + let mut t = t.clone(); + t.order = reordered.len(); + reordered.push(t); + } + } + save_tasks(&reordered); + emit_tasks(&app); +} diff --git a/src/windows/pet/App.svelte b/src/windows/pet/App.svelte index 420cc59..dcf9c0f 100644 --- a/src/windows/pet/App.svelte +++ b/src/windows/pet/App.svelte @@ -5,6 +5,9 @@ import { currentSvg, currentState, dndEnabled, currentLang } from '../../lib/stores'; import { get } from 'svelte/store'; + interface TaskItem { id: string; text: string; order: number; } + let tasks: TaskItem[] = $state([]); + import _idleFollowRaw from '../../../assets/svg/clyde-idle-follow.svg?raw'; const rawModules = import.meta.glob('../../../assets/svg/*.svg', { @@ -99,6 +102,12 @@ snapPreview = payload.active; })); + // Load tasks and listen for changes + tasks = await invoke('get_tasks'); + unlisten.push(await listen('tasks-changed', ({ payload }) => { + tasks = payload; + })); + unlisten.push(await listen('trigger-yawn', () => { invoke('trigger_sleep_sequence'); })); unlisten.push(await listen('trigger-wake', () => { invoke('trigger_wake'); })); unlisten.push(await listen('mini-peek-in', () => { invoke('mini_peek_in'); })); @@ -119,6 +128,16 @@
+ {#if tasks.length > 0} +
+ {#each tasks.slice(0, 3) as task, i} +
+ {i + 1} + {task.text} +
+ {/each} +
+ {/if}
{@html svgContent}
@@ -151,6 +170,46 @@ #pet-container:not(.snap-preview) { transition: transform 150ms ease-out, opacity 150ms ease-out; } + /* ── Task panel ── */ + .task-panel { + position: absolute; + top: 6px; + left: 6px; + z-index: 10; + display: flex; + flex-direction: column; + gap: 3px; + max-width: 70%; + pointer-events: none; + } + .task-item { + display: flex; + align-items: baseline; + gap: 4px; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + border-radius: 4px; + padding: 2px 6px; + line-height: 1.3; + } + .task-index { + font-size: 9px; + font-weight: 700; + color: rgba(255, 255, 255, 0.5); + flex-shrink: 0; + min-width: 10px; + } + .task-text { + font-size: 10px; + font-weight: 500; + color: rgba(255, 255, 255, 0.88); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; + } + /* Smooth eye/body tracking — interpolate between 50ms tick updates */ .svg-wrapper :global(#eyes-js), .svg-wrapper :global(#body-js), diff --git a/src/windows/tasks/App.svelte b/src/windows/tasks/App.svelte new file mode 100644 index 0000000..75f84a1 --- /dev/null +++ b/src/windows/tasks/App.svelte @@ -0,0 +1,283 @@ + + +
+
+ MY TASKS + +
+ +
+ {#each tasks as task, i (task.id)} +
i} + class:drop-below={dropIdx === i && dragIdx !== null && dragIdx < i} + > + onGripDown(i, e)} + role="button" + tabindex="-1" + >⠿ + onInput(i, e)} + onblur={onBlur} + onkeydown={onKeyDown} + /> + +
+ {/each} +
+ + {#if tasks.length < 5} + + {/if} +
+ + diff --git a/src/windows/tasks/index.html b/src/windows/tasks/index.html new file mode 100644 index 0000000..8d3515f --- /dev/null +++ b/src/windows/tasks/index.html @@ -0,0 +1,16 @@ + + + + + + Tasks + + + +
+ + + diff --git a/src/windows/tasks/main.ts b/src/windows/tasks/main.ts new file mode 100644 index 0000000..88d7af4 --- /dev/null +++ b/src/windows/tasks/main.ts @@ -0,0 +1,4 @@ +import { mount } from 'svelte'; +import App from './App.svelte'; +const app = mount(App, { target: document.getElementById('app')! }); +export default app; diff --git a/vite.config.ts b/vite.config.ts index a3510ed..18c7d0b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ hit: path.resolve(__dirname, 'src/windows/hit/index.html'), bubble: path.resolve(__dirname, 'src/windows/bubble/index.html'), menu: path.resolve(__dirname, 'src/windows/menu/index.html'), + tasks: path.resolve(__dirname, 'src/windows/tasks/index.html'), }, }, }, From 4ea7fd14525c4fbf8eb1c0faf0400d92cc53be2e Mon Sep 17 00:00:00 2001 From: letteryzzm Date: Tue, 7 Apr 2026 11:19:04 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E4=BB=BB=E5=8A=A1=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=8E=9F=E7=94=9F=E5=AF=B9=E8=AF=9D=E6=A1=86?= =?UTF-8?q?=20+=20=E4=BF=AE=E5=A4=8D=E4=B8=AD=E6=96=87=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除无法接收事件的 tasks 独立窗口方案,改用右键菜单子菜单 + osascript 原生对话框编辑任务。弹框前临时切换 ActivationPolicy 到 Regular 使输入法正常工作,关闭后切回 Accessory。 Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/lib.rs | 178 +++++++++++++++++++++++++++-------- src/windows/tasks/App.svelte | 9 +- 2 files changed, 144 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5342450..dd03f5f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -805,9 +805,61 @@ fn show_context_menu( } } - // Tasks editor - if let Ok(edit_tasks) = MenuItem::with_id(&app, "ctx-edit-tasks", "📋 编辑任务", true, None::<&str>) { - items.push(Box::new(edit_tasks)); + // Tasks submenu — click task to edit, ✕ to delete, + to add + { + let current_tasks = tasks::get_tasks(); + let mut task_items: Vec>> = Vec::new(); + for task in ¤t_tasks { + let label = format!("{}. {}", task.order + 1, task.text); + // Click to edit + if let Ok(item) = MenuItem::with_id( + &app, + format!("ctx-task-edit-{}", task.id), + format!("✏️ {}", label), + true, + None::<&str>, + ) { + task_items.push(Box::new(item)); + } + // Delete + if let Ok(item) = MenuItem::with_id( + &app, + format!("ctx-task-del-{}", task.id), + format!(" ✕ 删除"), + true, + None::<&str>, + ) { + task_items.push(Box::new(item)); + } + if let Ok(sep) = PredefinedMenuItem::separator(&app) { + task_items.push(Box::new(sep)); + } + } + // Move up / move down + for task in ¤t_tasks { + if task.order > 0 { + if let Ok(item) = MenuItem::with_id( + &app, + format!("ctx-task-up-{}", task.id), + format!("↑ 上移「{}」", truncate_str(&task.text, 8)), + true, + None::<&str>, + ) { + task_items.push(Box::new(item)); + } + } + } + if let Ok(sep) = PredefinedMenuItem::separator(&app) { + task_items.push(Box::new(sep)); + } + if let Ok(add) = MenuItem::with_id(&app, "ctx-task-add", "+ 添加任务", true, None::<&str>) { + task_items.push(Box::new(add)); + } + let task_refs: Vec<&dyn tauri::menu::IsMenuItem> = + task_items.iter().map(|i| i.as_ref()).collect(); + if let Ok(submenu) = Submenu::with_items(&app, "📋 任务", true, &task_refs) { + items.push(Box::new(submenu)); + } } // Hide / About / Quit @@ -1536,8 +1588,43 @@ fn handle_context_menu_event(app: &AppHandle, state: &SharedState, id: &str) { let _ = open::that("https://github.com/QingJ01/Clyde"); } "quit" => app.exit(0), - "edit-tasks" => { - show_tasks_editor(app); + "task-add" => { + let text = prompt_text_input(app, "添加任务", "输入任务内容:", ""); + if let Some(text) = text { + if !text.is_empty() { + tasks::add_task(app.clone(), text); + } + } + } + _ if action.starts_with("task-edit-") => { + let task_id = action.strip_prefix("task-edit-").unwrap_or(""); + let current = tasks::load_tasks(); + if let Some(task) = current.iter().find(|t| t.id == task_id) { + let text = prompt_text_input(app, "编辑任务", "修改任务内容:", &task.text); + if let Some(text) = text { + if !text.is_empty() { + tasks::update_task(app.clone(), task_id.to_string(), text); + } + } + } + } + _ if action.starts_with("task-del-") => { + let task_id = action.strip_prefix("task-del-").unwrap_or(""); + if !task_id.is_empty() { + tasks::remove_task(app.clone(), task_id.to_string()); + } + } + _ if action.starts_with("task-up-") => { + let task_id = action.strip_prefix("task-up-").unwrap_or(""); + let mut current = tasks::load_tasks(); + current.sort_by_key(|t| t.order); + if let Some(idx) = current.iter().position(|t| t.id == task_id) { + if idx > 0 { + current.swap(idx, idx - 1); + let ids: Vec = current.iter().map(|t| t.id.clone()).collect(); + tasks::reorder_tasks(app.clone(), ids); + } + } } _ => {} } @@ -1551,44 +1638,53 @@ fn handle_context_menu_event(app: &AppHandle, state: &SharedState, id: &str) { } } -fn show_tasks_editor(app: &AppHandle) { - use tauri::WebviewWindowBuilder; - // If already open, just focus it - if let Some(win) = app.get_webview_window("tasks") { - let _ = win.show(); - let _ = win.set_focus(); - return; - } - // Position near the pet window - let (x, y) = if let Some(pet) = app.get_webview_window("pet") { - if let Ok(pos) = pet.outer_position() { - (pos.x + 20, pos.y + 20) - } else { - (200, 200) - } - } else { - (200, 200) - }; - if let Ok(win) = WebviewWindowBuilder::new( - app, - "tasks", - tauri::WebviewUrl::App("src/windows/tasks/index.html".into()), - ) - .title("Tasks") - .inner_size(240.0, 260.0) - .position(x as f64, y as f64) - .decorations(false) - .transparent(false) - .shadow(true) - .always_on_top(true) - .resizable(false) - .skip_taskbar(true) - .build() +/// Prompt user for text input via macOS native dialog (osascript). +/// Returns None if cancelled, Some(text) if confirmed. +fn prompt_text_input(app: &AppHandle, title: &str, message: &str, default: &str) -> Option { + #[cfg(target_os = "macos")] { - // Use a dark, opaque background so macOS receives mouse events. - // (Fully transparent windows on macOS don't receive clicks.) - let _ = win.set_background_color(Some(tauri::window::Color(30, 30, 30, 255))); + // Temporarily switch to Regular so the system IME (Chinese etc.) works + app.set_activation_policy(tauri::ActivationPolicy::Regular); + + let script = format!( + r#"display dialog "{}" default answer "{}" with title "{}" buttons {{"取消", "确定"}} default button "确定""#, + message.replace('"', r#"\""#), + default.replace('"', r#"\""#), + title.replace('"', r#"\""#), + ); + let output = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + .ok(); + + // Switch back to Accessory (hide Dock icon again) + app.set_activation_policy(tauri::ActivationPolicy::Accessory); + + let output = output?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .split("text returned:") + .nth(1) + .map(|s| s.trim().to_string()) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (app, title, message, default); + None + } +} + +fn truncate_str(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let truncated: String = s.chars().take(max_chars).collect(); + format!("{truncated}…") } } diff --git a/src/windows/tasks/App.svelte b/src/windows/tasks/App.svelte index 75f84a1..7a8bfd7 100644 --- a/src/windows/tasks/App.svelte +++ b/src/windows/tasks/App.svelte @@ -1,6 +1,7 @@
-
- MY TASKS +
+ MY TASKS