Skip to content

feat: keyboard shortcut overlay pipeline#1636

Open
chindris-mihai-alexandru wants to merge 13 commits intoCapSoftware:mainfrom
chindris-mihai-alexandru:feature/keyboard-overlay-upstream
Open

feat: keyboard shortcut overlay pipeline#1636
chindris-mihai-alexandru wants to merge 13 commits intoCapSoftware:mainfrom
chindris-mihai-alexandru:feature/keyboard-overlay-upstream

Conversation

@chindris-mihai-alexandru
Copy link

@chindris-mihai-alexandru chindris-mihai-alexandru commented Feb 27, 2026

Summary

  • Adds end-to-end keyboard shortcut overlay support by capturing keyboard events during recording, persisting them with cursor events, and exposing them in the desktop editor preview.
  • Introduces a Tauri command (get_keyboard_events) and renders active/recent shortcuts with normalized modifiers and fade-out behavior.
  • Integrates a dedicated keyboard overlay render layer and shader into the existing rendering pipeline.

Implementation Details

  • Added KeyboardEvent to cursor event models in crates/project/src/cursor.rs.
  • Captured key down/up events (with active modifiers) in crates/recording/src/cursor.rs and persisted them in studio recording metadata.
  • Added get_keyboard_events command in apps/desktop/src-tauri/src/lib.rs.
  • Added preview overlay UI logic in apps/desktop/src/routes/editor/Player.tsx.
  • Added keyboard rendering components:
    • crates/rendering/src/layers/keyboard.rs
    • crates/rendering/src/shaders/keyboard-overlay.wgsl
    • wiring in crates/rendering/src/layers/mod.rs and crates/rendering/src/lib.rs

Compatibility

  • Existing cursor event data remains backward-compatible via defaulted keyboard fields.
  • Includes branch follow-up fixes (imports/formatting/minor Rust cleanup) and upstream sync merge.

Greptile Summary

This PR adds end-to-end keyboard shortcut overlay support to the screen recorder by capturing keyboard events during recording and rendering them in both the preview player and final rendered video.

Key changes:

  • Introduced KeyboardEvent model with backward-compatible serialization (#[serde(default)])
  • Captured keyboard events in recording pipeline using device_query crate with modifier tracking
  • Added get_keyboard_events Tauri command to expose events to desktop editor
  • Implemented keyboard overlay UI in Player.tsx with fade-out animation (850ms window)
  • Created dedicated keyboard rendering layer with SDF-based shader for rounded rectangle overlays
  • Used glyphon for text rendering with proper modifier symbol normalization (⌘, ⌃, ⌥, ⇧)

Issues found:

  • Keycode matching in cursor.rs uses fragile Debug format string checking instead of pattern matching
  • Modifier normalization logic is duplicated across TypeScript UI, Rust rendering, and Rust recording code

The implementation is comprehensive with good test coverage in the rendering layer and proper backward compatibility for existing recordings.

Confidence Score: 4/5

  • Safe to merge with minor style improvements recommended
  • Score of 4 reflects solid implementation with backward compatibility and tests, but minor issues exist: fragile Debug-based keycode matching could break with library updates, and duplicated normalization logic across TypeScript/Rust could diverge over time. No logic bugs or security issues found.
  • Pay attention to crates/recording/src/cursor.rs which uses fragile Debug format string matching for keycode detection

Important Files Changed

Filename Overview
crates/project/src/cursor.rs Adds KeyboardEvent model with serde defaults for backward compatibility
crates/recording/src/cursor.rs Captures keyboard events during recording, uses Debug format strings for keycode matching which is fragile
apps/desktop/src/routes/editor/Player.tsx Adds keyboard shortcut overlay UI with fade animation, duplicates normalization logic from Rust
crates/rendering/src/layers/keyboard.rs New rendering layer for keyboard shortcuts with SDF-based overlay and glyphon text, includes tests
crates/rendering/src/lib.rs Integrates KeyboardLayer into rendering pipeline with prepare and render calls

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Recording Started] --> B[device_query captures keyboard state]
    B --> C{Key state changed?}
    C -->|Yes| D[Create KeyboardEvent with modifiers]
    C -->|No| B
    D --> E[Store in CursorActorResponse]
    E --> F[Flush to cursor data JSON every 5s]
    F --> G[Recording Complete]
    
    G --> H[Desktop Editor Loads Recording]
    H --> I[get_keyboard_events Tauri command]
    I --> J[Load KeyboardEvents from cursor JSON]
    
    J --> K[Player.tsx Preview]
    K --> L[Calculate current source time]
    L --> M{Active shortcut?}
    M -->|Yes| N[Show overlay with full opacity]
    M -->|Released recently| O[Show with fade-out 0-850ms]
    M -->|None| P[Hide overlay]
    
    J --> Q[Rendering Pipeline]
    Q --> R[KeyboardLayer.prepare]
    R --> S[active_shortcut_label logic]
    S --> T{Active shortcut?}
    T -->|Yes| U[Create rounded rect overlays]
    T -->|No| V[Skip rendering]
    U --> W[Render with glyphon text]
    W --> X[Final rendered video]
