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
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,24 @@ This section tracks work toward v0.2.0 (editor completeness). See
[`docs/sprint-records/wave-9.md`](docs/sprint-records/wave-9.md),
[`docs/sprint-records/wave-10.md`](docs/sprint-records/wave-10.md), and
[`docs/sprint-records/wave-11.md`](docs/sprint-records/wave-11.md), and
[`docs/sprint-records/wave-12.md`](docs/sprint-records/wave-12.md).
[`docs/sprint-records/wave-12.md`](docs/sprint-records/wave-12.md), and
[`docs/sprint-records/wave-13.md`](docs/sprint-records/wave-13.md).

## [v0.3.0 — in progress]

### Added — Wave 13 (projector CSS filter panel)

- The presenter gains a **projector compensation filter panel**: invert,
brightness, contrast, saturation, sepia, and hue-rotate controls applied
to the audience window via CSS `filter`. A toggle button in the presenter
toolbar opens a popover with sliders + a reset button.
- New `ProjectorFilters` type on `PresenterSettings` (additive,
`#[serde(default)]`) persists the settings on the deck. Slider changes
are debounced to coalesce into a single `SetPresenterSettings` call.
- The CSS filter string only includes non-default properties (e.g.
`brightness(1)` is omitted) for clean output.
- `docs/sprint-records/wave-13.md` documenting the wave scope.

### Added — Wave 12 (bundled fonts + stepped code highlighting)

- **Bundled open-licensed fonts**: Inter (sans), Source Serif 4 (serif),
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ What v0.1.0 does:
- Presents a deck locally in **dual-display mode**: a presenter window
(controls, notes, timer, next-slide preview) and a separate fullscreen
audience window. Includes a **laser pointer** (`L`), **highlighter**
(`H`), and **black/white slide** (`B`/`W`) for Q&A. Keyboard navigation
(`H`), **black/white slide** (`B`/`W`) for Q&A, and a **projector filter
panel** (invert, brightness, contrast, saturation, sepia, hue-rotate).
Keyboard navigation
only.
- Recovers work after a crash or accidental quit via debounced autosave
snapshots and a startup recovery prompt.
Expand Down
75 changes: 75 additions & 0 deletions apps/desktop/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,9 @@ pub struct PresenterSettingsDto {
/// Highlighter color as a CSS hex string. Defaults to yellow.
#[serde(default = "default_highlighter_color_dto")]
pub highlighter_color: String,
/// Projector compensation CSS filters applied to the audience window.
#[serde(default)]
pub projector_filters: ProjectorFiltersDto,
}

impl Default for PresenterSettingsDto {
Expand All @@ -283,6 +286,7 @@ impl Default for PresenterSettingsDto {
laser_color: default_laser_color_dto(),
highlighter: false,
highlighter_color: default_highlighter_color_dto(),
projector_filters: ProjectorFiltersDto::default(),
}
}
}
Expand All @@ -297,13 +301,83 @@ fn default_highlighter_color_dto() -> String {
String::from("#ffff00")
}

/// Projector compensation CSS filters, mirroring
/// [`slides_core::ProjectorFilters`]. Serialized to the audience window as a
/// CSS `filter` string. Old snapshots without `projectorFilters` deserialize
/// to the neutral default via the field-level `#[serde(default)]`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectorFiltersDto {
/// Invert all colors.
#[serde(default)]
pub invert: bool,
/// Brightness multiplier (1.0 = normal, 0.0 = black, 2.0 = double).
#[serde(default = "default_filter_unit")]
pub brightness: f64,
/// Contrast multiplier (1.0 = normal).
#[serde(default = "default_filter_unit")]
pub contrast: f64,
/// Saturation multiplier (1.0 = normal, 0.0 = grayscale).
#[serde(default = "default_filter_unit")]
pub saturation: f64,
/// Sepia intensity (0.0 = none, 1.0 = full sepia).
#[serde(default)]
pub sepia: f64,
/// Hue rotation in degrees (0.0 = none, 360.0 = full rotation).
#[serde(default)]
pub hue_rotate: f64,
}

impl Default for ProjectorFiltersDto {
fn default() -> Self {
Self {
invert: false,
brightness: default_filter_unit(),
contrast: default_filter_unit(),
saturation: default_filter_unit(),
sepia: 0.0,
hue_rotate: 0.0,
}
}
}

