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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 —
- Shapes: lines, rectangles, ellipses, polygons (with fill toggle)
- Arrows with optional auto-numbered labels; step markers for walkthroughs
- Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out
- Spotlight tool: dims everything except the regions you draw
- Spotlight tool: dims everything except the regions you draw, with optional 1×–4× magnification
- Multiline text and sticky notes with smoothing
- Selection: <kbd>Alt</kbd>-drag, <kbd>V</kbd> tool, properties panel
- Duplicate (<kbd>Ctrl+D</kbd>), delete (<kbd>Delete</kbd>), undo/redo
Expand Down Expand Up @@ -177,7 +177,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 —
- Input HUD (<kbd>Ctrl+Shift+K</kbd>): on-screen keystroke and click chips for demos and screencasts (opt-in system-wide capture via the `input-monitor` build feature — see [docs/CONFIG.md](docs/CONFIG.md#uiinput_hud---input-hud-keystrokes-and-clicks))
- Light passthrough (layer-shell): draw while input passes through to the app underneath — see [Light passthrough mode](#light-passthrough-mode)
- Screen freeze (<kbd>Ctrl+Shift+F</kbd>): pause the display while apps keep running. Freeze prefers compositor-native `wlr-screencopy` or `ext-image-copy-capture` and falls back to the screenshot portal when available
- Spotlight: drag an ellipse to dim everything around it; stack several to highlight multiple areas. Dim strength and edge softness are configurable under `[spotlight]`
- Spotlight: drag an ellipse to dim everything around it; stack several to highlight multiple areas. Each Spotlight can magnify its opening from 1× to 4×, while dim strength and edge softness remain shared under `[spotlight]`. Magnification uses complete pixels from a solid board, Freeze, Zoom, or a captured/export backdrop; a live transparent board keeps the ordinary opening and prompts you to Freeze.

### Callouts and zoom
- **Numbered callouts:** auto-numbered arrow labels and step markers; reset arrow labels with <kbd>Ctrl+Shift+R</kbd>
Expand Down Expand Up @@ -804,7 +804,7 @@ Use `--exit-after-capture` / `--no-exit-after-capture` to override whether the o
<details>
<summary>PDF export</summary>

Canvas export commands are available in the command palette and keybindings. `export_board_pdf_file` saves the active board as a multi-page PDF, `export_all_boards_pdf_file` saves every board in board order, and both PDF actions are unbound by default. PDF exports keep transparent pages blank unless `[export.pdf] transparent_background = "desktop"` is set, which captures the live desktop behind the overlay for transparent pages only.
Canvas export commands are available in the command palette and keybindings. `export_board_pdf_file` saves the active board as a multi-page PDF, `export_all_boards_pdf_file` saves every board in board order, and both PDF actions are unbound by default. PDF exports keep transparent pages blank unless `[export.pdf] transparent_background = "desktop"` is set, which captures the live desktop behind the overlay for transparent pages only. Pages with a magnified Spotlight are rasterized so the loupe can sample completed pixels; page labels are still written as vector content on top.

</details>

Expand Down Expand Up @@ -857,7 +857,7 @@ Press <kbd>F1</kbd> for the complete in-app cheat sheet.
| Triangle / parallelogram / rhombus / regular polygon | **Shape picker** in the top strip (bindable) |
| Freeform polygon | **Shape picker**, then click vertices; <kbd>Enter</kbd> or double-click to finish |
| Blur | **Shape picker** (bindable) — drag a region; style via **Cycle Blur Style** |
| Spotlight | **Shape picker** (bindable) — drag an ellipse; everything else dims |
| Spotlight | **Shape picker** (bindable) — drag an ellipse; everything else dims; set 1×–4× magnification in the style pill, scroll over the loupe, or select an unlocked loupe and drag its on-canvas knob |
| Step marker tool | Toolbar (bindable) |
| Highlight brush | <kbd>Ctrl+Alt+H</kbd> |
| Text mode | <kbd>T</kbd>, <kbd>Click</kbd> to place, type, <kbd>Enter</kbd> to finish |
Expand Down Expand Up @@ -1175,7 +1175,7 @@ See [Session manager examples](examples/session-manager.md) for complete CLI, ov
<details>
<summary>Behavior notes</summary>

- Config values seed startup defaults. When `restore_tool_state` is enabled (default), the last-used tool settings saved in the session (including arrow head placement) override those config defaults on startup. Run `wayscriber --clear-tool-state` to remove only that saved tool layer so config defaults apply next startup while saved boards/history remain. In a running overlay, use Command Palette → Reset Tool Defaults to clear the saved layer and immediately apply config defaults to the active tools.
- Config values seed startup defaults. When `restore_tool_state` is enabled (default), the last-used tool settings saved in the session (including arrow head placement and the starting Spotlight magnification) override those config defaults on startup. Run `wayscriber --clear-tool-state` to remove only that saved tool layer so config defaults apply next startup while saved boards/history remain. In a running overlay, use Command Palette → Reset Tool Defaults to clear the saved layer and immediately apply config defaults to the active tools.
- `--session-file` uses exactly the selected file, implies persistence for that overlay run, rejects directories/symlinks/special files, and does not create missing parent directories. A running daemon can launch a hidden overlay with a named target; if the overlay is already visible, hide it before switching to a different named session.
- The overlay Session controls live in the top toolbar's Session popover (overflow menu → Session...). They can open an existing named session, save the current overlay as another named session, show session info, clear the active session, reopen recent named sessions, and jump to the configurator. The Open/Save As dialogs use `zenity` or `kdialog`; Save As appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts.
- The configurator Session tab manages recent named sessions recorded when named-session targets are opened or saved from the CLI, daemon, or overlay. It can rename catalog labels, reveal files, and forget metadata without touching files. Clear Tool State removes only the saved tool layer; Clear Saved Data removes session files. Duplicate, Move, Clear Tool State, and Clear are disabled while an overlay, manually started daemon, or background service is active.
Expand Down
4 changes: 4 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1298,6 +1298,10 @@ head_at_end = true
# attention lands where you point. Select the tool from the toolbar or bind
# `select_spotlight_tool`.
[spotlight]
# Starting magnification for newly drawn spotlights (1.0 - 4.0). Existing
# spotlights keep their own saved value. The toolbar changes this in 0.25 steps.
magnification = 1.0

# How strongly the area outside every spotlight is dimmed (0.1 - 0.95)
dim_opacity = 0.6

Expand Down
18 changes: 16 additions & 2 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,13 +424,23 @@ says the save failed.
# attention lands where you point. Select the tool from the toolbar or bind
# `select_spotlight_tool`.
[spotlight]
# Starting magnification for newly drawn spotlights (1.0 - 4.0). Existing
# spotlights keep their own saved value. The toolbar changes this in 0.25 steps.
magnification = 1.0

# How strongly the area outside every spotlight is dimmed (0.1 - 0.95)
dim_opacity = 0.6

# Fraction of each spotlight radius spent fading out at the edge (0.0 - 0.9).
# 0.0 gives a hard-edged opening.
feather = 0.35

# Magnification needs complete pixels beneath the canvas. Solid boards, Freeze,
# Zoom, captured regions, and persisted-image exports provide them. On a live
# transparent board, Wayscriber keeps the dim opening and asks you to Freeze the
# screen; transparent exports with magnified spotlights fail instead of silently
# saving an unmagnified image.

[presets]
slot_count = 5

Expand Down Expand Up @@ -580,6 +590,10 @@ With `WAYSCRIBER_PERF_LOG=1`, the `perf.input_to_paint_latency proxy=input_to_wa
line reports an input-to-Wayland-commit proxy metric. It measures from input sample receipt inside
the app to Wayland surface commit. It is not photons-on-screen display latency; compositor
scheduling, display scanout, and hardware can add more latency outside Wayscriber.
The `perf.render_stage` line also reports Spotlight magnifier work separately as
`spotlight_snapshot_ms` and `spotlight_paint_ms`, together with the region count,
regional/full-surface snapshot strategy, and copied source-pixel count. At 1× these
fields remain zero/`none`, because no source snapshot is created.

In local continuous-drawing measurements, 120 FPS low-latency mode held p95 around 8-9 ms and
p99 around 8-9 ms for this proxy metric. Isolated max spikes existed, but p99 stayed under 16 ms.
Expand Down Expand Up @@ -1356,7 +1370,7 @@ mappings = [
- Set `apply_to_ui = false` to preview remapped canvas content while keeping screen-space UI text and controls in the normal theme.
- Profiles do not recolor the compositor-owned live desktop seen through a transparent overlay.
- Explicit canvas PNG export applies its resolved export profile to persisted Wayscriber canvas content only, uses the current panned board viewport, respects output scale, and excludes frozen/zoom desktop pixels.
- Board PDF export writes the active board or every board to a file with one PDF page per Wayscriber page. PDF export preserves board/page order and solid board backgrounds, but does not apply export render profiles.
- Board PDF export writes the active board or every board to a file with one PDF page per Wayscriber page. PDF export preserves board/page order and solid board backgrounds, but does not apply export render profiles. A page with a magnified Spotlight is rasterized for correct pixel sampling; its optional PDF labels remain vector content drawn afterward.
- `[export.pdf]` controls PDF filename fallback, page size, orientation, fit mode, and optional page labels.
- Explicit canvas export and its clipboard-failure fallback save PNG data as `.png`; screenshot clipboard fallback still uses `[capture].format`.
- `[capture].enabled` disables compositor screenshot capture actions, not explicit export actions.
Expand Down Expand Up @@ -1700,7 +1714,7 @@ backup_retention = 1

- `persist_*` — choose which boards survive restarts (`persist_transparent` for overlay, `persist_whiteboard`/`persist_blackboard` gate non-transparent boards for legacy compatibility)
- `persist_history` — when `true`, persist undo/redo stacks so that history survives restarts; set to `false` to save only visible drawings
- `restore_tool_state` — save pen colour, thickness, font size, and arrow settings (including head placement); when `true`, the last-used tool state overrides config defaults at startup. Chrome is not tool state: status bar and badge visibility come from `[ui]` on every start, and an overlay toggle of them applies to that run only. Sessions written by older releases still carry a `show_status_bar` value; it is ignored on load and no longer written
- `restore_tool_state` — save pen colour, thickness, font size, arrow settings (including head placement), and the starting Spotlight magnification; when `true`, the last-used tool state overrides config defaults at startup. Chrome is not tool state: status bar and badge visibility come from `[ui]` on every start, and an overlay toggle of them applies to that run only. Sessions written by older releases still carry a `show_status_bar` value; it is ignored on load and no longer written
- `storage` — `auto` (XDG data dir, e.g. `~/.local/share/wayscriber`), `config` (same directory as `config.toml`), or `custom`
- `custom_directory` — absolute path used when `storage = "custom"`; supports `~`
- `per_output` — when `true` (default) keep a separate session file for each monitor; set to `false` to share one file per Wayland display as in earlier releases
Expand Down
3 changes: 2 additions & 1 deletion docs/codebase-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ Freeze capture waits for the overlay-suppression frame, then selects `wlr-screen
2. **Mouse events (`handlers/pointer.rs`)**
- Update `current_mouse_x/y`.
- Call `InputState::on_mouse_press`, `on_mouse_motion`, `on_mouse_release`.
- Adjust pen thickness or font size via scroll wheel + modifiers.
- Adjust pen thickness or font size via scroll wheel + modifiers; scrolling over a Spotlight loupe adjusts its magnification instead.
- Keep touchpad-finger Spotlight adjustments in one undo gesture until Wayland reports `axis_stop`; wheel-like sources use a quiet-period fallback when no stop arrives.

3. **`InputState` responsibilities**
- Holds `input::BoardManager`, whose ordered `BoardState` entries each own `draw::BoardPages`,
Expand Down
6 changes: 6 additions & 0 deletions src/backend/wayland/backend/event_loop/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ pub(super) fn handle_pending_actions(
}
handle_frozen_toggle(state);
state.drain_pending_board_runtime_ui_actions();
if state
.input_state
.take_pending_spotlight_magnifier_feedback()
{
state.show_spotlight_magnifier_feedback_if_unavailable();
}

if let Some(action) = state.input_state.take_pending_backend_action() {
match action {
Expand Down
133 changes: 133 additions & 0 deletions src/backend/wayland/backend/event_loop/interaction.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//! Deadlines that finish a pointer or keyboard gesture the user has stopped
//! driving.
//!
//! Separate from capture and render deadlines because the thing being closed is
//! input history, not a compositor request: a gesture that ends because nothing
//! more arrived still owes its undo entry, and burying that in a capture poll
//! hides when it happens.

use std::time::{Duration, Instant};

use crate::input::InputState;

/// Fires any interaction deadline that has come due.
pub(super) fn poll_interaction_deadlines(
input_state: &mut InputState,
spotlight_wheel_idle_deadline: &mut Option<Instant>,
now: Instant,
) {
// A wheel burst over a loupe is one undo entry. Discrete wheels send no
// end-of-gesture signal, so a quiet period is what ends it; without this
// the entry would wait for some unrelated interaction to close it.
if spotlight_wheel_idle_deadline.is_some_and(|deadline| now >= deadline) {
input_state.flush_spotlight_magnification_gesture();
*spotlight_wheel_idle_deadline = None;
}
}

/// How long the loop may sleep before an interaction deadline needs it awake.
pub(super) fn interaction_timeout(
spotlight_wheel_idle_deadline: Option<Instant>,
now: Instant,
) -> Option<Duration> {
spotlight_wheel_idle_deadline.map(|deadline| deadline.saturating_duration_since(now))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::config::Action;
use crate::draw::Shape;
use crate::input::state::{SpotlightWheelOutcome, test_support::make_test_input_state};

#[test]
fn polling_the_owning_path_finishes_one_idle_wheel_burst() {
let mut input_state = make_test_input_state();
let shape_id = input_state
.boards
.active_frame_mut()
.add_shape(Shape::Spotlight {
cx: 200,
cy: 200,
rx: 60,
ry: 40,
magnification: 2.0,
});
let now = Instant::now();
let mut deadline = Some(now + Duration::from_millis(600));

assert_eq!(
input_state.nudge_spotlight_magnification_at(200, 200, 1),
SpotlightWheelOutcome::Adjusted
);
poll_interaction_deadlines(
&mut input_state,
&mut deadline,
now + Duration::from_millis(599),
);
assert!(
deadline.is_some(),
"the gesture is still inside its quiet period"
);

poll_interaction_deadlines(
&mut input_state,
&mut deadline,
now + Duration::from_millis(600),
);
assert!(
deadline.is_none(),
"the owning poll clears a fired deadline"
);

assert_eq!(
input_state.nudge_spotlight_magnification_at(200, 200, 1),
SpotlightWheelOutcome::Adjusted
);
input_state.handle_action(Action::Undo);
let magnification = match input_state
.boards
.active_frame()
.shape(shape_id)
.expect("spotlight")
.shape
{
Shape::Spotlight { magnification, .. } => magnification,
ref other => panic!("expected a spotlight, got {other:?}"),
};
assert_eq!(
magnification, 2.25,
"the post-idle tick must be a separately undoable gesture"
);

input_state.handle_action(Action::Undo);
let magnification = match input_state
.boards
.active_frame()
.shape(shape_id)
.expect("spotlight")
.shape
{
Shape::Spotlight { magnification, .. } => magnification,
ref other => panic!("expected a spotlight, got {other:?}"),
};
assert_eq!(magnification, 2.0);
}

#[test]
fn the_timeout_shrinks_as_the_deadline_approaches_and_never_goes_negative() {
let now = Instant::now();
let deadline = now + Duration::from_millis(600);

assert_eq!(
interaction_timeout(Some(deadline), now),
Some(Duration::from_millis(600))
);
// A deadline already passed asks for an immediate wake, not a wrap.
assert_eq!(
interaction_timeout(Some(deadline), now + Duration::from_secs(5)),
Some(Duration::ZERO)
);
assert_eq!(interaction_timeout(None, now), None);
}
}
9 changes: 9 additions & 0 deletions src/backend/wayland/backend/event_loop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use super::tray::{durable_action_retry_due, durable_action_retry_timeout, proces

mod capture;
mod dispatch;
mod interaction;
mod render;
pub(in crate::backend::wayland) mod session_save;

Expand Down Expand Up @@ -134,6 +135,8 @@ pub(super) fn run_event_loop(
let focus_exit_timeout = state.focus_exit_timeout(now);
let command_palette_repeat_timeout = state.input_state.command_palette_repeat_timeout(now);
let capture_timeout = capture::capture_timeout(state, now);
let interaction_timeout =
interaction::interaction_timeout(state.spotlight_wheel_idle_deadline, now);
let durable_action_timeout = durable_action_retry_timeout(state, now);
// Backend output actions are drained one at a time, and the toolbar
// persistence queue drains on the same pass. If either holds
Expand Down Expand Up @@ -169,6 +172,7 @@ pub(super) fn run_event_loop(
let timeout = min_timeout(timeout, toolbar_handoff_timeout);
let timeout = min_timeout(timeout, command_palette_repeat_timeout);
let timeout = min_timeout(timeout, capture_timeout);
let timeout = min_timeout(timeout, interaction_timeout);
let timeout = min_timeout(timeout, durable_action_timeout);
let timeout = min_timeout(timeout, pending_backend_action_timeout);
// A radial menu waiting out its paint delay must appear without
Expand All @@ -189,6 +193,11 @@ pub(super) fn run_event_loop(
// recovery before this iteration reaches toolbar synchronization and
// rendering so the restored frame is not delayed by another block.
capture::poll_capture_deadlines(state, qh, Instant::now());
interaction::poll_interaction_deadlines(
&mut state.input_state,
&mut state.spotlight_wheel_idle_deadline,
Instant::now(),
);

if !state.input_state.should_exit {
state.reconcile_live_source_interaction_if_idle(
Expand Down
Loading
Loading