mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
Merge PR #1079: Color Theme: extract a placed image palette and add it to Swatches
crates/ui-egui/src/dialogs.rs: the branch's dialog body is laid out differently; the Color Theme body goes into its match beside Text Frame Options, and its OK label into the branch's translated labels. crates/ui-egui/src/menus.rs: keep the branch's Effects dialog opener and add the Color Theme opener. crates/ui-egui/src/panels/swatches.rs: keep both menu rows (Swatch Options… from #416, Color Theme…). crates/tools/src/tests.rs: the PR's Graphic literal names clip, wrap and fill. docs/agents.md: keep both sections. ROADMAP.md: the PR's line moves under "Newest first."; its copy of an existing 2026-10-13 line is dropped.
This commit is contained in:
@@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-15** · Color Theme: the toolbar tool previews up to five colours from a placed raster and adds them as one undoable swatch group. Local deterministic extraction; transparent padding is ignored. No readiness percentage change.
|
||||
- **2026-10-14** · Threading keeps generated and linked stories, text that refers to a story, local IDs and locked frames intact; a colour chosen at a caret waits for the typing, Swap Fill and Stroke on text keeps each run's colours, control-channel input never reads or writes the system clipboard, and IDML frame preferences given as properties import with bounded column counts (#84). No readiness percentage change.
|
||||
- **2026-10-14** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. No readiness percentage change.
|
||||
- **2026-10-14** · On an object-based page the Page tool shows a selected object's liquid pins and resize locks as InDesign does: a click pins a side to the page edge or locks the width or height, with InDesign's tooltips. No readiness percentage change.
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Local, deterministic palettes from placed raster images. No network service is involved.
|
||||
use designcraft_color::{Color, ColorType, Swatch, SwatchValue};
|
||||
use designcraft_doc::{ColorGroup, ItemId, Styles};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "color.extractTheme", "Extract Color Theme", [], None,
|
||||
"{id?: selected image frame} → {from, colors: [up to five RGB hex colours]} — samples the source raster, ignoring transparent pixels; at most 16 million pixels",
|
||||
has_doc, extract),
|
||||
cmd!(
|
||||
"swatch.addColorTheme",
|
||||
"Color Theme…",
|
||||
[],
|
||||
None,
|
||||
"{colors: [one to five RGB hex colours], name?: group name} → {name, swatches} — adds the previewed palette as one undoable swatch group",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn extract(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "color.extractTheme";
|
||||
let st = s.doc()?;
|
||||
let id = match p.get("id") {
|
||||
Some(v) => ItemId(v.as_u64().ok_or_else(|| bad(ID, "id must be an image frame id"))?),
|
||||
None => {
|
||||
if st.selection.items.len() != 1 {
|
||||
return Err(bad(ID, "select one placed raster image"));
|
||||
}
|
||||
st.selection.items.first().copied().ok_or_else(|| bad(ID, "select one placed raster image"))?
|
||||
}
|
||||
};
|
||||
let graphic = st.doc.item(id).and_then(|it| it.graphic()).ok_or_else(|| bad(ID, "select one placed raster image"))?;
|
||||
let asset = st.doc.assets.get(&graphic.asset).filter(|a| a.is_raster()).ok_or_else(|| bad(ID, "a raster image is required"))?;
|
||||
let (width, height) = designcraft_images::pixel_size(&asset.data).ok_or_else(|| bad(ID, "cannot read image dimensions"))?;
|
||||
if width == 0 || height == 0 || u64::from(width) * u64::from(height) > 16_000_000 {
|
||||
return Err(bad(ID, "the image must contain between 1 and 16 million pixels"));
|
||||
}
|
||||
let img = designcraft_images::decode_rgba(&asset.data).ok_or_else(|| bad(ID, "cannot decode this image"))?;
|
||||
// A regular 128 × 128 grid bounds work and includes both axes even for very thin images.
|
||||
let (nx, ny) = (width.min(128), height.min(128));
|
||||
let mut samples = Vec::with_capacity((nx * ny) as usize);
|
||||
for y in 0..ny {
|
||||
for x in 0..nx {
|
||||
let px = ((u64::from(x) * 2 + 1) * u64::from(width) / (u64::from(nx) * 2)) as u32;
|
||||
let py = ((u64::from(y) * 2 + 1) * u64::from(height) / (u64::from(ny) * 2)) as u32;
|
||||
if let Some(pixel) = img.get_pixel_checked(px, py) {
|
||||
let [r, g, b, a] = pixel.0;
|
||||
if a > 0 {
|
||||
samples.push(([r, g, b], u64::from(a)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let colors = palette(samples);
|
||||
if colors.is_empty() {
|
||||
return Err(bad(ID, "the image has no visible sampled pixels"));
|
||||
}
|
||||
Ok(json!({"from": id.0, "colors": colors}))
|
||||
}
|
||||
|
||||
type Sample = ([u8; 3], u64);
|
||||
|
||||
/// Weighted median cut: transparent padding never becomes a colour; partial alpha weights it.
|
||||
fn palette(samples: Vec<Sample>) -> Vec<String> {
|
||||
if samples.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let extent = |bucket: &[Sample]| {
|
||||
(0..3)
|
||||
.map(|axis| {
|
||||
let lo = bucket.iter().filter_map(|(rgb, _)| rgb.get(axis)).min().copied().unwrap_or(0);
|
||||
let hi = bucket.iter().filter_map(|(rgb, _)| rgb.get(axis)).max().copied().unwrap_or(0);
|
||||
(hi.saturating_sub(lo), axis)
|
||||
})
|
||||
.max()
|
||||
.unwrap_or((0, 0))
|
||||
};
|
||||
let mut buckets = vec![samples];
|
||||
while buckets.len() < 5 {
|
||||
let Some((index, (_, axis))) = buckets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (i, extent(b)))
|
||||
.filter(|(_, (range, _))| *range > 0)
|
||||
.max_by_key(|(i, (range, _))| (u64::from(*range) * buckets.get(*i).map_or(0, |b| b.iter().map(|(_, w)| w).sum::<u64>()), *i))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let mut bucket = buckets.remove(index);
|
||||
bucket.sort_by_key(|(rgb, _)| rgb.get(axis).copied().unwrap_or(0));
|
||||
let half = bucket.iter().map(|(_, w)| w).sum::<u64>() / 2;
|
||||
let mut accumulated = 0;
|
||||
let mut split = 1;
|
||||
for (i, (_, weight)) in bucket.iter().enumerate().take(bucket.len().saturating_sub(1)) {
|
||||
accumulated += weight;
|
||||
split = i + 1;
|
||||
if accumulated >= half {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let other = bucket.split_off(split);
|
||||
buckets.push(bucket);
|
||||
buckets.push(other);
|
||||
}
|
||||
let mut colors = Vec::new();
|
||||
for bucket in buckets {
|
||||
let weight = bucket.iter().map(|(_, w)| w).sum::<u64>();
|
||||
if weight == 0 {
|
||||
continue;
|
||||
}
|
||||
let average = |axis| (bucket.iter().map(|(rgb, w)| u64::from(rgb.get(axis).copied().unwrap_or(0)) * w).sum::<u64>() / weight) as u8;
|
||||
let color = Color::rgb8(average(0), average(1), average(2)).to_hex();
|
||||
if !colors.contains(&color) {
|
||||
colors.push(color);
|
||||
}
|
||||
}
|
||||
colors.sort();
|
||||
colors
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "swatch.addColorTheme";
|
||||
let input = p
|
||||
.get("colors")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|a| !a.is_empty() && a.len() <= 5)
|
||||
.ok_or_else(|| bad(ID, "colors must contain one to five RGB hex colours"))?;
|
||||
let mut colors = Vec::with_capacity(input.len());
|
||||
for value in input {
|
||||
let hex = value
|
||||
.as_str()
|
||||
.filter(|s| s.len() == 7 && s.starts_with('#') && s.get(1..).is_some_and(|v| v.bytes().all(|b| b.is_ascii_hexdigit())))
|
||||
.ok_or_else(|| bad(ID, "each colour must be #rrggbb"))?;
|
||||
colors.push(super::style::parse_color(&json!(hex)).ok_or_else(|| bad(ID, "invalid colour"))?);
|
||||
}
|
||||
let base = str_param(p, "name").unwrap_or("Color Theme").trim();
|
||||
if base.is_empty() || base.len() > 256 {
|
||||
return Err(bad(ID, "the group name must contain 1 to 256 bytes"));
|
||||
}
|
||||
s.edit(|doc, _| {
|
||||
let name = Styles::unique_name(|n| doc.color_groups.iter().any(|g| g.name == n), base);
|
||||
let mut names = Vec::new();
|
||||
for (i, color) in colors.iter().enumerate() {
|
||||
let label = Styles::unique_name(|n| doc.swatch(n).is_some(), &format!("{name} {}", i + 1));
|
||||
doc.swatches.push(Swatch {
|
||||
name: label.clone(),
|
||||
value: SwatchValue::Color { color: *color, color_type: ColorType::Process },
|
||||
locked: false,
|
||||
named: true,
|
||||
hidden: false,
|
||||
});
|
||||
names.push(label);
|
||||
}
|
||||
doc.color_groups.push(ColorGroup { name: name.clone(), swatches: names.clone() });
|
||||
Ok(json!({"name": name, "swatches": names}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_source_pixels_without_mutating_and_ignores_transparent_padding() {
|
||||
let png = designcraft_render::Rendered { width: 3, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255, 0, 255, 0, 0] }.to_png();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let placed = s
|
||||
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name":"palette.png", "x":36, "y":36, "width":144}))
|
||||
.unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let result = s.execute("color.extractTheme", &json!({"id":placed["id"]})).unwrap();
|
||||
assert_eq!(result["colors"], json!(["#0000ff", "#ff0000"]));
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert!(s.execute("color.extractTheme", &json!({"id":-1})).is_err());
|
||||
assert!(s.execute("color.extractTheme", &json!({"id":999999})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_is_bounded_deterministic_and_keeps_solid_colors() {
|
||||
assert_eq!(palette(vec![([12, 45, 78], 255); 100]), vec!["#0c2d4e"]);
|
||||
let samples: Vec<_> = (0..=255).map(|n| ([n, 255 - n, 45], 255)).collect();
|
||||
let result = palette(samples.clone());
|
||||
assert_eq!(result.len(), 5);
|
||||
assert_eq!(result, palette(samples));
|
||||
assert!(palette(vec![]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_theme_is_atomic_undoable_and_names_do_not_collide() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let p = json!({"colors":["#123456", "#abcdef"], "name":"Fashion"});
|
||||
let first = s.execute("swatch.addColorTheme", &p).unwrap();
|
||||
assert_eq!(first["swatches"].as_array().unwrap().len(), 2);
|
||||
let added = s.doc().unwrap().doc.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, added);
|
||||
let second = s.execute("swatch.addColorTheme", &p).unwrap();
|
||||
assert_ne!(first["name"], second["name"]);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for p in [json!({"colors":[]}), json!({"colors":["#123456", "bad"]}), json!({"colors":["#123456"],"name":" "})] {
|
||||
assert!(s.execute("swatch.addColorTheme", &p).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
assert!(s.execute("color.extractTheme", &json!({})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod book;
|
||||
mod buttons;
|
||||
pub(crate) mod captions;
|
||||
mod changes;
|
||||
mod color_theme;
|
||||
mod colorsettings;
|
||||
mod conditions;
|
||||
mod datamerge;
|
||||
@@ -182,6 +183,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(buttons::specs());
|
||||
v.extend(captions::specs());
|
||||
v.extend(colorsettings::specs());
|
||||
v.extend(color_theme::specs());
|
||||
v.extend(changes::specs());
|
||||
v.extend(conditions::specs());
|
||||
v.extend(library::specs());
|
||||
|
||||
@@ -494,6 +494,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
|
||||
"scale" => Box::new(xform::XformTool::new("scale")),
|
||||
"shear" => Box::new(xform::XformTool::new("shear")),
|
||||
"eyedropper" => Box::new(xform::EyedropperTool),
|
||||
"colorTheme" => Box::new(xform::ColorThemeTool),
|
||||
"hand" => Box::new(nav::HandTool::default()),
|
||||
"placeGun" => Box::new(nav::PlaceGun::default()),
|
||||
"zoom" => Box::new(nav::ZoomTool::default()),
|
||||
|
||||
@@ -22,6 +22,33 @@ fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_theme_tool_requests_a_palette_without_editing_the_image() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let id = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
d.item_mut(id).unwrap().content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
asset: designcraft_doc::AssetId(1),
|
||||
size: (100.0, 100.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
clip: None,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
});
|
||||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
|
||||
let off = l.xf(SpreadRef::Doc(0)).translation();
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let mut tool = create("colorTheme");
|
||||
assert_eq!(tool.id(), "colorTheme");
|
||||
assert_eq!(
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0 + off.x, 150.0 + off.y)),
|
||||
vec![Action::Dialog("colorTheme".into(), serde_json::json!({"id":id.0}))]
|
||||
);
|
||||
assert!(tool.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0 + off.x, 150.0 + off.y)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_tool_drag_emits_begin_preview_commit() {
|
||||
let d = Document::new(&NewDocument::default());
|
||||
|
||||
@@ -290,6 +290,28 @@ fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> {
|
||||
#[derive(Default)]
|
||||
pub struct EyedropperTool;
|
||||
|
||||
/// The palette tool only requests a preview. Adding the swatches is a separate command.
|
||||
pub struct ColorThemeTool;
|
||||
|
||||
impl Tool for ColorThemeTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"colorTheme"
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if ev.kind != PointerKind::Down {
|
||||
return vec![];
|
||||
}
|
||||
let Some((_, id)) = cx.hit(ev.pos) else { return vec![] };
|
||||
if cx.doc.item(id).and_then(|it| it.graphic()).is_none() {
|
||||
return vec![];
|
||||
}
|
||||
vec![Action::Dialog("colorTheme".into(), json!({"id": id.0}))]
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Eyedropper
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for EyedropperTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"eyedropper"
|
||||
|
||||
@@ -137,6 +137,66 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours.
|
||||
pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) {
|
||||
match app.run("color.extractTheme", params) {
|
||||
Ok(mut theme) => {
|
||||
theme["name"] = json!("Color Theme");
|
||||
app.ui.dialog = Some(Dialog::new("colorTheme", theme));
|
||||
}
|
||||
Err(message) => app.ui.dialog = Some(Dialog::new("alert", json!({"message": message}))),
|
||||
}
|
||||
}
|
||||
|
||||
fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:"));
|
||||
text_field(ui, d, "name", 280.0);
|
||||
ui.add_space(8.0);
|
||||
if let Some(colors) = d.fields.get("colors").and_then(Value::as_array) {
|
||||
ui.horizontal(|ui| {
|
||||
for hex in colors.iter().filter_map(Value::as_str).take(5) {
|
||||
ui.vertical(|ui| {
|
||||
if let Ok(color) = egui::Color32::from_hex(hex) {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(54.0, 40.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, 2.0, color);
|
||||
}
|
||||
ui.label(hex);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(status) = d.fields.get("status").and_then(Value::as_str) {
|
||||
crate::rtl::label(ui, status);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_theme_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let before = app.session.active().unwrap().doc.clone();
|
||||
let theme = json!({"name":"Fashion", "colors":["#102030", "#f0a020"]});
|
||||
app.ui.dialog = Some(Dialog::new("colorTheme", theme.clone()));
|
||||
cancel(&mut app);
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
app.ui.dialog = Some(Dialog::new("colorTheme", theme));
|
||||
confirm(&mut app).unwrap();
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
assert_eq!(doc.color_groups.last().unwrap().name, "Fashion");
|
||||
assert_eq!(doc.color_groups.last().unwrap().swatches.len(), 2);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
app.ui.dialog = Some(Dialog::new("colorTheme", json!({"name":" ","colors":["#102030"]})));
|
||||
assert!(confirm(&mut app).is_err());
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().id, "colorTheme");
|
||||
assert_eq!(app.session.active().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit › Keyboard Shortcuts: every command and its shortcut; click a shortcut, then press the
|
||||
/// new keys (Esc cancels, Backspace clears).
|
||||
/// Edit › Spelling › User Dictionary: added words and hyphenation exceptions (applied at once).
|
||||
@@ -1980,6 +2040,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"),
|
||||
"marginsAndColumns" => crate::i18n::tr(&app.ui.language, "Margins and Columns"),
|
||||
"textFrameOptions" => crate::i18n::tr(&app.ui.language, "Text Frame Options"),
|
||||
"colorTheme" => crate::i18n::tr(&app.ui.language, "Color Theme"),
|
||||
"documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"),
|
||||
"numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'),
|
||||
"findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"),
|
||||
@@ -2668,6 +2729,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
seed_text_frame_options(app, &mut d);
|
||||
text_frame_options_dialog(app, ui, &mut d);
|
||||
}
|
||||
"colorTheme" => color_theme(app, ui, &mut d),
|
||||
"documentSetup" => {
|
||||
// Adjust Layout applies at once.
|
||||
let adjust = document_setup(app, ui, &mut d);
|
||||
@@ -2693,6 +2755,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
let ok_label = match d.id.as_str() {
|
||||
"colorTheme" => crate::i18n::tr(&app.ui.language, "Add to Swatches").to_string(),
|
||||
"closeDocument" => crate::i18n::tr(&app.ui.language, " Save ").to_string(),
|
||||
"updateLink" => crate::i18n::tr(&app.ui.language, " Update ").to_string(),
|
||||
"findFont" => crate::i18n::tr(&app.ui.language, "Replace All").to_string(),
|
||||
@@ -2899,6 +2962,15 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
app.cancel_pdf_import();
|
||||
}
|
||||
match d.id.as_str() {
|
||||
"colorTheme" => {
|
||||
let result = app.run("swatch.addColorTheme", json!({"name": d.s("name"), "colors": d.fields.get("colors")}));
|
||||
if let Err(error) = &result {
|
||||
let mut d = d;
|
||||
d.fields.insert("status".into(), json!(error));
|
||||
app.ui.dialog = Some(d);
|
||||
}
|
||||
result
|
||||
}
|
||||
"createGuides" | "marginsAndColumns" => {
|
||||
// The preview goes; the change is made once, as one undo step.
|
||||
if d.fields.contains_key(PREVIEWED) {
|
||||
|
||||
@@ -880,6 +880,7 @@ impl DesignApp {
|
||||
for r in reqs {
|
||||
let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange");
|
||||
match r {
|
||||
UiRequest::Dialog { id, params } if id == "colorTheme" => dialogs::open_color_theme(self, params),
|
||||
UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)),
|
||||
UiRequest::View { params } => canvas::apply_view_request(self, ¶ms),
|
||||
UiRequest::Pick { purpose, params } => {
|
||||
|
||||
@@ -2383,6 +2383,10 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) {
|
||||
crate::effects_dialog::open(app, page);
|
||||
return;
|
||||
}
|
||||
if params.is_null() && id == "swatch.addColorTheme" {
|
||||
crate::dialogs::open_color_theme(app, json!({}));
|
||||
return;
|
||||
}
|
||||
if params.is_null() && id == "file.close" {
|
||||
close_document(app, None);
|
||||
return;
|
||||
|
||||
@@ -98,6 +98,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let _ = app.run("app.swatchOptionsDialog", json!({"name": current}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Theme…"))).clicked() {
|
||||
crate::menus::activate(app, "swatch.addColorTheme", &serde_json::Value::Null);
|
||||
ui.close();
|
||||
}
|
||||
crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| {
|
||||
let Ok(l) = app.session.execute("ink.list", &json!({})) else { return };
|
||||
let mut all = l["allToProcess"].as_bool().unwrap_or(false);
|
||||
|
||||
@@ -54,6 +54,15 @@ same references, and MCP's `batch` tool takes `commands` or `script` text with t
|
||||
`.epub`, `.html`, and `.rtf` or `.txt` (the story being edited or of the selected frame). A name with no extension
|
||||
is a PNG of the page; another extension is an error, and nothing runs.
|
||||
|
||||
## Color themes
|
||||
|
||||
`color.extractTheme {id?}` previews up to five RGB colours from a placed raster image (or the selected image).
|
||||
`swatch.addColorTheme {colors, name?}` adds that palette as a colour group in one undo step.
|
||||
Extraction samples the whole source image on a bounded grid, weights partial transparency, and ignores fully transparent pixels.
|
||||
The image must contain at most 16 million pixels. PDF/SVG graphics are unsupported. This is local median-cut extraction,
|
||||
not Adobe's palette algorithm. In the UI, click an image with the Color Theme Tool (Shift+I), or choose Color Theme…
|
||||
in the Swatches panel menu with one image selected. Cancel keeps the document unchanged.
|
||||
|
||||
## Data merge
|
||||
|
||||
`data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it,
|
||||
|
||||
Reference in New Issue
Block a user