Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions crates/darkly/presets/gimp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,14 @@ mouse_clicks:
# isolateLayer mouse_click: GIMP has no thumbnail-modifier equivalent,
# so intentionally unbound here. The hotkey form (KeyI above) is the
# GIMP-native path.
# maskToSelection mouse_click: GIMP's native gesture is Alt+click
# (gimpitemtreeview.c → gimp_modifiers_to_channel_op), but Darkly's
# alt+click thumbnail slot is isolate's. GIMP itself ships no keyboard
# accelerator for Mask→Selection (layers-actions.c: NULL accel), so this
# is intentionally unbound here — reachable via the mask context menu and
# the command palette, faithful to GIMP's menu-only surface.
# maskToSelection / alphaToSelection mouse_clicks: GIMP's native gesture for
# both is Alt+click on the item thumbnail (gimpitemtreeview.c →
# gimp_modifiers_to_channel_op), but Darkly's alt+click thumbnail slot is
# isolate's. GIMP itself ships no keyboard accelerator for either
# (layers-actions.c: NULL accel on layers-mask-selection-* and
# layers-alpha-selection-*), so both are intentionally unbound here —
# reachable via the layer/mask context menus and the command palette,
# faithful to GIMP's menu-only surface.

settings:
tools.colorPickerSampleSource: merged
6 changes: 6 additions & 0 deletions crates/darkly/presets/krita.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,13 @@ mouse_clicks:
# Krita loads a mask/alpha as a selection with Ctrl+click on the thumbnail
# (NodeDelegate.cpp → SelectOpaqueRole). $mod maps to Ctrl on Linux/Win,
# Cmd on macOS. alt+click is taken by isolate above.
#
# Krita routes both thumbnails through the one Select Opaque path
# (kis_selection_manager.cc → selectOpaqueOnNode reads the node's
# projection opacity). Darkly splits them by which op is cheaper: a mask
# is an R8 clone, a layer needs its alpha read back.
maskToSelection: maskThumb:$mod+click
alphaToSelection: layerThumb:$mod+click

settings:
tools.colorPickerSampleSource: merged
2 changes: 2 additions & 0 deletions crates/darkly/presets/photoshop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,11 @@ mouse_clicks:
- layerThumb:alt+click
- maskThumb:alt+click
# Photoshop loads a mask as a selection with Ctrl/Cmd+click on the mask
# thumbnail, and the layer's transparency with the same chord on the layer
# thumbnail. $mod maps to Ctrl on Linux/Win, Cmd on macOS. alt+click is
# taken by isolate above.
maskToSelection: maskThumb:$mod+click
alphaToSelection: layerThumb:$mod+click