/// Neutral multiplier default (1.0) for brightness / contrast / saturation.
fn default_filter_unit() -> f64 {
1.0
}

/// Converts a model [`slides_core::ProjectorFilters`] into its DTO.
fn projector_filters_to_dto(filters: &slides_core::ProjectorFilters) -> ProjectorFiltersDto {
ProjectorFiltersDto {
invert: filters.invert,
brightness: filters.brightness,
contrast: filters.contrast,
saturation: filters.saturation,
sepia: filters.sepia,
hue_rotate: filters.hue_rotate,
}
}

/// Converts a [`ProjectorFiltersDto`] into the model type.
fn projector_filters_from_dto(dto: &ProjectorFiltersDto) -> slides_core::ProjectorFilters {
slides_core::ProjectorFilters {
invert: dto.invert,
brightness: dto.brightness,
contrast: dto.contrast,
saturation: dto.saturation,
sepia: dto.sepia,
hue_rotate: dto.hue_rotate,
}
}

/// Converts a model [`slides_core::PresenterSettings`] into its DTO.
fn presenter_settings_to_dto(settings: &slides_core::PresenterSettings) -> PresenterSettingsDto {
PresenterSettingsDto {
laser_pointer: settings.laser_pointer,
laser_color: settings.laser_color.clone(),
highlighter: settings.highlighter,
highlighter_color: settings.highlighter_color.clone(),
projector_filters: projector_filters_to_dto(&settings.projector_filters),
}
}

Expand All @@ -314,6 +388,7 @@ fn presenter_settings_from_dto(dto: &PresenterSettingsDto) -> slides_core::Prese
laser_color: dto.laser_color.clone(),
highlighter: dto.highlighter,
highlighter_color: dto.highlighter_color.clone(),
projector_filters: projector_filters_from_dto(&dto.projector_filters),
}
}

Expand Down
18 changes: 16 additions & 2 deletions apps/desktop/src/AudienceWindow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn, type Event } from '@tauri-apps/api/event'
import SlideCanvas from './SlideCanvas.svelte'
import type { ColorDto, PresenterState } from './lib/types'
import type { ColorDto, PresenterState, ProjectorFiltersDto } from './lib/types'
import {
PRESENTER_EVENTS,
defaultProjectorFilters,
projectorFilterCss,
runMorph,
slideRectFromStage,
type BlankMode,
Expand All @@ -26,6 +28,10 @@
let strokes = $state<HighlighterStroke[]>([])
/** Audience blank mode. */
let blankMode = $state<BlankMode>('none')
/** Projector CSS filters applied to the slide container. */
let appliedFilters = $state<ProjectorFiltersDto>(defaultProjectorFilters())
/** CSS `filter` string derived from {@link appliedFilters}. */
let filterCss = $derived(projectorFilterCss(appliedFilters))
/** Bound stage element, used to measure the rendered slide. */
let stageEl = $state<HTMLElement | null>(null)
/** Rendered slide box in stage-local pixels. */
Expand All @@ -40,7 +46,10 @@
const unlisteners: Array<() => void> = []

invoke<PresenterState>('get_presenter_state').then((state) => {
if (!cancelled) presenterState = state
if (!cancelled) {
presenterState = state
appliedFilters = state.presenterSettings.projectorFilters ?? defaultProjectorFilters()
}
})

/** Registers a listener and ensures it is torn down even if the effect is. */
Expand All @@ -55,6 +64,7 @@

on<PresenterState>(PRESENTER_EVENTS.state, (state) => {
presenterState = state
appliedFilters = state.presenterSettings.projectorFilters ?? defaultProjectorFilters()
})
on<{ step: number }>(PRESENTER_EVENTS.buildStep, (payload) => {
activeBuildStep = payload.step
Expand All @@ -71,6 +81,9 @@
on<{ mode: BlankMode }>(PRESENTER_EVENTS.blank, (payload) => {
blankMode = payload.mode
})
on<ProjectorFiltersDto>(PRESENTER_EVENTS.filters, (payload) => {
appliedFilters = payload
})
on<MorphPayload>(PRESENTER_EVENTS.morph, (payload) => {
morph = payload
})
Expand Down Expand Up @@ -154,6 +167,7 @@
<div
class="stage-content {transitionClass()}"
style:--transition-duration="{transitionDurationMs()}ms"
style:filter={filterCss || 'none'}
>
<SlideCanvas
slide={presenterState.currentSlide}
Expand Down
Loading
Loading