| 1 | //! The controls of the owner's shell (Aurora), rebuilt in iced. Aurora is flat and dense: square |
| 2 | //! corners, 1 px lines, accent fills with background-colored text, uppercase accent section |
| 3 | //! labels, lowercase words. |
| 4 | |
| 5 | use iced::alignment::{Horizontal, Vertical}; |
| 6 | use iced::widget::{Button, Column, Row, button, column, container, progress_bar, row, rule, scrollable, space, text}; |
| 7 | use iced::{Alignment, Border, Color, Element, Length, Padding}; |
| 8 | |
| 9 | use crate::Message; |
| 10 | use crate::glyphs; |
| 11 | use crate::theme::Look; |
| 12 | |
| 13 | /// The page title: uppercase and bold. |
| 14 | pub fn title<'a>(look: Look, title: &str) -> Element<'a, Message> { |
| 15 | text(title.to_uppercase()) |
| 16 | .size(look.px(4.0)) |
| 17 | .font(look.bold()) |
| 18 | .color(look.p.fg) |
| 19 | .into() |
| 20 | } |
| 21 | |
| 22 | /// An uppercase accent label, with a line filling the rest of the row. |
| 23 | pub fn section<'a>(look: Look, label: &str) -> Element<'a, Message> { |
| 24 | let p = look.p; |
| 25 | let line = rule::horizontal(1).style(move |_| rule::Style { |
| 26 | color: p.line, |
| 27 | radius: 0.0.into(), |
| 28 | fill_mode: rule::FillMode::Full, |
| 29 | snap: true, |
| 30 | }); |
| 31 | row![text(label.to_uppercase()).size(look.px(-2.0)).color(p.accent), line] |
| 32 | .spacing(8) |
| 33 | .align_y(Alignment::Center) |
| 34 | .into() |
| 35 | } |
| 36 | |
| 37 | /// Text in the base size and color. |
| 38 | pub fn label<'a>(look: Look, words: impl text::IntoFragment<'a>) -> text::Text<'a> { |
| 39 | text(words).size(look.size).color(look.p.fg) |
| 40 | } |
| 41 | |
| 42 | /// Smaller, dimmer text, for hints. |
| 43 | pub fn hint<'a>(look: Look, words: impl text::IntoFragment<'a>) -> text::Text<'a> { |
| 44 | text(words).size(look.px(-2.0)).color(look.p.dim) |
| 45 | } |
| 46 | |
| 47 | fn hovered(status: button::Status) -> bool { |
| 48 | matches!(status, button::Status::Hovered | button::Status::Pressed) |
| 49 | } |
| 50 | |
| 51 | /// An on/off setting: its label, an optional hint below, and a square switch on the right. The |
| 52 | /// whole row is the click target. |
| 53 | pub fn switch_row<'a>( |
| 54 | look: Look, |
| 55 | name: String, |
| 56 | about: Option<&'a str>, |
| 57 | on: bool, |
| 58 | toggle: Message, |
| 59 | ) -> Element<'a, Message> { |
| 60 | let p = look.p; |
| 61 | let words = column![label(look, name)] |
| 62 | .push(about.map(|about| hint(look, about))) |
| 63 | .width(Length::Fill); |
| 64 | let content = row![words, switch(look, on)] |
| 65 | .spacing(10) |
| 66 | .align_y(Alignment::Center) |
| 67 | .padding([5, 0]); |
| 68 | button(content) |
| 69 | .on_press(toggle) |
| 70 | .width(Length::Fill) |
| 71 | .padding(0) |
| 72 | .style(move |_, status| button::Style { |
| 73 | background: hovered(status).then_some(p.surface.into()), |
| 74 | text_color: p.fg, |
| 75 | ..button::Style::default() |
| 76 | }) |
| 77 | .into() |
| 78 | } |
| 79 | |
| 80 | /// A square track with a square knob: left and dim when off, right on the accent when on. |
| 81 | fn switch<'a>(look: Look, on: bool) -> Element<'a, Message> { |
| 82 | let p = look.p; |
| 83 | let knob = container(space()) |
| 84 | .width(12) |
| 85 | .height(12) |
| 86 | .style(move |_| container::Style { |
| 87 | background: Some((if on { p.bg } else { p.dim }).into()), |
| 88 | ..container::Style::default() |
| 89 | }); |
| 90 | container(knob) |
| 91 | .width(36) |
| 92 | .height(18) |
| 93 | .padding(3) |
| 94 | .align_x(if on { Horizontal::Right } else { Horizontal::Left }) |
| 95 | .align_y(Vertical::Center) |
| 96 | .style(move |_| container::Style { |
| 97 | background: Some((if on { p.accent } else { p.surface_high }).into()), |
| 98 | border: Border { |
| 99 | color: if on { p.accent } else { p.line }, |
| 100 | width: 1.0, |
| 101 | radius: 0.0.into(), |
| 102 | }, |
| 103 | ..container::Style::default() |
| 104 | }) |
| 105 | .into() |
| 106 | } |
| 107 | |
| 108 | /// A button with a 1 px border, an optional glyph and a label. `active` fills it with the accent. |
| 109 | pub fn button_<'a>( |
| 110 | look: Look, |
| 111 | glyph: Option<char>, |
| 112 | words: &'a str, |
| 113 | active: bool, |
| 114 | on_press: Message, |
| 115 | ) -> Button<'a, Message> { |
| 116 | let p = look.p; |
| 117 | let color = if active { p.bg } else { p.fg }; |
| 118 | let content = Row::new() |
| 119 | .push(glyph.map(|g| text(g.to_string()).size(15).color(color))) |
| 120 | .push((!words.is_empty()).then(|| text(words).size(look.px(-1.0)).color(color))) |
| 121 | .spacing(8) |
| 122 | .align_y(Alignment::Center); |
| 123 | button(container(content).center(Length::Fill)) |
| 124 | .on_press(on_press) |
| 125 | .height(26) |
| 126 | .padding(0) |
| 127 | .style(move |_, status| { |
| 128 | let hover = hovered(status); |
| 129 | button::Style { |
| 130 | background: if active { |
| 131 | Some(p.accent.into()) |
| 132 | } else { |
| 133 | hover.then_some(p.surface_high.into()) |
| 134 | }, |
| 135 | text_color: color, |
| 136 | border: Border { |
| 137 | color: if active || hover { p.accent } else { p.line }, |
| 138 | width: 1.0, |
| 139 | radius: 0.0.into(), |
| 140 | }, |
| 141 | ..button::Style::default() |
| 142 | } |
| 143 | }) |
| 144 | } |
| 145 | |
| 146 | /// One option of a [`choice`]. |
| 147 | pub struct Opt<'a> { |
| 148 | pub glyph: Option<char>, |
| 149 | pub words: &'a str, |
| 150 | pub chosen: bool, |
| 151 | pub on_press: Message, |
| 152 | } |
| 153 | |
| 154 | /// A label, then one button per option, all the same width, the chosen one filled. |
| 155 | pub fn choice<'a>(look: Look, name: &'a str, options: Vec<Opt<'a>>) -> Element<'a, Message> { |
| 156 | let buttons = options.into_iter().fold(Row::new().spacing(2), |buttons, o| { |
| 157 | buttons.push(button_(look, o.glyph, o.words, o.chosen, o.on_press).width(Length::FillPortion(1))) |
| 158 | }); |
| 159 | row![label(look, name).width(150), buttons] |
| 160 | .spacing(8) |
| 161 | .align_y(Alignment::Center) |
| 162 | .into() |
| 163 | } |
| 164 | |
| 165 | /// A label, a level bar and the value, like a battery's charge. |
| 166 | pub fn stat_bar<'a>(look: Look, name: &'a str, fraction: f32, value: String, fill: Color) -> Element<'a, Message> { |
| 167 | let p = look.p; |
| 168 | let bar = progress_bar(0.0..=1.0, fraction) |
| 169 | .girth(6) |
| 170 | .style(move |_| progress_bar::Style { |
| 171 | background: p.surface_high.into(), |
| 172 | bar: fill.into(), |
| 173 | border: Border::default(), |
| 174 | }); |
| 175 | row![ |
| 176 | text(name).size(look.px(-1.0)).color(p.dim).width(72), |
| 177 | bar, |
| 178 | text(value) |
| 179 | .size(look.px(-1.0)) |
| 180 | .color(p.fg) |
| 181 | .width(96) |
| 182 | .align_x(Horizontal::Right) |
| 183 | ] |
| 184 | .spacing(10) |
| 185 | .align_y(Alignment::Center) |
| 186 | .into() |
| 187 | } |
| 188 | |
| 189 | /// A row that opens a page: its label, an optional hint below, and on the right a value and an |
| 190 | /// arrow. Lined up with the switch rows. |
| 191 | pub fn link_row<'a>( |
| 192 | look: Look, |
| 193 | name: &'a str, |
| 194 | about: Option<&'a str>, |
| 195 | value: Option<String>, |
| 196 | on_press: Message, |
| 197 | ) -> Element<'a, Message> { |
| 198 | let p = look.p; |
| 199 | let words = column![label(look, name)] |
| 200 | .push(about.map(|about| hint(look, about))) |
| 201 | .width(Length::Fill); |
| 202 | let content = row![words] |
| 203 | .push(value.map(|value| text(value).size(look.px(-1.0)).color(p.dim))) |
| 204 | .push(text(glyphs::CHEVRON.to_string()).size(look.px(4.0)).color(p.dim)) |
| 205 | .spacing(10) |
| 206 | .align_y(Alignment::Center) |
| 207 | .padding([5, 0]); |
| 208 | button(content) |
| 209 | .on_press(on_press) |
| 210 | .width(Length::Fill) |
| 211 | .padding(0) |
| 212 | .style(move |_, status| button::Style { |
| 213 | background: hovered(status).then_some(p.surface.into()), |
| 214 | text_color: p.fg, |
| 215 | ..button::Style::default() |
| 216 | }) |
| 217 | .into() |
| 218 | } |
| 219 | |
| 220 | /// The sidebar's title: a glyph and a name, bold, on the accent. |
| 221 | pub fn sidebar_title<'a>(look: Look, glyph: char, name: &str) -> Element<'a, Message> { |
| 222 | text(format!("{glyph} {name}")) |
| 223 | .size(look.px(1.0)) |
| 224 | .font(look.bold()) |
| 225 | .color(look.p.accent) |
| 226 | .into() |
| 227 | } |
| 228 | |
| 229 | /// A group heading in the sidebar. |
| 230 | pub fn group<'a>(look: Look, name: &str) -> Element<'a, Message> { |
| 231 | text(name.to_uppercase()).size(look.px(-3.0)).color(look.p.dim).into() |
| 232 | } |
| 233 | |
| 234 | /// A page in the sidebar. The open one is filled with the accent. |
| 235 | pub fn nav_item<'a>(look: Look, glyph: char, name: &str, open: bool, on_press: Message) -> Element<'a, Message> { |
| 236 | let p = look.p; |
| 237 | let words = text(format!("{glyph} {name}")) |
| 238 | .size(look.size) |
| 239 | .font(if open { look.bold() } else { look.font }) |
| 240 | .color(if open { p.bg } else { p.fg }); |
| 241 | button(container(words).height(Length::Fill).align_y(Vertical::Center)) |
| 242 | .on_press(on_press) |
| 243 | .width(Length::Fill) |
| 244 | .height(30) |
| 245 | .padding([0, 10]) |
| 246 | .style(move |_, status| button::Style { |
| 247 | background: if open { |
| 248 | Some(p.accent.into()) |
| 249 | } else { |
| 250 | hovered(status).then_some(p.surface_high.into()) |
| 251 | }, |
| 252 | text_color: if open { p.bg } else { p.fg }, |
| 253 | ..button::Style::default() |
| 254 | }) |
| 255 | .into() |
| 256 | } |
| 257 | |
| 258 | /// The buds in the sidebar: a glyph, the name and a status line, in a 1 px frame that is the |
| 259 | /// accent while they are connected. |
| 260 | pub fn device<'a>(look: Look, name: &'a str, status: String, connected: bool) -> Element<'a, Message> { |
| 261 | let p = look.p; |
| 262 | let mark = if connected { p.accent } else { p.dim }; |
| 263 | let content = row![ |
| 264 | text(glyphs::HEADPHONES.to_string()).size(17).color(mark), |
| 265 | column![ |
| 266 | text(name).size(look.size).font(look.bold()).color(p.fg), |
| 267 | text(status).size(look.px(-3.0)).color(mark) |
| 268 | ] |
| 269 | ] |
| 270 | .spacing(10) |
| 271 | .align_y(Alignment::Center); |
| 272 | container(content) |
| 273 | .padding([7, 10]) |
| 274 | .width(Length::Fill) |
| 275 | .style(move |_| container::Style { |
| 276 | border: Border { |
| 277 | color: if connected { p.accent } else { p.line }, |
| 278 | width: 1.0, |
| 279 | radius: 0.0.into(), |
| 280 | }, |
| 281 | ..container::Style::default() |
| 282 | }) |
| 283 | .into() |
| 284 | } |
| 285 | |
| 286 | /// A column of sections with Aurora's spacing: 8 px between rows, a little more above each section. |
| 287 | pub fn sections<'a>(sections: Vec<Vec<Element<'a, Message>>>) -> Element<'a, Message> { |
| 288 | let mut page = Column::new().spacing(14); |
| 289 | for rows in sections { |
| 290 | page = page.push(Column::with_children(rows).spacing(8)); |
| 291 | } |
| 292 | page.into() |
| 293 | } |
| 294 | |
| 295 | /// A scrolling area with a thin, square scrollbar, clear of the content. |
| 296 | pub fn scroll<'a>(look: Look, content: Element<'a, Message>) -> Element<'a, Message> { |
| 297 | let p = look.p; |
| 298 | let padded = container(content).padding(Padding { |
| 299 | top: 0.0, |
| 300 | right: 14.0, |
| 301 | bottom: 8.0, |
| 302 | left: 0.0, |
| 303 | }); |
| 304 | scrollable(padded) |
| 305 | .height(Length::Fill) |
| 306 | .style(move |theme, status| { |
| 307 | let mut style = scrollable::default(theme, status); |
| 308 | for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] { |
| 309 | rail.background = None; |
| 310 | rail.border = Border::default(); |
| 311 | rail.scroller.background = p.surface_high.into(); |
| 312 | rail.scroller.border = Border::default(); |
| 313 | } |
| 314 | style |
| 315 | }) |
| 316 | .into() |
| 317 | } |
| 318 | |
| 319 | /// A 1 px vertical line, between the sidebar and the page. |
| 320 | pub fn divider<'a>(look: Look) -> Element<'a, Message> { |
| 321 | let p = look.p; |
| 322 | rule::vertical(1) |
| 323 | .style(move |_| rule::Style { |
| 324 | color: p.line, |
| 325 | radius: 0.0.into(), |
| 326 | fill_mode: rule::FillMode::Full, |
| 327 | snap: true, |
| 328 | }) |
| 329 | .into() |
| 330 | } |