Rebased onto main as one commit (the branch had merge commits from main). Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
3fd97c3e58
commit
39f51ca292
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (any text file opens, #569; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (More Colors… and the Colors dialog, #606; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
agent control. This page is the summary; the assessment is
|
||||
|
|
@ -145,6 +145,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
|
||||
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
|
||||
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
|
||||
| 2026-10-11 | Custom colours (#606): every colour drop-down gains Recent Colors and More Colors…, which opens a Colors dialog with a Standard honeycomb and a Custom hue/saturation field, lightness slider, RGB/HSL numbers and a hex box |
|
||||
| 2026-10-11 | Complex scripts (#604): Myanmar, Khmer and Indic text shaped one script at a time in OpenType fallback fonts (regular weight, not AAT-only fonts), the caret and line breaks never split a syllable, Myanmar wraps between syllables, Backspace removes one sign at a time |
|
||||
| 2026-10-11 | Any text file opens (#569): `.m3u`/`.m3u8` playlists and other unknown extensions open as plain text with encoding detection (UTF-8, BOMs, UTF-16, Windows-1252), misnamed Word/RTF files by their signature; binary files give a clear error |
|
||||
| 2026-10-11 | Select by dragging whole units (#549): after a double-click the drag grows by words, after a triple-click by paragraphs, after Ctrl/⌘+click by sentences, as in Word |
|
||||
|
|
@ -196,6 +197,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | trivial | Progress log: Outline view (#493) |
|
||||
| 2026-10-11 | trivial | Progress log: vertical sections (#491) |
|
||||
| 2026-10-11 | trivial | Progress log: Paragraph dialog options, mirror indents (#512) |
|
||||
| 2026-10-11 | trivial | Progress log: custom colours via More Colors… (#606) |
|
||||
| 2026-10-11 | trivial | Progress log: complex-script shaping (#604) |
|
||||
| 2026-10-11 | trivial | Progress log: unknown text extensions open as text (#569) |
|
||||
| 2026-10-11 | trivial | Progress log: drag-select by words/paragraphs/sentences (#549) |
|
||||
|
|
|
|||
|
|
@ -3218,6 +3218,32 @@ fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours()
|
|||
assert!(s.run("picture.iconColor", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// #606: the colour commands behind More Colors… take any `RRGGBB` (with or without `#`, any
|
||||
/// case), not only the palette's colours; junk is a params error and changes nothing.
|
||||
#[test]
|
||||
fn colour_commands_take_any_custom_hex() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Custom"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
for (given, want) in [("7A3EC8", "7A3EC8"), ("#0b5d1e", "0B5D1E"), (" ff8800 ", "FF8800")] {
|
||||
run(&mut s, "format.color", json!({"color": given}));
|
||||
assert_eq!(run(&mut s, "format.state", json!({})).get("color"), Some(&json!(want)));
|
||||
}
|
||||
let doc = s.doc.clone();
|
||||
for junk in [json!("#12"), json!("purple"), json!("GG0000"), json!(12), json!(null), json!("ééé")] {
|
||||
assert!(s.run("format.color", &json!({"color": junk})).is_err(), "{junk}");
|
||||
}
|
||||
assert_eq!(s.doc, doc);
|
||||
run(&mut s, "format.color", json!({"color": "auto"}));
|
||||
assert_eq!(run(&mut s, "format.state", json!({})).get("color"), Some(&json!("auto")));
|
||||
// Shading and page colour take custom colours the same way.
|
||||
run(&mut s, "para.shading", json!({"color": "#C0FFEE"}));
|
||||
let shading = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.shading);
|
||||
assert_eq!(shading, wordcraft_doc::Rgb::parse("C0FFEE"));
|
||||
run(&mut s, "design.pageColor", json!({"color": "123ABC"}));
|
||||
assert_eq!(s.doc.settings.page_color, wordcraft_doc::Rgb::parse("123ABC"));
|
||||
}
|
||||
|
||||
/// #569: a playlist (.m3u / .m3u8) — or any file with an extension WordCraft doesn't know —
|
||||
/// opens as plain text with its encoding detected, like Word's text import; a binary file is a
|
||||
/// clean error, never garbage or a panic.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,847 @@
|
|||
//! Colour menus and the Colors dialog (More Colors…, #606).
|
||||
//!
|
||||
//! Every colour drop-down (Font Color, Shading, Page Color, Shape Fill/Outline, pen colours, and
|
||||
//! the colour fields inside dialogs) shows the theme and standard colours, a Recent Colors row and
|
||||
//! a More Colors… item. More Colors… opens the Colors dialog: a Standard tab (a honeycomb of
|
||||
//! colours and a row of greys) and a Custom tab (a hue × saturation field with a lightness
|
||||
//! slider, RGB or HSL numbers and a hex box). Everything is drawn in code.
|
||||
//!
|
||||
//! What a pick does is a [`Target`]: run a command with the colour put into its params, or hand
|
||||
//! the colour back to a dialog's field. The colour a command takes is any `RRGGBB`, so a custom
|
||||
//! colour needs nothing new below the UI.
|
||||
|
||||
use egui::{Color32, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::Rgb;
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::theme::{Tokens, c32};
|
||||
|
||||
/// How many colours Recent Colors keeps.
|
||||
pub const RECENT_MAX: usize = 10;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Pure helpers
|
||||
|
||||
/// A colour typed by a person: `RRGGBB`, `#RRGGBB`, `#RGB`, `0xRRGGBB`, `rgb(r, g, b)` or three
|
||||
/// numbers 0–255 separated by commas or spaces. Anything else is `None`.
|
||||
pub fn parse(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim();
|
||||
let hex = s.trim_start_matches('#');
|
||||
let hex = hex.strip_prefix("0x").or_else(|| hex.strip_prefix("0X")).unwrap_or(hex);
|
||||
if hex.len() == 6 {
|
||||
if let Some(c) = Rgb::parse(hex) {
|
||||
return Some(c);
|
||||
}
|
||||
} else if hex.len() == 3 && hex.is_ascii() {
|
||||
let d = |i: usize| hex.get(i..i + 1).and_then(|h| u8::from_str_radix(h, 16).ok()).map(|v| v * 17);
|
||||
if let (Some(r), Some(g), Some(b)) = (d(0), d(1), d(2)) {
|
||||
return Some(Rgb(r, g, b));
|
||||
}
|
||||
}
|
||||
let lower = s.to_ascii_lowercase();
|
||||
let inner = lower.strip_prefix("rgb").map(|r| r.trim().trim_start_matches('(').trim_end_matches(')')).unwrap_or(&lower);
|
||||
let parts: Vec<&str> = inner.split(|c: char| c == ',' || c.is_whitespace()).filter(|p| !p.is_empty()).collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let n = |i: usize| parts.get(i).and_then(|p| p.parse::<u8>().ok());
|
||||
Some(Rgb(n(0)?, n(1)?, n(2)?))
|
||||
}
|
||||
|
||||
/// Hue (degrees, 0–360), saturation and lightness (0–1) of a colour.
|
||||
pub fn to_hsl(c: Rgb) -> (f32, f32, f32) {
|
||||
let (r, g, b) = (c.0 as f32 / 255.0, c.1 as f32 / 255.0, c.2 as f32 / 255.0);
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let l = (max + min) / 2.0;
|
||||
let d = max - min;
|
||||
if d <= f32::EPSILON {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == r {
|
||||
(g - b) / d + if g < b { 6.0 } else { 0.0 }
|
||||
} else if max == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
((h * 60.0).rem_euclid(360.0), s.clamp(0.0, 1.0), l.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// The colour with hue `h` (degrees, any value wraps), saturation `s` and lightness `l` (0–1,
|
||||
/// clamped). Non-finite input counts as 0.
|
||||
pub fn from_hsl(h: f32, s: f32, l: f32) -> Rgb {
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let h = fin(h).rem_euclid(360.0);
|
||||
let s = fin(s).clamp(0.0, 1.0);
|
||||
let l = fin(l).clamp(0.0, 1.0);
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let hp = h / 60.0;
|
||||
let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
|
||||
let (r, g, b) = match hp as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = l - c / 2.0;
|
||||
let q = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(q(r), q(g), q(b))
|
||||
}
|
||||
|
||||
/// Puts `hex` first in a Recent Colors list (no repeats, at most [`RECENT_MAX`]). Junk is ignored.
|
||||
pub fn push_recent(list: &mut Vec<String>, hex: &str) {
|
||||
let Some(c) = parse(hex) else { return };
|
||||
let hex = c.hex();
|
||||
list.retain(|h| parse(h).map(Rgb::hex).as_deref() != Some(hex.as_str()));
|
||||
list.insert(0, hex);
|
||||
list.truncate(RECENT_MAX);
|
||||
}
|
||||
|
||||
/// The Recent Colors to show from a saved list: valid colours only, no repeats, at most
|
||||
/// [`RECENT_MAX`].
|
||||
pub fn recent(list: &[String]) -> Vec<Rgb> {
|
||||
let mut out: Vec<Rgb> = Vec::new();
|
||||
for c in list.iter().filter_map(|h| parse(h)) {
|
||||
if !out.contains(&c) {
|
||||
out.push(c);
|
||||
}
|
||||
if out.len() >= RECENT_MAX {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rings of the Standard tab's honeycomb around its white centre.
|
||||
pub const HONEYCOMB_RINGS: i32 = 6;
|
||||
|
||||
/// The Standard tab's honeycomb: axial cell coordinates `(q, r)` and their colours. The centre is
|
||||
/// white; going out, colours get more saturated and then darker; the hue follows the angle.
|
||||
pub fn honeycomb() -> Vec<(i32, i32, Rgb)> {
|
||||
let n = HONEYCOMB_RINGS;
|
||||
let mut cells = Vec::new();
|
||||
for r in -n..=n {
|
||||
for q in -n..=n {
|
||||
let d = q.abs().max(r.abs()).max((q + r).abs());
|
||||
if d > n {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = cell_xy(q, r);
|
||||
let hue = (-y).atan2(x).to_degrees();
|
||||
let t = d as f32 / n as f32;
|
||||
cells.push((q, r, from_hsl(hue, 1.0, 1.0 - 0.75 * t)));
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// The greys under the honeycomb, white to black.
|
||||
pub fn greys() -> Vec<Rgb> {
|
||||
(0..=14u32)
|
||||
.map(|i| {
|
||||
let v = (255 - (i * 255 / 14).min(255)) as u8;
|
||||
Rgb(v, v, v)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A cell's centre in units of the cell radius (pointy-top hexagons).
|
||||
fn cell_xy(q: i32, r: i32) -> (f32, f32) {
|
||||
(3f32.sqrt() * (q as f32 + r as f32 / 2.0), 1.5 * r as f32)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Targets
|
||||
|
||||
/// What a menu remembers as the split button's colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Remember {
|
||||
Nothing,
|
||||
FontColor,
|
||||
Shading,
|
||||
}
|
||||
|
||||
/// Where a picked colour goes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Target {
|
||||
/// Run `cmd` with the colour (`RRGGBB`) put into `params` at the JSON pointer `at`.
|
||||
Run { cmd: String, params: Value, at: String, remember: Remember },
|
||||
/// A dialog's colour field (see [`field_menu`]).
|
||||
Field(Id),
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// `cmd` with params `{"color": RRGGBB}`.
|
||||
pub fn cmd(cmd: &str) -> Target {
|
||||
Target::Run { cmd: cmd.into(), params: json!({"color": null}), at: "/color".into(), remember: Remember::Nothing }
|
||||
}
|
||||
|
||||
/// `cmd` with the colour at the JSON pointer `at` inside `params`.
|
||||
pub fn at(cmd: &str, params: Value, at: &str) -> Target {
|
||||
Target::Run { cmd: cmd.into(), params, at: at.into(), remember: Remember::Nothing }
|
||||
}
|
||||
|
||||
pub fn remember(self, r: Remember) -> Target {
|
||||
match self {
|
||||
Target::Run { cmd, params, at, .. } => Target::Run { cmd, params, at, remember: r },
|
||||
t => t,
|
||||
}
|
||||
}
|
||||
|
||||
/// The command and params a pick of `hex` runs (`None` for a field).
|
||||
pub fn command(&self, hex: &str) -> Option<(String, Value)> {
|
||||
let Target::Run { cmd, params, at, .. } = self else { return None };
|
||||
let mut p = params.clone();
|
||||
match p.pointer_mut(at) {
|
||||
Some(slot) => *slot = json!(hex),
|
||||
None => p["color"] = json!(hex),
|
||||
}
|
||||
Some((cmd.clone(), p))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a picked colour to its target.
|
||||
pub fn apply(app: &mut WordApp, ctx: &egui::Context, target: &Target, hex: &str) {
|
||||
match target {
|
||||
Target::Run { remember, .. } => {
|
||||
match remember {
|
||||
Remember::FontColor => app.canvas.last_font_color = hex.to_string(),
|
||||
Remember::Shading => app.canvas.last_shading = hex.to_string(),
|
||||
Remember::Nothing => {}
|
||||
}
|
||||
if let Some((cmd, p)) = target.command(hex) {
|
||||
let _ = app.run(&cmd, p);
|
||||
}
|
||||
}
|
||||
Target::Field(id) => {
|
||||
ctx.data_mut(|d| d.insert_temp(*id, FieldResult(hex.to_string())));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour a target has now (the dialog's Current swatch).
|
||||
fn current_of(app: &mut WordApp, target: &Target) -> Option<Rgb> {
|
||||
let Target::Run { cmd, .. } = target else { return None };
|
||||
match cmd.as_str() {
|
||||
"format.color" => {
|
||||
let st = app.session.run("format.state", &json!({})).ok()?;
|
||||
st.get("color").and_then(Value::as_str).and_then(parse)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The drop-down
|
||||
|
||||
/// A choice in a colour drop-down.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Pick {
|
||||
Color(String),
|
||||
More,
|
||||
}
|
||||
|
||||
fn recent_id() -> Id {
|
||||
Id::new("wordcraft.recentColors")
|
||||
}
|
||||
|
||||
fn request_id() -> Id {
|
||||
Id::new("wordcraft.moreColorsRequest")
|
||||
}
|
||||
|
||||
fn open_flag_id() -> Id {
|
||||
Id::new("wordcraft.colorsDialogOpen")
|
||||
}
|
||||
|
||||
/// Removes and returns a temporary value.
|
||||
fn take<T: Clone + Send + Sync + 'static>(d: &mut egui::util::IdTypeMap, id: Id) -> Option<T> {
|
||||
let v = d.get_temp::<T>(id);
|
||||
d.remove::<T>(id);
|
||||
v
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FieldRequest(Id, Option<Rgb>);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FieldResult(String);
|
||||
|
||||
/// The drop-down's body: theme and standard colours, Recent Colors, More Colors….
|
||||
pub fn palette(ui: &mut Ui, theme: &[Rgb]) -> Option<Pick> {
|
||||
let mut picked = crate::widgets::color_grid(ui, theme).map(Pick::Color);
|
||||
let recent: Vec<String> = ui.data(|d| d.get_temp::<Vec<String>>(recent_id())).unwrap_or_default();
|
||||
let recent = self::recent(&recent);
|
||||
if !recent.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!("Recent Colors")).small().weak());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(3.0, 0.0);
|
||||
for c in recent {
|
||||
if crate::widgets::swatch(ui, c) {
|
||||
picked = Some(Pick::Color(c.hex()));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
ui.separator();
|
||||
if more_item(ui).clicked() {
|
||||
picked = Some(Pick::More);
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
/// The More Colors… row: a small rainbow wheel and the label.
|
||||
fn more_item(ui: &mut Ui) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 22.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 2.0, t.hover);
|
||||
}
|
||||
let c = pos2(r.min.x + 10.0, r.center().y);
|
||||
for i in 0..12 {
|
||||
let a0 = (i as f32) * std::f32::consts::TAU / 12.0;
|
||||
let a1 = a0 + std::f32::consts::TAU / 12.0;
|
||||
let p = |a: f32| c + vec2(a.cos(), a.sin()) * 6.5;
|
||||
let col = c32(from_hsl(i as f32 * 30.0, 1.0, 0.5));
|
||||
ui.painter().add(egui::Shape::convex_polygon(vec![c, p(a0), p(a1)], col, Stroke::NONE));
|
||||
}
|
||||
ui.painter().circle_stroke(c, 6.5, Stroke::new(0.5, t.border_strong));
|
||||
ui.painter().text(
|
||||
pos2(r.min.x + 22.0, r.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
tl!("More Colors…"),
|
||||
egui::TextStyle::Button.resolve(ui.style()),
|
||||
t.text,
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A command colour drop-down's body: picks run `target`, More Colors… opens the Colors dialog.
|
||||
pub fn menu(ui: &mut Ui, app: &mut WordApp, theme: &[Rgb], target: Target) {
|
||||
match palette(ui, theme) {
|
||||
Some(Pick::Color(hex)) => {
|
||||
let ctx = ui.ctx().clone();
|
||||
apply(app, &ctx, &target, &hex);
|
||||
ui.close();
|
||||
}
|
||||
Some(Pick::More) => {
|
||||
let current = current_of(app, &target);
|
||||
app.colors = Some(ColorsDialog::new(current, target));
|
||||
ui.close();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A dialog field's drop-down body (the dialog has no access to the app): a pick sets `value`;
|
||||
/// More Colors… asks for the Colors dialog, whose answer [`take_field`] collects. `id` must be
|
||||
/// the same each frame.
|
||||
pub fn field_menu(ui: &mut Ui, theme: &[Rgb], id: Id, value: &mut String) {
|
||||
match palette(ui, theme) {
|
||||
Some(Pick::Color(hex)) => {
|
||||
*value = hex;
|
||||
ui.close();
|
||||
}
|
||||
Some(Pick::More) => {
|
||||
let current = parse(value);
|
||||
ui.data_mut(|d| d.insert_temp(request_id(), FieldRequest(id, current)));
|
||||
ui.close();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Colors dialog's answer for the field `id`, if it just closed with OK.
|
||||
pub fn take_field(ctx: &egui::Context, id: Id, value: &mut String) {
|
||||
if let Some(FieldResult(hex)) = ctx.data_mut(|d| take::<FieldResult>(d, id)) {
|
||||
*value = hex;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the Colors dialog was open last frame: dialogs under it leave Enter and Escape to it.
|
||||
pub fn is_open(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|d| d.get_temp::<bool>(open_flag_id())).unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The Colors dialog
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ColorsTab {
|
||||
Standard,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Model {
|
||||
Rgb,
|
||||
Hsl,
|
||||
}
|
||||
|
||||
/// The Colors dialog's state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ColorsDialog {
|
||||
pub tab: ColorsTab,
|
||||
/// The new colour.
|
||||
pub color: Rgb,
|
||||
/// Hue, saturation, lightness of `color`; the hue is kept through greys so dragging the
|
||||
/// lightness back out of black or white returns to it.
|
||||
pub hsl: (f32, f32, f32),
|
||||
/// The colour the target had when the dialog opened.
|
||||
pub current: Option<Rgb>,
|
||||
pub model: Model,
|
||||
/// The hex box's text (may be mid-edit).
|
||||
pub hex: String,
|
||||
pub target: Target,
|
||||
}
|
||||
|
||||
impl ColorsDialog {
|
||||
pub fn new(current: Option<Rgb>, target: Target) -> ColorsDialog {
|
||||
let c = current.unwrap_or(Rgb::BLACK);
|
||||
let mut d = ColorsDialog { tab: ColorsTab::Standard, color: c, hsl: (0.0, 0.0, 0.0), current, model: Model::Rgb, hex: String::new(), target };
|
||||
d.set_rgb(c);
|
||||
d
|
||||
}
|
||||
|
||||
pub fn set_rgb(&mut self, c: Rgb) {
|
||||
let (h, s, l) = to_hsl(c);
|
||||
let h = if s > 0.0 && l > 0.0 && l < 1.0 { h } else { self.hsl.0 };
|
||||
self.color = c;
|
||||
self.hsl = (h, s, l);
|
||||
self.hex = c.hex();
|
||||
}
|
||||
|
||||
pub fn set_hsl(&mut self, h: f32, s: f32, l: f32) {
|
||||
self.hsl = (h.rem_euclid(360.0), s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
|
||||
self.color = from_hsl(self.hsl.0, self.hsl.1, self.hsl.2);
|
||||
self.hex = self.color.hex();
|
||||
}
|
||||
|
||||
/// Sets the colour from typed text; returns whether it was a colour.
|
||||
pub fn set_text(&mut self, s: &str) -> bool {
|
||||
match parse(s) {
|
||||
Some(c) => {
|
||||
let (h, sat, l) = to_hsl(c);
|
||||
let h = if sat > 0.0 { h } else { self.hsl.0 };
|
||||
self.color = c;
|
||||
self.hsl = (h, sat, l);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the Colors dialog if it is open (and opens it for a dialog field that asked).
|
||||
pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
ctx.data_mut(|d| d.insert_temp(recent_id(), app.ui.recent_colors.clone()));
|
||||
if let Some(FieldRequest(id, current)) = ctx.data_mut(|d| take::<FieldRequest>(d, request_id())) {
|
||||
app.colors = Some(ColorsDialog::new(current, Target::Field(id)));
|
||||
}
|
||||
let Some(mut d) = app.colors.take() else {
|
||||
ctx.data_mut(|m| m.insert_temp(open_flag_id(), false));
|
||||
return;
|
||||
};
|
||||
let resp = egui::Modal::new(Id::new("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d));
|
||||
let mut done = resp.inner;
|
||||
if done.is_none() && resp.should_close() {
|
||||
done = Some(false);
|
||||
}
|
||||
match done {
|
||||
Some(ok) => {
|
||||
ctx.data_mut(|m| m.insert_temp(open_flag_id(), false));
|
||||
if ok {
|
||||
finish(app, ctx, &d);
|
||||
}
|
||||
if matches!(d.target, Target::Run { .. }) && app.dialog.is_none() {
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ctx.data_mut(|m| m.insert_temp(open_flag_id(), true));
|
||||
app.colors = Some(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OK: the colour joins Recent Colors and goes to the target.
|
||||
pub fn finish(app: &mut WordApp, ctx: &egui::Context, d: &ColorsDialog) {
|
||||
let hex = d.color.hex();
|
||||
push_recent(&mut app.ui.recent_colors, &hex);
|
||||
ctx.data_mut(|m| m.insert_temp(recent_id(), app.ui.recent_colors.clone()));
|
||||
apply(app, ctx, &d.target, &hex);
|
||||
}
|
||||
|
||||
/// The dialog's contents. `Some(true)` for OK, `Some(false)` for Cancel.
|
||||
fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option<bool> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_width(430.0);
|
||||
ui.label(egui::RichText::new(tl!("Colors")).font(crate::theme::semibold(14.0)));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (tab, label) in [(ColorsTab::Standard, "Standard"), (ColorsTab::Custom, "Custom")] {
|
||||
if ui.selectable_label(d.tab == tab, tl!(label)).clicked() {
|
||||
d.tab = tab;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(330.0);
|
||||
match d.tab {
|
||||
ColorsTab::Standard => standard(ui, d),
|
||||
ColorsTab::Custom => custom(ui, d),
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(110.0);
|
||||
preview(ui, d, &t);
|
||||
});
|
||||
});
|
||||
let mut out = None;
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Cancel")).clicked() {
|
||||
out = Some(false);
|
||||
}
|
||||
let okb = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(Color32::WHITE)).fill(crate::theme::APP_COLOR));
|
||||
let typing = ui.ctx().memory(|m| m.focused().is_some());
|
||||
if okb.clicked() || (!typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
|
||||
out = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// New over Current, as two halves of one swatch.
|
||||
fn preview(ui: &mut Ui, d: &ColorsDialog, t: &Tokens) {
|
||||
ui.label(egui::RichText::new(tl!("New")).small());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(64.0, 72.0), Sense::hover());
|
||||
let top = Rect::from_min_max(r.min, pos2(r.max.x, r.center().y));
|
||||
let bottom = Rect::from_min_max(pos2(r.min.x, r.center().y), r.max);
|
||||
ui.painter().rect_filled(top, 0.0, c32(d.color));
|
||||
match d.current {
|
||||
Some(c) => {
|
||||
ui.painter().rect_filled(bottom, 0.0, c32(c));
|
||||
}
|
||||
None => {
|
||||
ui.painter().rect_filled(bottom, 0.0, t.panel);
|
||||
ui.painter().line_segment([bottom.left_bottom(), bottom.right_top()], Stroke::new(1.0, t.text_dim));
|
||||
}
|
||||
}
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
|
||||
ui.label(egui::RichText::new(tl!("Current")).small());
|
||||
}
|
||||
|
||||
/// Pointy-top hexagon corners around `c` with radius `s`.
|
||||
fn hexagon(c: Pos2, s: f32) -> Vec<Pos2> {
|
||||
(0..6)
|
||||
.map(|i| {
|
||||
let a = (60.0 * i as f32 - 30.0).to_radians();
|
||||
c + vec2(a.cos(), a.sin()) * s
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn standard(ui: &mut Ui, d: &mut ColorsDialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(tl!("Colors:"));
|
||||
let s = 11.0;
|
||||
let n = HONEYCOMB_RINGS as f32;
|
||||
let size = vec2(3f32.sqrt() * s * (2.0 * n + 1.0) + 2.0, s * (3.0 * n + 2.0) + 2.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), size.y), Sense::click());
|
||||
let centre = rect.center();
|
||||
let cells = honeycomb();
|
||||
let at = |q: i32, r: i32| {
|
||||
let (x, y) = cell_xy(q, r);
|
||||
centre + vec2(x, y) * s
|
||||
};
|
||||
let hit = |p: Pos2, centres: Vec<(Pos2, Rgb)>| centres.into_iter().find(|(c, _)| c.distance(p) < s * 0.9).map(|(_, col)| col);
|
||||
let mut selected = None;
|
||||
for &(q, r, col) in &cells {
|
||||
let c = at(q, r);
|
||||
ui.painter().add(egui::Shape::convex_polygon(hexagon(c, s), c32(col), Stroke::new(0.5, t.border_strong)));
|
||||
if col == d.color {
|
||||
selected = Some(c);
|
||||
}
|
||||
}
|
||||
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.clicked())
|
||||
&& let Some(col) = hit(p, cells.iter().map(|&(q, r, col)| (at(q, r), col)).collect())
|
||||
{
|
||||
d.set_rgb(col);
|
||||
}
|
||||
// Greys: one row of hexagons under the honeycomb.
|
||||
ui.add_space(6.0);
|
||||
let greys = greys();
|
||||
let w = 3f32.sqrt() * s;
|
||||
let (row, gresp) = ui.allocate_exact_size(vec2(ui.available_width(), s * 2.0 + 2.0), Sense::click());
|
||||
let x0 = row.center().x - w * (greys.len() as f32 - 1.0) / 2.0;
|
||||
let gat = |i: usize| pos2(x0 + w * i as f32, row.center().y);
|
||||
for (i, col) in greys.iter().enumerate() {
|
||||
let c = gat(i);
|
||||
ui.painter().add(egui::Shape::convex_polygon(hexagon(c, s), c32(*col), Stroke::new(0.5, t.border_strong)));
|
||||
if *col == d.color {
|
||||
selected = Some(c);
|
||||
}
|
||||
}
|
||||
if let Some(p) = gresp.interact_pointer_pos().filter(|_| gresp.clicked())
|
||||
&& let Some(col) = hit(p, greys.iter().enumerate().map(|(i, col)| (gat(i), *col)).collect())
|
||||
{
|
||||
d.set_rgb(col);
|
||||
}
|
||||
if let Some(c) = selected {
|
||||
ui.painter().add(egui::Shape::closed_line(hexagon(c, s), Stroke::new(2.5, Color32::WHITE)));
|
||||
ui.painter().add(egui::Shape::closed_line(hexagon(c, s + 1.5), Stroke::new(1.0, Color32::BLACK)));
|
||||
}
|
||||
}
|
||||
|
||||
fn custom(ui: &mut Ui, d: &mut ColorsDialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(tl!("Colors:"));
|
||||
ui.horizontal(|ui| {
|
||||
// Hue across, saturation up; drawn at the middle lightness.
|
||||
let (field, fresp) = ui.allocate_exact_size(vec2(280.0, 170.0), Sense::click_and_drag());
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let (cols, rows) = (36u32, 6u32);
|
||||
for j in 0..=rows {
|
||||
for i in 0..=cols {
|
||||
let (u, v) = (i as f32 / cols as f32, j as f32 / rows as f32);
|
||||
let p = pos2(field.min.x + u * field.width(), field.min.y + v * field.height());
|
||||
mesh.colored_vertex(p, c32(from_hsl(u * 360.0, 1.0 - v, 0.5)));
|
||||
}
|
||||
}
|
||||
for j in 0..rows {
|
||||
for i in 0..cols {
|
||||
let a = j * (cols + 1) + i;
|
||||
mesh.add_triangle(a, a + 1, a + cols + 1);
|
||||
mesh.add_triangle(a + 1, a + cols + 2, a + cols + 1);
|
||||
}
|
||||
}
|
||||
ui.painter().add(egui::Shape::mesh(mesh));
|
||||
ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Outside);
|
||||
if let Some(p) = fresp.interact_pointer_pos().filter(|_| fresp.clicked() || fresp.dragged()) {
|
||||
let u = ((p.x - field.min.x) / field.width()).clamp(0.0, 1.0);
|
||||
let v = ((p.y - field.min.y) / field.height()).clamp(0.0, 1.0);
|
||||
let l = if d.hsl.2 <= 0.0 || d.hsl.2 >= 1.0 { 0.5 } else { d.hsl.2 };
|
||||
d.set_hsl(u * 359.9, 1.0 - v, l);
|
||||
}
|
||||
let m = pos2(field.min.x + d.hsl.0 / 360.0 * field.width(), field.min.y + (1.0 - d.hsl.1) * field.height());
|
||||
ui.painter().circle_stroke(m, 5.0, Stroke::new(2.0, Color32::BLACK));
|
||||
ui.painter().circle_stroke(m, 4.0, Stroke::new(1.0, Color32::WHITE));
|
||||
|
||||
// Lightness: white at the top, black at the bottom, through the picked hue and saturation.
|
||||
let (bar, bresp) = ui.allocate_exact_size(vec2(16.0, 170.0), Sense::click_and_drag());
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let steps = 16u32;
|
||||
for k in 0..=steps {
|
||||
let v = k as f32 / steps as f32;
|
||||
let col = c32(from_hsl(d.hsl.0, d.hsl.1, 1.0 - v));
|
||||
let y = bar.min.y + v * bar.height();
|
||||
mesh.colored_vertex(pos2(bar.min.x, y), col);
|
||||
mesh.colored_vertex(pos2(bar.max.x, y), col);
|
||||
}
|
||||
for k in 0..steps {
|
||||
let a = k * 2;
|
||||
mesh.add_triangle(a, a + 1, a + 2);
|
||||
mesh.add_triangle(a + 1, a + 3, a + 2);
|
||||
}
|
||||
ui.painter().add(egui::Shape::mesh(mesh));
|
||||
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Outside);
|
||||
if let Some(p) = bresp.interact_pointer_pos().filter(|_| bresp.clicked() || bresp.dragged()) {
|
||||
let v = ((p.y - bar.min.y) / bar.height()).clamp(0.0, 1.0);
|
||||
d.set_hsl(d.hsl.0, d.hsl.1, 1.0 - v);
|
||||
}
|
||||
let y = bar.min.y + (1.0 - d.hsl.2) * bar.height();
|
||||
let tip = pos2(bar.max.x + 2.0, y);
|
||||
ui.painter().add(egui::Shape::convex_polygon(vec![tip, tip + vec2(7.0, -5.0), tip + vec2(7.0, 5.0)], t.text, Stroke::NONE));
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("colors_custom").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Color model:"));
|
||||
egui::ComboBox::from_id_salt("colors_model")
|
||||
.selected_text(match d.model {
|
||||
Model::Rgb => "RGB",
|
||||
Model::Hsl => "HSL",
|
||||
})
|
||||
.width(90.0)
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut d.model, Model::Rgb, "RGB");
|
||||
ui.selectable_value(&mut d.model, Model::Hsl, "HSL");
|
||||
});
|
||||
ui.end_row();
|
||||
match d.model {
|
||||
Model::Rgb => {
|
||||
let mut c = [d.color.0, d.color.1, d.color.2];
|
||||
let mut changed = false;
|
||||
for (i, label) in ["Red:", "Green:", "Blue:"].into_iter().enumerate() {
|
||||
ui.label(tl!(label));
|
||||
if let Some(v) = c.get_mut(i) {
|
||||
changed |= ui.add(egui::DragValue::new(v).range(0..=255).speed(1.0)).changed();
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
if changed {
|
||||
d.set_rgb(Rgb(c[0], c[1], c[2]));
|
||||
}
|
||||
}
|
||||
Model::Hsl => {
|
||||
// Word-style 0–255 scales for all three.
|
||||
let to255 = |v: f32| (v * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
let mut v = [to255(d.hsl.0 / 360.0), to255(d.hsl.1), to255(d.hsl.2)];
|
||||
let mut changed = false;
|
||||
for (i, label) in ["Hue:", "Sat:", "Lum:"].into_iter().enumerate() {
|
||||
ui.label(tl!(label));
|
||||
if let Some(x) = v.get_mut(i) {
|
||||
changed |= ui.add(egui::DragValue::new(x).range(0..=255).speed(1.0)).changed();
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
if changed {
|
||||
d.set_hsl(v[0] as f32 / 255.0 * 360.0, v[1] as f32 / 255.0, v[2] as f32 / 255.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.label(tl!("Hex:"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("#");
|
||||
let mut text = d.hex.clone();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(80.0).char_limit(16));
|
||||
if r.changed() {
|
||||
d.hex = text.clone();
|
||||
d.set_text(&text);
|
||||
}
|
||||
if r.lost_focus() {
|
||||
d.hex = d.color.hex();
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_hex_short_hex_and_rgb_triples() {
|
||||
assert_eq!(parse("1A2B3C"), Some(Rgb(0x1A, 0x2B, 0x3C)));
|
||||
assert_eq!(parse(" #1a2b3c "), Some(Rgb(0x1A, 0x2B, 0x3C)));
|
||||
assert_eq!(parse("0xFF8800"), Some(Rgb(0xFF, 0x88, 0x00)));
|
||||
assert_eq!(parse("#f80"), Some(Rgb(0xFF, 0x88, 0x00)));
|
||||
assert_eq!(parse("rgb(12, 34, 56)"), Some(Rgb(12, 34, 56)));
|
||||
assert_eq!(parse("12 34 56"), Some(Rgb(12, 34, 56)));
|
||||
for junk in ["", "#", "#12345", "GGGGGG", "rgb(1,2)", "256,0,0", "-1,0,0", "ééé", "#ééé", "1,2,3,4", "\u{0}"] {
|
||||
assert_eq!(parse(junk), None, "{junk:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsl_round_trips_every_colour_channel_step() {
|
||||
for r in (0..=255u8).step_by(15) {
|
||||
for g in (0..=255u8).step_by(17) {
|
||||
for b in (0..=255u8).step_by(51) {
|
||||
let c = Rgb(r, g, b);
|
||||
let (h, s, l) = to_hsl(c);
|
||||
assert!((0.0..360.0).contains(&h) && (0.0..=1.0).contains(&s) && (0.0..=1.0).contains(&l));
|
||||
assert_eq!(from_hsl(h, s, l), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(from_hsl(0.0, 1.0, 0.5), Rgb(255, 0, 0));
|
||||
assert_eq!(from_hsl(120.0, 1.0, 0.5), Rgb(0, 255, 0));
|
||||
assert_eq!(from_hsl(240.0, 1.0, 0.5), Rgb(0, 0, 255));
|
||||
assert_eq!(from_hsl(-120.0, 1.0, 0.5), Rgb(0, 0, 255), "hue wraps");
|
||||
assert_eq!(from_hsl(f32::NAN, f32::INFINITY, -3.0), Rgb(0, 0, 0), "junk input is clamped, never a panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_colors_dedupe_and_cap() {
|
||||
let mut list = vec!["junk".to_string()];
|
||||
for i in 0..15u8 {
|
||||
push_recent(&mut list, &Rgb(i, i, i).hex());
|
||||
}
|
||||
push_recent(&mut list, "#0e0e0e");
|
||||
push_recent(&mut list, "not a colour");
|
||||
assert_eq!(list.len(), RECENT_MAX);
|
||||
assert_eq!(list.first().map(String::as_str), Some("0E0E0E"));
|
||||
assert_eq!(list.iter().filter(|h| *h == "0E0E0E").count(), 1);
|
||||
let saved = vec!["FF0000".to_string(), "ff0000".into(), "nope".into(), "00FF00".into()];
|
||||
assert_eq!(recent(&saved), vec![Rgb(255, 0, 0), Rgb(0, 255, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honeycomb_is_white_in_the_middle_and_has_every_hue() {
|
||||
let cells = honeycomb();
|
||||
assert_eq!(cells.len(), 127, "6 rings around the centre");
|
||||
assert_eq!(cells.iter().find(|(q, r, _)| (*q, *r) == (0, 0)).map(|c| c.2), Some(Rgb::WHITE));
|
||||
let mid: Vec<Rgb> = cells.iter().filter(|(q, r, _)| q.abs().max(r.abs()).max((q + r).abs()) == 3).map(|c| c.2).collect();
|
||||
assert!(mid.iter().any(|c| c.0 > 200 && c.1 < 100 && c.2 < 100), "a red");
|
||||
assert!(mid.iter().any(|c| c.1 > 200 && c.0 < 100 && c.2 < 100), "a green");
|
||||
assert!(mid.iter().any(|c| c.2 > 200 && c.0 < 100 && c.1 < 100), "a blue");
|
||||
let g = greys();
|
||||
assert_eq!((g.first().copied(), g.last().copied()), (Some(Rgb::WHITE), Some(Rgb::BLACK)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_put_the_colour_where_the_command_wants_it() {
|
||||
assert_eq!(Target::cmd("format.color").command("1A2B3C"), Some(("format.color".into(), json!({"color": "1A2B3C"}))));
|
||||
let glow = Target::at("shape.effects", json!({"glow": {"color": null, "size": 8.0}}), "/glow/color");
|
||||
assert_eq!(glow.command("00FF00"), Some(("shape.effects".into(), json!({"glow": {"color": "00FF00", "size": 8.0}}))));
|
||||
assert_eq!(Target::Field(Id::new("x")).command("00FF00"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_keeps_hue_through_grey_and_reads_typed_colours() {
|
||||
let mut d = ColorsDialog::new(Some(Rgb(255, 0, 0)), Target::cmd("format.color"));
|
||||
d.set_hsl(200.0, 0.0, 0.5);
|
||||
assert_eq!(d.hsl.0, 200.0, "grey keeps the hue");
|
||||
d.set_hsl(d.hsl.0, 1.0, 0.5);
|
||||
assert_eq!(d.color, from_hsl(200.0, 1.0, 0.5));
|
||||
assert!(d.set_text("#123456"));
|
||||
assert_eq!(d.color, Rgb(0x12, 0x34, 0x56));
|
||||
assert!(!d.set_text("#12"), "an unfinished hex keeps the colour");
|
||||
assert_eq!(d.color, Rgb(0x12, 0x34, 0x56));
|
||||
}
|
||||
|
||||
/// #606: More Colors… for the font colour opens the Colors dialog with the selection's colour
|
||||
/// as Current; OK applies the custom colour, remembers it on the split button and puts it
|
||||
/// first in Recent Colors.
|
||||
#[test]
|
||||
fn custom_font_color_applies_and_joins_recent_colors() {
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
let _ = app.run("document.setText", json!({"text": "Custom colour"}));
|
||||
let _ = app.run("select.all", json!({}));
|
||||
let _ = app.run("format.color", json!({"color": "112233"}));
|
||||
let r = app.run("ui.dialog", json!({"name": "colors"}));
|
||||
assert_eq!(r.ok().and_then(|v| v.get("dialog").cloned()), Some(json!("colors")));
|
||||
assert!(!crate::mini_toolbar::visible(&app), "the mini toolbar stays out of the Colors dialog's way");
|
||||
let mut d = app.colors.take().unwrap_or_else(|| panic!("the Colors dialog opens"));
|
||||
assert_eq!(d.current, Some(Rgb(0x11, 0x22, 0x33)));
|
||||
assert!(d.set_text("#7A3EC8"));
|
||||
let ctx = egui::Context::default();
|
||||
finish(&mut app, &ctx, &d);
|
||||
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||||
assert_eq!(st.get("color"), Some(&json!("7A3EC8")));
|
||||
assert_eq!(app.canvas.last_font_color, "7A3EC8");
|
||||
assert_eq!(app.ui.recent_colors.first().map(String::as_str), Some("7A3EC8"));
|
||||
// A dialog field gets the colour back instead.
|
||||
let id = Id::new("field");
|
||||
d.target = Target::Field(id);
|
||||
finish(&mut app, &ctx, &d);
|
||||
let mut value = String::new();
|
||||
take_field(&ctx, id, &mut value);
|
||||
assert_eq!(value, "7A3EC8");
|
||||
}
|
||||
}
|
||||
|
|
@ -803,8 +803,16 @@ fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value {
|
|||
|
||||
/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none.
|
||||
pub(crate) fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) {
|
||||
let c = wordcraft_doc::Rgb::parse(value);
|
||||
color_field(ui, theme, value, "No Color", "");
|
||||
}
|
||||
|
||||
/// A colour field: a swatch and a drop-down with `none_label` (which sets `none_value`), the
|
||||
/// colour grid, Recent Colors and More Colors… (#606). `value` is hex or `none_value`.
|
||||
pub(crate) fn color_field(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_label: &str, none_value: &str) {
|
||||
ui.horizontal(|ui| {
|
||||
let id = ui.id().with("colorField");
|
||||
crate::colors::take_field(ui.ctx(), id, value);
|
||||
let c = wordcraft_doc::Rgb::parse(value);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||||
let t = Tokens::get(ui.ctx());
|
||||
match c {
|
||||
|
|
@ -816,16 +824,13 @@ pub(crate) fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut
|
|||
}
|
||||
}
|
||||
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
|
||||
let label = if c.is_some() { value.clone() } else { tl!("No Color").to_string() };
|
||||
let label = if c.is_some() { value.clone() } else { tl!(none_label).to_string() };
|
||||
ui.menu_button(label, |ui| {
|
||||
if ui.button(tl!("No Color")).clicked() {
|
||||
value.clear();
|
||||
ui.close();
|
||||
}
|
||||
if let Some(hex) = crate::widgets::color_grid(ui, theme) {
|
||||
*value = hex;
|
||||
if ui.button(tl!(none_label)).clicked() {
|
||||
*value = none_value.to_string();
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::field_menu(ui, theme, id, value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1343,11 +1348,13 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
|
|||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
// Enter and Escape belong to the Colors dialog while it is open above this one.
|
||||
let keys = !crate::colors::is_open(ui.ctx());
|
||||
if ui.button(tl!("Cancel")).clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Escape))) {
|
||||
r.1 = true;
|
||||
}
|
||||
let okb = ui.add(egui::Button::new(egui::RichText::new(ok).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR));
|
||||
if okb.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
if okb.clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
|
||||
r.0 = true;
|
||||
}
|
||||
});
|
||||
|
|
@ -1388,6 +1395,9 @@ fn font_props(d: &Dialog) -> serde_json::Map<String, Value> {
|
|||
}
|
||||
if let Some(c) = wordcraft_doc::Rgb::parse(color) {
|
||||
props.insert("color".into(), json!({"Rgb": [c.0, c.1, c.2]}));
|
||||
} else if color.eq_ignore_ascii_case("auto") {
|
||||
// Automatic, from the colour field's drop-down (#606).
|
||||
props.insert("color".into(), json!("Auto"));
|
||||
}
|
||||
props
|
||||
}
|
||||
|
|
@ -1463,7 +1473,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
ui.text_edit_singleline(size);
|
||||
ui.end_row();
|
||||
ui.label(tl!("Font color:"));
|
||||
ui.text_edit_singleline(color);
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
color_field(ui, &theme, color, "Automatic", "auto");
|
||||
ui.end_row();
|
||||
ui.label(tl!("Spacing (pt):"));
|
||||
ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0));
|
||||
|
|
|
|||
|
|
@ -416,8 +416,10 @@ fn choice(ui: &mut Ui, id: &str, value: &mut String, options: &[(&str, &str)]) {
|
|||
/// A colour: `none_label` (by author, or `auto` when given) or a colour from the grid. `value` is
|
||||
/// hex.
|
||||
fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_label: &str, auto: Option<wordcraft_doc::Rgb>) {
|
||||
let c = wordcraft_doc::Rgb::parse(value);
|
||||
ui.horizontal(|ui| {
|
||||
let id = ui.id().with("colorField");
|
||||
crate::colors::take_field(ui.ctx(), id, value);
|
||||
let c = wordcraft_doc::Rgb::parse(value);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||||
let t = Tokens::get(ui.ctx());
|
||||
match c.or(auto) {
|
||||
|
|
@ -440,10 +442,7 @@ fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_lab
|
|||
value.clear();
|
||||
ui.close();
|
||||
}
|
||||
if let Some(hex) = crate::widgets::color_grid(ui, theme) {
|
||||
*value = hex;
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::field_menu(ui, theme, id, value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1535,6 +1535,19 @@
|
|||
New Group Nuevo grupo
|
||||
Rename… Cambiar nombre…
|
||||
Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido:
|
||||
Recent Colors Colores recientes
|
||||
More Colors… Más colores…
|
||||
Standard Estándar
|
||||
Current Actual
|
||||
Colors: Colores:
|
||||
Color model: Modelo de color:
|
||||
Red: Rojo:
|
||||
Green: Verde:
|
||||
Blue: Azul:
|
||||
Hue: Matiz:
|
||||
Sat: Sat.:
|
||||
Lum: Lum.:
|
||||
Hex: Hex.:
|
||||
Accessibility Pane Panel de accesibilidad
|
||||
Alt Text Pane Panel de texto alternativo
|
||||
Alt\nText Texto\nalternativo
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1521,6 +1521,19 @@
|
|||
Ribbon Lint
|
||||
Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba:
|
||||
Ribbon tabs Lindi vahekaardid
|
||||
Recent Colors Viimati kasutatud värvid
|
||||
More Colors… Rohkem värve…
|
||||
Standard Standard
|
||||
Current Praegune
|
||||
Colors: Värvid:
|
||||
Color model: Värvimudel:
|
||||
Red: Punane:
|
||||
Green: Roheline:
|
||||
Blue: Sinine:
|
||||
Hue: Toon:
|
||||
Sat: Küllastus:
|
||||
Lum: Heledus:
|
||||
Hex: Hex:
|
||||
Right-to-Left Table Paremalt vasakule tabel
|
||||
Right-to-Left Section Paremalt vasakule jaotis
|
||||
Table direction: Tabeli suund:
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 12.
|
|
|
@ -1519,6 +1519,19 @@
|
|||
New Group 新しいグループ
|
||||
Rename… 名前の変更…
|
||||
Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー:
|
||||
Recent Colors 最近使用した色
|
||||
More Colors… その他の色…
|
||||
Standard 標準
|
||||
Current 現在
|
||||
Colors: 色:
|
||||
Color model: カラー モデル:
|
||||
Red: 赤:
|
||||
Green: 緑:
|
||||
Blue: 青:
|
||||
Hue: 色合い:
|
||||
Sat: 鮮やかさ:
|
||||
Lum: 明るさ:
|
||||
Hex: 16 進数:
|
||||
Accessibility Pane アクセシビリティ ウィンドウ
|
||||
Alt Text Pane 代替テキスト ウィンドウ
|
||||
Alt\nText 代替\nテキスト
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -1531,6 +1531,19 @@
|
|||
New Group Novo Grupo
|
||||
Rename… Renomear…
|
||||
Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido:
|
||||
Recent Colors Cores recentes
|
||||
More Colors… Mais cores…
|
||||
Standard Padrão
|
||||
Current Atual
|
||||
Colors: Cores:
|
||||
Color model: Modelo de cor:
|
||||
Red: Vermelho:
|
||||
Green: Verde:
|
||||
Blue: Azul:
|
||||
Hue: Matiz:
|
||||
Sat: Sat.:
|
||||
Lum: Lum.:
|
||||
Hex: Hex.:
|
||||
Accessibility Pane Painel de acessibilidade
|
||||
Alt Text Pane Painel de texto alternativo
|
||||
Alt\nText Texto\nalternativo
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1790,6 +1790,19 @@ preview Title Пример
|
|||
Left Only Только слева
|
||||
Right Only Только справа
|
||||
Largest Only Только по большей стороне
|
||||
Recent Colors Последние цвета
|
||||
More Colors… Другие цвета…
|
||||
Standard Обычные
|
||||
Current Текущий
|
||||
Colors: Цвета:
|
||||
Color model: Цветовая модель:
|
||||
Red: Красный:
|
||||
Green: Зеленый:
|
||||
Blue: Синий:
|
||||
Hue: Оттенок:
|
||||
Sat: Насыщенность:
|
||||
Lum: Яркость:
|
||||
Hex: Hex:
|
||||
Accessibility Pane Область специальных возможностей
|
||||
Alt Text Pane Область замещающего текста
|
||||
Alt\nText Замещающий\nтекст
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -1529,6 +1529,19 @@
|
|||
New Group Nova grupa
|
||||
Rename… Preimenuj…
|
||||
Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup:
|
||||
Recent Colors Nedavne boje
|
||||
More Colors… Još boja…
|
||||
Standard Standardne
|
||||
Current Trenutna
|
||||
Colors: Boje:
|
||||
Color model: Model boja:
|
||||
Red: Crvena:
|
||||
Green: Zelena:
|
||||
Blue: Plava:
|
||||
Hue: Nijansa:
|
||||
Sat: Zasićenost:
|
||||
Lum: Osvetljenost:
|
||||
Hex: Heks:
|
||||
Accessibility Pane Okno pristupačnosti
|
||||
Alt Text Pane Okno alternativnog teksta
|
||||
Alt\nText Alternativni\ntekst
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1533,6 +1533,19 @@
|
|||
New Group Нова група
|
||||
Rename… Преименуј…
|
||||
Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ:
|
||||
Recent Colors Недавне боје
|
||||
More Colors… Још боја…
|
||||
Standard Стандардне
|
||||
Current Тренутна
|
||||
Colors: Боје:
|
||||
Color model: Модел боја:
|
||||
Red: Црвена:
|
||||
Green: Зелена:
|
||||
Blue: Плава:
|
||||
Hue: Нијанса:
|
||||
Sat: Засићеност:
|
||||
Lum: Осветљеност:
|
||||
Hex: Хекс:
|
||||
Accessibility Pane Окно приступачности
|
||||
Alt Text Pane Окно алтернативног текста
|
||||
Alt\nText Алтернативни\nтекст
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
|
|
@ -1408,6 +1408,19 @@
|
|||
Left Only Yalnızca Sol
|
||||
Right Only Yalnızca Sağ
|
||||
Largest Only Yalnızca En Geniş
|
||||
Recent Colors Son Kullanılan Renkler
|
||||
More Colors… Diğer Renkler…
|
||||
Standard Standart
|
||||
Current Geçerli
|
||||
Colors: Renkler:
|
||||
Color model: Renk modeli:
|
||||
Red: Kırmızı:
|
||||
Green: Yeşil:
|
||||
Blue: Mavi:
|
||||
Hue: Ton:
|
||||
Sat: Doygunluk:
|
||||
Lum: Parlaklık:
|
||||
Hex: Onaltılık:
|
||||
Accessibility Pane Erişilebilirlik Bölmesi
|
||||
Alt Text Pane Alternatif Metin Bölmesi
|
||||
Alt\nText Alternatif\nMetin
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 18.
|
|
|
@ -1531,6 +1531,19 @@
|
|||
New Group Нова група
|
||||
Rename… Перейменувати…
|
||||
Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу:
|
||||
Recent Colors Останні кольори
|
||||
More Colors… Інші кольори…
|
||||
Standard Стандартні
|
||||
Current Поточний
|
||||
Colors: Кольори:
|
||||
Color model: Колірна модель:
|
||||
Red: Червоний:
|
||||
Green: Зелений:
|
||||
Blue: Синій:
|
||||
Hue: Відтінок:
|
||||
Sat: Насиченість:
|
||||
Lum: Яскравість:
|
||||
Hex: Hex:
|
||||
Accessibility Pane Панель доступності
|
||||
Alt Text Pane Панель альтернативного тексту
|
||||
Alt\nText Альтернативний\nтекст
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
|
|
@ -1531,6 +1531,19 @@
|
|||
New Group 新建组
|
||||
Rename… 重命名…
|
||||
Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏:
|
||||
Recent Colors 最近使用的颜色
|
||||
More Colors… 其他颜色…
|
||||
Standard 标准
|
||||
Current 当前
|
||||
Colors: 颜色:
|
||||
Color model: 颜色模式:
|
||||
Red: 红色:
|
||||
Green: 绿色:
|
||||
Blue: 蓝色:
|
||||
Hue: 色调:
|
||||
Sat: 饱和度:
|
||||
Lum: 亮度:
|
||||
Hex: 十六进制:
|
||||
Accessibility Pane 辅助功能窗格
|
||||
Alt Text Pane 替代文字窗格
|
||||
Alt\nText 替代\n文字
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -1520,6 +1520,19 @@
|
|||
New Group 新增群組
|
||||
Rename… 重新命名…
|
||||
Ribbon and Quick Access Toolbar: 功能區和快速存取工具列:
|
||||
Recent Colors 最近使用的色彩
|
||||
More Colors… 其他色彩…
|
||||
Standard 標準
|
||||
Current 目前
|
||||
Colors: 色彩:
|
||||
Color model: 色彩模式:
|
||||
Red: 紅色:
|
||||
Green: 綠色:
|
||||
Blue: 藍色:
|
||||
Hue: 色調:
|
||||
Sat: 飽和度:
|
||||
Lum: 亮度:
|
||||
Hex: 十六進位:
|
||||
Accessibility Pane 協助工具窗格
|
||||
Alt Text Pane 替代文字窗格
|
||||
Alt\nText 替代\n文字
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 4.
|
|
|
@ -18,6 +18,7 @@ pub mod a11y_pane;
|
|||
pub mod backstage;
|
||||
pub mod canvas;
|
||||
pub mod chrome;
|
||||
pub mod colors;
|
||||
pub mod content_controls;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
|
|
@ -111,6 +112,8 @@ pub struct UiState {
|
|||
pub recent: Vec<String>,
|
||||
/// Symbols inserted from the Symbol dialog, most recent first (#321).
|
||||
pub recent_symbols: Vec<dialogs_insert::RecentSymbol>,
|
||||
/// Colours picked in the Colors dialog (More Colors…), most recent first (#606).
|
||||
pub recent_colors: Vec<String>,
|
||||
/// Interface theme: light, dark, or follow the OS appearance (#115).
|
||||
pub theme: theme::Appearance,
|
||||
/// Interface size in percent (#475): scales the whole interface on top of the system's
|
||||
|
|
@ -185,6 +188,7 @@ impl Default for UiState {
|
|||
ribbon_collapsed: false,
|
||||
recent: Vec::new(),
|
||||
recent_symbols: Vec::new(),
|
||||
recent_colors: Vec::new(),
|
||||
theme: theme::Appearance::default(),
|
||||
interface_size: theme::InterfaceSize::default(),
|
||||
legacy_dark: None,
|
||||
|
|
@ -238,6 +242,8 @@ pub struct WordApp {
|
|||
/// Content control frames, check boxes and lists on the page.
|
||||
pub controls: content_controls::ControlUi,
|
||||
pub dialog: Option<dialogs::Dialog>,
|
||||
/// The Colors dialog (More Colors…), shown above any other dialog (#606).
|
||||
pub colors: Option<colors::ColorsDialog>,
|
||||
/// The Restrict Editing pane (#412).
|
||||
pub restrict: dialogs_review::RestrictPane,
|
||||
pub status_msg: Option<(String, f64)>,
|
||||
|
|
@ -332,6 +338,7 @@ impl WordApp {
|
|||
canvas: canvas::CanvasState::default(),
|
||||
controls: Default::default(),
|
||||
dialog: None,
|
||||
colors: None,
|
||||
restrict: Default::default(),
|
||||
status_msg: None,
|
||||
previews: previews::Previews::default(),
|
||||
|
|
@ -709,12 +716,25 @@ impl WordApp {
|
|||
}
|
||||
json!({"backstage": self.ui.backstage})
|
||||
}
|
||||
"ui.dialog" if s("name") == Some("colors") => {
|
||||
// The font colour's More Colors… (#606); `tab`: standard or custom.
|
||||
let target = colors::Target::cmd("format.color").remember(colors::Remember::FontColor);
|
||||
let current =
|
||||
self.session.run("format.state", &json!({})).ok().and_then(|st| st.get("color").and_then(Value::as_str).and_then(colors::parse));
|
||||
let mut d = colors::ColorsDialog::new(current, target);
|
||||
if s("tab") == Some("custom") {
|
||||
d.tab = colors::ColorsTab::Custom;
|
||||
}
|
||||
self.colors = Some(d);
|
||||
json!({"dialog": "colors"})
|
||||
}
|
||||
"ui.dialog" => {
|
||||
self.dialog = dialogs::Dialog::open(s("name").unwrap_or(""), self);
|
||||
json!({"dialog": self.dialog.as_ref().map(|d| d.name())})
|
||||
}
|
||||
"ui.closeDialog" => {
|
||||
self.dialog = None;
|
||||
self.colors = None;
|
||||
json!({})
|
||||
}
|
||||
"ui.changePicture" => {
|
||||
|
|
@ -1110,6 +1130,7 @@ impl WordApp {
|
|||
}
|
||||
content_controls::popup(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
colors::show(self, &ctx);
|
||||
zotero::show_alert(self, &ctx);
|
||||
read_aloud::show(self, &ctx);
|
||||
crate::keytips::show(self, &ctx, ui);
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ pub fn shown_id() -> egui::Id {
|
|||
/// Whether the bar may be shown for the current app state.
|
||||
pub fn visible(app: &WordApp) -> bool {
|
||||
// Never over a modal dialog or the Backstage.
|
||||
if app.dialog.is_some() || app.ui.backstage {
|
||||
if app.dialog.is_some() || app.colors.is_some() || app.ui.backstage {
|
||||
return false;
|
||||
}
|
||||
// Never while the right-click context menu is open: it owns the pointer and would double up.
|
||||
|
|
@ -205,7 +205,7 @@ fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Font colour: Automatic plus the standard colour grid.
|
||||
/// Font colour: Automatic, the colour grid, Recent Colors and More Colors… (#606).
|
||||
fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
|
||||
if ui.selectable_label(false, "Automatic").clicked() {
|
||||
|
|
@ -214,11 +214,8 @@ fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
|||
return;
|
||||
}
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(hex) = widgets::color_grid(ui, &theme) {
|
||||
app.canvas.last_font_color = hex.clone();
|
||||
let _ = app.run("format.color", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
let target = crate::colors::Target::cmd("format.color").remember(crate::colors::Remember::FontColor);
|
||||
crate::colors::menu(ui, app, &theme, target);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::colors::{Remember, Target};
|
||||
use crate::theme::{Tokens, medium, regular, semibold};
|
||||
use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, color_grid, combo, font_combo, group, menu_button, small, split};
|
||||
use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, combo, font_combo, group, menu_button, small, split};
|
||||
use crate::{WordApp, icons};
|
||||
|
||||
pub const TABS: [&str; 13] = wordcraft_engine::ribbon::TABS;
|
||||
|
|
@ -505,11 +506,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
|||
split(ui, app, "fontcolor", "Font Color", "format.color", json!({"color": fc}), false, sw, |ui, app| {
|
||||
mi(ui, app, "Automatic", "format.color", json!({"color": "auto"}));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
app.canvas.last_font_color = hex.clone();
|
||||
let _ = app.run("format.color", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::menu(ui, app, &theme, Target::cmd("format.color").remember(Remember::FontColor));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -598,11 +595,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
|||
split(ui, app, "shading", "Shading", "para.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
|
||||
mi(ui, app, "No Color", "para.shading", json!({"color": null}));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
app.canvas.last_shading = hex.clone();
|
||||
let _ = app.run("para.shading", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::menu(ui, app, &theme, Target::cmd("para.shading").remember(Remember::Shading));
|
||||
});
|
||||
split(ui, app, "borders", "Borders", "para.borders", json!({"kind": "bottom"}), false, None, |ui, app| {
|
||||
for (l, k) in [
|
||||
|
|
@ -840,15 +833,11 @@ fn draw(app: &mut WordApp, ui: &mut Ui) {
|
|||
let sw = Some(crate::theme::c32(set.color));
|
||||
split(ui, app, "fontcolor", "Color", pen_command(tool), json!({}), false, sw, |ui, app| {
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
// With ink lasso-selected the colour goes to the selection, else to the pen.
|
||||
if lasso_on {
|
||||
let _ = app.run("draw.lassoColor", json!({"color": hex}));
|
||||
} else {
|
||||
let _ = app.run(pen_command(tool), json!({"color": hex}));
|
||||
update_gallery_pen(app, custom, tool);
|
||||
}
|
||||
ui.close();
|
||||
// With ink lasso-selected the colour goes to the selection, else to the pen.
|
||||
let cmd = if lasso_on { "draw.lassoColor" } else { pen_command(tool) };
|
||||
crate::colors::menu(ui, app, &theme, Target::cmd(cmd));
|
||||
if !lasso_on {
|
||||
update_gallery_pen(app, custom, tool);
|
||||
}
|
||||
});
|
||||
menu_button(ui, app, "thickness", None, "Thickness", false, |ui, app| {
|
||||
|
|
@ -984,10 +973,7 @@ fn design(app: &mut WordApp, ui: &mut Ui) {
|
|||
menu_button(ui, app, "pageColor", Some("Page\nColor"), "Page Color", true, |ui, app| {
|
||||
mi(ui, app, "No Color", "design.pageColor", json!({"color": null}));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
let _ = app.run("design.pageColor", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::menu(ui, app, &theme, Target::cmd("design.pageColor"));
|
||||
});
|
||||
big(ui, app, "pageBorders", "Page\nBorders", "design.pageBorders", json!({}), false);
|
||||
});
|
||||
|
|
@ -1733,17 +1719,11 @@ fn shape_format(app: &mut WordApp, ui: &mut Ui) {
|
|||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, |ui, app| {
|
||||
mi(ui, app, "No Color", "shape.fill", json!({"color": null}));
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
let _ = app.run("shape.fill", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::menu(ui, app, &theme, Target::cmd("shape.fill"));
|
||||
});
|
||||
menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, |ui, app| {
|
||||
mi(ui, app, "No Color", "shape.outline", json!({"color": null}));
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
let _ = app.run("shape.outline", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::menu(ui, app, &theme, Target::cmd("shape.outline"));
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Weight"), |ui| {
|
||||
let color = stroke.unwrap_or(wordcraft_doc::Rgb::BLACK).hex();
|
||||
|
|
@ -1875,10 +1855,8 @@ fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rg
|
|||
}
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Glow Colors"), |ui| {
|
||||
if let Some(hex) = color_grid(ui, theme) {
|
||||
let _ = app.run("shape.effects", json!({"glow": {"color": hex, "size": glow_size.unwrap_or(8.0)}}));
|
||||
ui.close();
|
||||
}
|
||||
let target = Target::at("shape.effects", json!({"glow": {"color": null, "size": glow_size.unwrap_or(8.0)}}), "/glow/color");
|
||||
crate::colors::menu(ui, app, theme, target);
|
||||
});
|
||||
});
|
||||
ui.menu_button(tl!("Soft Edges"), |ui| {
|
||||
|
|
@ -1913,10 +1891,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
menu_button(ui, app, "icons", Some("Icon Color"), "Recolor a one-color picture, such as an icon", true, |ui, app| {
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
let _ = app.run("picture.iconColor", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::menu(ui, app, &theme, Target::cmd("picture.iconColor"));
|
||||
});
|
||||
menu_button(ui, app, "picture", Some("Transparency"), "Picture transparency", true, |ui, app| {
|
||||
for v in [0.0, 15.0, 30.0, 50.0, 65.0, 80.0, 95.0] {
|
||||
|
|
@ -2102,11 +2077,7 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
|||
split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
|
||||
mi(ui, app, "No Color", "table.shading", json!({"color": null}));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
app.canvas.last_shading = hex.clone();
|
||||
let _ = app.run("table.shading", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
crate::colors::menu(ui, app, &theme, Target::cmd("table.shading").remember(Remember::Shading));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -399,7 +399,7 @@ pub fn color_grid(ui: &mut Ui, theme: &[wordcraft_doc::Rgb]) -> Option<String> {
|
|||
picked
|
||||
}
|
||||
|
||||
fn swatch(ui: &mut Ui, c: wordcraft_doc::Rgb) -> bool {
|
||||
pub(crate) fn swatch(ui: &mut Ui, c: wordcraft_doc::Rgb) -> bool {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(15.0, 15.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(c.0, c.1, c.2));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (drag after a double/triple-click extends by words/paragraphs, #549; previously trivial: Find and Replace special characters and wildcards, #543; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (More Colors… and the Colors dialog, #606; previously trivial: Find and Replace special characters and wildcards, #543; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
|
||||
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
|
||||
|
|
@ -13,6 +13,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
|||
| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, Outlining (Outline view, #493); ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape, Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math | Same plus Draw | 85% | (Draw in features) |
|
||||
| Keytips (Alt / ⌃⌥ letters) | ✅ (#43) | ✅ | 85% | 1–2 |
|
||||
| Mini-toolbar on selection | ✅ (#43, two rows #535) | ✅ | 80% | 1–2 |
|
||||
| Mini-toolbar on selection | ✅ (#43) | ✅ | 80% | 1–2 |
|
||||
| Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); no Gradient or eyedropper | ✅ | 90% | 1 |
|
||||
| Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 |
|
||||
| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 |
|
||||
| Keyboard shortcuts | **137** commands bound in `CommandSpec::key`, 156 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 |
|
||||
|
|
@ -45,6 +47,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
|||
| 2026-10-11 | trivial | Right-click on a picture, shape or text box selects it and offers Wrap Text: the seven wrapping styles (current one checked), Move with Text and Fix Position on Page (`arrange.moveWithText`) (#513) |
|
||||
| 2026-10-11 | trivial | Paragraph dialog: Mirror indents (Inside/Outside labels), Don't add space between paragraphs of the same style, Formatting exceptions (Suppress line numbers, Don't hyphenate) (#512) |
|
||||
| 2026-10-11 | trivial | Screen readers: the document canvas exposes paragraphs, headings, lists, tables, links, pictures and the caret through AccessKit (#488); 25% → 50% |
|
||||
| 2026-10-11 | trivial | More Colors… in every colour drop-down and a Colors dialog (Standard and Custom tabs, RGB/HSL numbers, hex box) with Recent Colors (#606) |
|
||||
| 2026-10-11 | trivial | Selection by units: double-click and drag extends by whole words, triple-click and drag by paragraphs, Ctrl/⌘+click and drag by sentences; `select.dragUnit` (#549) |
|
||||
| 2026-10-11 | trivial | Compare / Combine Documents (original and revised document, label changes with, comparison settings, word or character level, show changes in) and AutoCorrect Options (replacement list, capitalization, exceptions, AutoFormat As You Type, Math AutoCorrect) dialogs; Restrict Editing pane (formatting and editing restrictions, exceptions for everyone, password); modal dialog rows merged into one (#412) |
|
||||
| 2026-10-11 | trivial | Line Numbers, Hyphenation (with Manual Hyphenation), Language, Envelopes and Labels dialogs (#407); the three duplicated modal-dialog rows merged into one |
|
||||
|
|
|
|||
Loading…
Reference in New Issue