fix(type): recolour selected type from the Color and Swatches panels (#364)

With characters selected while editing type, a new foreground colour from
the Color panel, the Swatches or the Color Picker now recolours them, as in
Photoshop, within the editing session's history step. The HSB sliders now
act only on an edit: their h/s/v round trip isn't exact, so comparing
values rewrote the foreground every frame.
This commit is contained in:
reese
2026-10-07 06:52:12 -04:00
committed by GitHub
parent 68831a4336
commit 0b7cd68f8b
4 changed files with 125 additions and 5 deletions
+5 -1
View File
@@ -406,7 +406,11 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
p["color"] = json!(color);
return app.run(cmd, p);
}
app.run("tools.setColors", json!({ target: color }))
let r = app.run("tools.setColors", json!({ target: color }))?;
if target == "foreground" {
crate::type_tool::foreground_changed(app);
}
Ok(r)
}
#[cfg(test)]
+65 -4
View File
@@ -1198,6 +1198,7 @@ fn swatches(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let c = [s[0] as f32 / 255.0, s[1] as f32 / 255.0, s[2] as f32 / 255.0, 1.0];
if resp.clicked() {
app.session.tools.foreground = c;
crate::type_tool::foreground_changed(app);
}
if resp.secondary_clicked() {
app.session.tools.background = c;
@@ -1218,16 +1219,19 @@ fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let mut s = hsva0.s * 100.0;
let mut v = hsva0.v * 100.0;
let hue = widgets::hue_stops();
widgets::slider_row(ui, tl!("Hue"), &mut h, 0.0..=360.0, "°", Some(&hue));
let mut changed = widgets::slider_row(ui, tl!("Hue"), &mut h, 0.0..=360.0, "°", Some(&hue)).changed();
let sat_stops =
[egui::ecolor::Hsva::new(hsva0.h, 0.0, hsva0.v.max(0.2), 1.0), egui::ecolor::Hsva::new(hsva0.h, 1.0, hsva0.v.max(0.2), 1.0)].map(Color32::from);
widgets::slider_row(ui, tl!("Saturation"), &mut s, 0.0..=100.0, "%", Some(&sat_stops));
changed |= widgets::slider_row(ui, tl!("Saturation"), &mut s, 0.0..=100.0, "%", Some(&sat_stops)).changed();
let val_stops = [Color32::BLACK, Color32::from(egui::ecolor::Hsva::new(hsva0.h, hsva0.s, 1.0, 1.0))];
widgets::slider_row(ui, tl!("Brightness"), &mut v, 0.0..=100.0, "%", Some(&val_stops));
changed |= widgets::slider_row(ui, tl!("Brightness"), &mut v, 0.0..=100.0, "%", Some(&val_stops)).changed();
let hsva = egui::ecolor::Hsva::new(h / 360.0, s / 100.0, v / 100.0, 1.0);
if hsva != hsva0 {
// Only an edit counts: the h/s/v round trip isn't exact, so comparing values would rewrite
// the foreground (and recolour selected type) every frame.
if changed {
app.session.tools.foreground = hsva_srgb(hsva);
ui.data_mut(|d| d.insert_temp(key, hsva));
crate::type_tool::foreground_changed(app);
}
let [r, g, b, _] = hsva.to_srgba_unmultiplied();
let t = Tokens::get(ui.ctx());
@@ -2180,6 +2184,7 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.painter().add(egui::Shape::convex_polygon(tri, t.text, Stroke::NONE));
if fresp.dragged() || fresp.clicked() || sresp.dragged() || sresp.clicked() {
app.session.tools.foreground = hsva_srgb(hsva);
crate::type_tool::foreground_changed(app);
ui.data_mut(|d| d.insert_temp(key, hsva.h));
}
});
@@ -2476,3 +2481,59 @@ mod lock_tests {
assert!(!app.session.active().unwrap().doc.layers[0].locks.transparency);
}
}
#[cfg(test)]
mod swatch_type_tests {
use super::*;
use egui::Modifiers;
use egui_kittest::Harness;
/// Clicking a swatch while characters are selected recolours them, not just the foreground.
#[test]
fn clicking_a_swatch_recolours_selected_type() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
let id = app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 20, "y": 100, "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
app.ui.tool = Tool::Type;
app.ui.text_edit =
Some(crate::state::TextEdit { layer: id, caret: 0, anchor: 5, session: "s".into(), created: false, dragging: false, resize: None, preedit: None });
let mut h = Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state(|ui, app: &mut PhotocraftApp| swatches(app, ui), app);
h.run_steps(2);
// The first swatch is the top-left cell of the panel's content.
let p = h.ctx.input(|i| i.viewport_rect()).min + vec2(12.0, 12.0);
h.hover_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let s = SWATCHES[0];
assert_eq!(h.state().session.tools.foreground, [s[0] as f32 / 255.0, s[1] as f32 / 255.0, s[2] as f32 / 255.0, 1.0]);
let st = h.state().session.active().unwrap();
let Some(photocraft_doc::LayerContent::Text(t)) = st.doc.layer(photocraft_doc::LayerId(id)).map(|l| &l.content) else { panic!("type layer") };
let runs = t.char_runs();
assert_eq!(runs[0].len, 5, "the selection is its own run");
assert_eq!(runs[0].style.color.to_rgba8(), [s[0], s[1], s[2], 255]);
assert_eq!(runs[1].style.color.to_rgba8(), [255, 255, 255, 255]);
}
/// The HSB sliders only act on an edit. Their h/s/v round trip isn't exact for every colour
/// (#D8452E isn't), and comparing values used to rewrite the foreground every frame, which
/// would recolour selected type the moment it was selected.
#[test]
fn idle_hsb_sliders_leave_the_foreground_and_selected_type_alone() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
let id = app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 20, "y": 100, "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
app.run("tools.setColors", json!({"foreground": "#d8452e"})).unwrap();
let fg = app.session.tools.foreground;
app.ui.tool = Tool::Type;
app.ui.text_edit =
Some(crate::state::TextEdit { layer: id, caret: 0, anchor: 5, session: "s".into(), created: false, dragging: false, resize: None, preedit: None });
let mut h = Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_picker(app, ui), app);
h.run_steps(4);
assert_eq!(h.state().session.tools.foreground, fg);
let st = h.state().session.active().unwrap();
let Some(photocraft_doc::LayerContent::Text(t)) = st.doc.layer(photocraft_doc::LayerId(id)).map(|l| &l.content) else { panic!("type layer") };
assert_eq!(t.char_runs().len(), 1, "still one white run");
}
}
+9
View File
@@ -757,6 +757,15 @@ fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, props: serde_json::Value)
let _ = app.run("type.setStyle", p);
}
/// While characters are selected in a type layer, a new foreground colour (Color and Swatches
/// panels, the Color Picker) recolours them, in the editing session's history step.
pub fn foreground_changed(app: &mut PhotocraftApp) {
let Some((layer, Some(range))) = target(app) else { return };
let Some(ed) = app.ui.text_edit.as_ref() else { return };
let p = json!({"layer": layer, "range": range, "color": hex(app.session.tools.foreground), "coalesce": ed.session});
let _ = app.run("type.setStyle", p);
}
/// Photoshop's Type options bar.
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
+46
View File
@@ -365,3 +365,49 @@ fn vertical_type_caret_selection_and_arrows_follow_the_columns() {
}
assert_eq!(super::flow_key(egui::Key::ArrowUp, false), egui::Key::ArrowUp);
}
fn rgb_at(app: &PhotocraftApp, id: LayerId, ci: usize) -> [u8; 4] {
let t = text(app, id);
let b = t.text.char_indices().nth(ci).map_or(t.text.len(), |(b, _)| b);
let mut at = 0;
for r in t.char_runs() {
if b < at + r.len {
return r.style.color.to_rgba8();
}
at += r.len;
}
[0; 4]
}
/// A new foreground colour recolours the selected characters only, inside the editing session's
/// history step; with nothing selected (a caret, or not editing) the type keeps its colour.
#[test]
fn foreground_colour_recolours_only_selected_type() {
let mut app = new_app();
let id =
LayerId(app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 300, "y": 420, "color": "#000000"})).unwrap()["layer"].as_u64().unwrap());
app.run("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
super::foreground_changed(&mut app);
assert_eq!(rgb_at(&app, id, 0), [0, 0, 0, 255], "not editing: unchanged");
let edit = |caret, anchor| crate::state::TextEdit {
layer: id.0,
caret,
anchor,
session: "s".into(),
created: false,
dragging: false,
resize: None,
preedit: None,
};
app.ui.text_edit = Some(edit(3, 3));
super::foreground_changed(&mut app);
assert_eq!(rgb_at(&app, id, 0), [0, 0, 0, 255], "a caret: unchanged");
let steps = app.session.active().unwrap().history.entries().len();
app.ui.text_edit = Some(edit(11, 6));
super::foreground_changed(&mut app);
app.run("tools.setColors", json!({"foreground": "#00ff00"})).unwrap();
super::foreground_changed(&mut app);
assert_eq!((rgb_at(&app, id, 0), rgb_at(&app, id, 5)), ([0, 0, 0, 255], [0, 0, 0, 255]));
assert_eq!((rgb_at(&app, id, 6), rgb_at(&app, id, 10)), ([0, 255, 0, 255], [0, 255, 0, 255]));
assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 1, "one step for the session");
}