| 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 | use iced::alignment::Horizontal; |
| 6 | use iced::widget::{Column, Row, Stack, button, column, container, row, space, text}; |
| 7 | use iced::{Alignment, Border, Color, Element, Length}; |
| 8 | |
| 9 | use crate::Message; |
| 10 | use crate::icons::{self, Icon}; |
| 11 | use crate::theme::{Look, Palette}; |
| 12 | |
| 13 | /// Round corners: cards, and the rows in them. |
| 14 | const CARD: f32 = 28.0; |
| 15 | /// Fully round: pills, chips and circles. |
| 16 | const ROUND: f32 = 999.0; |
| 17 | |
| 18 | /// A caps label in the monospace font, like "LEFT" or "BUILT IN". |
| 19 | pub 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. |
| 28 | pub 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". |
| 37 | pub 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. |
| 44 | pub 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. |
| 49 | pub 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. |
| 54 | pub 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. |
| 59 | pub 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. |
| 70 | pub 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. |
| 85 | fn 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 | |
| 91 | fn 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. |
| 100 | pub 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. |
| 116 | pub 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)] |
| 130 | pub 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. |
| 176 | fn 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. |
| 184 | fn 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 | |
| 192 | fn 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. |
| 198 | pub 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. |
| 216 | pub 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. |
| 221 | pub 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. |
| 230 | pub 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. |
| 272 | pub 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. |
| 300 | pub 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. |
| 305 | pub 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. |
| 325 | pub 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". |
| 340 | pub 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. |
| 356 | pub 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". |
| 365 | pub 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. |
| 378 | pub 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 | } |