| author | |
| committer | |
| log | 24912d53294b686bf8bfebdd7ab2e17ca14aba1f |
| tree | 03559933d286545395d2cef8b1b12ff3f29f3ac6 |
| parent | cc021c98d7181a67b148b9ce001f9411260accd5 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- Page Color menu: a third row of background art ending in Show All, which
opens the template art gallery for any page; a rainbow Custom Color slot
opens a hue, lightness and saturation picker.
- The sync status is always an icon, with its state as the tooltip.
- Folded toolbar groups hand their width back to the row, so trailing
controls sit flush with the page frame.
- Empty tab-row space drags the window and double-click zooms it.
- Mac OS X 10.6: the unified gradient runs through the tab row to the
notebook frame.
Assisted-by: claude-opus-5.514 files changed, 541 insertions(+), 116 deletions(-)
crates/notebook/examples/scratch_bgsync.rs created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | //! Scratch lab writer (delete before reporting): appends TEXT to the first body paragraph of | |
| 2 | //! the first page of a share-relative section. `scratch_bgsync ADDRESS PATH TEXT` | |
| 3 | use notebook::{ | |
| 4 | Replica, SmbRemote, | |
| 5 | smb::{Client, Credentials}, | |
| 6 | }; | |
| 7 | use onestore::op::{Edit, Op, PageOp}; | |
| 8 | use std::time::Duration; | |
| 9 | ||
| 10 | fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| 11 | let args: Vec<_> = std::env::args().skip(1).collect(); | |
| 12 | let [address, path, with] = &args[..] else { | |
| 13 | return Err("scratch_bgsync ADDRESS PATH TEXT".into()); | |
| 14 | }; | |
| 15 | let connect = || Client::connect(address, "agent", Credentials::default(), Duration::from_secs(5)); | |
| 16 | let source = connect()?.read_storage(path, 1 << 26)?; | |
| 17 | let directory = tempfile::tempdir()?; | |
| 18 | let replica = Replica::create(directory.path().join("cache.sqlite"), &source)?; | |
| 19 | let space = replica.pages()?[0].0; | |
| 20 | let page = replica.page(space)?; | |
| 21 | let (text, len) = page | |
| 22 | .objects | |
| 23 | .iter() | |
| 24 | .find_map(|object| match object { | |
| 25 | onestore::page::PageObject::Outline(outline) => outline | |
| 26 | .paragraphs | |
| 27 | .iter() | |
| 28 | .find_map(|p| p.text().map(|t| (t.id, t.text.text().encode_utf16().count() as u32))), | |
| 29 | _ => None, | |
| 30 | }) | |
| 31 | .ok_or("no text")?; | |
| 32 | replica.apply( | |
| 33 | "Lab", | |
| 34 | Edit { | |
| 35 | at: 134_000_000_000_000_000, | |
| 36 | ops: vec![Op::Page { space, op: PageOp::Text { text, range: len..len, with: with.clone() } }], | |
| 37 | }, | |
| 38 | )?; | |
| 39 | let mut remote = SmbRemote::new(connect()?, path.clone(), 1 << 26); | |
| 40 | println!("{:?}", replica.sync_once(&mut remote)?.edit); | |
| 41 | Ok(()) | |
| 42 | } |
crates/snowbound/assets/icons/custom-color.svg created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> | |
| 2 | <path d="M8 8L8.00 1.00A7 7 0 0 1 12.95 3.05Z" fill="#ff4d4d"/> | |
| 3 | <path d="M8 8L12.95 3.05A7 7 0 0 1 15.00 8.00Z" fill="#ffa53d"/> | |
| 4 | <path d="M8 8L15.00 8.00A7 7 0 0 1 12.95 12.95Z" fill="#ffe14d"/> | |
| 5 | <path d="M8 8L12.95 12.95A7 7 0 0 1 8.00 15.00Z" fill="#5fd35f"/> | |
| 6 | <path d="M8 8L8.00 15.00A7 7 0 0 1 3.05 12.95Z" fill="#4dc3ff"/> | |
| 7 | <path d="M8 8L3.05 12.95A7 7 0 0 1 1.00 8.00Z" fill="#5a6cff"/> | |
| 8 | <path d="M8 8L1.00 8.00A7 7 0 0 1 3.05 3.05Z" fill="#b45aff"/> | |
| 9 | <path d="M8 8L3.05 3.05A7 7 0 0 1 8.00 1.00Z" fill="#ff5ac8"/> | |
| 10 | <circle cx="8" cy="8" r="2.5" fill="#fff"/> | |
| 11 | </svg> |
crates/snowbound/src/aqua.rs+1-1| ... | ... | @@ -29,7 +29,7 @@ pub(crate) fn before_lion() -> bool { |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | 31 | /// Makes the window textured, AppKit's gradient running from the title through `row` points |
| 32 | /// of content below it, as 10.6's unified toolbars do. Where frames are transparent it | |
| 32 | /// of content below it, as 10.6's unified toolbars run theirs. Where frames are transparent it | |
| 33 | 33 | /// shows through. Returns whether the system draws windows so, which only 10.6 does here. |
| 34 | 34 | pub fn textured(window: &Window, row: f32) -> bool { |
| 35 | 35 | if !before_lion() { |
crates/snowbound/src/art.rs+1| ... | ... | @@ -55,6 +55,7 @@ pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter"); |
| 55 | 55 | pub const FORWARD: &[&str] = art!("icons/forward"); |
| 56 | 56 | pub const INSERT_SPACE: &[&str] = art!("icons/insert-space"); |
| 57 | 57 | pub const PAGE_COLOR: &[&str] = art!("icons/page-color"); |
| 58 | pub const CUSTOM_COLOR: &[&str] = art!("icons/custom-color"); | |
| 58 | 59 | pub const PASTE: &[&str] = art!("icons/paste"); |
| 59 | 60 | pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio"); |
| 60 | 61 | pub const RECORD_VIDEO: &[&str] = art!("icons/record-video"); |
crates/snowbound/src/background.rs+122-36| ... | ... | @@ -2,8 +2,9 @@ |
| 2 | 2 | //! lines. |
| 3 | 3 | |
| 4 | 4 | use crate::{ |
| 5 | art, | |
| 5 | 6 | commands::Choice, |
| 6 | templates::{TILE, Thumbnails}, | |
| 7 | templates::{TILE, Thumbnails, View}, | |
| 7 | 8 | }; |
| 8 | 9 | use canvas::{ |
| 9 | 10 | gpu::{Paper, colorref}, |
| ... | ... | @@ -17,38 +18,84 @@ const SWATCH: [f32; 2] = [40.0, 32.0]; |
| 17 | 18 | const RULES: f32 = 40.0; |
| 18 | 19 | /// Points of page per point of a rule-line thumbnail. |
| 19 | 20 | const SHRINK: f32 = 4.0; |
| 20 | /// The art offered after None. | |
| 21 | const ART: [&str; 5] = ["Ivy", "Purple Clouds", "Notebook", "Bamboo", "Blue Clouds"]; | |
| 21 | /// The art offered between None and Show All. | |
| 22 | const ART: [&str; 7] = [ | |
| 23 | "Ivy", | |
| 24 | "Purple Clouds", | |
| 25 | "Notebook", | |
| 26 | "Blue Clouds", | |
| 27 | "Tulips", | |
| 28 | "Bamboo", | |
| 29 | "Sparks", | |
| 30 | ]; | |
| 31 | ||
| 32 | #[derive(Clone, Copy, PartialEq)] | |
| 33 | enum Cell { | |
| 34 | /// A COLORREF, or none. | |
| 35 | Color(Option<u32>), | |
| 36 | Custom, | |
| 37 | Art(Option<&'static str>), | |
| 38 | ShowAll, | |
| 39 | /// An index into `RULE_LINES`, or none. | |
| 40 | Rules(Option<usize>), | |
| 41 | } | |
| 22 | 42 | |
| 23 | 43 | /// Builds popup `id` beside `anchor` while it is open, for a page on `paper` (its own colour |
| 24 | /// aside) coloured `color` and ruled with `rule_lines`. Returns the choice made. | |
| 44 | /// aside) coloured `color` and ruled with `rule_lines`, and the custom colour picker it | |
| 45 | /// opens. Show All opens every template's art over the page through `view`. Returns the | |
| 46 | /// choice made. | |
| 25 | 47 | #[allow(clippy::too_many_arguments)] |
| 26 | 48 | pub fn menu( |
| 27 | 49 | ui: &mut Ui, |
| 28 | 50 | id: Id, |
| 29 | 51 | anchor: Anchor, |
| 30 | 52 | thumbnails: &mut Thumbnails, |
| 53 | view: &mut View, | |
| 31 | 54 | paper: Paper, |
| 32 | 55 | color: Option<u32>, |
| 33 | 56 | rule_lines: Option<RuleLines>, |
| 34 | 57 | ) -> Option<Choice> { |
| 35 | let cells: Vec<Choice> = std::iter::once(Choice::PageColor(None)) | |
| 36 | .chain((0..PAGE_COLORS.len()).map(|index| Choice::PageColor(Some(index)))) | |
| 37 | .chain(std::iter::once(Choice::Art(None))) | |
| 38 | .chain(ART.map(|name| Choice::Art(Some(name)))) | |
| 39 | .chain(std::iter::once(Choice::RuleLines(None))) | |
| 40 | .chain((0..RULE_LINES.len()).map(|index| Choice::RuleLines(Some(index)))) | |
| 58 | let picker = id.child("custom"); | |
| 59 | // A pale blue to start from on a page without a colour. | |
| 60 | let [red, green, blue, _] = color.unwrap_or(0x00f8eedd).to_le_bytes(); | |
| 61 | if let Some([red, green, blue]) = ui::popup::color_picker( | |
| 62 | ui, | |
| 63 | picker, | |
| 64 | anchor, | |
| 65 | "Custom Color", | |
| 66 | [red, green, blue], | |
| 67 | |[red, green, blue]| { | |
| 68 | paper | |
| 69 | .colored(Some(u32::from_le_bytes([red, green, blue, 0]))) | |
| 70 | .color | |
| 71 | }, | |
| 72 | ) { | |
| 73 | return Some(Choice::PageColor(Some(u32::from_le_bytes([ | |
| 74 | red, green, blue, 0, | |
| 75 | ])))); | |
| 76 | } | |
| 77 | let custom = color.filter(|color| !PAGE_COLORS.iter().any(|(_, listed)| listed == color)); | |
| 78 | let cells: Vec<Cell> = std::iter::once(Cell::Color(None)) | |
| 79 | .chain( | |
| 80 | PAGE_COLORS | |
| 81 | .iter() | |
| 82 | .map(|(_, color)| Cell::Color(Some(*color))), | |
| 83 | ) | |
| 84 | .chain([Cell::Custom, Cell::Art(None)]) | |
| 85 | .chain(ART.map(|name| Cell::Art(Some(name)))) | |
| 86 | .chain([Cell::ShowAll, Cell::Rules(None)]) | |
| 87 | .chain((0..RULE_LINES.len()).map(|index| Cell::Rules(Some(index)))) | |
| 41 | 88 | .collect(); |
| 42 | 89 | let groups = [ |
| 43 | 90 | ui::popup::Group { |
| 44 | 91 | heading: "Page Color", |
| 45 | cells: 1 + PAGE_COLORS.len(), | |
| 92 | cells: 2 + PAGE_COLORS.len(), | |
| 46 | 93 | columns: COLUMNS, |
| 47 | 94 | size: SWATCH, |
| 48 | 95 | }, |
| 49 | 96 | ui::popup::Group { |
| 50 | 97 | heading: "Background", |
| 51 | cells: 1 + ART.len(), | |
| 98 | cells: 2 + ART.len(), | |
| 52 | 99 | columns: 3, |
| 53 | 100 | size: [TILE[0] + 8.0, TILE[1] + 8.0], |
| 54 | 101 | }, |
| ... | ... | @@ -60,13 +107,12 @@ pub fn menu( |
| 60 | 107 | }, |
| 61 | 108 | ]; |
| 62 | 109 | let shown = [ |
| 63 | Choice::PageColor(color.map(|color| { | |
| 64 | PAGE_COLORS | |
| 65 | .iter() | |
| 66 | .position(|(_, listed)| *listed == color) | |
| 67 | .unwrap_or(usize::MAX) | |
| 68 | })), | |
| 69 | Choice::RuleLines(rule_lines.map(|lines| { | |
| 110 | if custom.is_some() { | |
| 111 | Cell::Custom | |
| 112 | } else { | |
| 113 | Cell::Color(color) | |
| 114 | }, | |
| 115 | Cell::Rules(rule_lines.map(|lines| { | |
| 70 | 116 | RULE_LINES |
| 71 | 117 | .iter() |
| 72 | 118 | .position(|(_, listed)| { |
| ... | ... | @@ -79,42 +125,71 @@ pub fn menu( |
| 79 | 125 | ]; |
| 80 | 126 | let current: Vec<usize> = shown |
| 81 | 127 | .iter() |
| 82 | .filter_map(|choice| cells.iter().position(|cell| cell == choice)) | |
| 128 | .filter_map(|shown| cells.iter().position(|cell| cell == shown)) | |
| 83 | 129 | .collect(); |
| 84 | 130 | let theme = ui.theme.clone(); |
| 85 | 131 | let scale = ui.scale(); |
| 86 | 132 | let dark = paper.ink[0] > paper.color[0]; |
| 133 | let label = |ui: &mut Ui, text: &str| { | |
| 134 | ui.leaf( | |
| 135 | "label", | |
| 136 | Spec { | |
| 137 | size: [fill(), fill()], | |
| 138 | text: Some(text), | |
| 139 | font_size: Some(theme.font_size - 2.0), | |
| 140 | fill: Some(paper.color), | |
| 141 | border: Some(theme.chip), | |
| 142 | radius: 2.0, | |
| 143 | center: true, | |
| 144 | ..Spec::default() | |
| 145 | }, | |
| 146 | ); | |
| 147 | }; | |
| 87 | 148 | let chosen = ui::popup::gallery(ui, id, anchor, &groups, &current, |ui, index| { |
| 88 | 149 | match cells[index] { |
| 89 | Choice::PageColor(None) | Choice::Art(None) | Choice::RuleLines(None) => { | |
| 150 | Cell::Color(None) | Cell::Art(None) | Cell::Rules(None) => label(ui, "None"), | |
| 151 | Cell::ShowAll => label(ui, "Show All…"), | |
| 152 | Cell::Color(Some(color)) => { | |
| 90 | 153 | ui.leaf( |
| 91 | "none", | |
| 154 | "swatch", | |
| 92 | 155 | Spec { |
| 93 | 156 | size: [fill(), fill()], |
| 94 | text: Some("None"), | |
| 95 | font_size: Some(theme.font_size - 2.0), | |
| 96 | fill: Some(paper.color), | |
| 157 | fill: Some(paper.colored(Some(color)).color), | |
| 97 | 158 | border: Some(theme.chip), |
| 98 | 159 | radius: 2.0, |
| 99 | center: true, | |
| 100 | 160 | ..Spec::default() |
| 101 | 161 | }, |
| 102 | 162 | ); |
| 103 | 163 | } |
| 104 | Choice::PageColor(Some(index)) => { | |
| 105 | let (name, color) = PAGE_COLORS[index]; | |
| 106 | ui.leaf( | |
| 107 | name, | |
| 164 | // The rainbow, or the page's custom colour with the rainbow in its corner. | |
| 165 | Cell::Custom => { | |
| 166 | ui.open( | |
| 167 | "custom", | |
| 108 | 168 | Spec { |
| 109 | 169 | size: [fill(), fill()], |
| 110 | fill: Some(paper.colored(Some(color)).color), | |
| 111 | border: Some(theme.chip), | |
| 170 | fill: custom.map(|color| paper.colored(Some(color)).color), | |
| 171 | border: custom.map(|_| theme.chip), | |
| 112 | 172 | radius: 2.0, |
| 173 | icon: custom.is_none().then_some(art::CUSTOM_COLOR), | |
| 174 | center: true, | |
| 113 | 175 | ..Spec::default() |
| 114 | 176 | }, |
| 115 | 177 | ); |
| 178 | if custom.is_some() { | |
| 179 | ui.leaf( | |
| 180 | "rainbow", | |
| 181 | Spec { | |
| 182 | flags: Flags::FLOAT, | |
| 183 | size: [px(12.0), px(12.0)], | |
| 184 | position: [SWATCH[0] - 22.0, SWATCH[1] - 22.0], | |
| 185 | icon: Some(art::CUSTOM_COLOR), | |
| 186 | ..Spec::default() | |
| 187 | }, | |
| 188 | ); | |
| 189 | } | |
| 190 | ui.close(); | |
| 116 | 191 | } |
| 117 | Choice::Art(Some(name)) => { | |
| 192 | Cell::Art(Some(name)) => { | |
| 118 | 193 | ui.open( |
| 119 | 194 | name, |
| 120 | 195 | Spec { |
| ... | ... | @@ -143,11 +218,22 @@ pub fn menu( |
| 143 | 218 | } |
| 144 | 219 | ui.close(); |
| 145 | 220 | } |
| 146 | Choice::RuleLines(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]), | |
| 147 | _ => unreachable!("The menu offers colours, art and rule lines"), | |
| 221 | Cell::Rules(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]), | |
| 148 | 222 | } |
| 149 | 223 | })?; |
| 150 | Some(cells[chosen].clone()) | |
| 224 | match cells[chosen] { | |
| 225 | Cell::Color(color) => Some(Choice::PageColor(color)), | |
| 226 | Cell::Custom => { | |
| 227 | ui.open_popup(picker); | |
| 228 | None | |
| 229 | } | |
| 230 | Cell::Art(name) => Some(Choice::Art(name)), | |
| 231 | Cell::ShowAll => { | |
| 232 | *view = View::Art; | |
| 233 | None | |
| 234 | } | |
| 235 | Cell::Rules(index) => Some(Choice::RuleLines(index)), | |
| 236 | } | |
| 151 | 237 | } |
| 152 | 238 | |
| 153 | 239 | /// A corner of a page ruled with `lines`, drawn a quarter size. |
crates/snowbound/src/commands.rs+2-3| ... | ... | @@ -82,8 +82,8 @@ pub enum Choice { |
| 82 | 82 | List(Option<ListStyle>), |
| 83 | 83 | /// An empty table of rows by columns. |
| 84 | 84 | Table([usize; 2]), |
| 85 | /// An index into `PAGE_COLORS`, or `None` for no colour. | |
| 86 | PageColor(Option<usize>), | |
| 85 | /// A page colour, COLORREF, or `None` for no colour. | |
| 86 | PageColor(Option<u32>), | |
| 87 | 87 | /// An index into `RULE_LINES`, or `None` for none. |
| 88 | 88 | RuleLines(Option<usize>), |
| 89 | 89 | /// A template whose art becomes the page's background, or `None` for none. |
| ... | ... | @@ -815,7 +815,6 @@ impl State { |
| 815 | 815 | return Ok(()); |
| 816 | 816 | } |
| 817 | 817 | Choice::PageColor(color) => { |
| 818 | let color = color.map(|index| canvas::template::PAGE_COLORS[index].1); | |
| 819 | 818 | return self.paper_page(color, self.view.editor.rule_lines()); |
| 820 | 819 | } |
| 821 | 820 | Choice::Art(name) => { |
crates/snowbound/src/macos.rs+3-2| ... | ... | @@ -254,9 +254,10 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO |
| 254 | 254 | /// through it to the view it lies in. |
| 255 | 255 | pub fn install_backdrop(window: &Window) -> bool { |
| 256 | 256 | MainThreadMarker::new().expect("Views belong to the main thread"); |
| 257 | // Before 10.10 AppKit has no materials; 10.6's textured window stands in. | |
| 257 | // Before 10.10 AppKit has no materials; 10.6's textured window stands in, its gradient | |
| 258 | // running through the toolbar and the tab row to the notebook's frame. | |
| 258 | 259 | let Some(effect) = AnyClass::get("NSVisualEffectView") else { |
| 259 | return crate::aqua::textured(window, crate::TITLE); | |
| 260 | return crate::aqua::textured(window, crate::TITLE + crate::TAB_ROW); | |
| 260 | 261 | }; |
| 261 | 262 | let RawWindowHandle::AppKit(handle) = |
| 262 | 263 | window.window_handle().expect("Live AppKit window").as_raw() |
crates/snowbound/src/main.rs+34-29| ... | ... | @@ -68,11 +68,10 @@ use winit::{ |
| 68 | 68 | /// Height of the toolbar's row, which is the title bar where the platform lets it: a |
| 69 | 69 | /// unified compact toolbar's on macOS. |
| 70 | 70 | const TITLE: f32 = 38.0; |
| 71 | /// Space between toolbar groups, and the toolbar's margin at an edge without window controls. | |
| 71 | /// Space between toolbar groups. | |
| 72 | 72 | const GAP: f32 = 6.0; |
| 73 | const EDGE: f32 = 8.0; | |
| 74 | 73 | const TAB_ROW: f32 = 28.0; |
| 75 | /// Width of the section colour around the page. | |
| 74 | /// Width of the section colour around the page, and the rows' margin at the window's sides. | |
| 76 | 75 | const FRAME: f32 = 6.0; |
| 77 | 76 | const PAGE_LIST: f32 = 240.0; |
| 78 | 77 | /// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. |
| ... | ... | @@ -593,6 +592,11 @@ fn strip() -> Id { |
| 593 | 592 | Id::ROOT.child("strip") |
| 594 | 593 | } |
| 595 | 594 | |
| 595 | /// The section tabs' row, whose empty space drags the window as the strip's does. | |
| 596 | fn tab_row() -> Id { | |
| 597 | Id::ROOT.child("tab row") | |
| 598 | } | |
| 599 | ||
| 596 | 600 | fn page() -> Id { |
| 597 | 601 | Id::ROOT.child("page") |
| 598 | 602 | } |
| ... | ... | @@ -1042,14 +1046,18 @@ impl State { |
| 1042 | 1046 | ..Spec::default() |
| 1043 | 1047 | }, |
| 1044 | 1048 | ); |
| 1045 | let tabs = self.ui.id("tabs"); | |
| 1046 | 1049 | let height = self |
| 1047 | 1050 | .ui |
| 1048 | .animate(tabs, if self.full_page { 0.0 } else { TAB_ROW }); | |
| 1049 | let tab_row = self.ui.open( | |
| 1050 | "tabs", | |
| 1051 | .animate(tab_row(), if self.full_page { 0.0 } else { TAB_ROW }); | |
| 1052 | let drags = self.chrome_drags(); | |
| 1053 | let tab_row = self.ui.open_as( | |
| 1054 | tab_row(), | |
| 1051 | 1055 | Spec { |
| 1052 | flags: Flags::CLIP, | |
| 1056 | flags: if drags { | |
| 1057 | Flags::CLIP | Flags::CLICKABLE | |
| 1058 | } else { | |
| 1059 | Flags::CLIP | |
| 1060 | }, | |
| 1053 | 1061 | size: [fill(), px(height)], |
| 1054 | 1062 | fill: Some(theme.strip), |
| 1055 | 1063 | pad: [FRAME, 0.0], |
| ... | ... | @@ -1387,6 +1395,12 @@ impl State { |
| 1387 | 1395 | ); |
| 1388 | 1396 | } |
| 1389 | 1397 | |
| 1398 | /// Whether the chrome's empty space drags the window: where it is the title bar, or | |
| 1399 | /// over the title's gradient. | |
| 1400 | fn chrome_drags(&self) -> bool { | |
| 1401 | !platform::system_titlebar(&self.window) || self.surface.translucent() | |
| 1402 | } | |
| 1403 | ||
| 1390 | 1404 | /// The toolbar: one row of groups that fold as the window narrows. Where the platform |
| 1391 | 1405 | /// lets it, the row is the window's title bar, and on the welcome page, without `tools`, |
| 1392 | 1406 | /// that is all it is. |
| ... | ... | @@ -1395,8 +1409,7 @@ impl State { |
| 1395 | 1409 | if !tools && !title { |
| 1396 | 1410 | return Ok(()); |
| 1397 | 1411 | } |
| 1398 | // Over the title's gradient, the row's empty space drags the window as the title does. | |
| 1399 | let strip_row = title || self.surface.translucent(); | |
| 1412 | let strip_row = self.chrome_drags(); | |
| 1400 | 1413 | let spec = Spec { |
| 1401 | 1414 | flags: if strip_row { |
| 1402 | 1415 | Flags::CLICKABLE |
| ... | ... | @@ -1414,7 +1427,7 @@ impl State { |
| 1414 | 1427 | } else { |
| 1415 | 1428 | self.ui.open("toolbar", spec); |
| 1416 | 1429 | } |
| 1417 | let lead = if title { platform::LEADING } else { EDGE }; | |
| 1430 | let lead = if title { platform::LEADING } else { FRAME }; | |
| 1418 | 1431 | self.ui.leaf( |
| 1419 | 1432 | "lead", |
| 1420 | 1433 | Spec { |
| ... | ... | @@ -1439,7 +1452,7 @@ impl State { |
| 1439 | 1452 | self.ui.leaf( |
| 1440 | 1453 | "trail", |
| 1441 | 1454 | Spec { |
| 1442 | size: [px(EDGE - GAP), px(1.0)], | |
| 1455 | size: [px(FRAME - GAP), px(1.0)], | |
| 1443 | 1456 | ..Spec::default() |
| 1444 | 1457 | }, |
| 1445 | 1458 | ); |
| ... | ... | @@ -1662,7 +1675,7 @@ impl State { |
| 1662 | 1675 | choice = group( |
| 1663 | 1676 | ui, |
| 1664 | 1677 | "character", |
| 1665 | 6, | |
| 1678 | 5, | |
| 1666 | 1679 | |ui| { |
| 1667 | 1680 | divider(ui, theme); |
| 1668 | 1681 | let mut choice = None; |
| ... | ... | @@ -1757,7 +1770,7 @@ impl State { |
| 1757 | 1770 | choice = group( |
| 1758 | 1771 | ui, |
| 1759 | 1772 | "paragraph", |
| 1760 | 5, | |
| 1773 | 4, | |
| 1761 | 1774 | |ui| { |
| 1762 | 1775 | divider(ui, theme); |
| 1763 | 1776 | let mut choice = None; |
| ... | ... | @@ -1885,7 +1898,7 @@ impl State { |
| 1885 | 1898 | choice = group( |
| 1886 | 1899 | ui, |
| 1887 | 1900 | "tags", |
| 1888 | 7, | |
| 1901 | 6, | |
| 1889 | 1902 | |ui| { |
| 1890 | 1903 | divider(ui, theme); |
| 1891 | 1904 | let mut choice = None; |
| ... | ... | @@ -1950,17 +1963,7 @@ impl State { |
| 1950 | 1963 | }, |
| 1951 | 1964 | ); |
| 1952 | 1965 | if let Some(session) = session { |
| 1953 | ui.open( | |
| 1954 | "sync", | |
| 1955 | Spec { | |
| 1956 | fold: Some(4), | |
| 1957 | ..Spec::default() | |
| 1958 | }, | |
| 1959 | ); | |
| 1960 | for compact in [false, true] { | |
| 1961 | sync::control(ui, session, theme, compact); | |
| 1962 | } | |
| 1963 | ui.close(); | |
| 1966 | sync::control(ui, session, theme); | |
| 1964 | 1967 | } |
| 1965 | 1968 | // Spelling awaits a platform spell checker. |
| 1966 | 1969 | let views = [ |
| ... | ... | @@ -1991,9 +1994,11 @@ impl State { |
| 1991 | 1994 | toolbar_popup("page color"), |
| 1992 | 1995 | anchor, |
| 1993 | 1996 | &mut self.thumbnails, |
| 1997 | &mut self.templates, | |
| 1998 | // The page's own colour aside. | |
| 1994 | 1999 | canvas::gpu::Paper { |
| 1995 | color: theme.paper, | |
| 1996 | ink: theme.paper_ink, | |
| 2000 | color: ui.theme.paper, | |
| 2001 | ink: ui.theme.paper_ink, | |
| 1997 | 2002 | }, |
| 1998 | 2003 | self.view.editor.page_color(), |
| 1999 | 2004 | self.view.editor.rule_lines(), |
| ... | ... | @@ -3111,7 +3116,7 @@ impl State { |
| 3111 | 3116 | pressed: true, |
| 3112 | 3117 | at, |
| 3113 | 3118 | } = event |
| 3114 | && self.ui.box_at(self.pointer) == Some(strip()) | |
| 3119 | && matches!(self.ui.box_at(self.pointer), Some(id) if id == strip() || id == tab_row()) | |
| 3115 | 3120 | { |
| 3116 | 3121 | let double = self.strip_press.is_some_and(|last| { |
| 3117 | 3122 | at.saturating_duration_since(last) <= platform::double_click_interval() |
crates/snowbound/src/sync.rs+14-35| ... | ... | @@ -71,47 +71,26 @@ fn when(time: u64) -> String { |
| 71 | 71 | } |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | /// The status in the toolbar, which opens the popup: its label, or where `compact` its icon, | |
| 75 | /// naming it in a tooltip. | |
| 76 | pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, compact: bool) { | |
| 74 | /// The status's icon in the toolbar, named in its tooltip, which opens the popup. | |
| 75 | pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) { | |
| 77 | 76 | let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline(); |
| 78 | 77 | let (label, icon, _) = describe(&session.sync); |
| 79 | let spec = Spec { | |
| 80 | flags: Flags::CLICKABLE, | |
| 81 | color: Some(if strong { theme.text } else { theme.text_dim }), | |
| 82 | hover_fill: Some(theme.hover()), | |
| 83 | radius: 4.0, | |
| 84 | center: true, | |
| 85 | ..Spec::default() | |
| 86 | }; | |
| 87 | // The popup opens from the label's box, which stands where the icon shows. | |
| 88 | let shown = if compact { | |
| 89 | button().child("icon") | |
| 90 | } else { | |
| 91 | button() | |
| 92 | }; | |
| 93 | 78 | ui.open_as( |
| 94 | shown, | |
| 95 | if compact { | |
| 96 | Spec { | |
| 97 | size: [px(TOOL), px(TOOL)], | |
| 98 | icon: Some(icon), | |
| 99 | ..spec | |
| 100 | } | |
| 101 | } else { | |
| 102 | Spec { | |
| 103 | size: [fit(), px(TOOL)], | |
| 104 | text: Some(label), | |
| 105 | pad: [8.0, 0.0], | |
| 106 | ..spec | |
| 107 | } | |
| 79 | button(), | |
| 80 | Spec { | |
| 81 | flags: Flags::CLICKABLE, | |
| 82 | size: [px(TOOL), px(TOOL)], | |
| 83 | icon: Some(icon), | |
| 84 | color: Some(if strong { theme.text } else { theme.text_dim }), | |
| 85 | hover_fill: Some(theme.hover()), | |
| 86 | radius: 4.0, | |
| 87 | center: true, | |
| 88 | ..Spec::default() | |
| 108 | 89 | }, |
| 109 | 90 | ); |
| 110 | 91 | ui.close(); |
| 111 | if compact { | |
| 112 | ui::popup::tooltip(ui, label, "", None); | |
| 113 | } | |
| 114 | if ui.signal(shown).clicked { | |
| 92 | ui::popup::tooltip(ui, label, "", None); | |
| 93 | if ui.signal(button()).clicked { | |
| 115 | 94 | if ui.popup_open(id()) { |
| 116 | 95 | ui.close_popup(id()); |
| 117 | 96 | } else { |
crates/snowbound/src/templates.rs+51-4| ... | ... | @@ -48,6 +48,8 @@ pub enum View { |
| 48 | 48 | /// The strip with the page colours beneath it. |
| 49 | 49 | Colors, |
| 50 | 50 | Gallery, |
| 51 | /// Every template's art, from the Page Color menu, to lie behind the open page. | |
| 52 | Art, | |
| 51 | 53 | } |
| 52 | 54 | |
| 53 | 55 | /// A thumbnail's art: each raster and where it lies in the tile. |
| ... | ... | @@ -199,7 +201,8 @@ fn strip( |
| 199 | 201 | chosen |
| 200 | 202 | } |
| 201 | 203 | |
| 202 | /// Every template, as a scrolling grid over the page box `size` big. | |
| 204 | /// Every template, or with `art` only their art, as a scrolling grid over the page box | |
| 205 | /// `size` big. | |
| 203 | 206 | fn gallery( |
| 204 | 207 | ui: &mut Ui, |
| 205 | 208 | theme: &Theme, |
| ... | ... | @@ -207,6 +210,7 @@ fn gallery( |
| 207 | 210 | paper: Paper, |
| 208 | 211 | dark: bool, |
| 209 | 212 | size: [f32; 2], |
| 213 | art: bool, | |
| 210 | 214 | ) -> Option<Choice> { |
| 211 | 215 | let mut chosen = None; |
| 212 | 216 | let margin = 24.0; |
| ... | ... | @@ -236,7 +240,7 @@ fn gallery( |
| 236 | 240 | "title", |
| 237 | 241 | Spec { |
| 238 | 242 | size: [fill(), px(24.0)], |
| 239 | text: Some("Page Templates"), | |
| 243 | text: Some(if art { "Backgrounds" } else { "Page Templates" }), | |
| 240 | 244 | ..Spec::default() |
| 241 | 245 | }, |
| 242 | 246 | ); |
| ... | ... | @@ -264,7 +268,8 @@ fn gallery( |
| 264 | 268 | PAGE_COLORS |
| 265 | 269 | .iter() |
| 266 | 270 | .enumerate() |
| 267 | .map(|(index, (name, _))| (Choice::Color(index), *name)), | |
| 271 | .map(|(index, (name, _))| (Choice::Color(index), *name)) | |
| 272 | .filter(|_| !art), | |
| 268 | 273 | ) |
| 269 | 274 | .collect(); |
| 270 | 275 | for (row, choices) in choices.chunks(columns).enumerate() { |
| ... | ... | @@ -411,6 +416,9 @@ impl crate::State { |
| 411 | 416 | let Some(session) = &self.session else { |
| 412 | 417 | return; |
| 413 | 418 | }; |
| 419 | if self.templates == View::Art { | |
| 420 | return self.art_gallery(theme); | |
| 421 | } | |
| 414 | 422 | let space = session.space; |
| 415 | 423 | let editor = &self.view.editor; |
| 416 | 424 | let blank = editor |
| ... | ... | @@ -440,8 +448,17 @@ impl crate::State { |
| 440 | 448 | let chosen = match self.templates { |
| 441 | 449 | View::Gallery => { |
| 442 | 450 | let size = [rect[2] - rect[0], rect[3] - rect[1]]; |
| 443 | gallery(&mut self.ui, theme, &mut self.thumbnails, paper, dark, size) | |
| 451 | gallery( | |
| 452 | &mut self.ui, | |
| 453 | theme, | |
| 454 | &mut self.thumbnails, | |
| 455 | paper, | |
| 456 | dark, | |
| 457 | size, | |
| 458 | false, | |
| 459 | ) | |
| 444 | 460 | } |
| 461 | View::Art => unreachable!("The art gallery is built alone"), | |
| 445 | 462 | view => strip( |
| 446 | 463 | &mut self.ui, |
| 447 | 464 | theme, |
| ... | ... | @@ -475,4 +492,34 @@ impl crate::State { |
| 475 | 492 | None => {} |
| 476 | 493 | } |
| 477 | 494 | } |
| 495 | ||
| 496 | /// The Page Color menu's Show All: every template's art, one of which goes behind the | |
| 497 | /// open page. | |
| 498 | fn art_gallery(&mut self, theme: &Theme) { | |
| 499 | let Some(rect) = self.ui.rect(crate::page()) else { | |
| 500 | return; | |
| 501 | }; | |
| 502 | let paper = Paper { | |
| 503 | color: self.ui.theme.paper, | |
| 504 | ink: self.ui.theme.paper_ink, | |
| 505 | }; | |
| 506 | let dark = paper.ink[0] > paper.color[0]; | |
| 507 | let size = [rect[2] - rect[0], rect[3] - rect[1]]; | |
| 508 | match gallery( | |
| 509 | &mut self.ui, | |
| 510 | theme, | |
| 511 | &mut self.thumbnails, | |
| 512 | paper, | |
| 513 | dark, | |
| 514 | size, | |
| 515 | true, | |
| 516 | ) { | |
| 517 | Some(Choice::Template(name)) => { | |
| 518 | self.templates = View::Strip; | |
| 519 | self.choose(crate::commands::Choice::Art(Some(name))); | |
| 520 | } | |
| 521 | Some(Choice::Dismiss) => self.templates = View::Strip, | |
| 522 | _ => {} | |
| 523 | } | |
| 524 | } | |
| 478 | 525 | } |
crates/ui/src/layout.rs+8-5| ... | ... | @@ -3,8 +3,8 @@ use std::collections::HashMap; |
| 3 | 3 | |
| 4 | 4 | /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from |
| 5 | 5 | /// ancestors (pre-order), sizes summed from children (post-order), overflow taken back |
| 6 | /// (from space sized by ancestors, then by folding a row's groups, then from the least | |
| 7 | /// strict boxes first), and positions along each parent's flow. Labels too wide for their | |
| 6 | /// (by folding a row's groups for what space sized by ancestors can't give, then from that | |
| 7 | /// space, then from the least strict boxes first), and positions along each parent's flow. Labels too wide for their | |
| 8 | 8 | /// solved width wrap or shorten before heights are solved. Boxes are in build order, so |
| 9 | 9 | /// index order is pre-order. |
| 10 | 10 | pub(crate) fn solve( |
| ... | ... | @@ -75,10 +75,12 @@ pub(crate) fn solve( |
| 75 | 75 | if along(node, axis) { |
| 76 | 76 | let space = |
| 77 | 77 | |child: &&usize| matches!(nodes[**child].size[axis].size, Size::Fraction(_)); |
| 78 | let (space, sized): (Vec<_>, Vec<_>) = children.iter().partition(space); | |
| 78 | let (space, sized): (Vec<usize>, Vec<_>) = children.iter().partition(space); | |
| 79 | 79 | let mut excess = flow(nodes, index, axis) - room; |
| 80 | excess = give_back(nodes, &space, axis, excess); | |
| 81 | while excess > 0.0 && axis == 0 { | |
| 80 | // Groups fold only for what the space can't give, so the room a fold frees | |
| 81 | // goes back to the space. | |
| 82 | let spare: f32 = space.iter().map(|child| nodes[*child].computed[axis]).sum(); | |
| 83 | while excess > spare && axis == 0 { | |
| 82 | 84 | let Some(group) = children |
| 83 | 85 | .iter() |
| 84 | 86 | .copied() |
| ... | ... | @@ -96,6 +98,7 @@ pub(crate) fn solve( |
| 96 | 98 | excess -= nodes[group].computed[0] - width; |
| 97 | 99 | nodes[group].computed[0] = width; |
| 98 | 100 | } |
| 101 | excess = give_back(nodes, &space, axis, excess); | |
| 99 | 102 | let mut tiers: Vec<f32> = sized |
| 100 | 103 | .iter() |
| 101 | 104 | .map(|child| strictness(nodes, *child, axis)) |
crates/ui/src/lib.rs+3| ... | ... | @@ -542,6 +542,8 @@ struct Popup { |
| 542 | 542 | /// Its filter field's text, and the key of the row the keyboard or pointer last chose. |
| 543 | 543 | query: String, |
| 544 | 544 | highlight: Option<u64>, |
| 545 | /// A colour picker's hue in degrees, saturation and lightness, once it has one. | |
| 546 | picked: Option<[f32; 3]>, | |
| 545 | 547 | /// The height its results ease from and to, and the seconds since they set out. |
| 546 | 548 | height: Option<[f32; 3]>, |
| 547 | 549 | opened: Instant, |
| ... | ... | @@ -1000,6 +1002,7 @@ impl Ui { |
| 1000 | 1002 | focus: self.focus, |
| 1001 | 1003 | query: String::new(), |
| 1002 | 1004 | highlight: None, |
| 1005 | picked: None, | |
| 1003 | 1006 | height: None, |
| 1004 | 1007 | opened: self.now, |
| 1005 | 1008 | }); |
crates/ui/src/popup.rs+221| ... | ... | @@ -814,6 +814,227 @@ pub fn gallery( |
| 814 | 814 | None |
| 815 | 815 | } |
| 816 | 816 | |
| 817 | /// A colour picker's hue and lightness field, and its saturation bar beneath. | |
| 818 | const FIELD: [f32; 2] = [256.0, 128.0]; | |
| 819 | const BAR: f32 = 14.0; | |
| 820 | /// Columns the field is drawn in, each a gradient from white to the hue and to black. | |
| 821 | const HUES: usize = 64; | |
| 822 | ||
| 823 | /// Builds popup `id` beside `anchor` while it is open as a colour picker titled `title`, | |
| 824 | /// starting from `initial`, sRGB: hue across a field drawn fully saturated, lightness down | |
| 825 | /// it, saturation along a bar, and a preview of the colour as `preview` shows it, linear | |
| 826 | /// RGBA. Returns the colour applied. | |
| 827 | pub fn color_picker( | |
| 828 | ui: &mut Ui, | |
| 829 | id: Id, | |
| 830 | anchor: Anchor, | |
| 831 | title: &str, | |
| 832 | initial: [u8; 3], | |
| 833 | preview: impl Fn([u8; 3]) -> [f32; 4], | |
| 834 | ) -> Option<[u8; 3]> { | |
| 835 | if !ui.popup_open(id) { | |
| 836 | return None; | |
| 837 | } | |
| 838 | let [field, bar] = ["field", "saturation"].map(|part| id.child(part)); | |
| 839 | let apply = id.child("footer").child("apply"); | |
| 840 | let mut picked = state(ui, id).picked.unwrap_or_else(|| to_hsl(initial)); | |
| 841 | let pointer = ui.pointer(); | |
| 842 | let along = |ui: &Ui, part: Id| { | |
| 843 | let [left, top, right, bottom] = ui.rect(part)?; | |
| 844 | let [x, y] = pointer?; | |
| 845 | Some([ | |
| 846 | ((x - left) / (right - left)).clamp(0.0, 1.0), | |
| 847 | ((y - top) / (bottom - top)).clamp(0.0, 1.0), | |
| 848 | ]) | |
| 849 | }; | |
| 850 | let held = |ui: &mut Ui, part: Id| { | |
| 851 | let signal = ui.signal(part); | |
| 852 | signal.pressed || signal.dragging | |
| 853 | }; | |
| 854 | if held(ui, field) | |
| 855 | && let Some([x, y]) = along(ui, field) | |
| 856 | { | |
| 857 | picked[0] = x * 360.0; | |
| 858 | picked[2] = 1.0 - y; | |
| 859 | } | |
| 860 | if held(ui, bar) | |
| 861 | && let Some([x, _]) = along(ui, bar) | |
| 862 | { | |
| 863 | picked[1] = x; | |
| 864 | } | |
| 865 | let keys = navigation(ui, &[id], &[NamedKey::Enter]); | |
| 866 | state(ui, id).picked = Some(picked); | |
| 867 | let rgb = from_hsl(picked); | |
| 868 | if ui.signal(apply).clicked || !keys.is_empty() { | |
| 869 | ui.close_popup(id); | |
| 870 | return Some(rgb); | |
| 871 | } | |
| 872 | ||
| 873 | let theme = ui.theme.clone(); | |
| 874 | surface(ui, id, anchor, FIELD[0] + 2.0 * ui.theme.menu().pad); | |
| 875 | let color = |hsl| { | |
| 876 | let [red, green, blue] = from_hsl(hsl); | |
| 877 | draw::srgb(red, green, blue) | |
| 878 | }; | |
| 879 | let [hue, saturation, lightness] = picked; | |
| 880 | ui.leaf( | |
| 881 | "heading", | |
| 882 | Spec { | |
| 883 | size: [fill(), px(MENU_ROW)], | |
| 884 | text: Some(title), | |
| 885 | font_size: Some(theme.font_size - 2.0), | |
| 886 | bold: true, | |
| 887 | color: Some(theme.text_dim), | |
| 888 | pad: [PAD, 0.0], | |
| 889 | ..Spec::default() | |
| 890 | }, | |
| 891 | ); | |
| 892 | ui.open_as( | |
| 893 | field, | |
| 894 | Spec { | |
| 895 | flags: Flags::CLICKABLE, | |
| 896 | size: [px(FIELD[0]), px(FIELD[1])], | |
| 897 | ..Spec::default() | |
| 898 | }, | |
| 899 | ); | |
| 900 | let column = FIELD[0] / HUES as f32; | |
| 901 | for index in 0..HUES { | |
| 902 | let shade = (index as f32 + 0.5) / HUES as f32 * 360.0; | |
| 903 | for (half, [top, bottom]) in [[1.0, 0.5], [0.5, 0.0]].into_iter().enumerate() { | |
| 904 | ui.leaf( | |
| 905 | (index, half), | |
| 906 | Spec { | |
| 907 | flags: Flags::FLOAT, | |
| 908 | position: [index as f32 * column, half as f32 * FIELD[1] / 2.0], | |
| 909 | size: [px(column + 0.5), px(FIELD[1] / 2.0)], | |
| 910 | fill: Some(color([shade, 1.0, top])), | |
| 911 | gradient: Some(color([shade, 1.0, bottom])), | |
| 912 | ..Spec::default() | |
| 913 | }, | |
| 914 | ); | |
| 915 | } | |
| 916 | } | |
| 917 | // A white ring inside a dark one, seen on any colour. | |
| 918 | let ring = |ui: &mut Ui, part: &str, at: [f32; 2]| { | |
| 919 | for (index, (radius, color)) in [(6.0, [0.0, 0.0, 0.0, 0.6]), (5.0, [1.0; 4])] | |
| 920 | .into_iter() | |
| 921 | .enumerate() | |
| 922 | { | |
| 923 | ui.leaf( | |
| 924 | (part, index), | |
| 925 | Spec { | |
| 926 | flags: Flags::FLOAT, | |
| 927 | position: [at[0] - radius, at[1] - radius], | |
| 928 | size: [px(2.0 * radius), px(2.0 * radius)], | |
| 929 | border: Some(color), | |
| 930 | radius, | |
| 931 | ..Spec::default() | |
| 932 | }, | |
| 933 | ); | |
| 934 | } | |
| 935 | }; | |
| 936 | ring( | |
| 937 | ui, | |
| 938 | "ring", | |
| 939 | [hue / 360.0 * FIELD[0], (1.0 - lightness) * FIELD[1]], | |
| 940 | ); | |
| 941 | ui.close(); | |
| 942 | ui.open_as( | |
| 943 | bar, | |
| 944 | Spec { | |
| 945 | flags: Flags::CLICKABLE, | |
| 946 | size: [px(FIELD[0]), px(BAR)], | |
| 947 | radius: 2.0, | |
| 948 | ..Spec::default() | |
| 949 | }, | |
| 950 | ); | |
| 951 | let steps = 32; | |
| 952 | let step = FIELD[0] / steps as f32; | |
| 953 | for index in 0..steps { | |
| 954 | ui.leaf( | |
| 955 | index, | |
| 956 | Spec { | |
| 957 | flags: Flags::FLOAT, | |
| 958 | position: [index as f32 * step, 0.0], | |
| 959 | size: [px(step + 0.5), px(BAR)], | |
| 960 | fill: Some(color([hue, index as f32 / (steps - 1) as f32, lightness])), | |
| 961 | ..Spec::default() | |
| 962 | }, | |
| 963 | ); | |
| 964 | } | |
| 965 | ring(ui, "ring", [saturation * FIELD[0], BAR / 2.0]); | |
| 966 | ui.close(); | |
| 967 | ui.open( | |
| 968 | "footer", | |
| 969 | Spec { | |
| 970 | size: [fill(), px(ROW)], | |
| 971 | gap: 8.0, | |
| 972 | ..Spec::default() | |
| 973 | }, | |
| 974 | ); | |
| 975 | ui.leaf( | |
| 976 | "preview", | |
| 977 | Spec { | |
| 978 | size: [px(2.0 * ROW), px(ROW)], | |
| 979 | fill: Some(preview(rgb)), | |
| 980 | border: Some(theme.chip), | |
| 981 | radius: 4.0, | |
| 982 | ..Spec::default() | |
| 983 | }, | |
| 984 | ); | |
| 985 | let [red, green, blue] = rgb; | |
| 986 | let hex = format!("#{red:02X}{green:02X}{blue:02X}"); | |
| 987 | ui.leaf( | |
| 988 | "hex", | |
| 989 | Spec { | |
| 990 | size: [fill(), px(ROW)], | |
| 991 | text: Some(&hex), | |
| 992 | color: Some(theme.text_dim), | |
| 993 | ..Spec::default() | |
| 994 | }, | |
| 995 | ); | |
| 996 | crate::button(ui, "apply", "Apply"); | |
| 997 | ui.close(); | |
| 998 | ui.close(); | |
| 999 | None | |
| 1000 | } | |
| 1001 | ||
| 1002 | /// Hue in degrees, saturation and lightness of an sRGB colour. | |
| 1003 | fn to_hsl(rgb: [u8; 3]) -> [f32; 3] { | |
| 1004 | let [red, green, blue] = rgb.map(|channel| f32::from(channel) / 255.0); | |
| 1005 | let (most, least) = (red.max(green).max(blue), red.min(green).min(blue)); | |
| 1006 | let lightness = (most + least) / 2.0; | |
| 1007 | let chroma = most - least; | |
| 1008 | if chroma == 0.0 { | |
| 1009 | return [0.0, 0.0, lightness]; | |
| 1010 | } | |
| 1011 | let saturation = chroma / (1.0 - (2.0 * lightness - 1.0).abs()); | |
| 1012 | let sector = if most == red { | |
| 1013 | ((green - blue) / chroma).rem_euclid(6.0) | |
| 1014 | } else if most == green { | |
| 1015 | (blue - red) / chroma + 2.0 | |
| 1016 | } else { | |
| 1017 | (red - green) / chroma + 4.0 | |
| 1018 | }; | |
| 1019 | [sector * 60.0, saturation, lightness] | |
| 1020 | } | |
| 1021 | ||
| 1022 | fn from_hsl([hue, saturation, lightness]: [f32; 3]) -> [u8; 3] { | |
| 1023 | let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; | |
| 1024 | let sector = (hue / 60.0).rem_euclid(6.0); | |
| 1025 | let second = chroma * (1.0 - (sector % 2.0 - 1.0).abs()); | |
| 1026 | let [red, green, blue] = match sector as u32 { | |
| 1027 | 0 => [chroma, second, 0.0], | |
| 1028 | 1 => [second, chroma, 0.0], | |
| 1029 | 2 => [0.0, chroma, second], | |
| 1030 | 3 => [0.0, second, chroma], | |
| 1031 | 4 => [second, 0.0, chroma], | |
| 1032 | _ => [chroma, 0.0, second], | |
| 1033 | }; | |
| 1034 | let base = lightness - chroma / 2.0; | |
| 1035 | [red, green, blue].map(|channel| ((channel + base) * 255.0).round().clamp(0.0, 255.0) as u8) | |
| 1036 | } | |
| 1037 | ||
| 817 | 1038 | /// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it. |
| 818 | 1039 | fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { |
| 819 | 1040 | let style = ui.theme.menu(); |
crates/ui/src/tests.rs+28-1| ... | ... | @@ -209,7 +209,8 @@ fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() { |
| 209 | 209 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); |
| 210 | 210 | let rects = folding_row(&mut ui, 130.0, &[0, 1]); |
| 211 | 211 | // 80 + 20 + 20 fits 130 with 10 to spare, taken by the space. |
| 212 | assert_eq!(rects[0], Some([0.0, 0.0, 80.0, 20.0])); | |
| 212 | assert_eq!(rects[0], Some([10.0, 0.0, 90.0, 20.0])); | |
| 213 | assert_eq!(rects[4], Some([110.0, 0.0, 130.0, 20.0])); | |
| 213 | 214 | let rects = folding_row(&mut ui, 100.0, &[0, 1]); |
| 214 | 215 | assert_eq!(rects[0], Some([0.0, 0.0, 60.0, 20.0])); |
| 215 | 216 | assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0])); |
| ... | ... | @@ -2516,3 +2517,29 @@ fn the_wheel_scrolls_overflowing_tabs_sideways_either_way_it_turns() { |
| 2516 | 2517 | let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; |
| 2517 | 2518 | assert!((back - scrolled - 30.0).abs() < 0.5, "{scrolled} to {back}"); |
| 2518 | 2519 | } |
| 2520 | ||
| 2521 | #[test] | |
| 2522 | fn a_colour_picker_applies_its_colour_and_keeps_it_through_hsl() { | |
| 2523 | let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK); | |
| 2524 | let picker = Id::ROOT.child("picker"); | |
| 2525 | let build = |ui: &mut Ui| { | |
| 2526 | let mut chosen = None; | |
| 2527 | frame(ui, |ui| { | |
| 2528 | chosen = popup::color_picker( | |
| 2529 | ui, | |
| 2530 | picker, | |
| 2531 | BELOW, | |
| 2532 | "Custom Color", | |
| 2533 | [0xd4, 0xf9, 0xf2], | |
| 2534 | |_| [1.0; 4], | |
| 2535 | ); | |
| 2536 | }); | |
| 2537 | chosen | |
| 2538 | }; | |
| 2539 | build(&mut ui); | |
| 2540 | ui.open_popup(picker); | |
| 2541 | assert_eq!(build(&mut ui), None); | |
| 2542 | ui.event(key(NamedKey::Enter)); | |
| 2543 | assert_eq!(build(&mut ui), Some([0xd4, 0xf9, 0xf2])); | |
| 2544 | assert!(!ui.popup_open(picker)); | |
| 2545 | } |