settings:
# Photoshop's eyedropper samples the current layer by default.
Expand Down
8 changes: 7 additions & 1 deletion crates/darkly/shaders/brush/_prelude.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ struct IntrinsicUniforms {
// uniforms that follow `intrinsic` in the generated `Uniforms` struct keep
// their 16-byte alignment. Adding `canvas_origin` above pushed the size to
// 56; without this pad the node params would misalign and read garbage.
_pad0: u32,
// How many dabs land on a given texel as the brush passes over it
// once: `diameter / spacing`. Stroke-constant, published by the stroke
// engine, which owns the spacing that produced it. A terminal
// accumulating a per-dab quantity divides by this to express its rate
// per *pass* rather than per dab — otherwise the knob's meaning moves
// with the spacing setting and with pressure. 1.0 when unset.
dabs_per_pass: f32,
_pad1: u32,
_pad2: u32,
};
18 changes: 18 additions & 0 deletions crates/darkly/src/actions/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ const ACTIONS: &[ActionDef] = &[
description: "Add a new layer above the active one.",
icon: "fa6-solid:square-plus",
},
ActionDef {
id: "newFilterLayer",
display_name: "New Filter Layer",
description: "Add a non-destructive filter layer (curves, levels, invert, …) above the active one.",
icon: "fa6-solid:circle-half-stroke",
},
ActionDef {
id: "newVeil",
display_name: "New Veil",
description: "Add a veil — a post-process effect (rainy glass, VHS, grain, …) over the whole canvas.",
icon: "material-symbols:curtains-rounded",
},
ActionDef {
id: "newVoid",
display_name: "New Void",
description: "Add a void — a layer filled from a procedural or live source (noise, camera, screen share, …).",
icon: "tabler:galaxy",
},
ActionDef {
id: "newGroup",
display_name: "New Group",
Expand Down
6 changes: 6 additions & 0 deletions crates/darkly/src/actions/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ const ACTIONS: &[ActionDef] = &[
description: "Load the active layer's mask as the selection.",
icon: "radix-icons:mask-off",
},
ActionDef {
id: "alphaToSelection",
display_name: "Alpha to Selection",
description: "Load the layer's opacity as the selection.",
icon: "fa6-solid:clone",
},
ActionDef {
id: "clearSelectionContents",
display_name: "Clear Selection Contents",
Expand Down
133 changes: 114 additions & 19 deletions crates/darkly/src/brush/checkpoint_ring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,19 @@ struct CheckpointSlot {
tex_w: u32,
tex_h: u32,
/// Format the slot was allocated in. The ring snapshots the stroke
/// scratch, whose format is the terminal's business — colour for most
/// scratch, whose format is the terminal's business — color for most
/// brushes, a float displacement field for warp terminals — and
/// `copy_texture_to_texture` requires the two to match.
tex_format: wgpu::TextureFormat,
/// Snapshots of the stroke's channels, parallel to
/// `Scratch::channel_textures`. Empty for terminals that declare none.
///
/// Captured and restored with the stroke buffer because a channel is
/// what dabs *read* to decide what to deposit. Rewinding the pixels
/// but not the channel would leave it holding contributions from
/// discarded dabs, and the dabs replayed over those pixels would read
/// values describing a stroke that no longer exists.
extra: Vec<wgpu::Texture>,
/// The bbox region this checkpoint covers, in canvas pixel coords.
/// Stable across mid-stroke layer growth.
canvas_bbox: CanvasRect,
Expand Down Expand Up @@ -68,40 +77,64 @@ impl CheckpointSlot {
dab_count: 0,
},
valid: false,
extra: Vec::new(),
}
}

/// Ensure the texture is at least `w × h` and in `format`.
/// Reallocate if needed — including on a format change, since a
/// slot cached from a colour stroke cannot receive a warp field.
/// Ensure the stroke-buffer snapshot is at least `w × h` and in
/// `format`, plus one channel snapshot per entry in `extra_formats`.
/// Reallocate if needed — including on a format change, since a slot
/// cached from a color stroke cannot receive a warp field. The whole
/// set is reallocated together so a slot's snapshots always share
/// dimensions.
fn ensure_texture(
&mut self,
device: &wgpu::Device,
w: u32,
h: u32,
format: wgpu::TextureFormat,
extra_formats: &[wgpu::TextureFormat],
) {
if self.tex_w >= w && self.tex_h >= h && self.tex_format == format && self.texture.is_some()
// Slots outlive strokes — `clear()` only flips `valid` — so a slot
// allocated for one terminal is reused by the next. Comparing the
// formats, not just the count, is what stops a `paint` stroke's
// empty slot (or a differently-typed channel set) being reused as
// though it held this terminal's snapshots.
let formats_match = self.extra.len() == extra_formats.len()
&& self
.extra
.iter()
.zip(extra_formats)
.all(|(t, f)| t.format() == *f);
if self.tex_w >= w
&& self.tex_h >= h
&& self.tex_format == format
&& self.texture.is_some()
&& formats_match
{
return;
}
// Allocate with some headroom to reduce reallocation frequency.
let alloc_w = w.next_power_of_two().max(64);
let alloc_h = h.next_power_of_two().max(64);
self.texture = Some(device.create_texture(&wgpu::TextureDescriptor {
label: Some("checkpoint-slot"),
size: wgpu::Extent3d {
width: alloc_w,
height: alloc_h,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
}));
let make = |format: wgpu::TextureFormat| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("checkpoint-slot"),
size: wgpu::Extent3d {
width: alloc_w,
height: alloc_h,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
};
self.texture = Some(make(format));
self.extra = extra_formats.iter().copied().map(make).collect();
self.tex_w = alloc_w;
self.tex_h = alloc_h;
self.tex_format = format;
Expand Down Expand Up @@ -256,6 +289,7 @@ impl CheckpointRing {
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
stroke: &CanvasFrame<'_>,
extra: &[&wgpu::Texture],
save_point_index: usize,
vector_index: usize,
canvas_bbox: CanvasRect,
Expand All @@ -274,13 +308,15 @@ impl CheckpointRing {
None => return,
};

let extra_formats: Vec<wgpu::TextureFormat> = extra.iter().map(|t| t.format()).collect();
let slot_idx = self.pick_slot(tip_vi, max_div_window, vector_index);
let slot = &mut self.slots[slot_idx];
slot.ensure_texture(
device,
layer_rect.width,
layer_rect.height,
stroke.texture.format(),
&extra_formats,
);
slot.canvas_bbox = clipped_canvas;
slot.save_point_index = save_point_index;
Expand Down Expand Up @@ -313,6 +349,35 @@ impl CheckpointRing {
},
);

// Same region, same coordinates — the accumulators are layer-sized
// and grown in lockstep with the stroke buffer, so one rect
// addresses all of them.
for (src, dst) in extra.iter().zip(slot.extra.iter()) {
encoder.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: src,
mip_level: 0,
origin: wgpu::Origin3d {
x: layer_rect.x0(),
y: layer_rect.y0(),
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: dst,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d {
width: layer_rect.width,
height: layer_rect.height,
depth_or_array_layers: 1,
},
);
}

// Coverage invariant: after every save, at least one valid slot
// must sit at or below the divergence boundary. If this fires, the
// eviction policy lost the anchor or the stabilizer's bound was
Expand Down Expand Up @@ -397,6 +462,7 @@ impl CheckpointRing {
&self,
encoder: &mut wgpu::CommandEncoder,
stroke: &CanvasFrame<'_>,
extra: &[&wgpu::Texture],
div_vector_index: usize,
) -> Option<CheckpointRestore> {
let slot_idx = self.best_slot_before(div_vector_index)?;
Expand Down Expand Up @@ -433,6 +499,35 @@ impl CheckpointRing {
},
);

// Restore the accumulators the stroke buffer's pixels were
// derived from, or the next resolve recomputes this region from
// state describing dabs that were just discarded.
for (dst, src) in extra.iter().zip(slot.extra.iter()) {
encoder.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: src,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: dst,
mip_level: 0,
origin: wgpu::Origin3d {
x: layer_rect.x0(),
y: layer_rect.y0(),
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d {
width: layer_rect.width,
height: layer_rect.height,
depth_or_array_layers: 1,
},
);
}

Some(CheckpointRestore {
save_point_index: slot.save_point_index,
vector_index: slot.vector_index,
Expand Down
Loading
Loading