1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
// Copyright © The Daybrite Project
// SPDX-License-Identifier: MPL-2.0
//! Reusable application commands. See `docs/commands.md` for ownership and presentation rules.
use std::rc::Rc;
use day_reactive::{Scope, untrack};
use day_spec::{Icon, Shortcut, Symbol};
use crate::{
Button, Decorate, Decorated, IntoReactive, IntoText, MenuEntry, Reactive, TextSource,
ToolbarEntry,
};
/// An application operation shared by buttons, menus, toolbars, and direct callers
/// (docs/commands.md): one id, one title, one availability, one handler, presented through
/// [`button`](Command::button), [`menu_item`](Command::menu_item) and
/// [`toolbar_item`](Command::toolbar_item).
///
/// Built with [`Command::new`] in the scope that owns it. Clones share the handler and reactive
/// sources. Builders configure a copy, not existing presentations. Invocation after the owning
/// scope's disposal is a no-op. This is a UI-thread value, not a global registry or a native
/// edit role.
///
/// ```
/// use day_pieces::prelude::*;
/// use day_reactive::Signal;
///
/// let count = Signal::new(0);
/// let add = Command::new(day_core::id!(add), "Add one", move || count.update(|n| *n += 1))
/// .enabled(move || count.get() < 3);
/// assert!(add.invoke());
/// assert_eq!(count.get(), 1);
/// ```
#[derive(Clone)]
pub struct Command {
id: day_core::ElementId,
label: TextSource,
enabled: Reactive<bool>,
checked: Option<Reactive<bool>>,
icon: Option<Icon>,
shortcut: Option<Shortcut>,
handler: Rc<dyn Fn()>,
owner: Scope,
}
impl Command {
/// A command with its default presentation id, its title (the same sources as a button:
/// a literal, a localized text, a signal, a closure) and its synchronous handler.
/// Availability is checked before the handler is called. Captures the current reactive
/// scope as the command's owner.
pub fn new<M>(
id: day_core::ElementId,
label: impl IntoText<M>,
action: impl Fn() + 'static,
) -> Command {
Command {
id,
label: label.into_text(),
enabled: Reactive::Const(true),
checked: None,
icon: None,
shortcut: None,
handler: Rc::new(action),
owner: Scope::current(),
}
}
/// Availability shared by every presentation and checked again on every invocation.
pub fn enabled<M>(mut self, enabled: impl IntoReactive<bool, M>) -> Self {
self.enabled = enabled.into_reactive();
self
}
/// Optional check state. The handler owns mutations; invoking never flips this itself.
pub fn checked<M>(mut self, checked: impl IntoReactive<bool, M>) -> Self {
self.checked = Some(checked.into_reactive());
self
}
pub fn icon(mut self, symbol: Symbol) -> Self {
self.icon = Some(Icon::Symbol(symbol));
self
}
pub fn image(mut self, name: impl Into<day_spec::ImageName>) -> Self {
self.icon = Some(Icon::Image(name.into().as_str().to_owned()));
self
}
/// Menu accelerator metadata. Only installing a menu entry installs the shortcut.
pub fn shortcut(mut self, shortcut: Shortcut) -> Self {
self.shortcut = Some(shortcut);
self
}
pub fn id(&self) -> &str {
self.id.as_str()
}
/// Current title, with tracked reads for reactive presentation builders.
pub fn label(&self) -> String {
if self.owner.is_alive() {
self.label.resolve()
} else {
String::new()
}
}
pub fn is_enabled(&self) -> bool {
self.owner.is_alive() && self.enabled.get()
}
pub fn is_checked(&self) -> Option<bool> {
self.checked
.as_ref()
.map(|v| self.owner.is_alive() && v.get())
}
/// Run if still available. Returns whether the handler ran, not whether its work succeeded.
/// Reads are untracked; invoking inside an effect does not subscribe it to the handler.
pub fn invoke(&self) -> bool {
untrack(|| {
if !self.is_enabled() {
return false;
}
(self.handler)();
true
})
}
/// A live button with this command's default id, title, icon, availability, and handler.
/// A push button does not draw a check mark. Add presentation styling as usual.
pub fn button(&self) -> Decorated<Button> {
let (title, enabled, action) = (self.clone(), self.clone(), self.clone());
let mut button = crate::button(move || title.label())
.enabled(move || enabled.is_enabled())
.action(move || {
action.invoke();
});
button = match &self.icon {
Some(Icon::Symbol(s)) => button.icon(*s),
Some(Icon::Image(name)) => button.image(name.clone()),
None => button,
};
button.id(self.id.clone())
}
/// A live menu entry: its title, availability and check mark follow the command, and the
/// menu it sits in re-lowers when they change. Invocation always rechecks availability.
pub fn menu_item(&self) -> MenuEntry {
let (title, enabled, action) = (self.clone(), self.clone(), self.clone());
let mut item = crate::menu_item(move || title.label())
.id(self.id.clone())
.enabled(move || enabled.is_enabled())
.action(move || {
action.invoke();
});
if self.is_checked().is_some() {
let checked = self.clone();
item = item.checked(move || checked.is_checked().unwrap_or(false));
}
if let Some(shortcut) = &self.shortcut {
item = item.shortcut(shortcut.clone());
}
match &self.icon {
Some(Icon::Symbol(s)) => item.icon(*s),
Some(Icon::Image(name)) => item.image(name.clone()),
None => item,
}
}
/// Snapshot for a derived toolbar: `.toolbar(move || vec![command.toolbar_item()])`.
/// Checked commands draw a toggle; the handler remains the sole owner of its state.
/// Reactive builders refresh titles/checks; availability also gets a targeted live binding.
pub fn toolbar_item(&self) -> ToolbarEntry {
let (enabled, action) = (self.clone(), self.clone());
let mut item = crate::toolbar_button(self.id.clone(), self.label())
.enabled(move || enabled.is_enabled())
.action(move || {
action.invoke();
});
if self.is_checked().is_some() {
let checked = self.clone();
item = item.command_checked(Rc::new(move || checked.is_checked().unwrap_or(false)));
}
match &self.icon {
Some(Icon::Symbol(s)) => item.icon(*s),
Some(Icon::Image(name)) => item.image(name.clone()),
None => item,
}
}
}