| 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 | |
| 8 | use iced::widget::{Column, Row, Stack, button, column, container, row, space, text}; |
| 9 | use iced::{Alignment, Border, Color, Element, Length, Theme}; |
| 10 | |
| 11 | use crate::Message; |
| 12 | use crate::dots; |
| 13 | use crate::icons::{self, Icon}; |
| 14 | use crate::motion::{self, Paint}; |
| 15 | use crate::theme::Look; |
| 16 | |
| 17 | /// Round corners: cards, and the rows in them. |
| 18 | const CARD: f32 = 28.0; |
| 19 | /// Fully round: pills, chips and circles. |
| 20 | const ROUND: f32 = 999.0; |
| 21 | |
| 22 | /// A caps label in the monospace font, like "LEFT" or "BUILT IN". |
| 23 | pub 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. |
| 32 | pub 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". |
| 41 | pub 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. |
| 48 | pub 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. |
| 53 | pub 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. |
| 58 | pub 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. |
| 63 | pub 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. |
| 74 | pub 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. |
| 89 | fn 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 | |
| 95 | fn 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. |
| 104 | pub 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. |
| 121 | pub 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)] |
| 135 | pub 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. |
| 183 | fn 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. |
| 188 | fn 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. |
| 199 | pub 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. |
| 204 | pub 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. |
| 209 | pub 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. |
| 218 | pub 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. |
| 252 | pub 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. |
| 276 | pub 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. |
| 281 | pub 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. |
| 294 | pub 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". |
| 306 | pub 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. |
| 317 | pub 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". |
| 326 | pub 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. |
| 339 | pub 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 | } |