Files
ElyOffice/crates/ely-office/src/panel.rs
T
ZacharyZhang-NY d59159cea2 Fixes for M-02 to M-14, G-01 to G-14, K-02 to K-04, X-04 to X-14, not yet reviewed
Committed before review so Windows and WSL builds can start.
Each fix was tested and mutation-checked here; the codex review
of each item is still to come, so tasks/BUGS.md stays unticked.

M-02 is mid-review: round 1 findings are fixed (apps/ely-mark/src/tally.rs).
K-01 stays deferred. The ElyMark window test mark-outside.sh failed
once at its crash-recovery step in the last run; not yet looked into.
2026-10-10 02:29:19 -04:00

307 lines
10 KiB
Rust

//! Kernel-bound controls the property panels share.
use ely_gpui_component::{
buttons::{Button, ToggleButton, ToggleItem},
forms::{Choice, ColorPalette, ColorSwatch, Combobox, Select, TextInput},
overlays::Popover,
primitives::{IconName, Tooltip},
theme::{ActiveTheme as _, ControlSize},
};
use ely_lok::show::Paint;
use gpui::{
AnyElement, App, Context, Entity, Hsla, InteractiveElement as _, IntoElement,
ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div,
rgb,
};
/// Sizes the size menu offers, in points.
const SIZES: [&str; 17] = [
"8", "9", "10", "10.5", "11", "12", "14", "16", "18", "20", "22", "24", "26", "28", "36", "48",
"72",
];
/// Palette colors, 0xRRGGBB.
pub const COLORS: [u32; 10] = [
0x000000, 0x808080, 0xFF0000, 0xFF8000, 0xFFFF00, 0x00A933, 0x00FFFF, 0x2A6099, 0x800080,
0xFFFFFF,
];
/// A panel that edits one document through table commands.
pub trait Panel: Sized + 'static {
/// The kernel's state for `uno`.
fn state(&self, uno: &str, cx: &App) -> Option<SharedString>;
/// Runs table command `id` on the document.
fn run(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>);
}
fn text(cx: &App, key: &str) -> SharedString {
ely_ui::i18n::text(cx, key, &[])
}
fn uno_of(command: &str) -> &'static str {
ely_lok::commands::find(command)
.expect("a table command")
.uno
}
/// Runs `command` with `values` through `panel`.
pub fn send<P: Panel>(panel: &Entity<P>, command: &'static str, values: &[&str], cx: &mut App) {
panel.update(cx, |panel, cx| panel.run(command, values, cx));
}
/// A kernel-lit toggle; `why` explains it when disabled.
pub fn toggle<P: Panel>(
panel: &P,
command: &'static str,
(icon, label): (Option<IconName>, &str),
(tip, why): (&str, &str),
cx: &Context<P>,
) -> AnyElement {
let this = cx.entity();
let state = panel.state(uno_of(command), cx);
let mut item = ToggleItem::new(command);
if let Some(icon) = icon {
item = item.icon(icon);
}
let shown = match state.as_deref() == Some(ely_lok::MIXED) {
true => format!("{label} {}", text(cx, "office.props.mixed")),
false => label.to_string(),
};
if !shown.trim().is_empty() {
item = item.label(shown.trim().to_string());
}
let disabled = state.as_deref() == Some("disabled");
if disabled {
item = item.tooltip(text(cx, why));
} else if !tip.is_empty() {
item = item.tooltip(text(cx, tip));
}
ToggleButton::new(
SharedString::from(command),
item,
state.as_deref() == Some("true"),
)
.disabled(disabled)
.size(ControlSize::Sm)
.on_toggle(move |_, _, cx| send(&this, command, &[], cx))
.into_any_element()
}
/// A button for `command`; `why` explains it when disabled.
pub fn button<P: Panel>(
panel: &P,
command: &'static str,
key: &str,
why: &str,
cx: &Context<P>,
) -> AnyElement {
let this = cx.entity();
let off = panel.state(uno_of(command), cx).as_deref() == Some("disabled");
let button = Button::new(SharedString::from(command), text(cx, key))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| send(&this, command, &[], cx));
guarded(button, command, off.then(|| text(cx, why)))
}
/// A clear button: label key, command, value.
pub type Clear = (&'static str, &'static str, &'static str);
/// A color swatch; `empty` names no color.
pub fn palette<P: Panel>(
panel: &Entity<P>,
command: &'static str,
(current, empty): (Paint, &str),
clear: Option<Clear>,
cx: &App,
) -> AnyElement {
let (shown, tip) = match current {
Paint::Color(color) => (Hsla::from(rgb(color)), format!("#{color:06X}").into()),
Paint::Mixed => (gpui::transparent_black(), text(cx, "office.props.mixed")),
Paint::None => (gpui::transparent_black(), text(cx, empty)),
};
let label = (current == Paint::Mixed).then(|| muted(tip.clone(), cx));
let clear = clear.map(|(key, run, value)| (text(cx, key), run, value));
let this = panel.clone();
let popover = Popover::with_opener(
SharedString::from(format!("{command}-popover")),
move |toggle| {
ColorSwatch::new(SharedString::from(format!("{command}-swatch")), shown)
.tooltip(tip)
.on_click(move |window, cx| toggle(window, cx))
},
move |close, _, _| {
let swatches = COLORS.map(|color| (format!("#{color:06X}"), Hsla::from(rgb(color))));
let picked = this.clone();
let mut palette = ColorPalette::new(SharedString::from(command), swatches.clone())
.columns(5)
.on_change({
let close = close.clone();
move |color, window, cx| {
let at = swatches
.iter()
.position(|(_, swatch)| *swatch == color)
.expect("the palette hands back its own colors");
close(window, cx);
send(&picked, command, &[&COLORS[at].to_string()], cx)
}
});
if let Paint::Color(color) = current
&& COLORS.contains(&color)
{
palette = palette.selected(rgb(color));
}
let this = this.clone();
let clear = clear.clone().map(|(label, run, value)| {
Button::new(SharedString::from(format!("{command}-clear")), label)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
close(window, cx);
send(&this, run, &[value], cx)
})
});
div()
.flex()
.flex_col()
.gap_2()
.p_2()
.child(palette)
.children(clear)
},
);
row([popover.into_any_element()].into_iter().chain(label))
}
/// The kernel's state for `uno` as a color.
pub fn color_state<P: Panel>(panel: &P, uno: &str, cx: &App) -> Paint {
let state = panel.state(uno, cx);
if state.as_deref() == Some(ely_lok::MIXED) {
return Paint::Mixed;
}
state
.and_then(|color| color.parse::<u32>().ok())
.map_or(Paint::None, Paint::Color)
}
/// The font box; `id` keys its text field.
pub fn font_box<P: Panel>(
panel: &P,
id: &'static str,
fonts: &[SharedString],
window: &mut Window,
cx: &mut Context<P>,
) -> AnyElement {
let this = cx.entity();
let input = window.use_keyed_state(
SharedString::from(format!("{id}-input")),
cx,
TextInput::new,
);
let choices = fonts
.iter()
.map(|font| Choice::new(font.clone(), font.clone()));
let mut font = Combobox::new(id, &input, choices)
.size(ControlSize::Sm)
.on_change(move |value, _, cx| send(&this, "format.font", &[value.as_ref()], cx));
let name = panel.state(".uno:CharFontName", cx);
let hint = match name.as_deref() == Some(ely_lok::MIXED) {
true => text(cx, "office.props.mixed"),
false => SharedString::default(),
};
if input.read(cx).placeholder_text() != &hint {
input.update(cx, |input, cx| input.set_placeholder(hint, cx));
}
if let Some(name) = name.filter(|name| known(name)) {
font = font.selected(name);
}
font.into_any_element()
}
/// The size menu, in points.
pub fn size_menu<P: Panel>(panel: &P, id: &'static str, cx: &Context<P>) -> AnyElement {
let this = cx.entity();
let reported = panel.state(".uno:FontHeight", cx);
let size = reported.clone().filter(|size| known(size));
let mut sizes: Vec<SharedString> = SIZES.iter().map(|size| (*size).into()).collect();
if let Some(size) = size.clone().filter(|size| !sizes.contains(size)) {
sizes.push(size);
}
let sizes = ordered(sizes);
let mut menu = Select::new(
id,
sizes
.into_iter()
.map(|size| Choice::new(size.clone(), size)),
)
.size(ControlSize::Sm)
.on_change(move |value, _, cx| send(&this, "format.size", &[value.as_ref()], cx));
if reported.as_deref() == Some(ely_lok::MIXED) {
menu = menu.placeholder(text(cx, "office.props.mixed"));
}
if let Some(size) = size {
menu = menu.selected(size);
}
menu.into_any_element()
}
/// Controls side by side, wrapping when narrow.
pub fn row(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
div()
.flex()
.flex_wrap()
.items_center()
.gap_1()
.children(items)
.into_any_element()
}
/// `button`, or it disabled with `why` on hover.
pub fn guarded(button: Button, id: &str, why: Option<SharedString>) -> AnyElement {
match why {
None => button.into_any_element(),
Some(why) => div()
.id(SharedString::from(format!("{id}-why")))
.child(button.disabled(true))
.tooltip(Tooltip::text(why))
.into_any_element(),
}
}
/// Quiet text in place of a control.
pub fn muted(text: SharedString, cx: &App) -> AnyElement {
div()
.text_color(cx.theme().colors.fg_muted)
.child(text)
.into_any_element()
}
/// A real kernel value: neither empty nor mixed.
pub fn known(state: &str) -> bool {
!state.is_empty() && state != ely_lok::MIXED
}
/// Sizes by points; one the kernel words oddly shows last.
fn ordered(sizes: Vec<SharedString>) -> Vec<SharedString> {
let (mut sized, mut odd) = (Vec::new(), Vec::new());
for size in sizes {
match size.parse::<f64>() {
Ok(points) => sized.push((points, size)),
Err(_) => odd.push(size),
}
}
sized.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut sizes: Vec<SharedString> = sized.into_iter().map(|(_, size)| size).collect();
sizes.append(&mut odd);
sizes
}
#[cfg(test)]
mod tests {
#[test]
fn an_odd_size_shows_last_and_panics_nothing() {
let sizes = ["12", "disabled", "9", "10.5"]
.map(gpui::SharedString::from)
.to_vec();
assert_eq!(super::ordered(sizes), ["9", "10.5", "12", "disabled"]);
}
}