Skip to content

feat: add desktop teleprompter#2006

Merged
richiemcilroy merged 5 commits into
mainfrom
feature/desktop-teleprompter
Jul 14, 2026
Merged

feat: add desktop teleprompter#2006
richiemcilroy merged 5 commits into
mainfrom
feature/desktop-teleprompter

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

  • add a minimal editable desktop teleprompter with smooth adjustable auto-scroll
  • use native macOS Liquid Glass, traffic lights, window opacity, and always-on-top behavior
  • exclude the teleprompter from Cap recordings and persist its settings

Validation

  • pnpm exec biome check <touched files>
  • pnpm --dir apps/desktop exec tsc --noEmit --project tsconfig.json
  • pnpm --dir apps/desktop exec vitest run src/routes/teleprompter-utils.test.ts
  • pnpm --dir apps/desktop build
  • cargo fmt --all -- --check
  • cargo check -p cap-desktop
  • cargo test -p cap-desktop general_settings --lib

Greptile Summary

This PR adds a minimal desktop teleprompter window to Cap with smooth scroll-based playback, native macOS Liquid Glass styling, adjustable opacity and always-on-top behavior, and automatic exclusion from Cap recordings. The previously-noted issue with debounced saves being dropped on window close has been resolved — onCleanup now flushes the latest state before clearing the timer, and onCloseRequested persists state in a finally block guarded by an allowClose flag to prevent double-writes.

  • Recording exclusion: The teleprompter title is excluded at three complementary layers — default settings, a dynamic push at recording start (macOS only), and a hardcoded early-return in window_capture_excluded — ensuring content protection is applied reliably.
  • Window lifecycle: openTeleprompter uses a module-level creatingWindow sentinel (set synchronously before any further awaits) to prevent duplicate window creation; the async onCloseRequested unlisten race is handled with a disposed flag.
  • Utility layer: teleprompter-utils.ts extracts pure math (word count, speed calculation, sub-pixel position accumulation) with accompanying unit tests covering edge cases including negative inputs and 60-frame accumulation accuracy.

Confidence Score: 5/5

Safe to merge — the feature is well-contained, the previous close-handler data-loss issue has been fixed, and all validation steps in the PR description pass.

The teleprompter window lifecycle, persistence, recording exclusion, and animation loop are all implemented correctly. The previously flagged save-on-close bug is fixed. No logic errors, data races, or broken contracts were found in the changed paths.

No files require special attention.

Important Files Changed

Filename Overview
apps/desktop/src/routes/teleprompter.tsx Main teleprompter UI: correctly guards isLoaded() before saves, flushes state in onCleanup before clearing the debounce timer, and handles the async unlisten race with the disposed flag.
apps/desktop/src/utils/teleprompter.ts openTeleprompter correctly guards against duplicate creation with a module-level creatingWindow sentinel that is cleared on both tauri://created and tauri://error.
apps/desktop/src-tauri/src/windows.rs Adds Teleprompter variant to CapWindowId, moves MAIN_PANEL_LEVEL to module scope, adds two new Tauri commands, renames window_matches_exclusion_list to window_capture_excluded with a hardcoded early-return for the teleprompter — all logically consistent.
apps/desktop/src-tauri/src/recording.rs Dynamically pushes the teleprompter exclusion at recording start inside the existing macOS cfg block; compiles correctly because the new WindowExclusion import is also gated on cfg(target_os="macos").
apps/desktop/src/routes/teleprompter-utils.ts Pure utility functions (countWords, clamp, calculatePlaybackSpeed, advancePlaybackPosition) with good zero-guard math and accompanying unit tests.
apps/desktop/src/store.ts Adds TeleprompterStore type, teleprompterDefaults, and the teleprompterStore declaration — used as the single source of truth that the component merges against on load.
apps/desktop/src-tauri/src/general_settings.rs Adds Cap Teleprompter to DEFAULT_EXCLUDED_WINDOW_TITLES — a clean one-liner addition.
apps/desktop/src-tauri/src/platform/macos/mod.rs Adds set_window_opacity using setAlphaValue with a 0.45–1.0 clamp; uses the same unsafe objc pattern already present in the file.
apps/desktop/src/utils/tauri.ts Adds setTeleprompterWindowLevel and setTeleprompterWindowOpacity command bindings — straightforward additions matching the new Rust commands.
apps/desktop/src/utils/macos-window-material.ts Extends MacOSWindowMaterial union with teleprompter and adds a 22px Liquid Glass radius — consistent with the CSS rule added in theme.css.
apps/desktop/src/routes/(window-chrome)/new-main/index.tsx Adds the teleprompter toolbar button that calls openTeleprompter() — minimal, correctly async-voided.
apps/desktop/src/app.tsx Registers the /teleprompter route with AUTO_SHOW_WINDOW: false so the window is shown manually after setup.
apps/desktop/src/styles/theme.css Adds border-radius rules for the teleprompter material under both vibrancy (16px) and Liquid Glass (22px) — matches the radius values in macos-window-material.ts.
apps/desktop/src/routes/teleprompter-utils.test.ts Covers countWords edge cases, clamp bounds, speed calculation with negative input, and sub-pixel accumulation across 60 frames — solid coverage for the util layer.

