Krolyxon
Commit 4f7552f

Branches

Refs →

budctlcratesbudctl-guisrcui.rs

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