Krolyxon
Commit 0155f71

Branches

Refs →

budctlcratesbudctl-guisrcui.rs

345 lines · 12.5 KB · Rust

1//! The building blocks every page uses, in Nothing OS's shapes: caps labels, dot-matrix words,
2//! round cards, rows with their icon in a circle, pill switches, pill tracks and chips.
3//! Everything takes its colors from the [`Look`].
4//!
5//! What can be pressed sits on a [`motion::eased`] surface, so its hover and its changes of
6//! state fade rather than snap.
7
8use iced::widget::{Column, Row, Stack, button, column, container, row, space, text};
9use iced::{Alignment, Border, Color, Element, Length, Theme};
10
11use crate::Message;
12use crate::dots;
13use crate::icons::{self, Icon};
14use crate::motion::{self, Paint};
15use crate::theme::Look;
16
17/// Round corners: cards, and the rows in them.
18const CARD: f32 = 28.0;
19/// Fully round: pills, chips and circles.
20const ROUND: f32 = 999.0;
21
22/// A caps label in the monospace font, like "LEFT" or "BUILT IN".
23pub fn label<'a>(look: Look, words: &str, size: f32, color: Color) -> Element<'a, Message> {
24 text(words.to_uppercase())
25 .size(size)
26 .font(look.mono())
27 .color(color)
28 .into()
29}
30
31/// Numbers and short words as LED dots. Never under 26 px: smaller, the dots blur.
32pub fn num<'a>(look: Look, words: &str, size: f32, color: Color) -> Element<'a, Message> {
33 text(words.to_uppercase())
34 .size(size)
35 .font(look.dot())
36 .color(color)
37 .into()
38}
39
40/// A page's caption, at its top: "overview · battery, noise control, sound".
41pub fn title<'a>(look: Look, caption: &str) -> Element<'a, Message> {
42 container(label(look, caption, 12.0, look.p.grey))
43 .padding([0, 18])
44 .into()
45}
46
47/// A dialog's heading.
48pub fn headline<'a>(look: Look, words: impl text::IntoFragment<'a>) -> Element<'a, Message> {
49 text(words).size(24).font(look.semibold()).color(look.p.fg).into()
50}
51
52/// A heading above a card.
53pub fn heading<'a>(look: Look, words: &str) -> Element<'a, Message> {
54 container(label(look, words, 11.0, look.p.grey)).padding([0, 18]).into()
55}
56
57/// Plain words under or beside a card.
58pub fn note<'a>(look: Look, words: impl text::IntoFragment<'a>) -> Element<'a, Message> {
59 text(words).size(13.5).color(look.p.fg_dim).into()
60}
61
62/// A round card holding rows.
63pub fn card<'a>(look: Look, rows: Vec<Element<'a, Message>>) -> Element<'a, Message> {
64 let p = look.p;
65 layer(
66 container(Column::with_children(rows).spacing(2))
67 .padding(6)
68 .width(Length::Fill)
69 .style(move |_| surface(p.s1, CARD)),
70 )
71}
72
73/// A round card around content of its own.
74pub fn panel<'a>(look: Look, content: Element<'a, Message>) -> Element<'a, Message> {
75 let p = look.p;
76 layer(
77 container(content)
78 .padding(24)
79 .width(Length::Fill)
80 .style(move |_| surface(p.s1, CARD)),
81 )
82}
83
84/// `content` on a layer of its own, clipped to its bounds.
85///
86/// The CPU renderer repaints a page that scrolls patch by patch: one for each word, icon and
87/// row that moved, with the whole card under each patch painted again. A layer that moved is
88/// one patch, so a card is painted about once per frame instead of dozens of times.
89fn layer<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
90 // The stack takes its size from `content` and draws it on a layer of its own, above the
91 // empty space under it.
92 Stack::new().push(content).push_under(space()).clip(true).into()
93}
94
95fn surface(color: Color, radius: f32) -> container::Style {
96 container::Style {
97 background: Some(color.into()),
98 border: Border::default().rounded(radius),
99 ..container::Style::default()
100 }
101}
102
103/// `content` in the middle of the window, `width` wide, over the dimmed page.
104pub fn scrim<'a>(look: Look, content: Element<'a, Message>, width: f32) -> Element<'a, Message> {
105 let bg = look.p.bg;
106 container(container(content).width(width))
107 .center(Length::Fill)
108 .style(move |_| container::Style {
109 // As clear as the background is: a dialog fading in brings its dimming with it.
110 background: Some(Color { a: 0.74 * bg.a, ..bg }.into()),
111 ..container::Style::default()
112 })
113 .into()
114}
115
116/// A setting row: icon in a circle, title, an optional subtitle, and something on the right.
117/// With `on_press`, the whole row is the click target. `focused`: the keyboard cursor is on it.
118///
119/// A subtitle ending in "…" ("Saving…", "Starting…") says something is under way, and shows
120/// in the live color.
121pub fn setting<'a>(
122 look: Look,
123 icon: Icon,
124 title: impl text::IntoFragment<'a>,
125 subtitle: Option<String>,
126 trailing: Element<'a, Message>,
127 on_press: Option<Message>,
128 focused: bool,
129) -> Element<'a, Message> {
130 list_row(look, icon, false, title, subtitle, trailing, on_press, focused)
131}
132
133/// A [`setting`] row that can be `current`, like the preset in use: its circle is filled.
134#[allow(clippy::too_many_arguments)]
135pub fn list_row<'a>(
136 look: Look,
137 icon: Icon,
138 current: bool,
139 title: impl text::IntoFragment<'a>,
140 subtitle: Option<String>,
141 trailing: Element<'a, Message>,
142 on_press: Option<Message>,
143 focused: bool,
144) -> Element<'a, Message> {
145 let p = look.p;
146 let subtitle = subtitle.map(|words| {
147 let color = if words.ends_with('…') { p.live } else { p.grey };
148 label(look, &words, 10.5, color)
149 });
150 let words = column![text(title).size(15.5).font(look.medium()).color(p.fg)]
151 .push(subtitle)
152 .spacing(3)
153 .width(Length::Fill);
154 let circle = if current {
155 p.fill
156 } else if focused {
157 p.s3
158 } else {
159 p.s2
160 };
161 let glyph = if current { p.fill_fg } else { p.fg };
162 let content = row![
163 disc(look, icons::icon(icon, 20.0, glyph), 44.0, circle),
164 words,
165 trailing
166 ]
167 .spacing(14)
168 .align_y(Alignment::Center)
169 .padding([6, 14]);
170 let content = container(content).padding([0, 6]);
171 let interactive = on_press.is_some();
172 let rest = Paint::fill(if focused { p.s3 } else { Color::TRANSPARENT }).framed(p.fill, focused);
173 let hover = Paint { fill: p.s3, ..rest };
174 let row = button(content)
175 .on_press_maybe(on_press)
176 .padding(0)
177 .width(Length::Fill)
178 .style(bare(p.fg));
179 motion::eased(row, rest, hover, CARD, interactive, look.motion)
180}
181
182/// `content` centered in a circle `size` across.
183fn disc<'a>(look: Look, content: Element<'a, Message>, size: f32, color: Color) -> Element<'a, Message> {
184 motion::surface(container(content).center(size), Paint::fill(color), ROUND, look.motion)
185}
186
187/// A button with no paint of its own: the [`motion::eased`] surface under it paints it.
188fn bare(text_color: Color) -> impl Fn(&Theme, button::Status) -> button::Style {
189 move |_, _| button::Style {
190 background: None,
191 text_color,
192 border: Border::default(),
193 ..button::Style::default()
194 }
195}
196
197/// An on/off switch: off, a dark pill with a dim knob on the left; on, the fill runs to the
198/// right end, with a dot in it. It only shows the state: the row around it takes the click.
199pub fn switch<'a>(look: Look, on: bool) -> Element<'a, Message> {
200 dots::switch(look, on)
201}
202
203/// A chevron: the row opens a page.
204pub fn chevron<'a>(look: Look) -> Element<'a, Message> {
205 icons::icon(Icon::Chevron, 18.0, look.p.grey)
206}
207
208/// One choice of a [`segmented`] control.
209pub struct Segment<'a> {
210 pub icon: Option<Icon>,
211 pub label: &'a str,
212 pub selected: bool,
213 pub on_press: Message,
214}
215
216/// A one-of-n pill track: the chosen cell is filled. `large` puts an icon above each label.
217/// `focused`: the keyboard cursor is on it.
218pub fn segmented<'a>(look: Look, segments: Vec<Segment<'a>>, large: bool, focused: bool) -> Element<'a, Message> {
219 let p = look.p;
220 let mut choices = Row::new().spacing(4);
221 for segment in segments {
222 let selected = segment.selected;
223 let color = if selected { p.fill_fg } else { p.grey };
224 let words = label(look, segment.label, if large { 11.5 } else { 11.0 }, color);
225 let inner: Element<'a, Message> = match segment.icon {
226 Some(icon) if large => column![icons::icon(icon, 22.0, if selected { p.fill_fg } else { p.fg }), words]
227 .spacing(8)
228 .align_x(Alignment::Center)
229 .into(),
230 _ => words,
231 };
232 let choice = button(container(inner).center_x(Length::Fill))
233 .on_press(segment.on_press)
234 .width(Length::Fill)
235 .padding(if large { [14, 8] } else { [10, 12] })
236 .style(bare(color));
237 // The chosen cell's fill fades out as the next one's fades in.
238 let rest = Paint::fill(if selected { p.fill } else { Color::TRANSPARENT });
239 let hover = Paint::fill(if selected { p.fill } else { p.s3 });
240 choices = choices.push(motion::eased(choice, rest, hover, ROUND, true, look.motion));
241 }
242 let track = Paint::fill(p.s2).framed(p.fill, focused);
243 motion::surface(
244 container(choices).padding(6).width(Length::Fill),
245 track,
246 ROUND,
247 look.motion,
248 )
249}
250
251/// A page in the sidebar: a dot-matrix word, large with the live dot when open.
252pub fn nav_item<'a>(look: Look, word: &str, open: bool, on_press: Message) -> Element<'a, Message> {
253 let p = look.p;
254 let content: Element<'a, Message> = if open {
255 row![dot(p.live, 14.0), num(look, word, 40.0, p.fg)]
256 .spacing(14)
257 .align_y(Alignment::Center)
258 .into()
259 } else {
260 row![space().width(14), num(look, word, 26.0, p.mute)]
261 .spacing(14)
262 .align_y(Alignment::Center)
263 .into()
264 };
265 let item = button(content)
266 .on_press(on_press)
267 .width(Length::Fill)
268 .padding([6, 10])
269 .style(bare(p.fg));
270 let hover = if open { Paint::NONE } else { Paint::fill(p.s1) };
271 motion::eased(item, Paint::NONE, hover, CARD, true, look.motion)
272}
273
274/// A chip: a caps label in a pill. `filled` with the fill color, for the main action, or on a
275/// dark pill.
276pub fn pill<'a>(look: Look, words: &str, filled: bool, on_press: Message) -> Element<'a, Message> {
277 pill_with(look, words, filled, false, on_press)
278}
279
280/// A [`pill`] that can carry the keyboard cursor.
281pub fn pill_with<'a>(look: Look, words: &str, filled: bool, focused: bool, on_press: Message) -> Element<'a, Message> {
282 let p = look.p;
283 let color = if filled { p.fill_fg } else { p.fg };
284 let pill = button(label(look, words, 11.0, color))
285 .on_press(on_press)
286 .padding([10, 18])
287 .style(bare(color));
288 let rest = Paint::fill(if filled { p.fill } else { p.s3 }).framed(p.fill, focused);
289 let hover = Paint::fill(if filled { p.fill } else { p.mute }).framed(p.fill, focused);
290 motion::eased(pill, rest, hover, ROUND, true, look.motion)
291}
292
293/// A round button with just an icon, like the pencil on a preset's row.
294pub fn icon_button<'a>(look: Look, icon: Icon, focused: bool, on_press: Message) -> Element<'a, Message> {
295 let p = look.p;
296 let round = button(container(icons::icon(icon, 18.0, p.fg)).center(40))
297 .on_press(on_press)
298 .padding(0)
299 .style(bare(p.fg));
300 let rest = Paint::fill(if focused { p.mute } else { p.s2 }).framed(p.fill, focused);
301 let hover = Paint::fill(p.mute).framed(p.fill, focused);
302 motion::eased(round, rest, hover, ROUND, true, look.motion)
303}
304
305/// A wide chip, like "Refresh".
306pub fn wide_button<'a>(look: Look, words: &str, on_press: Message) -> Element<'a, Message> {
307 let p = look.p;
308 let wide = button(container(label(look, words, 11.0, p.fg)).center_x(Length::Fill))
309 .on_press(on_press)
310 .width(Length::Fill)
311 .padding([11, 12])
312 .style(bare(p.fg));
313 motion::eased(wide, Paint::fill(p.s3), Paint::fill(p.mute), ROUND, true, look.motion)
314}
315
316/// A filled circle, like the "connected" light or the live dot.
317pub fn dot<'a>(color: Color, size: f32) -> Element<'a, Message> {
318 container(space())
319 .width(size)
320 .height(size)
321 .style(move |_| surface(color, size / 2.0))
322 .into()
323}
324
325/// A small caps tag with a live dot, like "demo".
326pub fn tag<'a>(look: Look, words: &str) -> Element<'a, Message> {
327 let p = look.p;
328 container(
329 row![label(look, words, 10.0, p.fg), dot(p.live, 6.0)]
330 .spacing(6)
331 .align_y(Alignment::Center),
332 )
333 .padding([4, 10])
334 .style(move |_| surface(p.s2, ROUND))
335 .into()
336}
337
338/// A value in a small dark pill, like a bass level or a firmware version.
339pub fn value<'a>(look: Look, words: &str) -> Element<'a, Message> {
340 let p = look.p;
341 container(label(look, words, 11.0, p.fg))
342 .padding([7, 12])
343 .style(move |_| surface(p.s2, ROUND))
344 .into()
345}