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();