Comments Outside Diff (1)

  1. apps/desktop/src/routes/teleprompter.tsx, line 513-535 (link)

    P1 Pending save cancelled on window close

    onCleanup calls clearTimeout(saveTimer), so any state change made within the 250 ms debounce window before the component unmounts is silently dropped — the store write is cancelled before it executes. For a user who edits their script and then immediately closes the window, the last change (including potentially a large paste) won't be persisted.

    The fix is to flush the current state in onCleanup before clearing the timer: call void teleprompterStore.set(state()) before clearTimeout(saveTimer) so the latest state is always written on unmount.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/routes/teleprompter.tsx
    Line: 513-535
    
    Comment:
    **Pending save cancelled on window close**
    
    `onCleanup` calls `clearTimeout(saveTimer)`, so any state change made within the 250 ms debounce window before the component unmounts is silently dropped — the store write is cancelled before it executes. For a user who edits their script and then immediately closes the window, the last change (including potentially a large paste) won't be persisted.
    
    The fix is to flush the current state in `onCleanup` before clearing the timer: call `void teleprompterStore.set(state())` before `clearTimeout(saveTimer)` so the latest state is always written on unmount.
    
    How can I resolve this? If you propose a fix, please make it concise.

Reviews (4): Last reviewed commit: "fix: flush pending teleprompter saves" | Re-trigger Greptile

@cursor

cursor Bot commented Jul 14, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

Comment on lines +15 to +20
if (existingWindow) {
await commands.refreshWindowContentProtection();
await existingWindow.unminimize();
await existingWindow.show();
await existingWindow.setFocus();
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If refreshWindowContentProtection() ever throws (e.g. command not available / transient failure), it’d be nice to still bring the teleprompter to front.

Suggested change
if (existingWindow) {
await commands.refreshWindowContentProtection();
await existingWindow.unminimize();
await existingWindow.show();
await existingWindow.setFocus();
return;
try {
await commands.refreshWindowContentProtection();
} catch (error) {
console.error("Failed to refresh window content protection:", error);
}
await existingWindow.unminimize();
await existingWindow.show();
await existingWindow.setFocus();

Comment on lines +29 to +42
pub fn set_window_opacity(window: tauri::Window, opacity: f64) {
let opacity = opacity.clamp(0.45, 1.0);
let c_window = window.clone();
_ = window.run_on_main_thread(move || unsafe {
use cocoa::base::id;
use objc::{msg_send, sel, sel_impl};

let Ok(ns_win) = c_window.ns_window() else {
return;
};
let ns_win = ns_win as id;
let _: () = msg_send![ns_win, setAlphaValue: opacity];
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: run_on_main_thread can fail; logging makes this much easier to debug in the field.

Suggested change
pub fn set_window_opacity(window: tauri::Window, opacity: f64) {
let opacity = opacity.clamp(0.45, 1.0);
let c_window = window.clone();
_ = window.run_on_main_thread(move || unsafe {
use cocoa::base::id;
use objc::{msg_send, sel, sel_impl};
let Ok(ns_win) = c_window.ns_window() else {
return;
};
let ns_win = ns_win as id;
let _: () = msg_send![ns_win, setAlphaValue: opacity];
});
}
pub fn set_window_opacity(window: tauri::Window, opacity: f64) {
let opacity = opacity.clamp(0.45, 1.0);
let c_window = window.clone();
if let Err(error) = window.run_on_main_thread(move || unsafe {
use cocoa::base::id;
use objc::{msg_send, sel, sel_impl};
let Ok(ns_win) = c_window.ns_window() else {
return;
};
let ns_win = ns_win as id;
let _: () = msg_send![ns_win, setAlphaValue: opacity];
}) {
tracing::warn!(?error, "Failed to set window opacity");
}
}

Comment thread apps/desktop/src/routes/teleprompter.tsx Outdated
Comment thread apps/desktop/src/routes/teleprompter.tsx
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread apps/desktop/src/routes/teleprompter.tsx Outdated
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread apps/desktop/src/routes/teleprompter.tsx Outdated
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

const nextState = state();
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
void teleprompterStore.set(nextState);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

teleprompterStore.set() can reject here and become an unhandled promise rejection (since it’s inside a setTimeout). Worth catching + logging like the other save paths.

Suggested change
void teleprompterStore.set(nextState);
saveTimer = setTimeout(() => {
void teleprompterStore.set(nextState).catch((error) => {
console.error("Failed to persist teleprompter settings:", error);
});
}, 250);

@richiemcilroy richiemcilroy merged commit 6ba6956 into main Jul 14, 2026
19 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant