diff --git a/Cargo.lock b/Cargo.lock index bb8ff6b8..3553f150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6355,6 +6355,7 @@ dependencies = [ "rustc-hash 2.1.3", "schist-color", "serde", + "serde_json", "smallvec", ] @@ -6542,8 +6543,10 @@ dependencies = [ "fontdb 0.24.0", "fontdue", "log", + "rustybuzz 0.20.1", "schist-color", "schist-core", + "schist-vector", "serde", "serde_json", "ttf-parser 0.25.1", @@ -6621,6 +6624,7 @@ name = "schist-tools-type" version = "0.12.0" dependencies = [ "log", + "schist-codec-psd", "schist-color", "schist-core", "schist-plugin-api", diff --git a/Makefile b/Makefile index cbfe3c96..8b0be677 100644 --- a/Makefile +++ b/Makefile @@ -218,3 +218,14 @@ clean-helpers: rm -rf $(HELPER_STAGE) FORCE: + +# README's text layout and GPU effect work. +.PHONY: check-text check-gpu-fx check-readme +check-text: + $(CARGO) test -p schist-core -p schist-text-engine -p schist-tools-type -p schist-codec-affinity +check-gpu-fx: + $(CARGO) test -p schist-fx + $(CARGO) test -p schist-compositor-gpu --test fx_parity --test fx_wiring +check-readme: check-text check-gpu-fx + $(CARGO) clippy -p schist-core -p schist-text-engine -p schist-tools-type -p schist-codec-affinity -p schist-fx -p schist-compositor-gpu --all-targets -- -D warnings + $(CARGO) check -p schist-app diff --git a/README.md b/README.md index 860c1ed2..cdaa9d7d 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,10 @@ Path can fill, stroke or convert them to a selection. Rectangle, ellipse, line (with its own weight, 45° constrain and arrowheads), polygon and six custom shapes — as **live shape layers** by default, which keep their path, regenerate their pixels from it, and survive a PSD round trip as -vectors. Editable text layers. +vectors. Editable text layers, including **text on paths** with a baseline +offset and alignment, plus **OpenType controls** for kerning, ligatures, +discretionary ligatures and small caps. Text settings survive PSD/PSB +save and reopen. See [docs/text.md](docs/text.md). **Non-destructive.** Sixteen adjustments — levels, curves, hue/saturation, brightness/contrast, black & white, colour balance, vibrance, exposure, @@ -211,7 +214,12 @@ a lens blur at radius 60 is eleven thousand taps a pixel. **Content-Aware Scale** runs there too, and runs *entirely* there: find the lowest-energy seam, cut it, start again is hundreds of full-image passes for one command, so the whole loop stays on the device and only the finished image -comes back. The CPU is the semantic reference throughout: parity tests +comes back. **Large warps and seam carves can exceed a storage-buffer +binding**: warp sources live in texture arrays and their output is banded; +large carves keep their image planes in texture arrays and cumulative costs +in two rows. Device texture limits and available memory still apply, and +small carves stay on the CPU when dispatch overhead would cost more than +the GPU saves. The CPU is the semantic reference throughout: parity tests hold the GPU to it, anything it can't express (layers mid-drag) or can't fit falls back for that call, and machines with no usable adapter just run the CPU path. Toggle it in @@ -280,17 +288,13 @@ Remap anything in `~/.config/schist/keymap.json`: - **CMYK and Lab edit in RGB.** Files open, edit and save in their own mode, converting at the boundaries; the editing in between is RGB, so - individual ink channels are not separately editable. -- **Text is not on a path**, and the type engine has no OpenType - feature controls. -- **The mesh warp and the carve need the layer to fit one storage - binding.** A displacement may read anywhere in its source, and every - seam depends on the one before it over the whole image, so neither can - be split into bands the way the blurs are; on adapters at the 128 MB - baseline a large layer falls back to the CPU. Real GPUs report bindings - in the gigabytes and never reach it. The carve also stays on the CPU - below a couple of megapixels, where its row-by-row scan costs more in - dispatches than it saves. + individual ink channels are not separately editable. This remains + unresolved because tiles, editing operations and plugin APIs carry + RGBA, and import has already converted the native channels to RGB. + Correct native editing needs a coordinated storage, undo, compositor + and plugin-contract migration; RGB-derived controls cannot recover + independent CMYK separations. See the [implementation constraints and + remaining work](docs/native-colour-editing.md). ## Diagnostics @@ -368,6 +372,7 @@ already logged into. See [docs/ai-panel.md](docs/ai-panel.md). * [docs/architecture.md](docs/architecture.md) — how the pieces fit * [docs/gallery.md](docs/gallery.md) — the Picasa-style photo gallery * [docs/plugin-guide.md](docs/plugin-guide.md) — writing plugins +* [docs/text.md](docs/text.md) — OpenType controls and text on paths * [docs/mcp.md](docs/mcp.md) — the MCP server * [docs/ai-panel.md](docs/ai-panel.md) — the in-app AI sidebar * [docs/quicklook.md](docs/quicklook.md) — the macOS Quick Look extensions diff --git a/crates/app/assets/icons/character.svg b/crates/app/assets/icons/character.svg new file mode 100644 index 00000000..6412503f --- /dev/null +++ b/crates/app/assets/icons/character.svg @@ -0,0 +1 @@ + diff --git a/crates/app/assets/icons/type-align-center.svg b/crates/app/assets/icons/type-align-center.svg new file mode 100644 index 00000000..a79675e8 --- /dev/null +++ b/crates/app/assets/icons/type-align-center.svg @@ -0,0 +1 @@ + diff --git a/crates/app/assets/icons/type-align-left.svg b/crates/app/assets/icons/type-align-left.svg new file mode 100644 index 00000000..ee138fe7 --- /dev/null +++ b/crates/app/assets/icons/type-align-left.svg @@ -0,0 +1 @@ + diff --git a/crates/app/assets/icons/type-align-right.svg b/crates/app/assets/icons/type-align-right.svg new file mode 100644 index 00000000..dc04de7d --- /dev/null +++ b/crates/app/assets/icons/type-align-right.svg @@ -0,0 +1 @@ + diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index aed73da9..86fc6b62 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -29,6 +29,10 @@ macro_rules! icons { } icons!( + "character", + "type-align-left", + "type-align-center", + "type-align-right", "move", "swap", "eyedropper", diff --git a/crates/app/src/panels/info.rs b/crates/app/src/panels/info.rs index 882969cb..6314914d 100644 --- a/crates/app/src/panels/info.rs +++ b/crates/app/src/panels/info.rs @@ -12,15 +12,26 @@ const INFO_ROWS_MAX_H: f32 = 88.0; #[cfg(not(target_arch = "wasm32"))] const SIDE_PANEL_CONTENT_W: f32 = 260.0 - 16.0; -/// The side panel's top slot: a tab row when the open file has EXIF — -/// Info first and by default, Color beside it — and the plain colour -/// panel when it has none, exactly as before. +/// The top dock: Character while using Type, Info for files with EXIF, +/// and Color. Type's detailed controls stay here instead of wrapping the +/// options bar onto the canvas. pub(super) fn top_panel(ws: &mut Workspace, cx: &mut Context) -> gpui::AnyElement { ws.refresh_exif(); - let Some(exif) = ws.exif.as_ref().and_then(|(_, e)| e.clone()) else { + let exif = ws.exif.as_ref().and_then(|(_, e)| e.clone()); + let is_type = ws.editor.active_tool == "type"; + if exif.is_none() && !is_type { return color_panel(ws, cx).into_any_element(); + } + let default = if is_type { + SideTab::Character + } else { + SideTab::Info + }; + let tab = match ws.side_tab.unwrap_or(default) { + SideTab::Character if !is_type => default, + SideTab::Info if exif.is_none() => SideTab::Color, + tab => tab, }; - let tab = ws.side_tab.unwrap_or(SideTab::Info); let tab_chip = |label: &'static str, which: SideTab, cx: &mut Context| { let on = tab == which; div() @@ -47,7 +58,7 @@ pub(super) fn top_panel(ws: &mut Workspace, cx: &mut Context) -> gpui cx.notify(); }), ) - .child(label.to_uppercase()) + .child(label) }; let tabs = div() .flex() @@ -55,11 +66,13 @@ pub(super) fn top_panel(ws: &mut Workspace, cx: &mut Context) -> gpui .gap_1() .px_2() .pt_2() - .child(tab_chip("Info", SideTab::Info, cx)) + .children(is_type.then(|| tab_chip("Character", SideTab::Character, cx))) + .children(exif.is_some().then(|| tab_chip("Info", SideTab::Info, cx))) .child(tab_chip("Color", SideTab::Color, cx)); let body = match tab { - SideTab::Info => info_panel(ws, &exif, cx).into_any_element(), + SideTab::Info => info_panel(ws, exif.as_ref().unwrap(), cx).into_any_element(), SideTab::Color => color_panel(ws, cx).into_any_element(), + SideTab::Character => super::typography::character_panel(ws, cx), }; div() .flex() diff --git a/crates/app/src/panels/mod.rs b/crates/app/src/panels/mod.rs index d7008baa..560e2bdd 100644 --- a/crates/app/src/panels/mod.rs +++ b/crates/app/src/panels/mod.rs @@ -37,6 +37,7 @@ mod status; mod tabs; mod titlebar; mod toolbar; +mod typography; #[cfg(not(target_arch = "wasm32"))] pub use ai::*; diff --git a/crates/app/src/panels/toolbar.rs b/crates/app/src/panels/toolbar.rs index bc9607c1..5b89a0df 100644 --- a/crates/app/src/panels/toolbar.rs +++ b/crates/app/src/panels/toolbar.rs @@ -16,6 +16,9 @@ pub(super) type ToolSlot = ( pub fn tool_options_bar(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { let tool_id = ws.editor.active_tool; + if tool_id == "type" { + return super::typography::type_options_bar(ws, cx).into_any_element(); + } let (tool_icon, tool_name) = ws .registry .tools() @@ -27,11 +30,16 @@ pub fn tool_options_bar(ws: &mut Workspace, cx: &mut Context) -> impl let mut bar = div() .flex() .flex_row() + .flex_wrap() .items_center() - .gap_4() - .h(px(32.0)) + .gap_x_4() + .gap_y_1() + .min_h(px(32.0)) + .w_full() + .min_w_0() .flex_none() .px_3() + .py_1() .bg(gpui::rgb(palette().panel_bg)) .border_b_1() .border_color(gpui::rgb(palette().panel_edge)) @@ -84,9 +92,11 @@ pub fn tool_options_bar(ws: &mut Workspace, cx: &mut Context) -> impl .map(|t| t.options()) .unwrap_or_default() { - bar = bar.child(tool_option_control(ws, opt, cx)); + // Keep each control together when a tool has more options than + // one row can show. Type has its own compact bar and panel. + bar = bar.child(div().flex_none().child(tool_option_control(ws, opt, cx))); } - bar + bar.into_any_element() } /// Render one plugin-declared option. The shell knows the three kinds, not diff --git a/crates/app/src/panels/typography.rs b/crates/app/src/panels/typography.rs new file mode 100644 index 00000000..93eba267 --- /dev/null +++ b/crates/app/src/panels/typography.rs @@ -0,0 +1,504 @@ +//! A compact Type options bar and the docked Character panel. + +use super::*; +use crate::workspace::SideTab; +use schist_plugin_api::{OptionKind, OptionValue, ToolOption}; + +fn options(ws: &Workspace) -> Vec { + ws.registry + .tools() + .find(|tool| tool.id() == "type") + .map(|tool| tool.options()) + .unwrap_or_default() +} + +fn option(options: &[ToolOption], key: &str) -> ToolOption { + options + .iter() + .find(|option| option.key == key) + .unwrap() + .clone() +} + +fn separator() -> gpui::Div { + div() + .w(px(1.0)) + .h(px(20.0)) + .mx_1() + .flex_none() + .bg(gpui::rgb(palette().divider)) +} + +fn compact_button( + id: &'static str, + label: &'static str, + active: bool, +) -> gpui::Stateful { + div() + .id(id) + .flex() + .items_center() + .justify_center() + .h(px(24.0)) + .min_w(px(26.0)) + .flex_none() + .rounded_sm() + .border_1() + .border_color(gpui::rgb(if active { + palette().edge + } else { + palette().panel_bg + })) + .bg(gpui::rgb(if active { + palette().control_bg + } else { + palette().panel_bg + })) + .cursor_pointer() + .hover(|style| style.bg(gpui::rgb(palette().hover))) + .tooltip(ui::tip(label, None)) +} + +fn choice( + ws: &Workspace, + option: ToolOption, + popup_id: &'static str, + width: f32, + cx: &mut Context, +) -> gpui::AnyElement { + let OptionKind::Choice(labels) = option.kind else { + unreachable!() + }; + let key = option.key; + let current = option.value.index().min(labels.len().saturating_sub(1)); + let popup = Popup::Field(popup_id); + let spec = ui::Dropdown { + popup, + is_open: ws.open_popup == Some(popup), + current, + label: labels.get(current).copied().unwrap_or("").into(), + width, + options: labels + .iter() + .enumerate() + .map(|(i, label)| ((*label).into(), i)) + .collect(), + }; + let select = move |ws: &mut Workspace, value, cx: &mut Context| { + ws.commit_focused_field(); + ws.set_tool_option(key, OptionValue::Choice(value), cx); + }; + let control = if key == "type-family" { + ui::font_dropdown(&ws.dropdown, spec, select, cx).into_any_element() + } else { + ui::dropdown(&ws.dropdown, spec, select, cx).into_any_element() + }; + div() + .id(popup_id) + .flex_none() + .tooltip(ui::tip(option.label, None)) + .child(control) + .into_any_element() +} + +fn number( + ws: &Workspace, + option: ToolOption, + field_id: &'static str, + width: f32, + cx: &mut Context, +) -> gpui::AnyElement { + let OptionKind::Slider { suffix, .. } = option.kind else { + unreachable!() + }; + let value = option.value.num(); + let value = if value.fract().abs() < 0.001 { + format!("{value:.0}") + } else { + format!("{value:.1}") + }; + let focused = ws.focused_field == Some(field_id); + let selected = ws.type_field_selected(field_id); + let shown = if focused { + ws.field_buffer.clone() + } else { + value.clone() + }; + div() + .id(field_id) + .flex() + .items_center() + .justify_between() + .w(px(width)) + .h(px(22.0)) + .flex_none() + .px_1() + .rounded_sm() + .border_1() + .border_color(gpui::rgb(if focused { + palette().accent + } else { + palette().edge + })) + .bg(gpui::rgb(palette().field_bg)) + .text_size(px(11.0)) + .tooltip(ui::tip( + format!( + "{} · Type a value, or use ↑ / ↓ (Shift for larger steps)", + option.label + ), + None, + )) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e, _w, cx| { + ws.commit_focused_field(); + ws.focus_field(field_id, value.clone()); + cx.notify(); + }), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_ellipsis() + .when(selected, |d| d.bg(gpui::rgb(palette().selection_bg))) + .child(shown), + ) + .child( + div() + .flex_none() + .text_color(gpui::rgb(palette().text_dim)) + .child(suffix.trim()), + ) + .into_any_element() +} + +fn align_buttons(current: usize, panel: bool, cx: &mut Context) -> gpui::Div { + div().flex().items_center().gap(px(1.0)).children( + [ + ("type-align-left", "Align left"), + ("type-align-center", "Align center"), + ("type-align-right", "Align right"), + ] + .into_iter() + .enumerate() + .map(|(i, (name, label))| { + compact_button( + if panel { + [ + "character-align-left", + "character-align-center", + "character-align-right", + ][i] + } else { + name + }, + label, + current == i, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e, _w, cx| { + ws.commit_focused_field(); + ws.set_tool_option("type-align", OptionValue::Choice(i), cx); + }), + ) + .child(icon(name, 16.0, palette().text)) + }), + ) +} + +pub(super) fn type_options_bar( + ws: &mut Workspace, + cx: &mut Context, +) -> gpui::AnyElement { + let options = options(ws); + let editing = ws.tool_captures_keys(); + div() + .id("type-options-bar") + .flex() + .items_center() + .gap_2() + .h(px(36.0)) + .w_full() + .min_w_0() + .flex_none() + .px_2() + .bg(gpui::rgb(palette().panel_bg)) + .border_b_1() + .border_color(gpui::rgb(palette().panel_edge)) + .child( + compact_button("type-tool-indicator", "Type tool (T)", false) + .cursor_default() + .child(icon("type", 17.0, palette().text)), + ) + .child(separator()) + .child(choice( + ws, + option(&options, "type-family"), + "type-family", + 170.0, + cx, + )) + .child(choice( + ws, + option(&options, "type-style"), + "type-style", + 110.0, + cx, + )) + .child(number( + ws, + option(&options, "type-size"), + "type-size", + 68.0, + cx, + )) + .child(separator()) + .child(align_buttons( + option(&options, "type-align").value.index(), + false, + cx, + )) + .child(separator()) + .child( + compact_button("type-color", "Text color", false) + .on_mouse_down( + MouseButton::Left, + cx.listener(|ws, _e, _w, cx| { + ws.commit_focused_field(); + ws.open_color_picker(ColorTarget::Foreground, cx); + }), + ) + .child( + div() + .w(px(22.0)) + .h(px(14.0)) + .rounded_sm() + .border_1() + .border_color(gpui::rgb(palette().text_faint)) + .bg(swatch_hex(ws.editor.foreground)), + ), + ) + .child(separator()) + .child( + compact_button( + "show-character", + "Character panel", + ws.side_tab.unwrap_or(SideTab::Character) == SideTab::Character, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(|ws, _e, _w, cx| { + ws.commit_focused_field(); + ws.side_tab = Some( + if ws.side_tab.unwrap_or(SideTab::Character) == SideTab::Character { + SideTab::Color + } else { + SideTab::Character + }, + ); + cx.notify(); + }), + ) + .child(icon("character", 16.0, palette().text)), + ) + .child(separator()) + .child( + compact_button("type-cancel", "Cancel text edit", false) + .when(!editing, |d| d.opacity(0.3).cursor_default()) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e, _w, cx| { + if editing { + ws.commit_focused_field(); + ws.cancel_gesture(cx); + } + }), + ) + .child(icon("close", 16.0, palette().text)), + ) + .child( + compact_button("type-commit", "Commit text edit", false) + .when(!editing, |d| d.opacity(0.3).cursor_default()) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e, _w, cx| { + if editing { + ws.commit_focused_field(); + ws.commit_gesture(cx); + } + }), + ) + .child(icon("check", 17.0, palette().text)), + ) + .into_any_element() +} + +fn labeled(label: &'static str, child: gpui::AnyElement) -> gpui::Div { + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(px(10.0)) + .text_color(gpui::rgb(palette().text_dim)) + .child(label), + ) + .child(child) +} + +pub(super) fn character_panel(ws: &mut Workspace, cx: &mut Context) -> gpui::AnyElement { + let options = options(ws); + let path = option(&options, "type-path").value.bool(); + let row = || div().flex().items_center().gap_2(); + let section = |label| { + div() + .mt_1() + .pt_2() + .border_t_1() + .border_color(gpui::rgb(palette().divider)) + .text_size(px(10.0)) + .text_color(gpui::rgb(palette().text_dim)) + .child(label) + }; + let mut panel = div() + .flex() + .flex_col() + .gap_2() + .p_2() + .child( + row() + .child(choice( + ws, + option(&options, "type-family"), + "character-family", + 144.0, + cx, + )) + .child(choice( + ws, + option(&options, "type-style"), + "character-style", + 92.0, + cx, + )), + ) + .child( + row() + .child(labeled( + "Size", + number( + ws, + option(&options, "type-size"), + "character-size", + 118.0, + cx, + ), + )) + .child(labeled( + "Leading", + number( + ws, + option(&options, "type-leading"), + "character-leading", + 118.0, + cx, + ), + )), + ) + .child( + row() + .child(labeled( + "Tracking", + number( + ws, + option(&options, "type-tracking"), + "character-tracking", + 118.0, + cx, + ), + )) + .child(labeled( + "Alignment", + align_buttons(option(&options, "type-align").value.index(), true, cx) + .into_any_element(), + )), + ) + .child(section("OpenType")) + .child( + row().children( + [ + ("type-kern", "AV"), + ("type-liga", "fi"), + ("type-dlig", "st"), + ("type-smcp", "Tt"), + ] + .into_iter() + .map(|(key, glyph)| { + let option = option(&options, key); + let on = option.value.bool(); + compact_button(key, option.label, on) + .w(px(55.0)) + .h(px(28.0)) + .text_size(px(14.0)) + .when(on, |d| d.border_color(gpui::rgb(palette().accent))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e, _w, cx| { + ws.commit_focused_field(); + ws.set_tool_option(key, OptionValue::Bool(!on), cx); + }), + ) + .child(glyph) + }), + ), + ) + .child(section("Text on a path")) + .child( + div().flex().gap_1().children( + [(false, "Straight"), (true, "On path")] + .into_iter() + .map(|(on, label)| { + compact_button( + if on { + "character-on-path" + } else { + "character-straight" + }, + if on { + "Use a copy of the active path" + } else { + "Use the text layer's ordinary baseline" + }, + path == on, + ) + .w(px(120.0)) + .text_size(px(11.0)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e, _w, cx| { + ws.commit_focused_field(); + ws.set_tool_option("type-path", OptionValue::Bool(on), cx); + }), + ) + .child(label) + }), + ), + ); + if path { + panel = panel.child(labeled( + "Path offset", + number( + ws, + option(&options, "type-path-offset"), + "character-path-offset", + 118.0, + cx, + ), + )); + } + panel.into_any_element() +} diff --git a/crates/app/src/ui.rs b/crates/app/src/ui.rs index 13b5bdd5..f11e6cfe 100644 --- a/crates/app/src/ui.rs +++ b/crates/app/src/ui.rs @@ -606,7 +606,7 @@ fn dropdown_impl( MouseButton::Left, cx.listener(move |ws, _e, _w, cx| ws.toggle_popup(popup, cx)), ) - .child(label) + .child(div().flex_1().min_w_0().text_ellipsis().child(label)) .child(crate::panels::icon( "chevron-down", 11.0, diff --git a/crates/app/src/workspace/commands.rs b/crates/app/src/workspace/commands.rs index 64026c5b..40b9811d 100644 --- a/crates/app/src/workspace/commands.rs +++ b/crates/app/src/workspace/commands.rs @@ -105,6 +105,14 @@ impl Workspace { pub fn activate_tool(&mut self, id: &str, cx: &mut Context) { let previous = self.editor.active_tool; if previous != id { + if self.type_field_option().is_some() { + self.commit_focused_field(); + } + if id == "type" { + self.side_tab = Some(SideTab::Character); + } else if self.side_tab == Some(SideTab::Character) { + self.side_tab = None; + } if let (Some(doc), Some(tool)) = (self.doc.as_mut(), self.registry.tool_mut(previous)) { let mut ctx = ToolCtx { doc, diff --git a/crates/app/src/workspace/input.rs b/crates/app/src/workspace/input.rs index d0c44fad..88aef388 100644 --- a/crates/app/src/workspace/input.rs +++ b/crates/app/src/workspace/input.rs @@ -43,6 +43,9 @@ impl Workspace { cx: &mut Context, ) { window.focus(&self.focus); + if self.type_field_option().is_some() { + self.commit_focused_field(); + } // Clicking the canvas ends an inline layer rename or an open // note, keeping what was typed. self.commit_layer_rename(cx); diff --git a/crates/app/src/workspace/mod.rs b/crates/app/src/workspace/mod.rs index 938cb43d..39a4432c 100644 --- a/crates/app/src/workspace/mod.rs +++ b/crates/app/src/workspace/mod.rs @@ -78,6 +78,7 @@ mod services; mod styles; mod tiles; mod toolbar; +mod typography; mod view_options; mod viewport; @@ -1208,6 +1209,7 @@ pub enum ImportSource { pub enum SideTab { Info, Color, + Character, } /// A boundary in degrees: what the import map's rectangle means, and diff --git a/crates/app/src/workspace/modals.rs b/crates/app/src/workspace/modals.rs index d8391295..43a655a7 100644 --- a/crates/app/src/workspace/modals.rs +++ b/crates/app/src/workspace/modals.rs @@ -609,7 +609,9 @@ impl Workspace { // meant to do before `CancelGesture` -- bound in the // always-matching "Workspace" context -- got there ahead of it // and closed the whole dialog on the first press. - if self.modal.is_some() && self.focused_field.is_some() { + if self.focused_field.is_some() + && (self.modal.is_some() || self.type_field_option().is_some()) + { self.focused_field = None; self.field_buffer.clear(); cx.notify(); diff --git a/crates/app/src/workspace/render.rs b/crates/app/src/workspace/render.rs index 7f029130..5ea708d1 100644 --- a/crates/app/src/workspace/render.rs +++ b/crates/app/src/workspace/render.rs @@ -410,7 +410,9 @@ impl Workspace { cx.stop_propagation(); return; } - if ws.field_key(&ev.keystroke.key, ev.keystroke.key_char.as_deref()) { + if ws.type_field_key(ev, cx) + || ws.field_key(&ev.keystroke.key, ev.keystroke.key_char.as_deref()) + { cx.notify(); cx.stop_propagation(); return; @@ -586,6 +588,7 @@ impl Render for Workspace { let key_context = if self.modal.is_some() { "Workspace modal" } else if self.tool_captures_keys() + || self.type_field_option().is_some() || self.dropdown_open() || self.layer_rename.is_some() || self.note_edit.is_some() diff --git a/crates/app/src/workspace/typography.rs b/crates/app/src/workspace/typography.rs new file mode 100644 index 00000000..493e60ad --- /dev/null +++ b/crates/app/src/workspace/typography.rs @@ -0,0 +1,93 @@ +//! Keyboard editing for the Type tool's compact numeric fields. + +use super::*; +use schist_plugin_api::{OptionKind, OptionValue}; + +impl Workspace { + pub(crate) fn type_field_selected(&self, id: &str) -> bool { + self.focused_field == Some(id) && self.field_fresh + } + + pub(super) fn type_field_option(&self) -> Option<&'static str> { + if self.editor.active_tool != "type" { + return None; + } + match self.focused_field? { + "type-size" | "character-size" => Some("type-size"), + "character-leading" => Some("type-leading"), + "character-tracking" => Some("type-tracking"), + "character-path-offset" => Some("type-path-offset"), + _ => None, + } + } + + pub(super) fn type_field_key( + &mut self, + ev: &gpui::KeyDownEvent, + cx: &mut Context, + ) -> bool { + let Some(key) = self.type_field_option() else { + return false; + }; + let Some(option) = self + .registry + .tools() + .find(|tool| tool.id() == "type") + .and_then(|tool| tool.options().into_iter().find(|option| option.key == key)) + else { + return false; + }; + let OptionKind::Slider { min, max, .. } = option.kind else { + return false; + }; + if ev.keystroke.key == "a" + && (ev.keystroke.modifiers.control || ev.keystroke.modifiers.platform) + { + self.field_fresh = true; + return true; + } + let value = match ev.keystroke.key.as_str() { + "up" | "down" => { + let step = if key == "type-leading" { 0.1 } else { 1.0 }; + let step = step + * if ev.keystroke.modifiers.shift { + 10.0 + } else { + 1.0 + }; + let direction = if ev.keystroke.key == "up" { 1.0 } else { -1.0 }; + let value = (option.value.num() + direction * step).clamp(min, max); + self.focus_field(self.focused_field.unwrap(), format!("{value:.1}")); + Some(value) + } + "backspace" | "delete" if self.field_fresh => { + self.field_buffer.clear(); + self.field_fresh = false; + None + } + _ => { + if let Some(text) = ev.keystroke.key_char.as_deref() { + let base = if self.field_fresh { + "" + } else { + &self.field_buffer + }; + if !text.is_empty() && !numeric_accepts(base, text) { + return true; + } + if self.field_fresh && !text.is_empty() { + self.field_buffer.clear(); + } + } + self.field_key(&ev.keystroke.key, ev.keystroke.key_char.as_deref()); + self.field_buffer.parse::().ok() + } + }; + if let Some(value) = value.filter(|value| value.is_finite()) { + self.set_tool_option(key, OptionValue::Num(value.clamp(min, max)), cx); + } + // A numeric field owns all typing, including rejected letters: + // they must never leak into the text layer behind it. + true + } +} diff --git a/crates/codec-affinity/src/import/text.rs b/crates/codec-affinity/src/import/text.rs index f92531e0..8b298bb8 100644 --- a/crates/codec-affinity/src/import/text.rs +++ b/crates/codec-affinity/src/import/text.rs @@ -176,6 +176,8 @@ impl Walker<'_> { // Frame text reflows to its box; artistic text never wraps. wrap_width: (frame_text && frame_width > 8).then_some(frame_width as f32), runs: Vec::new(), + features: Vec::new(), + path: None, }; let mut raster = match schist_text_engine::rasterize(&spec) { Some(r) => r, diff --git a/crates/compositor-gpu/src/exec.rs b/crates/compositor-gpu/src/exec.rs index 3b3905b0..9c55210f 100644 --- a/crates/compositor-gpu/src/exec.rs +++ b/crates/compositor-gpu/src/exec.rs @@ -12,6 +12,8 @@ use crate::plan::{Plan, PlanSource}; use schist_core::{TileBuf, TileCoord, TILE_PIXELS}; use wgpu::util::DeviceExt; +mod carve_paged; + /// Per-chunk ceiling on any one storage buffer, and the tile count that /// keeps the f32 output under it (256 KiB × 4 channels × 4 bytes = 1 MiB /// per tile). @@ -39,6 +41,7 @@ pub struct GpuContext { /// whole run is a single set of buffers and no layout can drift /// between entry points. carve: CarvePipelines, + paged_carve: std::sync::OnceLock, info: wgpu::AdapterInfo, } @@ -52,6 +55,13 @@ struct CarvePipelines { layout: wgpu::BindGroupLayout, } +/// A warp snapshot stored in texture pages, independent of the maximum +/// storage-buffer binding size. The shader uses unfiltered texture loads. +pub struct WarpSource { + view: wgpu::TextureView, + pixels: usize, +} + /// Mirrors fx_carve.wgsl: rows per scan tile, and the columns a workgroup /// owns once the ±1 dependency has eaten one column an end per row. const CARVE_TILE_ROWS: usize = 64; @@ -207,6 +217,7 @@ impl GpuContext { fx_blur: make(&fx_blur_module, "box_pass"), fx_lens: make(&fx_lens_module, "lens_blur"), fx_warp: make(&fx_warp_module, "mesh_warp"), + paged_carve: std::sync::OnceLock::new(), carve: CarvePipelines { energy: carve_stage("energy_pass"), dp_seed: carve_stage("dp_seed"), @@ -370,9 +381,9 @@ impl GpuContext { /// Run a whole content-aware resize without coming back: every stage, /// every seam, one readback at the end. /// - /// `None` when a plane is too big for one storage binding — unlike the - /// blurs there is nothing to band, since each seam depends on the last - /// over the whole image. + /// Oversized planes use texture pages and two rows of cumulative + /// costs. `None` means device limits or available memory still prevent + /// the operation, so the caller should use the CPU reference. pub fn run_carve(&self, job: &schist_fx::CarveJob<'_>) -> Option { let (w0, h) = (job.width, job.height); let target = job.target_width.max(1); @@ -385,7 +396,7 @@ impl GpuContext { let plane = max_w.checked_mul(h)?; let limit = self.binding_limit(); if plane.checked_mul(16)? > limit { - return None; + return self.run_carve_paged(job); } let _work = self.work.lock(); @@ -814,31 +825,161 @@ impl GpuContext { self.finish_fx(encoder, &dst, bytes, "lens blur") } - /// Upload a warp source plane. Callers hold the buffer for as long as - /// the pixels behind it are unchanged — a whole Liquify drag — so the - /// per-move cost is one dispatch and one readback. - pub fn upload_warp_source(&self, src: &[f32]) -> Option { - let contents = crate::fx::cast_f32s(src); - if contents.is_empty() { + /// Upload a straight-alpha snapshot into bounded 2D texture pages. + /// A pixel's linear index determines its page, so even very wide or + /// tall layers need no special sampling path and no padded CPU copy. + pub fn upload_warp_source(&self, src: &[f32]) -> Option { + if src.is_empty() || !src.len().is_multiple_of(4) { return None; } - Some( - self.device - .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("fx-warp-source"), - contents, - usage: wgpu::BufferUsages::STORAGE, - }), - ) + let pixels = src.len() / 4; + let count = u32::try_from(pixels).ok()?; + let edge = self.device.limits().max_texture_dimension_2d.min(1024); + let width = count.min(edge); + let height = count.div_ceil(width).min(edge); + let page = (width * height) as usize; + let layers = u32::try_from(pixels.div_ceil(page)).ok()?; + if layers > self.device.limits().max_texture_array_layers { + return None; + } + let _work = self.work.lock(); + self.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + self.device.push_error_scope(wgpu::ErrorFilter::Validation); + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("fx-warp-source"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: layers, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + for (layer, data) in src.chunks(page * 4).enumerate() { + let rows = data.len() / (width as usize * 4); + let copy = |data: &[f32], y: u32, w: u32, h: u32| { + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: 0, + y, + z: layer as u32, + }, + aspect: wgpu::TextureAspect::All, + }, + crate::fx::cast_f32s(data), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(w * 16), + rows_per_image: None, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + }; + let full = rows * width as usize * 4; + if rows > 0 { + copy(&data[..full], 0, width, rows as u32); + } + if full < data.len() { + copy( + &data[full..], + rows as u32, + ((data.len() - full) / 4) as u32, + 1, + ); + } + } + let view = texture.create_view(&wgpu::TextureViewDescriptor { + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + let validation = pollster::block_on(self.device.pop_error_scope()); + let allocation = pollster::block_on(self.device.pop_error_scope()); + if let Some(error) = validation.or(allocation) { + log::warn!("GPU warp upload failed: {error}"); + return None; + } + Some(WarpSource { view, pixels }) } /// Warp through `src`, which the caller keeps resident across a drag. - pub fn run_warp( + pub fn run_warp(&self, job: &schist_fx::WarpParams<'_>, src: &WarpSource) -> Option> { + self.run_warp_banded(job, src, self.binding_limit()) + } + + /// As `run_warp`, with an optional tighter output budget, useful for + /// callers sharing a device and for exercising band edges in tests. + pub fn run_warp_banded( &self, job: &schist_fx::WarpParams<'_>, - src: &wgpu::Buffer, + src: &WarpSource, + budget: usize, ) -> Option> { + let count = job.src_width.checked_mul(job.src_height)?; + if count != src.pixels + || job.src_width > i32::MAX as usize + || job.src_height > i32::MAX as usize + { + return None; + } + let out_len = job.dst_width.checked_mul(job.dst_height)?.checked_mul(4)?; + if out_len == 0 { + return Some(Vec::new()); + } + let max_axis = self.device.limits().max_compute_workgroups_per_dimension as usize * 16; + let max_pixels = budget.min(self.binding_limit()) / 16; + let width = job.dst_width.min(max_axis).min(max_pixels); + if width == 0 { + return None; + } + let height = job.dst_height.min(max_axis).min(max_pixels / width); + if job.mesh.len().checked_mul(4)? > self.binding_limit() + || (job.mesh_cols >= 2 + && job.mesh_rows >= 2 + && (job.mesh_cols.checked_mul(job.mesh_rows)?.checked_mul(2)? > job.mesh.len() + || !job.cell.is_finite() + || job.cell <= 0.0)) + { + return None; + } + let mut out = vec![0.0; out_len]; + for y in (0..job.dst_height).step_by(height) { + for x in (0..job.dst_width).step_by(width) { + let w = width.min(job.dst_width - x); + let h = height.min(job.dst_height - y); + let tile = schist_fx::WarpParams { + dst_width: w, + dst_height: h, + dst_origin: ( + job.dst_origin.0.checked_add(i32::try_from(x).ok()?)?, + job.dst_origin.1.checked_add(i32::try_from(y).ok()?)?, + ), + ..*job + }; + let pixels = self.run_warp_tile(&tile, src)?; + for row in 0..h { + let offset = ((y + row) * job.dst_width + x) * 4; + out[offset..offset + w * 4] + .copy_from_slice(&pixels[row * w * 4..(row + 1) * w * 4]); + } + } + } + Some(out) + } + + fn run_warp_tile(&self, job: &schist_fx::WarpParams<'_>, src: &WarpSource) -> Option> { let _work = self.work.lock(); + self.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); self.device.push_error_scope(wgpu::ErrorFilter::Validation); let bytes = (job.dst_width * job.dst_height * 16) as u64; let dst = self.device.create_buffer(&wgpu::BufferDescriptor { @@ -887,7 +1028,10 @@ impl GpuContext { layout: &self.fx_warp.get_bind_group_layout(0), entries: &[ bind_entry(0, ¶ms), - bind_entry(1, src), + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&src.view), + }, bind_entry(2, &dst), bind_entry(3, &mesh), ], @@ -908,7 +1052,12 @@ impl GpuContext { 1, ); } - self.finish_fx(encoder, &dst, bytes, "warp") + let result = self.finish_fx(encoder, &dst, bytes, "warp"); + if let Some(error) = pollster::block_on(self.device.pop_error_scope()) { + log::warn!("GPU warp allocation failed: {error}"); + return None; + } + result } /// Submit, read `bytes` back out of `out`, and turn any validation @@ -920,35 +1069,39 @@ impl GpuContext { bytes: u64, what: &str, ) -> Option> { - let staging = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("fx-staging"), - size: bytes, - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - encoder.copy_buffer_to_buffer(out, 0, &staging, 0, bytes); - self.queue.submit([encoder.finish()]); - let slice = staging.slice(..); - let (tx, rx) = std::sync::mpsc::channel(); - slice.map_async(wgpu::MapMode::Read, move |r| { - let _ = tx.send(r); - }); - self.device.poll(wgpu::PollType::wait_indefinitely()).ok()?; - rx.recv().ok()?.ok()?; - let data = slice.get_mapped_range(); - let floats: Vec = data - .as_chunks::<4>() - .0 - .iter() - .map(|b| f32::from_le_bytes(*b)) - .collect(); - drop(data); - staging.unmap(); + let result = (|| { + let staging = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("fx-staging"), + size: bytes, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_buffer_to_buffer(out, 0, &staging, 0, bytes); + self.queue.submit([encoder.finish()]); + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + self.device.poll(wgpu::PollType::wait_indefinitely()).ok()?; + rx.recv().ok()?.ok()?; + let data = slice.get_mapped_range(); + let floats: Vec = data + .as_chunks::<4>() + .0 + .iter() + .map(|b| f32::from_le_bytes(*b)) + .collect(); + drop(data); + staging.unmap(); + Some(floats) + })(); + // A failed map/poll must not leave a scope for the next job to pop. if let Some(err) = pollster::block_on(self.device.pop_error_scope()) { log::warn!("gpu {what} failed, falling back to the CPU: {err}"); return None; } - Some(floats) + result } pub fn adapter_info(&self) -> &wgpu::AdapterInfo { diff --git a/crates/compositor-gpu/src/exec/carve_paged.rs b/crates/compositor-gpu/src/exec/carve_paged.rs new file mode 100644 index 00000000..d8c9836a --- /dev/null +++ b/crates/compositor-gpu/src/exec/carve_paged.rs @@ -0,0 +1,374 @@ +//! Large-layer seam carving. Texture arrays hold the image planes; the +//! cumulative-cost scan keeps just its previous and next boundary rows. +use super::*; + +pub(super) struct Pipelines { + stages: Vec, +} + +const ENTRIES: &[(&str, &[u32])] = &[ + ("energy_pass", &[0, 1, 3, 5]), + ("dp_seed", &[0, 6, 7, 9]), + ("dp_tile", &[0, 6, 7, 8, 9]), + ("pick", &[0, 6, 10]), + ("resample", &[0, 1, 2, 3, 4]), + ("advance_seam", &[0]), +]; + +struct Plane { + texture: wgpu::Texture, + view: wgpu::TextureView, + width: u32, + height: u32, + layers: u32, + channels: usize, +} + +impl Plane { + fn new( + device: &wgpu::Device, + size: wgpu::Extent3d, + format: wgpu::TextureFormat, + channels: usize, + ) -> Self { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("carve-paged-plane"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor { + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + Self { + texture, + view, + width: size.width, + height: size.height, + layers: size.depth_or_array_layers, + channels, + } + } + + fn upload( + &self, + queue: &wgpu::Queue, + data: &[f32], + width: usize, + stride: usize, + height: usize, + ) { + let page = self.width as usize * self.height as usize; + for layer in 0..self.layers { + let start = layer as usize * page; + let end = (start + page).min(stride * height); + let mut pixels = vec![0.0; page * self.channels]; + for y in start / stride..end.div_ceil(stride) { + let from = start.max(y * stride); + let to = end.min(y * stride + width); + if from < to { + let dst = (from - start) * self.channels; + let src = (y * width + from - y * stride) * self.channels; + let len = (to - from) * self.channels; + pixels[dst..dst + len].copy_from_slice(&data[src..src + len]); + } + } + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: layer, + }, + aspect: wgpu::TextureAspect::All, + }, + crate::fx::cast_f32s(&pixels), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(self.width * self.channels as u32 * 4), + rows_per_image: None, + }, + wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + ); + } + } + + fn read(&self, ctx: &GpuContext, count: usize) -> Option> { + let row_bytes = self.width as usize * self.channels * 4; + let padded_row = row_bytes.div_ceil(256) * 256; + let bytes = (padded_row * self.height as usize) as u64; + let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("carve-page-readback"), + size: bytes, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut out = Vec::with_capacity(count * self.channels); + for layer in 0..self.layers { + let mut encoder = ctx.device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &self.texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: layer, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_row as u32), + rows_per_image: None, + }, + }, + wgpu::Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + ); + ctx.queue.submit([encoder.finish()]); + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + ctx.device.poll(wgpu::PollType::wait_indefinitely()).ok()?; + rx.recv().ok()?.ok()?; + let data = slice.get_mapped_range(); + for row in data.chunks(padded_row) { + let remaining = count * self.channels - out.len(); + out.extend( + row[..row_bytes] + .as_chunks::<4>() + .0 + .iter() + .take(remaining) + .map(|b| f32::from_le_bytes(*b)), + ); + } + drop(data); + staging.unmap(); + } + Some(out) + } +} + +impl GpuContext { + /// Seam carving without full-image storage-buffer bindings. Also + /// public so parity tests can exercise this path on small fixtures. + pub fn run_carve_paged(&self, job: &schist_fx::CarveJob<'_>) -> Option { + self.run_carve_paged_with_edge(job, 1024) + } + + /// A smaller texture page edge can bound transient upload/readback + /// allocations. It also makes cross-page tests affordable. + pub fn run_carve_paged_with_edge( + &self, + job: &schist_fx::CarveJob<'_>, + edge: u32, + ) -> Option { + let (w, h) = (job.width, job.height); + let target = job.target_width.max(1); + if w == 0 || h == 0 || w == target || edge == 0 { + return None; + } + let stride = w.max(target); + let count = stride.checked_mul(h)?; + let count32 = u32::try_from(count).ok()?; + if job.px.len() != w.checked_mul(h)?.checked_mul(4)? || job.protect.len() != w * h { + return None; + } + let limits = self.device.limits(); + let edge = edge.min(1024).min(limits.max_texture_dimension_2d); + let tw = count32.min(edge); + let th = count32.div_ceil(tw).min(edge); + let layers = count32.div_ceil(tw * th); + if layers > limits.max_texture_array_layers + || stride.checked_mul(8)? > self.binding_limit() + || h.checked_add(8)?.checked_mul(4)? > self.binding_limit() + || stride.div_ceil(16).max(h.div_ceil(16)) + > limits.max_compute_workgroups_per_dimension as usize + { + return None; + } + let _work = self.work.lock(); + self.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + self.device.push_error_scope(wgpu::ErrorFilter::Validation); + // Always balance the device's error scopes, even if readback fails. + let result = (|| { + let pipelines = self.paged_carve.get_or_init(|| { + let module = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("fx_carve_paged.wgsl"), + source: wgpu::ShaderSource::Wgsl( + include_str!("../fx_carve_paged.wgsl").into(), + ), + }); + Pipelines { + stages: ENTRIES + .iter() + .map(|(entry, _)| { + self.device + .create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(entry), + layout: None, + module: &module, + entry_point: Some(entry), + compilation_options: Default::default(), + cache: None, + }) + }) + .collect(), + } + }); + let size = wgpu::Extent3d { + width: tw, + height: th, + depth_or_array_layers: layers, + }; + let plane = |format, channels| Plane::new(&self.device, size, format, channels); + let px = [ + plane(wgpu::TextureFormat::Rgba32Float, 4), + plane(wgpu::TextureFormat::Rgba32Float, 4), + ]; + let protect = [ + plane(wgpu::TextureFormat::R32Float, 1), + plane(wgpu::TextureFormat::R32Float, 1), + ]; + let energy = plane(wgpu::TextureFormat::R32Float, 1); + let directions = plane(wgpu::TextureFormat::R32Sint, 1); + px[0].upload(&self.queue, job.px, w, stride, h); + protect[0].upload(&self.queue, job.protect, w, stride, h); + let mut init = vec![0; h + 8]; + init[..4].copy_from_slice(&[w as u32, h as u32, stride as u32, u32::from(target > w)]); + init[6] = tw; + init[7] = th; + let state = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("carve-paged-state"), + contents: cast_u32s(&init), + usage: wgpu::BufferUsages::STORAGE, + }); + let cost = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("carve-boundary-costs"), + size: (stride * 8) as u64, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); + let tiles = (h - 1).div_ceil(CARVE_TILE_ROWS); + let mut bands = vec![0; tiles.max(1) * UNIFORM_ALIGN / 4]; + for i in 0..tiles { + bands[i * UNIFORM_ALIGN / 4] = (i * CARVE_TILE_ROWS) as u32; + } + let bands = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("carve-paged-bands"), + contents: cast_u32s(&bands), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bind = |stage: usize, direction: usize, band: usize| { + let resources = [ + state.as_entire_binding(), + wgpu::BindingResource::TextureView(&px[direction].view), + wgpu::BindingResource::TextureView(&px[1 - direction].view), + wgpu::BindingResource::TextureView(&protect[direction].view), + wgpu::BindingResource::TextureView(&protect[1 - direction].view), + wgpu::BindingResource::TextureView(&energy.view), + cost.as_entire_binding(), + wgpu::BindingResource::TextureView(&directions.view), + wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &bands, + offset: (band * UNIFORM_ALIGN) as u64, + size: wgpu::BufferSize::new(16), + }), + wgpu::BindingResource::TextureView(&energy.view), + wgpu::BindingResource::TextureView(&directions.view), + ]; + let entries: Vec<_> = ENTRIES[stage] + .1 + .iter() + .map(|&binding| wgpu::BindGroupEntry { + binding, + resource: resources[binding as usize].clone(), + }) + .collect(); + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(ENTRIES[stage].0), + layout: &pipelines.stages[stage].get_bind_group_layout(0), + entries: &entries, + }) + }; + let energy_binds = [bind(0, 0, 0), bind(0, 1, 0)]; + let seed = bind(1, 0, 0); + let tile_binds: Vec<_> = (0..tiles).map(|i| bind(2, 0, i)).collect(); + let pick = bind(3, 0, 0); + let resample = [bind(4, 0, 0), bind(4, 1, 0)]; + let advance = bind(5, 0, 0); + let seams = w.abs_diff(target); + for first in (0..seams).step_by(CARVE_SEAMS_PER_SUBMIT) { + let mut encoder = self.device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_compute_pass(&Default::default()); + for seam in first..(first + CARVE_SEAMS_PER_SUBMIT).min(seams) { + let mut dispatch = + |stage: usize, group: &wgpu::BindGroup, x: usize, y: usize| { + pass.set_pipeline(&pipelines.stages[stage]); + pass.set_bind_group(0, group, &[]); + pass.dispatch_workgroups(x as u32, y as u32, 1); + }; + dispatch( + 0, + &energy_binds[seam % 2], + stride.div_ceil(16), + h.div_ceil(16), + ); + dispatch(1, &seed, stride.div_ceil(CARVE_WG), 1); + for tile in &tile_binds { + dispatch(2, tile, stride.div_ceil(CARVE_TILE_COLS), 1); + } + dispatch(3, &pick, 1, 1); + dispatch(4, &resample[seam % 2], stride.div_ceil(16), h.div_ceil(16)); + dispatch(5, &advance, 1, 1); + } + } + self.queue.submit([encoder.finish()]); + } + let px = px[seams % 2].read(self, count)?; + let protect = protect[seams % 2].read(self, count)?; + Some(schist_fx::Carved { + px: unpad_rows(&px, stride * 4, target * 4, h), + protect: unpad_rows(&protect, stride, target, h), + width: target, + }) + })(); + let validation = pollster::block_on(self.device.pop_error_scope()); + let allocation = pollster::block_on(self.device.pop_error_scope()); + if let Some(error) = validation.or(allocation) { + log::warn!("GPU paged carve failed: {error}"); + return None; + } + result + } +} diff --git a/crates/compositor-gpu/src/fx.rs b/crates/compositor-gpu/src/fx.rs index 77892d2e..c8aa43a7 100644 --- a/crates/compositor-gpu/src/fx.rs +++ b/crates/compositor-gpu/src/fx.rs @@ -19,7 +19,7 @@ pub struct GpuFx { struct Resident { token: u64, - buffer: wgpu::Buffer, + buffer: crate::WarpSource, } impl GpuFx { @@ -29,15 +29,6 @@ impl GpuFx { resident: parking_lot::Mutex::new(None), } } - - /// Whether a plane fits in one storage binding. The blurs band - /// themselves past this; the warp cannot, because an arbitrary - /// displacement may read anywhere in its source. - fn plane_ok(&self, floats: usize) -> bool { - floats - .checked_mul(4) - .is_some_and(|bytes| bytes <= self.ctx.binding_limit()) - } } impl FxBackend for GpuFx { @@ -104,13 +95,10 @@ impl FxBackend for GpuFx { // two transfers of the whole layer. A tool that re-renders only // what its brush touched declines the deal by passing no token — // its jobs are too small to leave the CPU. - let pixels = params.dst_width * params.dst_height; + let pixels = params.dst_width.checked_mul(params.dst_height)?; if params.src_token == 0 || !schist_fx::worth_offloading(pixels, 24) { return None; } - if !self.plane_ok(pixels * 4) { - return None; - } let mut resident = self.resident.lock(); let reuse = resident .as_ref() @@ -118,7 +106,7 @@ impl FxBackend for GpuFx { if !reuse { // `src` is empty only when we just told the caller we had the // plane, so this is a genuine upload. - if src.is_empty() || !self.plane_ok(src.len()) { + if src.is_empty() { return None; } *resident = Some(Resident { diff --git a/crates/compositor-gpu/src/fx_carve_paged.wgsl b/crates/compositor-gpu/src/fx_carve_paged.wgsl new file mode 100644 index 00000000..b34318d9 --- /dev/null +++ b/crates/compositor-gpu/src/fx_carve_paged.wgsl @@ -0,0 +1,315 @@ +// Paged variant of fx_carve.wgsl. Image planes live in texture arrays; +// cumulative costs need only two rows, and all seams remain on the device. + +// Content-Aware Scale: the whole seam-carving loop, on the device. +// +// Unlike the other fx kernels this is not one sweep but hundreds, each +// depending on the last: find the lowest-energy top-to-bottom path, remove +// it, recompute, repeat. Coming back to the CPU between seams would cost +// more in round trips than the work is worth, so every stage lives here +// and only the finished image is read back. +// +// The awkward stage is the cumulative-cost scan, which is sequential down +// the rows. One dispatch per row would be tens of thousands of dispatches +// for one command, so `dp_tile` does TILE_ROWS rows at a time: a workgroup +// loads a span wider than it owns and lets the valid region shrink by one +// column per row, which is exactly how far the ±1 dependency spreads. The +// arithmetic mirrors `schist_fx`'s reference stage for stage. + +// state[] slots. Everything that changes between dispatches lives here, so +// one bind group serves the whole run. +const S_WIDTH: u32 = 0u; // current width, shrinking or growing +const S_HEIGHT: u32 = 1u; +const S_STRIDE: u32 = 2u; // row stride of every buffer: the widest width +const S_MODE: u32 = 3u; // 0 = carve, 1 = grow +const S_BEST: u32 = 5u; // column the seam ends on +const S_SEAM: u32 = 8u; // seam[y] follows, one column per row + +const TILE_ROWS: u32 = 64u; +const WG: u32 = 256u; +// Columns per thread. One keeps the workgroup count -- and so the +// parallelism -- up; carrying several columns each would cut the barrier +// traffic but leaves too few workgroups to fill a device. +const PER_THREAD: u32 = 1u; +const SPAN: u32 = WG * PER_THREAD; +// What a workgroup owns: the span it loads, less the column the cone gives +// up at each end for every row after the first. +const TILE_COLS: u32 = SPAN - 2u * (TILE_ROWS - 1u); + +const BIG: f32 = 3.4028235e38; + +@group(0) @binding(0) var state: array; +@group(0) @binding(1) var px_in: texture_2d_array; +@group(0) @binding(2) var px_out: texture_storage_2d_array; +@group(0) @binding(3) var prot_in: texture_2d_array; +@group(0) @binding(4) var prot_out: texture_storage_2d_array; +@group(0) @binding(5) var energy_out: texture_storage_2d_array; +@group(0) @binding(6) var cost: array; +@group(0) @binding(7) var from_out: texture_storage_2d_array; +@group(0) @binding(9) var energy_in: texture_2d_array; +@group(0) @binding(10) var from_in: texture_2d_array; +// Which band of rows this dispatch scans. A uniform with a dynamic offset +// rather than another counter in `state`: bumping a counter would need its +// own dispatch between every tile, and there are tens of thousands of them +// in one command. +struct Tile { + top: u32, + // Three scalars rather than a vec3, whose 16-byte alignment would push + // the struct to 32 and past the binding size declared for it. + _p0: u32, + _p1: u32, + _p2: u32, +} +@group(0) @binding(8) var tile: Tile; + +fn width() -> u32 { + return u32(state[S_WIDTH]); +} + +fn height() -> u32 { + return u32(state[S_HEIGHT]); +} + +fn stride() -> u32 { + return u32(state[S_STRIDE]); +} + +// State slots 6 and 7 hold the texture page dimensions. Logical image +// rows can cross pages; neighbours are always addressed in image space. +fn texel(x: u32, y: u32) -> vec3 { + let i = y * stride() + x; + let tw = u32(state[6]); + let th = u32(state[7]); + return vec3(i32(i % tw), i32((i / tw) % th), i32(i / (tw * th))); +} +fn pixel(x: u32, y: u32) -> vec4 { + let p = texel(x, y); + return textureLoad(px_in, p.xy, p.z, 0); +} +fn protection(x: u32, y: u32) -> f32 { + let p = texel(x, y); + return textureLoad(prot_in, p.xy, p.z, 0).x; +} +fn energy(x: u32, y: u32) -> f32 { + let p = texel(x, y); + return textureLoad(energy_in, p.xy, p.z, 0).x; +} +fn lum(x: u32, y: u32) -> f32 { + let p = pixel(x, y); + return 0.299 * p.x + 0.587 * p.y + 0.114 * p.z; +} + +// Gradient magnitude plus protection, with edge pixels clamping to +// themselves exactly as the reference's saturating_sub/min do. +@compute @workgroup_size(16, 16, 1) +fn energy_pass(@builtin(global_invocation_id) gid: vec3) { + let w = width(); + let h = height(); + if (gid.x >= w || gid.y >= h) { + return; + } + let x = gid.x; + let y = gid.y; + var xl = 0u; + if (x > 0u) { + xl = x - 1u; + } + var yu = 0u; + if (y > 0u) { + yu = y - 1u; + } + let l = lum(xl, y); + let r = lum(min(x + 1u, w - 1u), y); + let u = lum(x, yu); + let d = lum(x, min(y + 1u, h - 1u)); + // Fully transparent pixels are free to remove. + let alpha = pixel(x, y).a; + let p = texel(x, y); + textureStore(energy_out, p.xy, p.z, + vec4((abs(r - l) + abs(d - u)) * alpha + protection(x, y), 0.0, 0.0, 0.0)); +} + +// Row 0 of the scan is just the energy, which is where the reference's +// `cost = energy.clone()` starts it. +@compute @workgroup_size(WG, 1, 1) +fn dp_seed(@builtin(global_invocation_id) gid: vec3) { + if (gid.x >= width()) { + return; + } + cost[gid.x] = energy(gid.x, 0u); + let p = texel(gid.x, 0u); + textureStore(from_out, p.xy, p.z, vec4(0)); +} + +// Two rows of the scan, alternating: reading one while writing the other +// needs a single barrier a row instead of two. +var ring: array, 2>; + +// TILE_ROWS rows of the cumulative-cost scan. +// +// A workgroup owns TILE_COLS columns but loads TILE_ROWS-1 extra either +// side. Only columns in [r, SPAN-1-r] hold a correct value at row r, and +// the owned range sits inside that at every row, so what gets written is +// always what a whole-row scan would have written. +@compute @workgroup_size(WG, 1, 1) +fn dp_tile( + @builtin(workgroup_id) wid: vec3, + @builtin(local_invocation_id) lid: vec3, +) { + let w = width(); + let h = height(); + let s = stride(); + let own0 = wid.x * TILE_COLS; + if (own0 >= w) { + return; + } + let own1 = min(own0 + TILE_COLS, w); // exclusive + let top = tile.top + 1u; + // First column of the loaded span, as a signed value: it runs off the + // left edge for the first workgroup. + let span0 = i32(own0) - i32(TILE_ROWS - 1u); + + // Seed from the row above, which a previous dispatch finished. + for (var k = 0u; k < PER_THREAD; k++) { + let e = lid.x * PER_THREAD + k; + let c = span0 + i32(e); + var v = BIG; + if (c >= 0 && c < i32(w)) { + v = cost[((tile.top / TILE_ROWS) & 1u) * s + u32(c)]; + } + ring[0][e] = v; + } + + for (var r = 0u; r < TILE_ROWS; r++) { + let y = top + r; + if (y >= h) { + break; + } + workgroupBarrier(); + let src = r & 1u; + let dst = 1u - src; + for (var k = 0u; k < PER_THREAD; k++) { + let e = lid.x * PER_THREAD + k; + let c = span0 + i32(e); + var out = BIG; + var dir = 0; + if (c >= 0 && c < i32(w)) { + // Straight up first, then left, then right, each on a + // strict less-than: the reference's tie-breaking, which + // decides which of several equal-cost seams gets carved. + var best = ring[src][e]; + if (c > 0 && e > 0u && ring[src][e - 1u] < best) { + best = ring[src][e - 1u]; + dir = -1; + } + if (c + 1 < i32(w) && e + 1u < SPAN && ring[src][e + 1u] < best) { + best = ring[src][e + 1u]; + dir = 1; + } + out = energy(u32(c), y) + best; + } + if (c >= i32(own0) && c < i32(own1)) { + // Only the last row is needed by the next dispatch. + // Alternate row buffers so neighbouring workgroups never + // overwrite a seed that another is still reading. + cost[(1u - ((tile.top / TILE_ROWS) & 1u)) * s + u32(c)] = out; + let p = texel(u32(c), y); + textureStore(from_out, p.xy, p.z, vec4(dir, 0, 0, 0)); + } + ring[dst][e] = out; + } + } +} + +var best_val: array; +var best_col: array; + +// Cheapest end column, then walk the seam back up. The scan is sequential +// in the rows, so it is one thread — h steps, against the millions the +// rest of a seam costs. +@compute @workgroup_size(WG, 1, 1) +fn pick(@builtin(local_invocation_id) lid: vec3) { + let w = width(); + let h = height(); + let s = stride(); + let base = (((h - 1u + TILE_ROWS - 1u) / TILE_ROWS) & 1u) * s; + var bv = BIG; + var bc = 0u; + for (var x = lid.x; x < w; x += WG) { + let v = cost[base + x]; + if (v < bv) { + bv = v; + bc = x; + } + } + best_val[lid.x] = bv; + best_col[lid.x] = bc; + // Lowest cost wins, and the lowest column breaks the tie — which is + // what `min_by` does, since it keeps the first of equal elements. + for (var step = WG / 2u; step > 0u; step >>= 1u) { + workgroupBarrier(); + if (lid.x < step) { + let o = lid.x + step; + if (best_val[o] < best_val[lid.x] + || (best_val[o] == best_val[lid.x] && best_col[o] < best_col[lid.x])) { + best_val[lid.x] = best_val[o]; + best_col[lid.x] = best_col[o]; + } + } + } + workgroupBarrier(); + if (lid.x != 0u) { + return; + } + var x = i32(best_col[0]); + state[S_BEST] = x; + for (var y = i32(h) - 1; y >= 0; y--) { + state[S_SEAM + u32(y)] = x; + let p = texel(u32(x), u32(y)); + let d = textureLoad(from_in, p.xy, p.z, 0).x; + x = clamp(x + d, 0, i32(w) - 1); + } +} + +// Rebuild the image either side of the seam. Every output pixel names its +// own source, so this is the one stage that is trivially parallel. +@compute @workgroup_size(16, 16, 1) +fn resample(@builtin(global_invocation_id) gid: vec3) { + let w = width(); + let h = height(); + let s = stride(); + if (gid.y >= h) { + return; + } + let y = gid.y; + let x = gid.x; + let cut = u32(state[S_SEAM + y]); + let p = texel(x, y); + if (state[S_MODE] == 0) { + if (x + 1u >= w) { return; } + var sx = x; + if (x >= cut) { sx = x + 1u; } + textureStore(px_out, p.xy, p.z, pixel(sx, y)); + textureStore(prot_out, p.xy, p.z, vec4(protection(sx, y), 0.0, 0.0, 0.0)); + return; + } + if (x > w) { return; } + if (x == cut + 1u) { + textureStore(px_out, p.xy, p.z, (pixel(cut, y) + pixel(min(cut + 1u, w - 1u), y)) / 2.0); + textureStore(prot_out, p.xy, p.z, vec4(protection(cut, y) + 200.0, 0.0, 0.0, 0.0)); + return; + } + var sx = x; + if (x > cut) { sx = x - 1u; } + textureStore(px_out, p.xy, p.z, pixel(sx, y)); + textureStore(prot_out, p.xy, p.z, vec4(protection(sx, y), 0.0, 0.0, 0.0)); +} + +@compute @workgroup_size(1, 1, 1) +fn advance_seam() { + if (state[S_MODE] == 0) { + state[S_WIDTH] = state[S_WIDTH] - 1; + } else { + state[S_WIDTH] = state[S_WIDTH] + 1; + } +} diff --git a/crates/compositor-gpu/src/fx_warp.wgsl b/crates/compositor-gpu/src/fx_warp.wgsl index a7bfa30f..ebb46e94 100644 --- a/crates/compositor-gpu/src/fx_warp.wgsl +++ b/crates/compositor-gpu/src/fx_warp.wgsl @@ -25,7 +25,7 @@ struct Params { } @group(0) @binding(0) var p: Params; -@group(0) @binding(1) var src: array; +@group(0) @binding(1) var src: texture_2d_array; @group(0) @binding(2) var dst: array; @group(0) @binding(3) var mesh: array; @@ -37,8 +37,10 @@ fn src_pixel(x: i32, y: i32) -> vec4 { if (lx < 0 || ly < 0 || lx >= i32(p.src_width) || ly >= i32(p.src_height)) { return vec4(0.0); } - let i = (u32(ly) * p.src_width + u32(lx)) * 4u; - return vec4(src[i], src[i + 1u], src[i + 2u], src[i + 3u]); + let i = u32(ly) * p.src_width + u32(lx); + let size = textureDimensions(src); + let page = size.x * size.y; + return textureLoad(src, vec2(i32(i % size.x), i32((i % page) / size.x)), i32(i / page), 0); } fn mesh_at(c: u32, r: u32) -> vec2 { @@ -67,13 +69,15 @@ fn mesh_sample(x: f32, y: f32) -> vec2 { return top + (bottom - top) * ty; } -fn fetch(fx: f32, fy: f32) -> vec4 { - let x0f = floor(fx); - let y0f = floor(fy); - let tx = fx - x0f; - let ty = fy - y0f; - let x0 = i32(x0f); - let y0 = i32(y0f); +fn fetch(x: i32, y: i32, displacement: vec2) -> vec4 { + // Derive weights before adding the integer document position, matching + // the CPU without discarding subpixel precision on large coordinates. + let offset = floor(displacement); + let fraction = displacement - offset; + let tx = fraction.x; + let ty = fraction.y; + let x0 = x + i32(offset.x); + let y0 = y + i32(offset.y); var acc = vec4(0.0); for (var t = 0u; t < 4u; t++) { let dx = i32(t & 1u); @@ -109,7 +113,7 @@ fn mesh_warp(@builtin(global_invocation_id) gid: vec3) { let fx = f32(x) + 0.5; let fy = f32(y) + 0.5; let d = mesh_sample(fx, fy); - let px = fetch(fx + d.x - 0.5, fy + d.y - 0.5); + let px = fetch(x, y, d); let o = (gid.y * p.dst_width + gid.x) * 4u; dst[o] = px.x; dst[o + 1u] = px.y; diff --git a/crates/compositor-gpu/src/lib.rs b/crates/compositor-gpu/src/lib.rs index e77c2cf6..688b7f4f 100644 --- a/crates/compositor-gpu/src/lib.rs +++ b/crates/compositor-gpu/src/lib.rs @@ -16,7 +16,7 @@ mod exec; mod fx; pub mod plan; -pub use exec::GpuContext; +pub use exec::{GpuContext, WarpSource}; pub use fx::GpuFx; use exec::BatchOut; diff --git a/crates/compositor-gpu/tests/fx_parity.rs b/crates/compositor-gpu/tests/fx_parity.rs index 85bc31f8..f436fb21 100644 --- a/crates/compositor-gpu/tests/fx_parity.rs +++ b/crates/compositor-gpu/tests/fx_parity.rs @@ -12,6 +12,10 @@ fn gpu() -> Option<&'static Arc> { GPU.get_or_init(|| match GpuCompositor::new() { Ok(g) => Some(g), Err(e) => { + assert!( + !e.starts_with("pipeline creation:"), + "GPU shader initialization failed: {e}" + ); eprintln!("skipping GPU fx parity tests: {e}"); None } @@ -20,6 +24,23 @@ fn gpu() -> Option<&'static Arc> { .map(|g| g.context()) } +#[test] +fn fx_shaders_validate_without_an_adapter() { + for (name, source) in [ + ("warp", include_str!("../src/fx_warp.wgsl")), + ("paged carve", include_str!("../src/fx_carve_paged.wgsl")), + ] { + let module = naga::front::wgsl::parse_str(source) + .unwrap_or_else(|e| panic!("{name}: {}", e.emit_to_string(source))); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .unwrap_or_else(|e| panic!("{name}: {}", e.emit_to_string(source))); + } +} + struct Lcg(u64); impl Lcg { @@ -189,6 +210,65 @@ fn a_degenerate_mesh_warps_to_the_identity() { assert_close(&out, &schist_fx::warp_cpu(&job, &src), "degenerate mesh"); } +#[test] +fn fractional_warps_preserve_precision_at_large_document_origins() { + let Some(ctx) = gpu() else { return }; + let (w, h) = (16, 12); + let src = noise(w, h, 514); + let source = ctx.upload_warp_source(&src).expect("upload"); + let mesh = [0.1, -0.2].repeat(4); + let mut job = warp_job(&mesh, w, h, 2, 2, 0); + job.src_origin = (0, 0); + job.dst_origin = (0, 0); + job.mesh_origin = (0, 0); + let cpu = schist_fx::warp_cpu(&job, &src); + for origin in [ + (0, 0), + (923, 1024), + (-923, -1024), + (16_000_000, -16_000_000), + ] { + job.src_origin = origin; + job.dst_origin = origin; + job.mesh_origin = origin; + let out = ctx.run_warp(&job, &source).expect("translated warp"); + assert_close(&out, &cpu, &format!("fractional warp at {origin:?}")); + } +} + +#[test] +fn warp_bands_can_sample_across_texture_pages() { + let Some(ctx) = gpu() else { return }; + // A page holds 1024² pixels. Odd image width puts page boundaries + // inside image rows, and the last page ends in a partial texture row. + let (w, h) = (1031, 1021); + let src = noise(w, h, 515); + let mesh = [0.75, 1.25, -0.25, 1.75, 0.25, -1.75, -0.75, -1.25]; + let job = WarpParams { + src_width: w, + src_height: h, + src_origin: (-50, 20), + dst_width: 67, + dst_height: 25, + dst_origin: (875, 1019), + mesh: &mesh, + mesh_cols: 2, + mesh_rows: 2, + cell: 2000.0, + mesh_origin: (-50, 20), + src_token: 55, + }; + let source = ctx.upload_warp_source(&src).expect("paged upload"); + let cpu = schist_fx::warp_cpu(&job, &src); + for budget in [31 * 16, 67 * 3 * 16] { + let out = ctx + .run_warp_banded(&job, &source, budget) + .expect("banded warp"); + assert_close(&out, &cpu, "warp across pages and output bands"); + } + assert!(ctx.run_warp_banded(&job, &source, 15).is_none()); +} + #[test] fn the_backend_declines_work_too_small_to_ship() { let Some(ctx) = gpu() else { return }; @@ -376,6 +456,37 @@ fn growing_matches_the_cpu_reference() { assert_carve_matches(70, 55, 74, None, "grow 70->74"); } +#[test] +fn paged_carving_matches_the_cpu_across_scan_and_texture_boundaries() { + let Some(ctx) = gpu() else { return }; + for (w, h, target) in [ + (37, 1, 29), + (41, 65, 47), + (273, 131, 269), + (1, 77, 4), + (67, 70, 1), + ] { + let src = noise(w, h, 700 + w as u64); + let protect: Vec = (0..w * h) + .map(|i| if i % w == w / 2 { 500.0 } else { 0.0 }) + .collect(); + let job = schist_fx::CarveJob { + px: &src, + protect: &protect, + width: w, + height: h, + target_width: target, + }; + let gpu = ctx + .run_carve_paged_with_edge(&job, 32) + .expect("paged carve dispatch"); + let cpu = schist_fx::carve_cpu(&job); + assert_eq!(gpu.width, cpu.width); + assert_close(&gpu.px, &cpu.px, "paged carve pixels"); + assert_close(&gpu.protect, &cpu.protect, "paged carve protect mask"); + } +} + #[test] fn a_protect_mask_steers_the_seam_the_same_way() { let (w, h) = (80usize, 60usize); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 727fde3b..2941f494 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -12,4 +12,5 @@ rustc-hash.workspace = true rayon.workspace = true smallvec.workspace = true serde.workspace = true +serde_json.workspace = true log.workspace = true diff --git a/crates/core/src/document.rs b/crates/core/src/document.rs index 95dcfc07..1d7960b8 100644 --- a/crates/core/src/document.rs +++ b/crates/core/src/document.rs @@ -683,11 +683,49 @@ impl<'a> EditBuilder<'a> { if dx == 0 && dy == 0 { return; } + // Text pixels move with the layer, and their editable origin must + // move too. Keep the JSON contract here without depending on the + // text engine (which itself depends on core). Record complete + // before/after blocks so undo restores even their original bytes. + fn text_origins(layer: &Layer, dx: i32, dy: i32, out: &mut Vec<(LayerId, Vec)>) { + if let Some(index) = layer.extras.iter().position(|b| b.key == *b"PsTx") { + let translated = (|| { + let mut value: serde_json::Value = + serde_json::from_slice(&layer.extras[index].data).ok()?; + let origin = value.get_mut("origin")?.as_array_mut()?; + if origin.len() != 2 { + return None; + } + let x = i32::try_from(origin[0].as_i64()?).ok()?.checked_add(dx)?; + let y = i32::try_from(origin[1].as_i64()?).ok()?.checked_add(dy)?; + origin[0] = x.into(); + origin[1] = y.into(); + serde_json::to_vec(&value).ok() + })(); + if let Some(data) = translated { + let mut extras = layer.extras.clone(); + extras[index].data = data; + out.push((layer.id, extras)); + } + } + if let crate::layer::LayerKind::Group(group) = &layer.kind { + for child in &group.children { + text_origins(child, dx, dy, out); + } + } + } + let mut origins = Vec::new(); + if let Some(layer) = self.doc.tree.find(id) { + text_origins(layer, dx, dy, &mut origins); + } self.doc.translate_layer_content(id, dx, dy); if let Some(layer) = self.doc.tree.find(id) { self.damage = self.damage.union(&layer.content_bounds().inflated(1)); } self.ops.push(EditOp::LayerTranslate { layer: id, dx, dy }); + for (layer, extras) in origins { + self.set_extras(layer, extras); + } } pub fn move_layer(&mut self, from: LayerPath, to: LayerPath) { diff --git a/crates/fx/src/lib.rs b/crates/fx/src/lib.rs index d6f0aed8..2c0c54d4 100644 --- a/crates/fx/src/lib.rs +++ b/crates/fx/src/lib.rs @@ -369,7 +369,7 @@ pub fn warp_cpu(job: &WarpParams<'_>, src: &[f32]) -> Vec { let y = job.dst_origin.1 + row as i32; let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); let (dx, dy) = mesh_sample(job, fx, fy); - let px = fetch(job, src, fx + dx - 0.5, fy + dy - 0.5); + let px = fetch(job, src, x, y, dx, dy); dst[col * 4..col * 4 + 4].copy_from_slice(&px); } }); @@ -403,10 +403,14 @@ fn mesh_sample(job: &WarpParams<'_>, x: f32, y: f32) -> (f32, f32) { } /// Bilinear fetch on premultiplied alpha, returning straight alpha. -fn fetch(job: &WarpParams<'_>, src: &[f32], fx: f32, fy: f32) -> [f32; 4] { - let (x0, y0) = (fx.floor(), fy.floor()); - let (tx, ty) = (fx - x0, fy - y0); - let (x0, y0) = (x0 as i32, y0 as i32); +fn fetch(job: &WarpParams<'_>, src: &[f32], x: i32, y: i32, dx: f32, dy: f32) -> [f32; 4] { + // Keep the subpixel displacement separate from the document position. + // Adding them in f32 first loses fractional bits on large coordinates; + // GPU reassociation of the pixel-centre +/- 0.5 makes that loss differ + // from the CPU near powers of two. + let (ox, oy) = (dx.floor(), dy.floor()); + let (tx, ty) = (dx - ox, dy - oy); + let (x0, y0) = (x + ox as i32, y + oy as i32); let mut acc = [0.0f32; 4]; for (dx, dy, w) in [ (0, 0, (1.0 - tx) * (1.0 - ty)), @@ -709,6 +713,40 @@ mod tests { } } + #[test] + fn fractional_warp_sampling_is_independent_of_document_origin() { + let src = [ + 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, + ]; + let mesh = [0.1, 0.2].repeat(4); + for origin in [ + (0, 0), + (923, 1024), + (-923, -1024), + (16_000_000, -16_000_000), + ] { + let job = WarpParams { + src_width: 2, + src_height: 2, + src_origin: origin, + dst_origin: origin, + dst_width: 1, + dst_height: 1, + mesh: &mesh, + mesh_cols: 2, + mesh_rows: 2, + cell: 4.0, + mesh_origin: origin, + src_token: 0, + }; + let out = warp_cpu(&job, &src); + // The two white texels contribute 0.1 * 0.8 + 0.9 * 0.2. + for (actual, expected) in out.iter().zip([0.26, 0.26, 0.26, 1.0]) { + assert!((actual - expected).abs() < 1e-6, "{origin:?}: {out:?}"); + } + } + } + #[test] fn a_warp_outside_the_source_plane_is_transparent() { let (w, h) = (8, 8); diff --git a/crates/text-engine/Cargo.toml b/crates/text-engine/Cargo.toml index a1b7cc85..1e2dcb54 100644 --- a/crates/text-engine/Cargo.toml +++ b/crates/text-engine/Cargo.toml @@ -7,6 +7,8 @@ license.workspace = true [dependencies] schist-core.workspace = true schist-color.workspace = true +schist-vector.workspace = true +rustybuzz = "0.20.1" fontdue = "0.9" fontdb = "0.24" ttf-parser = "0.25" diff --git a/crates/text-engine/src/lib.rs b/crates/text-engine/src/lib.rs index 6c27550f..387f7aca 100644 --- a/crates/text-engine/src/lib.rs +++ b/crates/text-engine/src/lib.rs @@ -3,15 +3,26 @@ //! Scope: system font discovery, one colour per layer with the family, //! style and size free to change from character to character (see //! [`StyleRun`]), left-to-right line layout with kerning, word wrapping -//! and alignment, rasterized to an 8-bit coverage mask. Complex shaping (ligature -//! substitution, bidi, vertical scripts) is out of scope for v1 — those -//! need a full shaper, which is why parley/swash is the -//! eventual home for this crate. +//! and alignment, rasterized to an 8-bit coverage mask. OpenType overrides +//! enable rustybuzz shaping; stored paths position and rotate the glyphs. +//! Paragraph bidi and vertical scripts are not yet supported. use schist_core::IntRect; use std::path::PathBuf; use std::sync::{Arc, OnceLock, RwLock}; +mod shaping; +mod text_path; +pub use text_path::TextPath; + +/// A layer-wide OpenType feature override. Tags are four ASCII bytes, +/// e.g. `liga`, `kern`, `smcp`, or `ss01`; zero disables a feature. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct OpenTypeFeature { + pub tag: String, + pub value: u32, +} + /// Horizontal alignment of wrapped lines. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] pub enum Align { @@ -60,6 +71,11 @@ pub struct TextSpec { /// existed load as. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub runs: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub features: Vec, + /// A copy of the baseline path in layout coordinates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, } impl Default for TextSpec { @@ -75,6 +91,8 @@ impl Default for TextSpec { tracking: 0.0, wrap_width: None, runs: Vec::new(), + features: Vec::new(), + path: None, } } } @@ -161,6 +179,22 @@ impl CharStyle { } impl TextSpec { + pub fn feature(&self, tag: &str, default: bool) -> bool { + self.features + .iter() + .rev() + .find(|f| f.tag == tag) + .map_or(default, |f| f.value != 0) + } + + pub fn set_feature(&mut self, tag: &str, enabled: bool) { + self.features.retain(|f| f.tag != tag); + self.features.push(OpenTypeFeature { + tag: tag.into(), + value: u32::from(enabled), + }); + } + /// The layer's own font, which uncovered text is set in. pub fn base_style(&self) -> CharStyle { CharStyle { @@ -844,7 +878,7 @@ impl<'a> GposKern<'a> { /// One laid-out glyph, positioned relative to the layout origin. #[derive(Debug, Clone, Copy)] struct PlacedGlyph { - ch: char, + glyph: u16, x: f32, baseline: f32, /// Index into the layout's faces: which font, at which size. @@ -951,9 +985,14 @@ pub struct Caret { pub x: f32, pub top: f32, pub height: f32, + /// Clockwise rotation in radians; zero for ordinary horizontal text. + pub angle: f32, } fn layout(spec: &TextSpec, base: &LoadedFace) -> Layout { + if !spec.features.is_empty() { + return shaping::layout(spec, base); + } let faces = Faces::resolve(spec, base); // A face with GPOS kerning speaks through it alone; the legacy // `kern` table is only consulted when there is no GPOS to read. @@ -1010,9 +1049,9 @@ fn layout(spec: &TextSpec, base: &LoadedFace) -> Layout { // overflow rather than being broken mid-word. for word in raw_line.split_inclusive(' ') { let mut word_width = measure_word(word, at, prev); - let wraps = spec - .wrap_width - .is_some_and(|w| !current.is_empty() && width + word_width > w); + let wraps = spec.wrap_width.is_some_and(|w| { + spec.path.is_none() && !current.is_empty() && width + word_width > w + }); if wraps { lines.push(Line { text: std::mem::take(&mut current), @@ -1095,7 +1134,7 @@ fn layout(spec: &TextSpec, base: &LoadedFace) -> Layout { chars.push(CharPos { byte, x }); if !ch.is_whitespace() { placed.push(PlacedGlyph { - ch, + glyph: faces.faces[ix].0.font.lookup_glyph_index(ch), x, baseline, face: ix, @@ -1135,6 +1174,41 @@ pub fn line_spans(spec: &TextSpec) -> Vec { pub fn caret_at(spec: &TextSpec, byte: usize) -> Option { let face = load_font(&spec.family, spec.bold, spec.italic)?; let laid = layout(spec, &face); + let caret = caret_in_layout(spec, &laid, byte); + Some(match path_guide(spec, &laid) { + Some(guide) => guide.caret(caret, laid.first_baseline), + None => caret, + }) +} + +fn path_guide(spec: &TextSpec, laid: &Layout) -> Option { + text_path::Guide::new(spec.path.as_ref()?, spec.align, laid.layout_width) +} + +/// All insertion points in a single layout pass, including the final one. +pub fn carets(spec: &TextSpec) -> Vec<(usize, Caret)> { + let Some(face) = load_font(&spec.family, spec.bold, spec.italic) else { + return Vec::new(); + }; + let laid = layout(spec, &face); + let guide = path_guide(spec, &laid); + spec.text + .char_indices() + .map(|(i, _)| i) + .chain(std::iter::once(spec.text.len())) + .map(|i| { + let caret = caret_in_layout(spec, &laid, i); + ( + i, + guide + .as_ref() + .map_or(caret, |g| g.caret(caret, laid.first_baseline)), + ) + }) + .collect() +} + +fn caret_in_layout(spec: &TextSpec, laid: &Layout, byte: usize) -> Caret { let byte = clamp_to_boundary(&spec.text, byte); // The last line whose range starts at or before `byte`: with an @@ -1170,7 +1244,7 @@ pub fn caret_at(spec: &TextSpec, byte: usize) -> Option { .map(|c| c.x) .unwrap_or(span.x + span.width) }; - Some(Caret { + Caret { x, top: span.top, height: if span.height > 0.0 { @@ -1178,7 +1252,8 @@ pub fn caret_at(spec: &TextSpec, byte: usize) -> Option { } else { spec.size }, - }) + angle: 0.0, + } } /// The text position nearest a point in layout coordinates. @@ -1188,6 +1263,20 @@ pub fn caret_at(spec: &TextSpec, byte: usize) -> Option { /// that line is used to its left or right, so a drag can continue beyond /// the ink and still select predictably. pub fn hit_test(spec: &TextSpec, x: f32, y: f32) -> Option { + if spec.path.is_some() { + return carets(spec) + .into_iter() + .min_by(|(_, a), (_, b)| { + let distance = |c: &Caret| { + let (sin, cos) = c.angle.sin_cos(); + let (dx, dy) = (x - c.x, y - c.top); + let along = (-dx * sin + dy * cos).clamp(0.0, c.height); + (dx + sin * along).hypot(dy - cos * along) + }; + distance(a).total_cmp(&distance(b)) + }) + .map(|(i, _)| i); + } let face = load_font(&spec.family, spec.bold, spec.italic)?; let laid = layout(spec, &face); let span = laid.lines.iter().min_by(|a, b| { @@ -1238,13 +1327,15 @@ pub fn rasterize(spec: &TextSpec) -> Option { }); } let faces = Faces::resolve(spec, &face); + let laid = layout(spec, &face); + let guide = path_guide(spec, &laid); let Layout { glyphs: placed, first_baseline, line_advance, layout_width, .. - } = layout(spec, &face); + } = laid; if placed.is_empty() { return Some(TextRaster { bounds: IntRect::EMPTY, @@ -1261,13 +1352,20 @@ pub fn rasterize(spec: &TextSpec) -> Option { let mut bounds = IntRect::EMPTY; for g in &placed { let (font, size) = &faces.faces[g.face]; - let (metrics, bitmap) = font.font.rasterize(g.ch, *size); + let (metrics, bitmap) = font.font.rasterize_indexed(g.glyph, *size); if metrics.width == 0 || metrics.height == 0 { continue; } - let left = (g.x + metrics.xmin as f32).floor() as i32; - let top = (g.baseline - metrics.height as f32 - metrics.ymin as f32).floor() as i32; - let rect = IntRect::from_xywh(left, top, metrics.width as u32, metrics.height as u32); + let (rect, bitmap) = if let Some(guide) = &guide { + text_path::glyph_bitmap(guide, g, first_baseline, &metrics, bitmap) + } else { + let left = (g.x + metrics.xmin as f32).floor() as i32; + let top = (g.baseline - metrics.height as f32 - metrics.ymin as f32).floor() as i32; + ( + IntRect::from_xywh(left, top, metrics.width as u32, metrics.height as u32), + bitmap, + ) + }; bounds = bounds.union(&rect); rasterized.push((rect, bitmap)); } @@ -1314,6 +1412,124 @@ pub fn rasterize(spec: &TextSpec) -> Option { mod tests { use super::*; + fn opentype_spec(text: &str) -> (TextSpec, LoadedFace) { + // The browser's OFL font is also a deterministic shaping fixture. + let data = include_bytes!("../../../web/fonts/IBMPlexSans-Regular.ttf"); + let face = LoadedFace { + font: Arc::new(fontdue::Font::from_bytes(data.as_slice(), Default::default()).unwrap()), + data: Arc::new(data.to_vec()), + index: 0, + cap_ratio: None, + }; + let mut spec = spec(text); + spec.family = "Schist OpenType test fixture".into(); + font_cache() + .lock() + .unwrap() + .insert((spec.family.clone(), false, false), Some(face.clone())); + (spec, face) + } + + #[test] + fn opentype_ligatures_and_kerning_change_the_glyph_layout() { + let (mut s, face) = opentype_spec("office AV"); + s.set_feature("liga", false); + let separate = layout(&s, &face); + s.set_feature("liga", true); + let joined = layout(&s, &face); + assert!( + joined.glyphs.len() < separate.glyphs.len(), + "liga must substitute real glyphs" + ); + for (byte, c) in carets(&s) { + assert_eq!(hit_test(&s, c.x, c.top + c.height / 2.0), Some(byte)); + } + s.set_feature("kern", false); + let unkerned = layout(&s, &face).layout_width; + s.set_feature("kern", true); + assert!(layout(&s, &face).layout_width < unkerned); + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), s); + } + + #[test] + fn shaping_keeps_utf8_carets_wrapping_and_style_runs() { + let (mut s, _) = opentype_spec("café office second line"); + s.set_feature("liga", true); + s.wrap_width = Some(180.0); + s.apply_style( + 6..12, + &StyleRun { + size: Some(56.0), + ..Default::default() + }, + ); + assert!(line_spans(&s).len() > 1); + let raster = rasterize(&s).unwrap(); + assert!(!raster.is_empty()); + for (byte, c) in carets(&s) { + assert!(s.text.is_char_boundary(byte)); + assert!(c.x.is_finite() && c.top.is_finite()); + assert_eq!(hit_test(&s, c.x, c.top + c.height / 2.0), Some(byte)); + } + } + + #[test] + fn path_rotates_ink_carets_and_hit_testing_together() { + use schist_core::path::{Anchor, SubPath}; + let (mut s, _) = opentype_spec("office"); + s.set_feature("liga", true); + let straight = rasterize(&s).unwrap(); + s.path = Some(TextPath { + curve: SubPath { + anchors: vec![Anchor::corner(100.0, 0.0), Anchor::corner(100.0, 400.0)], + closed: false, + }, + offset: 0.0, + }); + let vertical = rasterize(&s).unwrap(); + assert!((vertical.bounds.height() - straight.bounds.width()).abs() <= 2); + assert!((vertical.bounds.width() - straight.bounds.height()).abs() <= 2); + for (byte, c) in carets(&s) { + assert!((c.angle - std::f32::consts::FRAC_PI_2).abs() < 0.001); + assert_eq!(hit_test(&s, c.x - c.height / 2.0, c.top), Some(byte)); + } + s.path.as_mut().unwrap().offset = 25.0; + let shifted = rasterize(&s).unwrap(); + assert_eq!(shifted.bounds, vertical.bounds.translated(0, 25)); + s.align = Align::Right; + let end = caret_at(&s, s.text.len()).unwrap(); + assert!((end.top - 425.0).abs() < 0.01); + } + + #[test] + fn curved_and_degenerate_paths_remain_renderable() { + use schist_core::path::{Anchor, SubPath}; + let (mut s, _) = opentype_spec("Along the curve"); + s.path = Some(TextPath { + curve: SubPath { + anchors: vec![ + Anchor::smooth(0.0, 100.0, 80.0, -100.0), + Anchor::smooth(300.0, 100.0, 80.0, 100.0), + ], + closed: false, + }, + offset: 0.0, + }); + assert!(!rasterize(&s).unwrap().is_empty()); + let cursors = carets(&s); + assert!((cursors[0].1.angle - cursors.last().unwrap().1.angle).abs() > 0.1); + s.path + .as_mut() + .unwrap() + .curve + .anchors + .fill(Anchor::corner(0.0, 0.0)); + let fallback = rasterize(&s).unwrap(); + s.path = None; + assert_eq!(fallback.coverage, rasterize(&s).unwrap().coverage); + } + fn spec(text: &str) -> TextSpec { TextSpec { text: text.into(), diff --git a/crates/text-engine/src/shaping.rs b/crates/text-engine/src/shaping.rs new file mode 100644 index 00000000..618bbda4 --- /dev/null +++ b/crates/text-engine/src/shaping.rs @@ -0,0 +1,162 @@ +//! Opt-in OpenType shaping. Specs without overrides retain their original +//! layout, including imported documents whose geometry was fitted to it. +use super::*; + +struct Shaped { + glyphs: Vec, + chars: Vec, + width: f32, +} + +fn shape(spec: &TextSpec, faces: &Faces, start: usize, end: usize) -> Shaped { + // Match the legacy defaults unless the user explicitly enables ligatures. + let mut features = vec![ + rustybuzz::Feature::new(ttf_parser::Tag::from_bytes(b"liga"), 0, ..), + rustybuzz::Feature::new(ttf_parser::Tag::from_bytes(b"clig"), 0, ..), + ]; + for f in &spec.features { + if f.tag.len() == 4 && f.tag.bytes().all(|b| b.is_ascii_graphic()) { + let tag = ttf_parser::Tag::from_bytes(f.tag.as_bytes().try_into().unwrap()); + features.retain(|f| f.tag != tag); + features.push(rustybuzz::Feature::new(tag, f.value, ..)); + } + } + let mut out = Shaped { + glyphs: Vec::new(), + chars: Vec::new(), + width: 0.0, + }; + let mut at = start; + while at < end { + let ix = faces.at(at); + let run_end = spec.text[at..end] + .char_indices() + .find(|(k, _)| faces.at(at + k) != ix) + .map_or(end, |(k, _)| at + k); + let (loaded, size) = &faces.faces[ix]; + let Some(face) = rustybuzz::Face::from_slice(&loaded.data, loaded.index) else { + break; + }; + let scale = size / face.units_per_em() as f32; + let text = &spec.text[at..run_end]; + let mut buffer = rustybuzz::UnicodeBuffer::new(); + buffer.push_str(text); + buffer.guess_segment_properties(); + // The editor currently lays out horizontal LTR paragraphs. Bidi + // requires paragraph itemization as well as a glyph shaper. + buffer.set_direction(rustybuzz::Direction::LeftToRight); + let shaped = rustybuzz::shape(&face, &features, buffer); + let infos = shaped.glyph_infos(); + let positions = shaped.glyph_positions(); + let mut i = 0; + while i < infos.len() { + let cluster = infos[i].cluster as usize; + let mut next = i + 1; + while next < infos.len() && infos[next].cluster == infos[i].cluster { + next += 1; + } + let cluster_end = infos.get(next).map_or(text.len(), |g| g.cluster as usize); + let char_bytes: Vec<_> = text[cluster..cluster_end] + .char_indices() + .map(|(k, _)| at + cluster + k) + .collect(); + let x = out.width; + for j in i..next { + let pos = positions[j]; + out.glyphs.push(PlacedGlyph { + glyph: infos[j].glyph_id as u16, + x: out.width + pos.x_offset as f32 * scale, + baseline: -pos.y_offset as f32 * scale, + face: ix, + }); + out.width += pos.x_advance as f32 * scale; + } + out.width += spec.tracking * char_bytes.len() as f32; + // Distribute insertion points within a ligature. Byte offsets + // stay UTF-8 boundaries even when several characters share ink. + let count = char_bytes.len().max(1) as f32; + for (k, byte) in char_bytes.into_iter().enumerate() { + out.chars.push(CharPos { + byte, + x: x + (out.width - x) * k as f32 / count, + }); + } + i = next; + } + at = run_end; + } + out +} + +pub(super) fn layout(spec: &TextSpec, base: &LoadedFace) -> Layout { + let faces = Faces::resolve(spec, base); + let mut lines = Vec::new(); + let mut start = 0; + for paragraph in spec.text.split('\n') { + let mut line_start = start; + let mut at = start; + let mut width = 0.0; + for word in paragraph.split_inclusive(' ') { + let word_width = shape(spec, &faces, at, at + word.len()).width; + if spec.path.is_none() + && spec + .wrap_width + .is_some_and(|w| at > line_start && width + word_width > w) + { + lines.push((line_start, at, shape(spec, &faces, line_start, at))); + line_start = at; + width = 0.0; + } + width += word_width; + at += word.len(); + } + lines.push((line_start, at, shape(spec, &faces, line_start, at))); + start = at + 1; + } + let max_width = lines.iter().map(|(_, _, l)| l.width).fold(0.0f32, f32::max); + let mut out = Layout { + glyphs: Vec::new(), + chars: Vec::new(), + lines: Vec::new(), + first_baseline: 0.0, + line_advance: 0.0, + layout_width: max_width, + }; + let mut top = 0.0; + for (i, (start, end, mut line)) in lines.into_iter().enumerate() { + let (ascent, step) = spec.text[start..end] + .char_indices() + .map(|(k, _)| faces.line_metrics(faces.at(start + k))) + .reduce(|(a, h), (b, j)| (a.max(b), h.max(j))) + .unwrap_or_else(|| faces.line_metrics(0)); + let height = step * spec.line_height.max(0.1); + if i == 0 { + out.first_baseline = ascent; + out.line_advance = height; + } + let x = match spec.align { + Align::Left => 0.0, + Align::Center => (max_width - line.width) / 2.0, + Align::Right => max_width - line.width, + }; + for g in &mut line.glyphs { + g.x += x; + g.baseline += top + ascent; + } + for c in &mut line.chars { + c.x += x; + } + out.glyphs.extend(line.glyphs); + out.chars.extend(line.chars); + out.lines.push(LineSpan { + start, + end, + x, + width: line.width, + top, + height, + }); + top += height; + } + out +} diff --git a/crates/text-engine/src/text_path.rs b/crates/text-engine/src/text_path.rs new file mode 100644 index 00000000..9cb81253 --- /dev/null +++ b/crates/text-engine/src/text_path.rs @@ -0,0 +1,187 @@ +use super::*; +use schist_core::path::SubPath; + +/// An independent copy of one vector subpath, used as the text's baseline. +/// Coordinates are relative to the text layer's origin, so moving the layer +/// moves the path too. Extra lines sit at their usual distance below it. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct TextPath { + pub curve: SubPath, + #[serde(default)] + pub offset: f32, +} + +pub(super) struct Guide { + points: Vec<(f32, f32)>, + distances: Vec, + offset: f32, +} + +impl Guide { + pub fn new(path: &TextPath, align: Align, width: f32) -> Option { + let anchors = &path.curve.anchors; + if anchors.len() < 2 + || !path.offset.is_finite() + || anchors.iter().any(|a| { + [a.point, a.handle_in, a.handle_out] + .into_iter() + .any(|(x, y)| !x.is_finite() || !y.is_finite()) + }) + { + return None; + } + let mut builder = schist_vector::PathBuilder::new(); + builder.move_to(anchors[0].point.0, anchors[0].point.1); + let count = anchors.len() - usize::from(!path.curve.closed); + for i in 0..count { + let a = anchors[i]; + let b = anchors[(i + 1) % anchors.len()]; + builder.cubic_to( + a.point.0 + a.handle_out.0, + a.point.1 + a.handle_out.1, + b.point.0 + b.handle_in.0, + b.point.1 + b.handle_in.1, + b.point.0, + b.point.1, + ); + } + let flat = builder.build(0.1); + let mut points = flat.subpaths.into_iter().next()?; + points.dedup_by(|a, b| (a.0 - b.0).hypot(a.1 - b.1) < 1e-5); + if points.len() < 2 { + return None; + } + let mut distances = vec![0.0]; + for pair in points.windows(2) { + distances + .push(distances.last()? + (pair[1].0 - pair[0].0).hypot(pair[1].1 - pair[0].1)); + } + let length = *distances.last()?; + if !length.is_finite() || length <= 0.0 { + return None; + } + let offset = path.offset + + match align { + Align::Left => 0.0, + Align::Center => (length - width) / 2.0, + Align::Right => length - width, + }; + Some(Self { + points, + distances, + offset, + }) + } + + /// Extrapolate past an endpoint along its tangent, so overflow stays + /// editable instead of piling glyphs on the last point of the curve. + pub fn at(&self, x: f32, y: f32) -> (f32, f32, f32) { + let distance = x + self.offset; + let i = self + .distances + .partition_point(|d| *d <= distance) + .saturating_sub(1) + .min(self.points.len() - 2); + let a = self.points[i]; + let b = self.points[i + 1]; + let length = self.distances[i + 1] - self.distances[i]; + let (tx, ty) = ((b.0 - a.0) / length, (b.1 - a.1) / length); + let along = distance - self.distances[i]; + ( + a.0 + tx * along - ty * y, + a.1 + ty * along + tx * y, + ty.atan2(tx), + ) + } + + pub fn caret(&self, mut caret: Caret, baseline: f32) -> Caret { + let (x, y, angle) = self.at(caret.x, caret.top - baseline); + caret.x = x; + caret.top = y; + caret.angle = angle; + caret + } +} + +/// Rotate an already rasterized glyph about its baseline origin. Inverse +/// bilinear sampling retains antialiasing without holes from forward splats. +pub(super) fn glyph_bitmap( + guide: &Guide, + glyph: &PlacedGlyph, + baseline: f32, + metrics: &fontdue::Metrics, + bitmap: Vec, +) -> (IntRect, Vec) { + let center = metrics.advance_width / 2.0; + let (px, py, angle) = guide.at(glyph.x + center, glyph.baseline - baseline); + let (sin, cos) = angle.sin_cos(); + // Cardinal rotations should preserve integer translations exactly; + // sin/cos otherwise leave tiny residuals that add an empty border. + let snap = |v: f32| { + if (v - v.round()).abs() < 1e-4 { + v.round() + } else { + v + } + }; + let (sin, cos) = (snap(sin), snap(cos)); + let left = metrics.xmin as f32 - center; + let top = -(metrics.height as f32) - metrics.ymin as f32; + let transform = |x: f32, y: f32| (px + x * cos - y * sin, py + x * sin + y * cos); + let corners = [ + (left, top), + (left + metrics.width as f32, top), + (left, top + metrics.height as f32), + (left + metrics.width as f32, top + metrics.height as f32), + ] + .map(|(x, y)| { + let (x, y) = transform(x, y); + (snap(x), snap(y)) + }); + let min_x = corners + .iter() + .map(|p| p.0) + .fold(f32::INFINITY, f32::min) + .floor() as i32; + let min_y = corners + .iter() + .map(|p| p.1) + .fold(f32::INFINITY, f32::min) + .floor() as i32; + let max_x = corners + .iter() + .map(|p| p.0) + .fold(f32::NEG_INFINITY, f32::max) + .ceil() as i32; + let max_y = corners + .iter() + .map(|p| p.1) + .fold(f32::NEG_INFINITY, f32::max) + .ceil() as i32; + let bounds = IntRect::new(min_x, min_y, max_x, max_y); + let mut out = vec![0; bounds.width() as usize * bounds.height() as usize]; + for y in min_y..max_y { + for x in min_x..max_x { + let (dx, dy) = (x as f32 + 0.5 - px, y as f32 + 0.5 - py); + let (sx, sy) = ( + dx * cos + dy * sin - left - 0.5, + -dx * sin + dy * cos - top - 0.5, + ); + let (ix, iy) = (sx.floor() as i32, sy.floor() as i32); + let (fx, fy) = (sx - sx.floor(), sy - sy.floor()); + let mut value = 0.0; + for (ox, wx) in [(0, 1.0 - fx), (1, fx)] { + for (oy, wy) in [(0, 1.0 - fy), (1, fy)] { + let (bx, by) = (ix + ox, iy + oy); + if bx >= 0 && by >= 0 && bx < metrics.width as i32 && by < metrics.height as i32 + { + value += bitmap[by as usize * metrics.width + bx as usize] as f32 * wx * wy; + } + } + } + out[(y - min_y) as usize * bounds.width() as usize + (x - min_x) as usize] = + value.round() as u8; + } + } + (bounds, out) +} diff --git a/docs/native-colour-editing.md b/docs/native-colour-editing.md new file mode 100644 index 00000000..e4788fa9 --- /dev/null +++ b/docs/native-colour-editing.md @@ -0,0 +1,33 @@ +# Native CMYK and Lab editing: unresolved README item + +The editor still edits RGB pixels in CMYK and Lab documents. This item +cannot be completed as an isolated channel-panel or colour-conversion +change: the current storage and editing contracts do not retain the native +channels needed for it. + +The constraints in the current implementation are concrete: + +- `crates/core/src/tile.rs` stores four interleaved RGBA components in + `TileBuf`, and its pixel API returns `Rgba`. CMYK with transparency needs + five independent components. Lab also needs its own channel meanings + and ranges, rather than RGB components bearing different labels. +- `crates/codec-psd/src/reader/layers.rs` converts CMYK/Lab planes to RGB + before populating the editable tiles. The writer converts edited RGB + back into the requested file mode. Different CMYK separations can + produce the same RGB colour, so that conversion cannot recover the + original individual inks. +- Painting, adjustments, filters, blending, GPU shaders, plugin buffers + and display transforms consume RGB/RGBA. Reinterpreting existing tiles + as native channels would change their behaviour throughout the editor. + +A correct implementation needs native channel storage and colour-space +metadata, matching edit/undo and serialization support, and an explicit +boundary for tools and plugins that operate in RGB. Native channels must +remain authoritative through import, edits, compositing and export; +rendered RGB is a display or processing representation. It also needs +channel-selection UI and regression fixtures for independent CMYK ink +edits, Lab channel edits, alpha, profiles, undo and save/reopen at each +supported depth. + +This migration has not been implemented. The README retains the limitation +instead of claiming that RGB-derived channel controls provide native edits. diff --git a/docs/text.md b/docs/text.md new file mode 100644 index 00000000..67502c87 --- /dev/null +++ b/docs/text.md @@ -0,0 +1,50 @@ +# Text layers + +The Type tool has a single-row options bar for font family, style, size, +alignment and colour, followed by the Character panel button and text edit +cancel/commit buttons. Click a numeric value to type it; Up/Down adjusts it +and Shift takes larger steps. Enter finishes the field and Escape releases +it without cancelling the text edit. + +The **Character** tab opens in the right sidebar when you select Type. It +contains leading, tracking, and **Kerning** (AV), **Ligatures** (fi), +**Discretionary ligatures** (st), and **Small caps** (Tt). Hover the buttons +for their names. These are OpenType features +from the selected font; a font without a requested feature keeps its +ordinary glyphs. They apply to the whole text layer, including its style +runs. Font, style and size can still be changed for selected characters. + +Existing text keeps its previous layout until an OpenType control is used. +New overrides use rustybuzz for glyph substitution and positioning. The +serialized `TextSpec.features` also accepts four-byte OpenType tags and +numeric values, including numbered stylistic sets and alternate glyphs. +Paragraph layout remains horizontal and left-to-right; bidi paragraph +layout and vertical writing are separate remaining work. + +To set text on a curve: + +1. Draw a path with Freeform Pen or Curvature Pen, or select a live shape + with Path Selection. The ordinary Pen can create a live shape in its + **Shape** mode. +2. Switch to Type and choose **On path** under **Text on a path** in the + Character panel, before creating text or while editing an existing layer. +3. Use **Path offset** to move the text along the baseline. Alignment places + it at the start, centre or end of the path. + +Text uses a copy of the active path's first subpath with at least two +anchors. As with Path Selection, an active live shape takes precedence +over a separately stored path. Later edits to the source path do not change that copy; toggle +**Straight** and then **On path** to take a fresh copy. The baseline stays with +the text layer. Glyphs rotate along the curve, and the insertion caret and +mouse selection follow it. Extra lines keep their normal line spacing; +word wrapping is disabled on paths. Text beyond either end continues +along the endpoint's tangent. A degenerate path uses ordinary text layout. + +Choose **Straight** to return to the layer's ordinary layout box. +Text, paths and feature settings participate in the existing text-edit +undo operation and survive PSD and PSB save/reopen in Schist's `PsTx` +layer block. Other editors see the rendered pixels, as with other Schist +text layers. + +`make check-text` runs the layout, editing, persistence and Affinity import +regression tests. diff --git a/plugins/tools-type/Cargo.toml b/plugins/tools-type/Cargo.toml index 3a0b0b82..cc055b8f 100644 --- a/plugins/tools-type/Cargo.toml +++ b/plugins/tools-type/Cargo.toml @@ -12,3 +12,6 @@ schist-text-engine.workspace = true serde.workspace = true serde_json.workspace = true log.workspace = true + +[dev-dependencies] +schist-codec-psd.workspace = true diff --git a/plugins/tools-type/src/lib.rs b/plugins/tools-type/src/lib.rs index 04806d7c..77e74ee0 100644 --- a/plugins/tools-type/src/lib.rs +++ b/plugins/tools-type/src/lib.rs @@ -16,7 +16,7 @@ use schist_plugin_api::{ EditorState, Modifiers, OptionValue, Overlay, PluginManifest, PluginRegistry, PointerInput, ToolCtx, ToolOption, ToolPlugin, }; -use schist_text_engine::{hit_test, line_spans, rasterize, Align, StyleRun, TextSpec}; +use schist_text_engine::{hit_test, line_spans, rasterize, Align, StyleRun, TextPath, TextSpec}; /// Additional-layer-info key under which the text spec is preserved. pub const TEXT_BLOCK_KEY: [u8; 4] = *b"PsTx"; @@ -111,6 +111,14 @@ fn render_tiles(doc: &Document, stored: &StoredText) -> (TileMap, IntRect) { /// descender. Editing chrome belongs to the complete line box instead, so /// the insertion caret cannot protrude through its outline. fn layout_bounds(stored: &StoredText) -> IntRect { + if stored.spec.path.is_some() { + return schist_text_engine::carets(&stored.spec) + .into_iter() + .fold(IntRect::EMPTY, |bounds, (_, c)| { + bounds.union(&caret_rect(c)) + }) + .translated(stored.origin.0, stored.origin.1); + } let mut spans = line_spans(&stored.spec).into_iter(); let Some(first) = spans.next() else { return IntRect::EMPTY; @@ -137,6 +145,17 @@ fn layout_bounds(stored: &StoredText) -> IntRect { IntRect::new(left, top, right, bottom) } +fn caret_rect(c: schist_text_engine::Caret) -> IntRect { + let end_x = c.x - c.angle.sin() * c.height; + let end_y = c.top + c.angle.cos() * c.height; + IntRect::new( + c.x.min(end_x).floor() as i32, + c.top.min(end_y).floor() as i32, + c.x.max(end_x).ceil() as i32 + 1, + c.top.max(end_y).ceil() as i32 + 1, + ) +} + /// Every font family the document's text layers ask for, in the order /// first seen and without repeats. pub fn families_used(doc: &Document) -> Vec { @@ -258,6 +277,7 @@ pub struct TypeTool { /// nothing is being edited. Editing a layer adopts its spec, so the /// bar always describes the text you are looking at. spec: TextSpec, + use_path: bool, } impl TypeTool { @@ -284,14 +304,20 @@ impl TypeTool { } fn start_new(&mut self, ctx: &mut ToolCtx, x: f32, y: f32) { - let stored = StoredText { + let mut stored = StoredText { spec: TextSpec { text: String::new(), + path: None, ..self.spec.clone() }, origin: (x.round() as i32, y.round() as i32), color: ctx.state.foreground.to_u8(), }; + if self.use_path { + stored.spec.path = active_text_path(ctx.doc, stored.origin); + } + self.spec.path = stored.spec.path.clone(); + self.use_path = self.spec.path.is_some(); let mut layer = Layer::new_raster("Text"); write_stored(&mut layer, &stored); let id = layer.id; @@ -363,6 +389,11 @@ impl TypeTool { /// pixels alone. fn contains_text(stored: &StoredText, x: f32, y: f32) -> bool { const SLOP: f32 = 4.0; + if stored.spec.path.is_some() { + return layout_bounds(stored) + .inflated(SLOP as i32) + .contains(x as i32, y as i32); + } let x = x - stored.origin.0 as f32; let y = y - stored.origin.1 as f32; line_spans(&stored.spec).iter().any(|line| { @@ -417,6 +448,22 @@ fn display_name(text: &str) -> String { } } +fn active_text_path(doc: &Document, origin: (i32, i32)) -> Option { + // Match Path Selection: the active live shape takes precedence over + // a separately stored path. + let path = doc + .active_layer + .and_then(|id| doc.tree.find(id)) + .and_then(|layer| layer.shape.as_deref()) + .map(|shape| &shape.path) + .or_else(|| doc.active_path.and_then(|i| doc.paths.get(i)))?; + let mut curve = path.subpaths.iter().find(|s| s.anchors.len() >= 2)?.clone(); + for anchor in &mut curve.anchors { + *anchor = anchor.translated(-(origin.0 as f32), -(origin.1 as f32)); + } + Some(TextPath { curve, offset: 0.0 }) +} + impl ToolPlugin for TypeTool { fn id(&self) -> &'static str { "type" @@ -479,6 +526,7 @@ impl ToolPlugin for TypeTool { runs: Vec::new(), ..stored.spec.clone() }; + self.use_path = stored.spec.path.is_some(); let local_x = input.x - stored.origin.0 as f32; let local_y = input.y - stored.origin.1 as f32; let at = hit_test(&stored.spec, local_x, local_y).unwrap_or(stored.spec.text.len()); @@ -677,6 +725,23 @@ impl ToolPlugin for TypeTool { 80.0, " px", ), + ToolOption::toggle("type-kern", "Kerning", self.spec.feature("kern", true)), + ToolOption::toggle("type-liga", "Ligatures", self.spec.feature("liga", false)), + ToolOption::toggle( + "type-dlig", + "Discretionary ligatures", + self.spec.feature("dlig", false), + ), + ToolOption::toggle("type-smcp", "Small caps", self.spec.feature("smcp", false)), + ToolOption::toggle("type-path", "On active path", self.use_path), + ToolOption::slider( + "type-path-offset", + "Path offset", + self.spec.path.as_ref().map_or(0.0, |p| p.offset), + -2000.0, + 2000.0, + " px", + ), ] } @@ -702,6 +767,15 @@ impl ToolPlugin for TypeTool { } "type-leading" => self.spec.line_height = value.num().clamp(0.5, 3.0), "type-tracking" => self.spec.tracking = value.num(), + "type-kern" | "type-liga" | "type-dlig" | "type-smcp" => { + self.spec.set_feature(&key[5..], value.bool()); + } + "type-path" => self.use_path = value.bool(), + "type-path-offset" => { + if let Some(path) = &mut self.spec.path { + path.offset = value.num(); + } + } _ => {} } } @@ -717,6 +791,15 @@ impl ToolPlugin for TypeTool { let Some(session) = &mut self.editing else { return; }; + if key == "type-path" { + session.stored.spec.path = if self.use_path { + active_text_path(ctx.doc, session.stored.origin) + } else { + None + }; + self.spec.path = session.stored.spec.path.clone(); + self.use_path = self.spec.path.is_some(); + } let over = match key { "type-family" => StyleRun { family: Some(self.spec.family.clone()), @@ -736,6 +819,10 @@ impl ToolPlugin for TypeTool { spec.align = self.spec.align; spec.line_height = self.spec.line_height; spec.tracking = self.spec.tracking; + spec.features = self.spec.features.clone(); + if key == "type-path-offset" { + spec.path = self.spec.path.clone(); + } StyleRun::default() } }; @@ -854,7 +941,19 @@ impl ToolPlugin for TypeTool { out.push(Overlay::Rect(outline_bounds.inflated(2))); } - if session.has_selection() { + if session.has_selection() && spec.path.is_some() { + let carets = schist_text_engine::carets(spec); + let range = session.selection(); + for pair in carets.windows(2) { + if range.contains(&pair[0].0) { + out.push(Overlay::Highlight( + caret_rect(pair[0].1) + .union(&caret_rect(pair[1].1)) + .translated(ox as i32, oy as i32), + )); + } + } + } else if session.has_selection() { let range = session.selection(); for span in schist_text_engine::line_spans(spec) { let from = range.start.max(span.start); @@ -894,8 +993,8 @@ impl ToolPlugin for TypeTool { out.push(Overlay::Caret { x1: x, y1: y, - x2: x, - y2: y + caret.height, + x2: x - caret.angle.sin() * caret.height, + y2: y + caret.angle.cos() * caret.height, color: Rgba::from_u8( session.stored.color[0], session.stored.color[1], @@ -1210,6 +1309,146 @@ mod tests { } } + #[test] + fn path_and_features_survive_psd_reopen_and_undo() { + let mut d = doc(); + let mut path = schist_core::path::VectorPath::new("Baseline"); + path.push_open_anchors(vec![ + schist_core::path::Anchor::corner(100.0, 40.0), + schist_core::path::Anchor::corner(100.0, 190.0), + ]); + d.paths.push(path); + d.active_path = Some(0); + let mut state = EditorState::default(); + let mut tool = TypeTool::default(); + let mut ctx = ToolCtx { + doc: &mut d, + state: &mut state, + }; + tool.set_option("type-path", OptionValue::Bool(true)); + tool.on_pointer_down(&mut ctx, input(100.0, 40.0)); + type_text(&mut tool, &mut ctx, "office"); + for (key, value) in [ + ("type-liga", OptionValue::Bool(true)), + ("type-path-offset", OptionValue::Num(12.0)), + ] { + tool.set_option(key, value); + tool.on_option_changed(&mut ctx, key); + } + let stored = tool.editing.as_ref().unwrap().stored.clone(); + assert_eq!(stored.spec.path.as_ref().unwrap().offset, 12.0); + assert!(stored.spec.feature("liga", false)); + let caret = tool + .overlays(ctx.doc, ctx.state) + .into_iter() + .find_map(|o| match o { + Overlay::Caret { x1, y1, x2, y2, .. } => Some((x1, y1, x2, y2)), + _ => None, + }) + .unwrap(); + assert!( + (caret.1 - caret.3).abs() < 0.001, + "vertical path needs a horizontal caret" + ); + assert!((caret.0 - caret.2).abs() > 10.0); + tool.on_commit(&mut ctx); + for psb in [false, true] { + let bytes = schist_codec_psd::write_psd_with(ctx.doc, psb).unwrap(); + let reopened = schist_codec_psd::read_psd(&bytes).unwrap(); + let back = reopened.tree.iter().find_map(read_stored).unwrap(); + assert_eq!(back.spec, stored.spec); + let before = render_tiles(ctx.doc, &stored).1; + assert_eq!(render_tiles(&reopened, &back).1, before); + } + // Editing the same layer is a single undoable change to metadata + // and pixels, including toggling the baseline back off. + let c = schist_text_engine::caret_at(&stored.spec, 0).unwrap(); + tool.on_pointer_down( + &mut ctx, + input(c.x + stored.origin.0 as f32, c.top + stored.origin.1 as f32), + ); + tool.set_option("type-path", OptionValue::Bool(false)); + tool.on_option_changed(&mut ctx, "type-path"); + tool.on_commit(&mut ctx); + assert!(ctx + .doc + .tree + .iter() + .find_map(read_stored) + .unwrap() + .spec + .path + .is_none()); + ctx.doc.undo(); + assert_eq!( + ctx.doc.tree.iter().find_map(read_stored).unwrap().spec, + stored.spec + ); + } + + #[test] + fn moving_a_text_layer_moves_its_editable_origin_and_undo_restores_bytes() { + let mut d = doc(); + let mut state = EditorState::default(); + let mut tool = TypeTool::default(); + let mut ctx = ToolCtx { + doc: &mut d, + state: &mut state, + }; + tool.on_pointer_down(&mut ctx, input(20.0, 40.0)); + type_text(&mut tool, &mut ctx, "Move me"); + tool.on_commit(&mut ctx); + let id = ctx.doc.active_layer.unwrap(); + let before = read_stored(ctx.doc.tree.find(id).unwrap()).unwrap(); + let extras = ctx.doc.tree.find(id).unwrap().extras.clone(); + let mut edit = ctx.doc.begin_edit("Move text"); + edit.translate_layer(id, 31, -13); + edit.commit(); + let moved = read_stored(ctx.doc.tree.find(id).unwrap()).unwrap(); + assert_eq!(moved.origin, (before.origin.0 + 31, before.origin.1 - 13)); + let (rendered, _) = render_tiles(ctx.doc, &moved); + let pixels = &ctx.doc.tree.find(id).unwrap().as_raster().unwrap().tiles; + assert_eq!(rendered.content_bounds(), pixels.content_bounds()); + for coord in TileCoord::covering(&rendered.content_bounds()) { + assert_eq!(rendered.get(coord), pixels.get(coord)); + } + ctx.doc.undo(); + assert_eq!(ctx.doc.tree.find(id).unwrap().extras, extras); + ctx.doc.redo(); + assert_eq!( + read_stored(ctx.doc.tree.find(id).unwrap()).unwrap().origin, + moved.origin + ); + } + + #[test] + fn text_uses_the_same_live_shape_as_path_selection() { + let mut d = doc(); + let mut path = schist_core::VectorPath::new("Shape baseline"); + path.push_open_anchors(vec![ + schist_core::Anchor::corner(20.0, 30.0), + schist_core::Anchor::corner(120.0, 80.0), + ]); + d.tree.find_mut(d.active_layer.unwrap()).unwrap().shape = Some(Box::new( + schist_core::VectorShape::new(path.clone(), Rgba::BLACK), + )); + d.paths.push(schist_core::VectorPath::new("Unrelated path")); + d.active_path = Some(0); + let copied = active_text_path(&d, (10, 15)).unwrap(); + assert_eq!(copied.curve.anchors[0].point, (10.0, 15.0)); + assert_eq!(copied.curve.anchors[1].point, (110.0, 65.0)); + assert_eq!( + d.tree + .find(d.active_layer.unwrap()) + .unwrap() + .shape + .as_ref() + .unwrap() + .path, + path + ); + } + #[test] fn the_options_bar_settings_reach_the_text() { let mut d = doc();