Loading

Last reviewed commit: 839612b

(5/5) You can turn off certain types of comments like style here!

@chindris-mihai-alexandru chindris-mihai-alexandru changed the title Feature/keyboard overlay upstream feat: keyboard shortcut overlay pipeline Feb 27, 2026
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

10 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

Comment on lines 41 to 46
_ if format!("{key:?}").contains("Meta")
|| format!("{key:?}").contains("Win")
|| format!("{key:?}").contains("Command") =>
{
"Meta".to_string()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

using format!("{key:?}").contains("Meta") is fragile and relies on Debug impl details

Suggested change
_ if format!("{key:?}").contains("Meta")
|| format!("{key:?}").contains("Win")
|| format!("{key:?}").contains("Command") =>
{
"Meta".to_string()
}
Keycode::LMeta | Keycode::RMeta => "Meta".to_string(),
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/cursor.rs
Line: 41-46

Comment:
using `format!("{key:?}").contains("Meta")` is fragile and relies on Debug impl details

```suggestion
        Keycode::LMeta | Keycode::RMeta => "Meta".to_string(),
```

How can I resolve this? If you propose a fix, please make it concise.

Choose a reason for hiding this comment

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

Fixed in 8dee855. Replaced Debug-string checks with explicit Keycode variant matching for Meta keys in keycode_to_label.

Comment on lines 73 to 77
if keys.iter().any(|key| {
let label = format!("{key:?}");
label.contains("Meta") || label.contains("Win") || label.contains("Command")
}) {
modifiers.push("Meta".to_string());
Copy link
Contributor

Choose a reason for hiding this comment

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

same issue as above - use pattern matching for Meta keys

Suggested change
if keys.iter().any(|key| {
let label = format!("{key:?}");
label.contains("Meta") || label.contains("Win") || label.contains("Command")
}) {
modifiers.push("Meta".to_string());
if keys.iter().any(|key| {
matches!(key, device_query::Keycode::LMeta | device_query::Keycode::RMeta)
}) {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/cursor.rs
Line: 73-77

Comment:
same issue as above - use pattern matching for Meta keys

```suggestion
    if keys.iter().any(|key| {
        matches!(key, device_query::Keycode::LMeta | device_query::Keycode::RMeta)
    }) {
```

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Choose a reason for hiding this comment

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

Addressed in 8dee855. active_modifiers now uses explicit matches! over Meta/Command variants instead of Debug-string matching.

Comment on lines 60 to 68
fn is_modifier(key: &device_query::Keycode) -> bool {
let label = format!("{key:?}");
label.contains("Shift")
|| label.contains("Control")
|| label.contains("Alt")
|| label.contains("Meta")
|| label.contains("Win")
|| label.contains("Command")
}
Copy link
Contributor

Choose a reason for hiding this comment

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

using format! with Debug to check modifier keys is inefficient - consider using explicit pattern matching with the device_query Keycode enum variants instead

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/cursor.rs
Line: 60-68

Comment:
using `format!` with Debug to check modifier keys is inefficient - consider using explicit pattern matching with the device_query Keycode enum variants instead

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Choose a reason for hiding this comment

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

Updated in 8dee855. is_modifier now relies on explicit Keycode pattern matching (Shift/Ctrl/Alt/Option/Meta/Command) rather than format!("{key:?}") checks.

Comment on lines +563 to +594
"ControlLeft",
"ControlRight",
"Alt",
"Option",
"Opt",
"AltLeft",
"AltRight",
"Shift",
"ShiftLeft",
"ShiftRight",
]);
const modifierOrder = new Map([
["⌃", 0],
["⌥", 1],
["⇧", 2],
["⌘", 3],
]);

const isModifierKey = (key: string) => modifierKeys.has(key);

const normalizeModifier = (
modifier: string,
): "⌘" | "⌃" | "⌥" | "⇧" | null => {
switch (modifier) {
case "Meta":
case "Command":
case "Cmd":
case "Super":
case "Win":
return "⌘";
case "Ctrl":
case "Control":
Copy link
Contributor

Choose a reason for hiding this comment

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

modifier normalization logic is duplicated in three places (TypeScript UI, Rust rendering layer, Rust recording) - consider extracting to a shared module to prevent divergence

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/editor/Player.tsx
Line: 563-594

Comment:
modifier normalization logic is duplicated in three places (TypeScript UI, Rust rendering layer, Rust recording) - consider extracting to a shared module to prevent divergence

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Author

@chindris-mihai-alexandru chindris-mihai-alexandru Feb 27, 2026

Choose a reason for hiding this comment

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

Reduced drift by aligning shortcut behavior across paths in 8dee855 (same shortcut-modifier gating in editor preview and renderer). Full cross-language dedupe is larger since this spans TS + Rust, so I kept this PR scoped and consistent.

Comment on lines 657 to 672
const normalizedModifiers = event.active_modifiers
.filter((modifier) => modifier !== event.key)
.map(normalizeModifier)
.filter(
(modifier): modifier is "⌘" | "⌃" | "⌥" | "⇧" => modifier !== null,
)
.sort()
.filter((value, index, values) => values.indexOf(value) === index)
.sort(
(a, b) =>
(modifierOrder.get(a) ?? Number.POSITIVE_INFINITY) -
(modifierOrder.get(b) ?? Number.POSITIVE_INFINITY),
);
const label = [...normalizedModifiers, normalizeKey(event.key)].join(
" + ",
);
Copy link

Choose a reason for hiding this comment

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

This currently displays single-key presses too (no Ctrl/Alt/Meta), which can accidentally surface typed content. If the intent is “shortcut overlay”, consider only tracking keydowns when there’s at least one chord modifier, while still processing keyup to clear state.

Suggested change
const normalizedModifiers = event.active_modifiers
.filter((modifier) => modifier !== event.key)
.map(normalizeModifier)
.filter(
(modifier): modifier is "⌘" | "⌃" | "⌥" | "⇧" => modifier !== null,
)
.sort()
.filter((value, index, values) => values.indexOf(value) === index)
.sort(
(a, b) =>
(modifierOrder.get(a) ?? Number.POSITIVE_INFINITY) -
(modifierOrder.get(b) ?? Number.POSITIVE_INFINITY),
);
const label = [...normalizedModifiers, normalizeKey(event.key)].join(
" + ",
);
const normalizedModifiers = event.active_modifiers
.filter((modifier) => modifier !== event.key)
.map(normalizeModifier)
.filter(
(modifier): modifier is "⌘" | "⌃" | "⌥" | "⇧" => modifier !== null,
)
.sort()
.filter((value, index, values) => values.indexOf(value) === index)
.sort(
(a, b) =>
(modifierOrder.get(a) ?? Number.POSITIVE_INFINITY) -
(modifierOrder.get(b) ?? Number.POSITIVE_INFINITY),
);
const hasShortcutModifier = normalizedModifiers.some(
(modifier) => modifier === "⌘" || modifier === "⌃" || modifier === "⌥",
);
if (event.down && !hasShortcutModifier) continue;
const label = [...normalizedModifiers, normalizeKey(event.key)].join(
" + ",
);

Choose a reason for hiding this comment

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

Implemented in 8dee855. Player.tsx now ignores down events that do not include ⌘/⌃/⌥, while retaining key-up processing for state cleanup.

Comment on lines +546 to +565
let mut parts = mods;
parts.push(normalize_key_label(&event.key));
let label = parts.join(" + ");

if event.down {
let state = ShortcutState {
label,
down_time: event.time_ms,
};

active.insert(event.key.clone(), state.clone());

if let Some(last) = &last_recent {
if state.down_time > last.down_time {
last_recent = Some(state);
}
} else {
last_recent = Some(state);
}
} else {
Copy link

Choose a reason for hiding this comment

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

Same concern on the renderer side: as-is, this will happily render plain keys / shift-only chords into the export. If this is meant to be “shortcuts”, filtering to ⌘/⌃/⌥ for down events keeps key-up handling intact and avoids showing typed text.

Suggested change
let mut parts = mods;
parts.push(normalize_key_label(&event.key));
let label = parts.join(" + ");
if event.down {
let state = ShortcutState {
label,
down_time: event.time_ms,
};
active.insert(event.key.clone(), state.clone());
if let Some(last) = &last_recent {
if state.down_time > last.down_time {
last_recent = Some(state);
}
} else {
last_recent = Some(state);
}
} else {
if is_modifier_key(&event.key) {
continue;
}
let has_shortcut_modifier = mods
.iter()
.any(|symbol| symbol == "⌘" || symbol == "⌃" || symbol == "⌥");
if event.down && !has_shortcut_modifier {
continue;
}
let mut parts = mods;
parts.push(normalize_key_label(&event.key));
let label = parts.join(" + ");
if event.down {
let state = ShortcutState {
label,
down_time: event.time_ms,
};
active.insert(event.key.clone(), state.clone());
if let Some(last) = &last_recent {
if state.down_time > last.down_time {
last_recent = Some(state);
}
} else {
last_recent = Some(state);
}
} else {
active.remove(&event.key);
}

Choose a reason for hiding this comment

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

Implemented in 8dee855. Renderer path now applies the same shortcut-modifier gating for down events to avoid plain-key overlays.

Comment on lines +546 to +548
let mut parts = mods;
parts.push(normalize_key_label(&event.key));
let label = parts.join(" + ");
Copy link

Choose a reason for hiding this comment

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

(Suggestion block tweak so it applies cleanly without duplicating the existing is_modifier_key check.)

Suggested change
let mut parts = mods;
parts.push(normalize_key_label(&event.key));
let label = parts.join(" + ");
let has_shortcut_modifier = mods
.iter()
.any(|symbol| symbol == "⌘" || symbol == "⌃" || symbol == "⌥");
if event.down && !has_shortcut_modifier {
continue;
}
let mut parts = mods;
parts.push(normalize_key_label(&event.key));
let label = parts.join(" + ");

Copy link
Author

@chindris-mihai-alexandru chindris-mihai-alexandru Feb 27, 2026

Choose a reason for hiding this comment

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

Applied that exact placement in 8dee855 (shortcut-modifier gate before label assembly, without duplicating the existing is_modifier_key branch).

Comment on lines 407 to 416
let _ = self.text_renderer.prepare(
device,
queue,
&mut self.font_system,
&mut self.text_atlas,
&self.viewport,
text_areas,
&mut self.swash_cache,
);
}
Copy link

Choose a reason for hiding this comment

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

TextRenderer::prepare returning an error here is probably worth handling; right now failures are silently ignored, but visible stays true and we still try to render.

Suggested change
let _ = self.text_renderer.prepare(
device,
queue,
&mut self.font_system,
&mut self.text_atlas,
&self.viewport,
text_areas,
&mut self.swash_cache,
);
}
if self
.text_renderer
.prepare(
device,
queue,
&mut self.font_system,
&mut self.text_atlas,
&self.viewport,
text_areas,
&mut self.swash_cache,
)
.is_err()
{
self.visible = false;
self.current_label = None;
self.overlays.clear();
return;
}

Choose a reason for hiding this comment

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

Handled in 8dee855. TextRenderer::prepare errors now clear visibility/state and return early instead of failing silently.

let full_path = meta.path(path);
CursorEvents::load_from_file(&full_path)
.map(|events| events.keyboard)
.unwrap_or_default()
Copy link

Choose a reason for hiding this comment

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

Swallowing cursor-event load errors makes it hard to debug corrupted/partial recordings. Consider logging the error (still returning an empty list so the UI keeps working).

Suggested change
.unwrap_or_default()
CursorEvents::load_from_file(&full_path)
.map(|events| events.keyboard)
.unwrap_or_else(|error| {
tracing::warn!(
path = %full_path.display(),
error = %error,
"Failed to load cursor events"
);
Vec::new()
})

Choose a reason for hiding this comment

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

Implemented in e022a56. get_keyboard_events now logs cursor-event load failures with warn! (including path and error) and still returns an empty vector for resilience.

if keys.iter().any(|key| {
matches!(
key,
device_query::Keycode::LAlt | device_query::Keycode::RAlt
Copy link

Choose a reason for hiding this comment

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

On macOS device_query uses LOption/ROption for the Option key; without including those here chords won’t be detected.

Suggested change
device_query::Keycode::LAlt | device_query::Keycode::RAlt
matches!(
key,
device_query::Keycode::LAlt
| device_query::Keycode::RAlt
| device_query::Keycode::LOption
| device_query::Keycode::ROption
)

Choose a reason for hiding this comment

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

Implemented in e022a56. Added LOption/ROption alongside LAlt/RAlt in active_modifiers, so macOS Option chords are correctly surfaced as Alt/.

Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
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