Krolyxon
Commit 7c15b41

Branches

Refs →

budctlcratesbudctl-guisrcui.rs

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