mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 00:10:11 +00:00
774 lines
41 KiB
Rust
774 lines
41 KiB
Rust
//! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush
|
|
//! applies it to the selected paths (and makes it the Paintbrush's current brush); double-clicking
|
|
//! one opens its Brush Options. Shift-click selects a range of brushes, Cmd/Ctrl-click adds or
|
|
//! removes one, and the panel menu's Select All Unused selects the brushes no stroke paints with,
|
|
//! for Delete Brush and Duplicate Brush.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use egui::{Sense, Ui, vec2};
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{Appearance, Document, Node, PressureProfile, color::Color, color::Paint};
|
|
|
|
use super::{first_selected, pstate, set_pstate};
|
|
use crate::VectorcraftApp;
|
|
use crate::dialogs::brush_options;
|
|
use crate::theme::Tokens;
|
|
use crate::widgets::{self, PanelDrag, menu_item};
|
|
|
|
const KINDS: [(&str, &str); 5] =
|
|
[("calligraphic", "Calligraphic"), ("scatter", "Scatter"), ("art", "Art"), ("bristle", "Bristle"), ("pattern", "Pattern")];
|
|
|
|
/// (name, type) of every brush in the active document's library.
|
|
pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option<String>) {
|
|
let Ok(v) = app.run("brush.list", json!({})) else { return (vec![], None) };
|
|
let list = v["brushes"]
|
|
.as_array()
|
|
.map(|a| a.iter().map(|b| (b["name"].as_str().unwrap_or("").to_string(), b["type"].as_str().unwrap_or("").to_string())).collect())
|
|
.unwrap_or_default();
|
|
(list, v["current"].as_str().map(str::to_string))
|
|
}
|
|
|
|
/// A stroke preview for brush definition `def`, rendered at `size` and cached by the definition's
|
|
/// JSON. The stroke goes from light to heavy pen pressure and back, so pressure-sensitive brushes
|
|
/// show how they vary.
|
|
fn preview(ui: &Ui, def: &Value, size: egui::Vec2) -> Option<egui::TextureHandle> {
|
|
widgets::doc_preview(ui, &format!("brush:{def}"), size, |w, h| {
|
|
let mut doc = Document::new(w, h);
|
|
doc.unknown.insert("brushes".into(), json!([def]));
|
|
let name = def["name"].as_str()?.to_string();
|
|
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
|
let st = ap.stroke_mut()?;
|
|
st.brush = Some(name);
|
|
st.pressure = Some(PressureProfile::sample());
|
|
// A gentle S-curve across the swatch.
|
|
let mut bp = vectorcraft_geom::BezPath::new();
|
|
let (x0, x1) = (h * 0.5, w - h * 0.5);
|
|
bp.move_to((x0, h * 0.5));
|
|
bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.1), (x0 + (x1 - x0) * 0.65, h * 0.9), (x1, h * 0.5));
|
|
let id = doc.alloc_id();
|
|
let l = doc.layers[0].id;
|
|
doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?;
|
|
Some(doc)
|
|
})
|
|
}
|
|
|
|
/// The stroke preview of brush definition `def` on white in `r` (the chip a dragged brush shows at
|
|
/// the pointer).
|
|
pub(crate) fn chip(ui: &Ui, r: egui::Rect, def: &Value) {
|
|
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
|
|
if let Some(tex) = preview(ui, def, r.size()) {
|
|
ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
|
}
|
|
}
|
|
|
|
fn selected_brush(app: &VectorcraftApp) -> Option<String> {
|
|
first_selected(app).and_then(|n| n.appearance.stroke().and_then(|s| s.brush.clone()))
|
|
}
|
|
|
|
/// What the panel's last click was made with: the document, its selected objects and the current
|
|
/// brush.
|
|
type ClickKey = (u64, Vec<u64>, Option<String>);
|
|
|
|
fn click_key(app: &VectorcraftApp, current: Option<&str>) -> Option<ClickKey> {
|
|
app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect(), current.map(str::to_string)))
|
|
}
|
|
|
|
/// Whether the panel's last click was made with the document, objects and current brush active
|
|
/// now. Panel state `br-selected` holds the multi-selection and the key of that click.
|
|
fn same_click(app: &VectorcraftApp, ctx: &egui::Context, current: Option<&str>) -> bool {
|
|
let (_, key): (Option<Vec<String>>, Option<ClickKey>) = pstate(ctx, "br-selected");
|
|
key.is_some() && key == click_key(app, current)
|
|
}
|
|
|
|
/// The brushes that Shift-clicks, Cmd/Ctrl-clicks and Select All Unused selected, of those in
|
|
/// `list`. It is `None` after a plain click, once none of its brushes is left, and once another
|
|
/// document, other objects or another current brush is active, which also clears it and the key
|
|
/// of the last click; `Some` of an empty list after Cmd/Ctrl-clicks deselected every brush or
|
|
/// Select All Unused found none.
|
|
fn multi(app: &VectorcraftApp, ctx: &egui::Context, list: &[(String, String)], current: Option<&str>) -> Option<Vec<String>> {
|
|
if !same_click(app, ctx, current) {
|
|
set_pstate(ctx, "br-selected", (None::<Vec<String>>, None::<ClickKey>));
|
|
return None;
|
|
}
|
|
let (names, _): (Option<Vec<String>>, Option<ClickKey>) = pstate(ctx, "br-selected");
|
|
let names = names?;
|
|
if names.is_empty() {
|
|
return Some(names);
|
|
}
|
|
let kept: Vec<String> = names.into_iter().filter(|n| list.iter().any(|b| &b.0 == n)).collect();
|
|
(!kept.is_empty()).then_some(kept)
|
|
}
|
|
|
|
/// The brushes Delete Brush and Duplicate Brush act on: the multi-selection, else the selected
|
|
/// path's brush, else the current one.
|
|
fn targets(app: &VectorcraftApp, ctx: &egui::Context, list: &[(String, String)], current: Option<&str>) -> Vec<String> {
|
|
multi(app, ctx, list, current).unwrap_or_else(|| selected_brush(app).or(current.map(str::to_string)).into_iter().collect())
|
|
}
|
|
|
|
/// Runs `brush.delete` or `brush.duplicate` on `names`.
|
|
fn run_on(app: &mut VectorcraftApp, id: &str, names: &[String]) {
|
|
let p = match names {
|
|
[name] => json!({ "name": name }),
|
|
_ => json!({ "names": names }),
|
|
};
|
|
app.run(id, p).ok();
|
|
}
|
|
|
|
/// The names in `list` in the order the panel shows them: by type in [`KINDS`] order, without the
|
|
/// hidden types.
|
|
fn shown<'a>(list: &'a [(String, String)], hidden: &[String]) -> Vec<&'a str> {
|
|
let kinds = KINDS.iter().filter(|(ty, _)| !hidden.iter().any(|h| h == ty));
|
|
kinds.flat_map(|(ty, _)| list.iter().filter(move |b| b.1 == *ty).map(|b| b.0.as_str())).collect()
|
|
}
|
|
|
|
/// A brush dragged over the tile or row `resp` of brush `name`, the `i`th brush of type `ty`
|
|
/// (`list`: rows stack downwards), lands before or after it by the pointer's half; a bar marks the
|
|
/// slot when the dragged brush is of type `ty` too (`types`: the (name, type) of every brush).
|
|
/// Released there, it sets `moved` to the `brush.move` params, which refuse a brush of another type.
|
|
fn reorder_drop(
|
|
ui: &Ui,
|
|
resp: &egui::Response,
|
|
types: &[(String, String)],
|
|
(name, ty, i): (&str, &str, usize),
|
|
list: bool,
|
|
moved: &mut Option<Value>,
|
|
) {
|
|
let Some(drag) = resp.dnd_hover_payload::<PanelDrag>() else { return };
|
|
let PanelDrag::Brush { name: moving, .. } = &*drag else { return };
|
|
if moving == name {
|
|
// Dropped on itself: it stays where it is.
|
|
resp.dnd_release_payload::<PanelDrag>();
|
|
return;
|
|
}
|
|
let r = resp.rect;
|
|
let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center());
|
|
let after = if list { at.y > r.center().y } else { at.x > r.center().x };
|
|
if types.iter().any(|b| &b.0 == moving && b.1 == ty) {
|
|
let accent = egui::Stroke::new(2.0, Tokens::get(ui.ctx()).accent);
|
|
if list {
|
|
let y = if after { r.bottom() } else { r.top() };
|
|
ui.painter().line_segment([egui::pos2(r.left(), y), egui::pos2(r.right(), y)], accent);
|
|
} else {
|
|
let x = if after { r.right() + 1.5 } else { r.left() - 1.5 };
|
|
ui.painter().line_segment([egui::pos2(x, r.top()), egui::pos2(x, r.bottom())], accent);
|
|
}
|
|
}
|
|
if resp.dnd_release_payload::<PanelDrag>().is_some() {
|
|
*moved = Some(json!({ "name": moving, "to": i + usize::from(after), "type": ty }));
|
|
}
|
|
}
|
|
|
|
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|
let t = Tokens::get(ui.ctx());
|
|
let (list, current) = brushes(app);
|
|
let sel_brush = selected_brush(app);
|
|
let mut multi = multi(app, ui.ctx(), &list, current.as_deref());
|
|
let list_view: bool = pstate(ui.ctx(), "br-list");
|
|
let hidden: Vec<String> = pstate(ui.ctx(), "br-hidden");
|
|
let defs: HashMap<String, Value> =
|
|
list.iter().filter_map(|(n, _)| app.run("brush.get", json!({"name": n})).ok().map(|v| (n.clone(), v))).collect();
|
|
let mut clicked: Option<(String, egui::Modifiers)> = None;
|
|
let mut options: Option<String> = None;
|
|
let mut moved: Option<Value> = None;
|
|
let list_rect = widgets::list_box(ui, |ui| {
|
|
ui.set_min_height(110.0);
|
|
ui.set_width(ui.available_width());
|
|
if list.is_empty() {
|
|
super::empty_state(ui, "paintbrush", tl!("No brushes"), tl!("Select art and use New Brush to make one."));
|
|
return ui.min_rect();
|
|
}
|
|
for (ty, _) in KINDS {
|
|
if hidden.iter().any(|h| h == ty) {
|
|
continue;
|
|
}
|
|
let group: Vec<&(String, String)> = list.iter().filter(|b| b.1 == ty).collect();
|
|
if group.is_empty() {
|
|
continue;
|
|
}
|
|
let row = |ui: &mut Ui, name: &str, size: egui::Vec2, label: bool| -> egui::Response {
|
|
let (r, resp) = ui.allocate_exact_size(size, Sense::click_and_drag());
|
|
// Dragged onto a path, the brush is applied to it.
|
|
widgets::drag_source(ui, &resp, || PanelDrag::Brush { name: name.to_string(), def: defs.get(name).cloned().unwrap_or_default() });
|
|
let on = match &multi {
|
|
Some(multi) => multi.iter().any(|m| m == name),
|
|
None => sel_brush.as_deref() == Some(name) || (sel_brush.is_none() && current.as_deref() == Some(name)),
|
|
};
|
|
if on {
|
|
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
|
} else if resp.hovered() {
|
|
ui.painter().rect_filled(r, 0.0, t.hover);
|
|
}
|
|
let pw = if label { 72.0 } else { size.x - 4.0 };
|
|
let pr = egui::Rect::from_min_size(r.left_top() + vec2(2.0, 2.0), vec2(pw, size.y - 4.0));
|
|
ui.painter().rect_filled(pr, 0.0, egui::Color32::WHITE);
|
|
if let Some(def) = defs.get(name)
|
|
&& let Some(tex) = preview(ui, def, pr.size())
|
|
{
|
|
ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
|
}
|
|
if label {
|
|
ui.painter().text(
|
|
r.left_center() + vec2(pw + 10.0, 0.0),
|
|
egui::Align2::LEFT_CENTER,
|
|
name,
|
|
egui::FontId::proportional(12.5),
|
|
t.text,
|
|
);
|
|
}
|
|
resp.on_hover_text(name)
|
|
};
|
|
// A click applies or selects the brush; the second click of a double-click opens its
|
|
// options.
|
|
let mut pick = |resp: egui::Response, name: &String| {
|
|
if resp.double_clicked() {
|
|
options = Some(name.clone());
|
|
} else if resp.clicked() {
|
|
clicked = Some((name.clone(), resp.ctx.input(|i| i.modifiers)));
|
|
}
|
|
};
|
|
if list_view {
|
|
for (i, (name, _)) in group.into_iter().enumerate() {
|
|
let resp = row(ui, name, vec2(ui.available_width(), 26.0), true);
|
|
reorder_drop(ui, &resp, &list, (name, ty, i), true, &mut moved);
|
|
pick(resp, name);
|
|
}
|
|
} else {
|
|
ui.horizontal_wrapped(|ui| {
|
|
for (i, (name, _)) in group.into_iter().enumerate() {
|
|
let resp = row(ui, name, vec2(64.0, 28.0), false);
|
|
reorder_drop(ui, &resp, &list, (name, ty, i), false, &mut moved);
|
|
pick(resp, name);
|
|
}
|
|
});
|
|
}
|
|
ui.separator();
|
|
}
|
|
ui.min_rect()
|
|
});
|
|
// Art dragged off the canvas and dropped on the list opens New Brush for that art, as the New
|
|
// Brush button opens it for the selection.
|
|
let zone = ui.interact(list_rect, ui.id().with("brushes-drop"), Sense::hover());
|
|
if let Some(ids) = widgets::art_drop(ui, &zone)
|
|
&& let Err(e) = app.run("ui.newBrush", json!({ "ids": ids }))
|
|
{
|
|
app.status(e);
|
|
}
|
|
// A brush dragged within the list moves among the brushes of its type.
|
|
if let Some(p) = moved
|
|
&& let Err(e) = app.run("brush.move", p)
|
|
{
|
|
app.status(e);
|
|
}
|
|
let highlighted = sel_brush.clone().or(current.clone());
|
|
match clicked {
|
|
// Shift and Cmd/Ctrl only select, as in the Swatches panel: they start from the
|
|
// multi-selection, else from the highlighted brush.
|
|
Some((name, m)) if m.shift || m.command => {
|
|
let order = shown(&list, &hidden);
|
|
// A range starts from the last brush clicked without Shift while the same document,
|
|
// art and current brush are active, else from the highlighted brush.
|
|
let anchor: String = pstate(ui.ctx(), "br-anchor");
|
|
if !same_click(app, ui.ctx(), current.as_deref()) || !order.contains(&anchor.as_str()) {
|
|
set_pstate(ui.ctx(), "br-anchor", highlighted.clone().unwrap_or_default());
|
|
}
|
|
let from = multi.unwrap_or_else(|| highlighted.iter().cloned().collect());
|
|
let sel = super::swatches::click_selection(ui, "br-anchor", from, &order, &name, m);
|
|
set_pstate(ui.ctx(), "br-selected", (Some(sel.clone()), click_key(app, current.as_deref())));
|
|
multi = Some(sel);
|
|
}
|
|
Some((name, _)) => {
|
|
set_pstate(ui.ctx(), "br-anchor", name.clone());
|
|
multi = None;
|
|
let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty());
|
|
if has_sel {
|
|
app.run("brush.apply", json!({"name": name})).ok();
|
|
} else {
|
|
app.run("brush.setCurrent", json!({"name": name})).ok();
|
|
}
|
|
let now = brushes(app).1;
|
|
set_pstate(ui.ctx(), "br-selected", (None::<Vec<String>>, click_key(app, now.as_deref())));
|
|
}
|
|
None => {}
|
|
}
|
|
if let Some(name) = options
|
|
&& let Err(e) = app.run("ui.brushOptions", json!({ "name": name }))
|
|
{
|
|
app.status(e);
|
|
}
|
|
let has_brush = sel_brush.is_some();
|
|
let targets = multi.unwrap_or_else(|| highlighted.into_iter().collect());
|
|
let has_doc = app.session.active().is_some();
|
|
widgets::bottom_bar(ui, |ui| {
|
|
widgets::icon_button_enabled(ui, "library", tl!("Brush Libraries (on the roadmap)"), false, false, 24.0);
|
|
if widgets::icon_button_enabled(ui, "dc-remove-brush", tl!("Remove Brush Stroke"), false, has_brush, 24.0).clicked() {
|
|
app.run("brush.remove", json!({})).ok();
|
|
}
|
|
if widgets::icon_button_enabled(ui, "dc-options", tl!("Expand Brush Strokes"), false, has_brush, 24.0).clicked() {
|
|
app.run("object.expandBrush", json!({})).ok();
|
|
}
|
|
ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0));
|
|
// New Brush asks for the type, then opens that type's Brush Options.
|
|
if widgets::icon_button_enabled(ui, "dc-new-item", tl!("New Brush"), false, has_doc, 24.0).clicked()
|
|
&& let Err(e) = app.run("ui.newBrush", json!({}))
|
|
{
|
|
app.status(e);
|
|
}
|
|
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Brush"), false, !targets.is_empty(), 24.0).clicked() {
|
|
run_on(app, "brush.delete", &targets);
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|
let sel = selected_brush(app);
|
|
let (list, current) = brushes(app);
|
|
let targets = targets(app, ui.ctx(), &list, current.as_deref());
|
|
let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty());
|
|
if menu_item(ui, tl!("New Brush…"), app.session.active().is_some(), false)
|
|
&& let Err(e) = app.run("ui.newBrush", json!({}))
|
|
{
|
|
app.status(e);
|
|
}
|
|
// The types' own items make a brush at once, without its Brush Options.
|
|
for (label, ty) in [(tl!("New Calligraphic Brush"), "calligraphic"), (tl!("New Bristle Brush"), "bristle")] {
|
|
if menu_item(ui, label, true, false) {
|
|
app.run("brush.new", json!({"type": ty})).ok();
|
|
}
|
|
}
|
|
for (label, ty) in [
|
|
(tl!("New Art Brush from Selection"), "art"),
|
|
(tl!("New Scatter Brush from Selection"), "scatter"),
|
|
(tl!("New Pattern Brush from Selection"), "pattern"),
|
|
] {
|
|
if menu_item(ui, label, has_sel, false) {
|
|
app.run("brush.new", json!({"type": ty})).ok();
|
|
}
|
|
}
|
|
if menu_item(ui, tl!("Duplicate Brush"), !targets.is_empty(), false) {
|
|
run_on(app, "brush.duplicate", &targets);
|
|
}
|
|
if menu_item(ui, tl!("Delete Brush"), !targets.is_empty(), false) {
|
|
run_on(app, "brush.delete", &targets);
|
|
}
|
|
if menu_item(ui, tl!("Remove Brush Stroke"), sel.is_some(), false) {
|
|
app.run("brush.remove", json!({})).ok();
|
|
}
|
|
if menu_item(ui, tl!("Expand Brush Strokes"), sel.is_some(), false) {
|
|
app.run("object.expandBrush", json!({})).ok();
|
|
}
|
|
// On the selected path's brush, else the current one.
|
|
if menu_item(ui, tl!("Brush Options…"), brush_options::available(app), false)
|
|
&& let Err(e) = app.run("ui.brushOptions", json!({}))
|
|
{
|
|
app.status(e);
|
|
}
|
|
ui.separator();
|
|
// Selects the brushes no stroke paints with, for Delete Brush and Duplicate Brush.
|
|
if menu_item(ui, tl!("Select All Unused"), app.session.active().is_some(), false)
|
|
&& let Ok(r) = app.run("brush.unused", json!({}))
|
|
{
|
|
let names: Vec<String> = serde_json::from_value(r["names"].clone()).unwrap_or_default();
|
|
set_pstate(ui.ctx(), "br-selected", (Some(names), click_key(app, current.as_deref())));
|
|
}
|
|
ui.separator();
|
|
let mut hidden: Vec<String> = pstate(ui.ctx(), "br-hidden");
|
|
for (ty, label) in KINDS {
|
|
let shown = !hidden.iter().any(|h| h == ty);
|
|
if menu_item(ui, &crate::i18n::fmt(tl!("Show {kind} Brushes"), &[("kind", tl!(label))]), true, shown) {
|
|
if shown {
|
|
hidden.push(ty.to_string());
|
|
} else {
|
|
hidden.retain(|h| h != ty);
|
|
}
|
|
set_pstate(ui.ctx(), "br-hidden", hidden.clone());
|
|
}
|
|
}
|
|
ui.separator();
|
|
let list: bool = pstate(ui.ctx(), "br-list");
|
|
if menu_item(ui, tl!("Thumbnail View"), true, !list) {
|
|
set_pstate(ui.ctx(), "br-list", false);
|
|
}
|
|
if menu_item(ui, tl!("List View"), true, list) {
|
|
set_pstate(ui.ctx(), "br-list", true);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use egui::{Pos2, Rect, pos2};
|
|
use vectorcraft_doc::NodeId;
|
|
use vectorcraft_engine::Session;
|
|
|
|
use super::*;
|
|
|
|
/// One headless frame of the panel, 236 pt wide as in the dock.
|
|
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) {
|
|
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))), events, ..Default::default() };
|
|
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
|
out.textures_delta.clear();
|
|
}
|
|
|
|
fn button(at: Pos2, pressed: bool) -> egui::Event {
|
|
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
|
}
|
|
|
|
/// One frame of `draw` at `time` with `modifiers` held, its output kept.
|
|
fn frame_with(
|
|
ctx: &egui::Context,
|
|
app: &mut VectorcraftApp,
|
|
events: Vec<egui::Event>,
|
|
modifiers: egui::Modifiers,
|
|
time: f64,
|
|
draw: fn(&mut VectorcraftApp, &mut Ui),
|
|
) -> egui::FullOutput {
|
|
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0)));
|
|
let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect();
|
|
let raw = egui::RawInput { screen_rect, time: Some(time), events, ..Default::default() };
|
|
let mut out = ctx.run_ui(raw, |ui| draw(app, ui));
|
|
out.textures_delta.clear();
|
|
out
|
|
}
|
|
|
|
/// A click at `at` with `modifiers` held, in a frame of `draw` at `time`.
|
|
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2, modifiers: egui::Modifiers, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui)) {
|
|
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers };
|
|
frame_with(ctx, app, vec![egui::Event::PointerMoved(at), press(true), press(false)], modifiers, time, draw);
|
|
}
|
|
|
|
/// The accessibility nodes of `out` that take a click, with their rectangles, top to bottom
|
|
/// and then left to right.
|
|
fn clickable(out: &egui::FullOutput) -> Vec<(Option<String>, Rect)> {
|
|
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
|
|
let mut v: Vec<(Option<String>, Rect)> = nodes
|
|
.iter()
|
|
.filter(|(_, n)| n.supports_action(egui::accesskit::Action::Click))
|
|
.filter_map(|(_, n)| {
|
|
let b = n.bounds()?;
|
|
Some((n.label().map(|l| l.trim().to_string()), Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))))
|
|
})
|
|
.collect();
|
|
v.sort_by(|a, b| a.1.min.y.total_cmp(&b.1.min.y).then(a.1.min.x.total_cmp(&b.1.min.x)));
|
|
v
|
|
}
|
|
|
|
/// #852: double-clicking a brush opens its Brush Options; OK changes the brush (and strokes
|
|
/// painted with it). A single click only applies the brush.
|
|
#[test]
|
|
fn double_clicking_a_brush_opens_its_options() {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
|
let ctx = egui::Context::default();
|
|
let clicks = |at: Pos2, n: usize| -> Vec<egui::Event> {
|
|
std::iter::once(egui::Event::PointerMoved(at)).chain((0..n).flat_map(|_| [button(at, true), button(at, false)])).collect()
|
|
};
|
|
let at_time = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
|
let raw = egui::RawInput {
|
|
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))),
|
|
time: Some(time),
|
|
events,
|
|
..Default::default()
|
|
};
|
|
ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear();
|
|
};
|
|
at_time(&mut app, 0.0, vec![]);
|
|
let (list, _) = brushes(&mut app);
|
|
let first = list[0].0.clone();
|
|
assert_eq!(list[0].1, "calligraphic");
|
|
// The first tile, clicked once: it becomes current, no dialog.
|
|
let tile = pos2(36.0, 16.0);
|
|
at_time(&mut app, 1.0, clicks(tile, 1));
|
|
assert!(app.ui.dialog.is_none());
|
|
assert_eq!(brushes(&mut app).1.as_deref(), Some(first.as_str()));
|
|
// Double-clicked: Calligraphic Brush Options on that brush.
|
|
at_time(&mut app, 3.0, clicks(tile, 2));
|
|
let d = app.ui.dialog.as_mut().expect("Brush Options opened");
|
|
assert_eq!((d.kind.as_str(), d.str("name"), d.str("sizeMode")), (crate::dialogs::brush_options::KIND, first.clone(), "fixed".into()));
|
|
d.fields.insert("sizeMode".into(), json!("pressure"));
|
|
d.fields.insert("sizeVariation".into(), json!(2));
|
|
d.fields.insert("name".into(), json!("Pressure Round"));
|
|
crate::dialogs::confirm(&mut app).unwrap();
|
|
assert!(app.ui.dialog.is_none());
|
|
let def = app.run("brush.get", json!({"name": "Pressure Round"})).unwrap();
|
|
assert_eq!((def["modes"].clone(), def["variation"][2].clone()), (json!(["fixed", "fixed", "pressure"]), json!(2.0)));
|
|
assert_eq!(brushes(&mut app).1.as_deref(), Some("Pressure Round"), "renamed, still current");
|
|
}
|
|
|
|
/// The New Brush button and the panel menu's New Brush… open New Brush, with nothing selected
|
|
/// too, and make no brush.
|
|
#[test]
|
|
fn the_new_brush_button_and_menu_item_open_new_brush() {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
|
let ctx = egui::Context::default();
|
|
ctx.enable_accesskit();
|
|
let before = names(&mut app);
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show);
|
|
let new = clickable(&out).into_iter().find(|(l, _)| l.as_deref() == Some("New Brush")).expect("a New Brush button").1.center();
|
|
click(&ctx, &mut app, new, egui::Modifiers::NONE, 1.0, show);
|
|
let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone());
|
|
assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::new_brush::KIND));
|
|
app.ui.dialog = None;
|
|
menu_click(&ctx, &mut app, "New Brush…", 2.0);
|
|
assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::new_brush::KIND));
|
|
assert_eq!(names(&mut app), before);
|
|
}
|
|
|
|
/// The rectangles `out` fills with the selected-row color.
|
|
fn lit(ctx: &egui::Context, out: &egui::FullOutput) -> Vec<Rect> {
|
|
let t = Tokens::get(ctx);
|
|
out.shapes
|
|
.iter()
|
|
.filter_map(|c| match &c.shape {
|
|
egui::Shape::Rect(r) if r.fill == t.row_selected => Some(r.rect),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn brush_of(app: &VectorcraftApp, id: NodeId) -> Option<String> {
|
|
app.session.active().unwrap().doc.node(id).unwrap().appearance.stroke().and_then(|s| s.brush.clone())
|
|
}
|
|
|
|
fn names(app: &mut VectorcraftApp) -> Vec<String> {
|
|
brushes(app).0.into_iter().map(|b| b.0).collect()
|
|
}
|
|
|
|
/// Click the panel menu's item `label` (frames at `time`, `time + 0.5`).
|
|
fn menu_click(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, time: f64) {
|
|
let out = frame_with(ctx, app, vec![], egui::Modifiers::NONE, time, menu);
|
|
let at = clickable(&out).into_iter().find(|(l, _)| l.as_deref() == Some(label)).unwrap_or_else(|| panic!("no `{label}`")).1.center();
|
|
click(ctx, app, at, egui::Modifiers::NONE, time + 0.5, menu);
|
|
}
|
|
|
|
/// The first three brushes (the Calligraphic ones): a click on the first applies it to the
|
|
/// selected rectangle, and then a Shift-click on the third selects all three without applying
|
|
/// the third.
|
|
fn select_three(ctx: &egui::Context, app: &mut VectorcraftApp, rect: NodeId) -> (Vec<String>, Vec<Rect>) {
|
|
let out = frame_with(ctx, app, vec![], egui::Modifiers::NONE, 0.0, show);
|
|
let tiles: Vec<Rect> = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).take(3).collect();
|
|
let (list, _) = brushes(app);
|
|
assert!(list[..3].iter().all(|b| b.1 == "calligraphic"), "{list:?}");
|
|
let three: Vec<String> = list[..3].iter().map(|b| b.0.clone()).collect();
|
|
click(ctx, app, tiles[0].center(), egui::Modifiers::NONE, 1.0, show);
|
|
assert_eq!(brush_of(app, rect).as_deref(), Some(three[0].as_str()));
|
|
click(ctx, app, tiles[2].center(), egui::Modifiers::SHIFT, 2.0, show);
|
|
assert_eq!(brush_of(app, rect).as_deref(), Some(three[0].as_str()), "a Shift-click only selects");
|
|
(three, tiles)
|
|
}
|
|
|
|
/// Shift-click selects a range of brushes and Cmd/Ctrl-click adds or removes one; the panel
|
|
/// highlights them, and Duplicate Brush and Delete Brush act on all of them in one undo step.
|
|
#[test]
|
|
fn shift_and_command_clicks_select_brushes_for_delete_and_duplicate() {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
|
let rect = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap());
|
|
let ctx = egui::Context::default();
|
|
ctx.enable_accesskit();
|
|
let (three, tiles) = select_three(&ctx, &mut app, rect);
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.5, show);
|
|
let lit_tiles = |out: &egui::FullOutput| -> Vec<usize> { (0..3).filter(|i| lit(&ctx, out).contains(&tiles[*i])).collect() };
|
|
assert_eq!(lit_tiles(&out), [0, 1, 2]);
|
|
// Cmd/Ctrl-click takes the second out again.
|
|
click(&ctx, &mut app, tiles[1].center(), egui::Modifiers::COMMAND, 3.0, show);
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.5, show);
|
|
assert_eq!(lit_tiles(&out), [0, 2]);
|
|
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
|
|
let before = (names(&mut app), undo(&app));
|
|
menu_click(&ctx, &mut app, "Duplicate Brush", 4.0);
|
|
let copies = [format!("{} copy", three[0]), format!("{} copy", three[2])];
|
|
assert_eq!(names(&mut app)[before.0.len()..], copies);
|
|
assert_eq!(undo(&app), before.1 + 1);
|
|
// The bottom bar's last button, Delete Brush.
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 5.0, show);
|
|
let trash = clickable(&out).last().unwrap().1.center();
|
|
click(&ctx, &mut app, trash, egui::Modifiers::NONE, 6.0, show);
|
|
let left = names(&mut app);
|
|
assert!(!left.contains(&three[0]) && !left.contains(&three[2]) && left.contains(&three[1]), "{left:?}");
|
|
assert!(copies.iter().all(|c| left.contains(c)), "{left:?}");
|
|
assert_eq!(brush_of(&app, rect), None);
|
|
assert_eq!(undo(&app), before.1 + 2);
|
|
}
|
|
|
|
/// Selecting other art ends the multi-selection: selecting the first art again does not bring
|
|
/// it back, and with a path stroked with another brush selected, the panel highlights that
|
|
/// brush and Delete Brush deletes it alone.
|
|
#[test]
|
|
fn selecting_other_art_ends_the_brush_multi_selection() {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
|
let rect = |app: &mut VectorcraftApp, x: f64| {
|
|
NodeId(app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap())
|
|
};
|
|
let a = rect(&mut app, 10.0);
|
|
let ctx = egui::Context::default();
|
|
ctx.enable_accesskit();
|
|
let (three, tiles) = select_three(&ctx, &mut app, a);
|
|
let lit_tiles = |out: &egui::FullOutput| -> Vec<usize> { (0..3).filter(|i| lit(&ctx, out).contains(&tiles[*i])).collect() };
|
|
// A path without a brush, then the first one again: the current brush is highlighted.
|
|
rect(&mut app, 60.0);
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.0, show);
|
|
assert_eq!(lit_tiles(&out), [0]);
|
|
app.run("select.set", json!({"ids": [a.0]})).unwrap();
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.5, show);
|
|
assert_eq!(lit_tiles(&out), [0]);
|
|
// A path stroked with the second brush.
|
|
let c = rect(&mut app, 110.0);
|
|
app.run("brush.apply", json!({"name": three[1]})).unwrap();
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 4.0, show);
|
|
assert_eq!(lit_tiles(&out), [1]);
|
|
menu_click(&ctx, &mut app, "Delete Brush", 5.0);
|
|
let left = names(&mut app);
|
|
assert!(left.contains(&three[0]) && !left.contains(&three[1]) && left.contains(&three[2]), "{left:?}");
|
|
assert_eq!((brush_of(&app, a).as_deref(), brush_of(&app, c)), (Some(three[0].as_str()), None));
|
|
}
|
|
|
|
/// After other art is selected, a Shift-click selects the range from that art's brush, not from
|
|
/// the brush clicked before.
|
|
#[test]
|
|
fn a_shift_click_after_selecting_other_art_starts_from_its_brush() {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
|
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
|
let ctx = egui::Context::default();
|
|
ctx.enable_accesskit();
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show);
|
|
let tiles: Vec<Rect> = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).take(3).collect();
|
|
let (list, _) = brushes(&mut app);
|
|
click(&ctx, &mut app, tiles[0].center(), egui::Modifiers::NONE, 1.0, show);
|
|
app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 40})).unwrap();
|
|
app.run("brush.apply", json!({"name": list[2].0})).unwrap();
|
|
click(&ctx, &mut app, tiles[1].center(), egui::Modifiers::SHIFT, 2.0, show);
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.5, show);
|
|
assert_eq!(lit(&ctx, &out).iter().filter(|r| tiles.contains(r)).collect::<Vec<_>>(), [&tiles[1], &tiles[2]]);
|
|
}
|
|
|
|
/// Cmd/Ctrl-clicks can deselect every brush: none is highlighted, and Delete Brush and
|
|
/// Duplicate Brush are off.
|
|
#[test]
|
|
fn command_clicks_deselect_every_brush() {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
|
let a = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap());
|
|
let ctx = egui::Context::default();
|
|
ctx.enable_accesskit();
|
|
let (_, tiles) = select_three(&ctx, &mut app, a);
|
|
for (i, tile) in tiles.iter().enumerate() {
|
|
click(&ctx, &mut app, tile.center(), egui::Modifiers::COMMAND, 3.0 + i as f64, show);
|
|
}
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 7.0, show);
|
|
assert!(!lit(&ctx, &out).iter().any(|r| tiles.contains(r)));
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 8.0, menu);
|
|
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
|
|
for label in ["Duplicate Brush", "Delete Brush"] {
|
|
let item = nodes.iter().find(|(_, n)| n.label().map(str::trim) == Some(label)).unwrap_or_else(|| panic!("no `{label}`"));
|
|
assert!(item.1.is_disabled(), "{label}");
|
|
}
|
|
}
|
|
|
|
/// Select All Unused in the panel menu highlights every brush but the one the rectangle is
|
|
/// painted with, and Delete Brush then deletes all of them.
|
|
#[test]
|
|
fn select_all_unused_selects_the_brushes_no_stroke_paints_with() {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
|
let (list, _) = brushes(&mut app);
|
|
let a = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap());
|
|
app.run("brush.apply", json!({"name": list[1].0})).unwrap();
|
|
let ctx = egui::Context::default();
|
|
ctx.enable_accesskit();
|
|
menu_click(&ctx, &mut app, "Select All Unused", 1.0);
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.0, show);
|
|
let tiles: Vec<Rect> = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect();
|
|
assert_eq!(tiles.len(), list.len());
|
|
let on: Vec<usize> = (0..tiles.len()).filter(|i| lit(&ctx, &out).contains(&tiles[*i])).collect();
|
|
assert_eq!(on, (0..tiles.len()).filter(|i| *i != 1).collect::<Vec<_>>());
|
|
menu_click(&ctx, &mut app, "Delete Brush", 3.0);
|
|
assert_eq!(names(&mut app), [list[1].0.clone()]);
|
|
assert_eq!(brush_of(&app, a), Some(list[1].0.clone()));
|
|
}
|
|
|
|
#[test]
|
|
fn art_dropped_on_the_panel_becomes_an_art_brush_and_a_brush_drags_out() {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
|
let id = app.run("shape.star", json!({"cx": 50, "cy": 50, "radius1": 20, "radius2": 9, "points": 5})).unwrap()["id"].as_u64().unwrap();
|
|
let ctx = egui::Context::default();
|
|
frame(&ctx, &mut app, vec![]);
|
|
let (before, _) = brushes(&mut app);
|
|
// Art dragged off the canvas and released over the list.
|
|
let over = pos2(100.0, 60.0);
|
|
egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(id)]));
|
|
frame(&ctx, &mut app, vec![egui::Event::PointerMoved(over), button(over, false)]);
|
|
// New Brush opens on Art for the dropped art, and OK twice makes the brush.
|
|
let d = app.ui.dialog.as_ref().expect("New Brush opened");
|
|
assert_eq!((d.kind.as_str(), d.str("type"), d.fields.get("__ids")), (crate::dialogs::new_brush::KIND, "art".into(), Some(&json!([id]))));
|
|
assert_eq!(brushes(&mut app).0, before);
|
|
crate::dialogs::confirm(&mut app).unwrap();
|
|
crate::dialogs::confirm(&mut app).unwrap();
|
|
let (after, _) = brushes(&mut app);
|
|
assert_eq!(after.len(), before.len() + 1);
|
|
let new: Vec<_> = after.iter().filter(|b| !before.contains(b)).collect();
|
|
assert!(matches!(new.as_slice(), [(_, ty)] if ty == "art"), "{new:?}");
|
|
// Dragging the first tile out of the panel carries that brush.
|
|
frame(&ctx, &mut app, vec![]);
|
|
let tile = pos2(36.0, 16.0);
|
|
frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile), button(tile, true)]);
|
|
frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile + vec2(40.0, 40.0))]);
|
|
frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile + vec2(80.0, 80.0))]);
|
|
let drag = egui::DragAndDrop::payload::<PanelDrag>(&ctx);
|
|
assert!(matches!(drag.as_deref(), Some(PanelDrag::Brush { name, def }) if *name == after[0].0 && def["name"] == json!(name)), "{drag:?}");
|
|
}
|
|
|
|
/// A brush dragged onto the left half of another brush of its type goes before it, with a bar
|
|
/// marking the slot while it is held there, as one undo step; dropped on a brush of another
|
|
/// type, it stays where it is and the status bar says why.
|
|
#[test]
|
|
fn a_brush_dragged_onto_another_of_its_type_moves_before_it() {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
|
let ctx = egui::Context::default();
|
|
ctx.enable_accesskit();
|
|
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show);
|
|
let tiles: Vec<Rect> = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect();
|
|
let (list, _) = brushes(&mut app);
|
|
let order: Vec<String> = shown(&list, &[]).into_iter().map(str::to_string).collect();
|
|
assert_eq!(order[..4], ["3 pt. Round", "6 pt. Flat", "10 pt. Oval", "Dots"]);
|
|
let accent = Tokens::get(&ctx).accent;
|
|
let bars = |out: &egui::FullOutput| -> Vec<f32> {
|
|
let lines = out.shapes.iter().filter_map(|c| match &c.shape {
|
|
egui::Shape::LineSegment { points, stroke } if stroke.color == accent => Some(points[0].x),
|
|
_ => None,
|
|
});
|
|
lines.collect()
|
|
};
|
|
// Press on a tile, drag it to `to`, hold it there for a frame (its output returned) and release.
|
|
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2, time: f64| -> egui::FullOutput {
|
|
frame_with(&ctx, app, vec![egui::Event::PointerMoved(from), button(from, true)], egui::Modifiers::NONE, time, show);
|
|
frame_with(&ctx, app, vec![egui::Event::PointerMoved(from + vec2(20.0, 0.0))], egui::Modifiers::NONE, time + 0.1, show);
|
|
frame_with(&ctx, app, vec![egui::Event::PointerMoved(to)], egui::Modifiers::NONE, time + 0.2, show);
|
|
let held = frame_with(&ctx, app, vec![], egui::Modifiers::NONE, time + 0.3, show);
|
|
frame_with(&ctx, app, vec![button(to, false)], egui::Modifiers::NONE, time + 0.4, show);
|
|
held
|
|
};
|
|
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
|
|
let steps = undo(&app);
|
|
let calligraphic =
|
|
|app: &mut VectorcraftApp| -> Vec<String> { brushes(app).0.into_iter().filter(|b| b.1 == "calligraphic").map(|b| b.0).collect() };
|
|
// "10 pt. Oval" onto the left half of "3 pt. Round".
|
|
let held = drag(&mut app, tiles[2].center(), tiles[0].center() - vec2(16.0, 0.0), 1.0);
|
|
assert_eq!(bars(&held), [tiles[0].left() - 1.5]);
|
|
assert_eq!(calligraphic(&mut app), ["10 pt. Oval", "3 pt. Round", "6 pt. Flat"]);
|
|
assert_eq!(undo(&app), steps + 1);
|
|
app.run("edit.undo", json!({})).unwrap();
|
|
assert_eq!(calligraphic(&mut app), ["3 pt. Round", "6 pt. Flat", "10 pt. Oval"]);
|
|
// "3 pt. Round" onto "Dots", a scatter brush: no bar, and nothing moves.
|
|
let held = drag(&mut app, tiles[0].center(), tiles[3].center(), 3.0);
|
|
assert!(bars(&held).is_empty(), "{:?}", bars(&held));
|
|
assert_eq!(brushes(&mut app).0, list);
|
|
assert_eq!(undo(&app), steps);
|
|
assert!(app.ui.status.contains("brushes move only within their type"), "{}", app.ui.status);
|
|
}
|
|
}
|