| 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,7 +29,7 @@ pub(crate) fn before_lion() -> bool { |
| 29 | } | 29 | } |
| 30 | 30 | ||
| 31 | /// Makes the window textured, AppKit's gradient running from the title through `row` points | 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 | /// shows through. Returns whether the system draws windows so, which only 10.6 does here. | 33 | /// shows through. Returns whether the system draws windows so, which only 10.6 does here. |
| 34 | pub fn textured(window: &Window, row: f32) -> bool { | 34 | pub fn textured(window: &Window, row: f32) -> bool { |
| 35 | if !before_lion() { | 35 | if !before_lion() { |
crates/snowbound/src/art.rs+1| ... | @@ -55,6 +55,7 @@ pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter"); | ... | @@ -55,6 +55,7 @@ pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter"); |
| 55 | pub const FORWARD: &[&str] = art!("icons/forward"); | 55 | pub const FORWARD: &[&str] = art!("icons/forward"); |
| 56 | pub const INSERT_SPACE: &[&str] = art!("icons/insert-space"); | 56 | pub const INSERT_SPACE: &[&str] = art!("icons/insert-space"); |
| 57 | pub const PAGE_COLOR: &[&str] = art!("icons/page-color"); | 57 | pub const PAGE_COLOR: &[&str] = art!("icons/page-color"); |
| 58 | pub const CUSTOM_COLOR: &[&str] = art!("icons/custom-color"); | ||
| 58 | pub const PASTE: &[&str] = art!("icons/paste"); | 59 | pub const PASTE: &[&str] = art!("icons/paste"); |
| 59 | pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio"); | 60 | pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio"); |
| 60 | pub const RECORD_VIDEO: &[&str] = art!("icons/record-video"); | 61 | pub const RECORD_VIDEO: &[&str] = art!("icons/record-video"); |
crates/snowbound/src/background.rs+122-36| ... | @@ -2,8 +2,9 @@ | ... | @@ -2,8 +2,9 @@ |
| 2 | //! lines. | 2 | //! lines. |
| 3 | 3 | ||
| 4 | use crate::{ | 4 | use crate::{ |
| 5 | art, | ||
| 5 | commands::Choice, | 6 | commands::Choice, |
| 6 | templates::{TILE, Thumbnails}, | 7 | templates::{TILE, Thumbnails, View}, |
| 7 | }; | 8 | }; |
| 8 | use canvas::{ | 9 | use canvas::{ |
| 9 | gpu::{Paper, colorref}, | 10 | gpu::{Paper, colorref}, |
| ... | @@ -17,38 +18,84 @@ const SWATCH: [f32; 2] = [40.0, 32.0]; | ... | @@ -17,38 +18,84 @@ const SWATCH: [f32; 2] = [40.0, 32.0]; |
| 17 | const RULES: f32 = 40.0; | 18 | const RULES: f32 = 40.0; |
| 18 | /// Points of page per point of a rule-line thumbnail. | 19 | /// Points of page per point of a rule-line thumbnail. |
| 19 | const SHRINK: f32 = 4.0; | 20 | const SHRINK: f32 = 4.0; |
| 20 | /// The art offered after None. | 21 | /// The art offered between None and Show All. |
| 21 | const ART: [&str; 5] = ["Ivy", "Purple Clouds", "Notebook", "Bamboo", "Blue Clouds"]; | 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 | /// Builds popup `id` beside `anchor` while it is open, for a page on `paper` (its own colour | 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 | #[allow(clippy::too_many_arguments)] | 47 | #[allow(clippy::too_many_arguments)] |
| 26 | pub fn menu( | 48 | pub fn menu( |
| 27 | ui: &mut Ui, | 49 | ui: &mut Ui, |
| 28 | id: Id, | 50 | id: Id, |
| 29 | anchor: Anchor, | 51 | anchor: Anchor, |
| 30 | thumbnails: &mut Thumbnails, | 52 | thumbnails: &mut Thumbnails, |
| 53 | view: &mut View, | ||
| 31 | paper: Paper, | 54 | paper: Paper, |
| 32 | color: Option<u32>, | 55 | color: Option<u32>, |
| 33 | rule_lines: Option<RuleLines>, | 56 | rule_lines: Option<RuleLines>, |
| 34 | ) -> Option<Choice> { | 57 | ) -> Option<Choice> { |
| 35 | let cells: Vec<Choice> = std::iter::once(Choice::PageColor(None)) | 58 | let picker = id.child("custom"); |
| 36 | .chain((0..PAGE_COLORS.len()).map(|index| Choice::PageColor(Some(index)))) | 59 | // A pale blue to start from on a page without a colour. |
| 37 | .chain(std::iter::once(Choice::Art(None))) | 60 | let [red, green, blue, _] = color.unwrap_or(0x00f8eedd).to_le_bytes(); |
| 38 | .chain(ART.map(|name| Choice::Art(Some(name)))) | 61 | if let Some([red, green, blue]) = ui::popup::color_picker( |
| 39 | .chain(std::iter::once(Choice::RuleLines(None))) | 62 | ui, |
| 40 | .chain((0..RULE_LINES.len()).map(|index| Choice::RuleLines(Some(index)))) | 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 | .collect(); | 88 | .collect(); |
| 42 | let groups = [ | 89 | let groups = [ |
| 43 | ui::popup::Group { | 90 | ui::popup::Group { |
| 44 | heading: "Page Color", | 91 | heading: "Page Color", |
| 45 | cells: 1 + PAGE_COLORS.len(), | 92 | cells: 2 + PAGE_COLORS.len(), |
| 46 | columns: COLUMNS, | 93 | columns: COLUMNS, |
| 47 | size: SWATCH, | 94 | size: SWATCH, |
| 48 | }, | 95 | }, |
| 49 | ui::popup::Group { | 96 | ui::popup::Group { |
| 50 | heading: "Background", | 97 | heading: "Background", |
| 51 | cells: 1 + ART.len(), | 98 | cells: 2 + ART.len(), |
| 52 | columns: 3, | 99 | columns: 3, |
| 53 | size: [TILE[0] + 8.0, TILE[1] + 8.0], | 100 | size: [TILE[0] + 8.0, TILE[1] + 8.0], |
| 54 | }, | 101 | }, |
| ... | @@ -60,13 +107,12 @@ pub fn menu( | ... | @@ -60,13 +107,12 @@ pub fn menu( |
| 60 | }, | 107 | }, |
| 61 | ]; | 108 | ]; |
| 62 | let shown = [ | 109 | let shown = [ |
| 63 | Choice::PageColor(color.map(|color| { | 110 | if custom.is_some() { |
| 64 | PAGE_COLORS | 111 | Cell::Custom |
| 65 | .iter() | 112 | } else { |
| 66 | .position(|(_, listed)| *listed == color) | 113 | Cell::Color(color) |
| 67 | .unwrap_or(usize::MAX) | 114 | }, |
| 68 | })), | 115 | Cell::Rules(rule_lines.map(|lines| { |
| 69 | Choice::RuleLines(rule_lines.map(|lines| { | ||
| 70 | RULE_LINES | 116 | RULE_LINES |
| 71 | .iter() | 117 | .iter() |
| 72 | .position(|(_, listed)| { | 118 | .position(|(_, listed)| { |
| ... | @@ -79,42 +125,71 @@ pub fn menu( | ... | @@ -79,42 +125,71 @@ pub fn menu( |
| 79 | ]; | 125 | ]; |
| 80 | let current: Vec<usize> = shown | 126 | let current: Vec<usize> = shown |
| 81 | .iter() | 127 | .iter() |
| 82 | .filter_map(|choice| cells.iter().position(|cell| cell == choice)) | 128 | .filter_map(|shown| cells.iter().position(|cell| cell == shown)) |
| 83 | .collect(); | 129 | .collect(); |
| 84 | let theme = ui.theme.clone(); | 130 | let theme = ui.theme.clone(); |
| 85 | let scale = ui.scale(); | 131 | let scale = ui.scale(); |
| 86 | let dark = paper.ink[0] > paper.color[0]; | 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 | let chosen = ui::popup::gallery(ui, id, anchor, &groups, &current, |ui, index| { | 148 | let chosen = ui::popup::gallery(ui, id, anchor, &groups, &current, |ui, index| { |
| 88 | match cells[index] { | 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 | ui.leaf( | 153 | ui.leaf( |
| 91 | "none", | 154 | "swatch", |
| 92 | Spec { | 155 | Spec { |
| 93 | size: [fill(), fill()], | 156 | size: [fill(), fill()], |
| 94 | text: Some("None"), | 157 | fill: Some(paper.colored(Some(color)).color), |
| 95 | font_size: Some(theme.font_size - 2.0), | ||
| 96 | fill: Some(paper.color), | ||
| 97 | border: Some(theme.chip), | 158 | border: Some(theme.chip), |
| 98 | radius: 2.0, | 159 | radius: 2.0, |
| 99 | center: true, | ||
| 100 | ..Spec::default() | 160 | ..Spec::default() |
| 101 | }, | 161 | }, |
| 102 | ); | 162 | ); |
| 103 | } | 163 | } |
| 104 | Choice::PageColor(Some(index)) => { | 164 | // The rainbow, or the page's custom colour with the rainbow in its corner. |
| 105 | let (name, color) = PAGE_COLORS[index]; | 165 | Cell::Custom => { |
| 106 | ui.leaf( | 166 | ui.open( |
| 107 | name, | 167 | "custom", |
| 108 | Spec { | 168 | Spec { |
| 109 | size: [fill(), fill()], | 169 | size: [fill(), fill()], |
| 110 | fill: Some(paper.colored(Some(color)).color), | 170 | fill: custom.map(|color| paper.colored(Some(color)).color), |
| 111 | border: Some(theme.chip), | 171 | border: custom.map(|_| theme.chip), |
| 112 | radius: 2.0, | 172 | radius: 2.0, |
| 173 | icon: custom.is_none().then_some(art::CUSTOM_COLOR), | ||
| 174 | center: true, | ||
| 113 | ..Spec::default() | 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 | ui.open( | 193 | ui.open( |
| 119 | name, | 194 | name, |
| 120 | Spec { | 195 | Spec { |
| ... | @@ -143,11 +218,22 @@ pub fn menu( | ... | @@ -143,11 +218,22 @@ pub fn menu( |
| 143 | } | 218 | } |
| 144 | ui.close(); | 219 | ui.close(); |
| 145 | } | 220 | } |
| 146 | Choice::RuleLines(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]), | 221 | Cell::Rules(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]), |
| 147 | _ => unreachable!("The menu offers colours, art and rule lines"), | ||
| 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 | /// A corner of a page ruled with `lines`, drawn a quarter size. | 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,8 +82,8 @@ pub enum Choice { |
| 82 | List(Option<ListStyle>), | 82 | List(Option<ListStyle>), |
| 83 | /// An empty table of rows by columns. | 83 | /// An empty table of rows by columns. |
| 84 | Table([usize; 2]), | 84 | Table([usize; 2]), |
| 85 | /// An index into `PAGE_COLORS`, or `None` for no colour. | 85 | /// A page colour, COLORREF, or `None` for no colour. |
| 86 | PageColor(Option<usize>), | 86 | PageColor(Option<u32>), |
| 87 | /// An index into `RULE_LINES`, or `None` for none. | 87 | /// An index into `RULE_LINES`, or `None` for none. |
| 88 | RuleLines(Option<usize>), | 88 | RuleLines(Option<usize>), |
| 89 | /// A template whose art becomes the page's background, or `None` for none. | 89 | /// A template whose art becomes the page's background, or `None` for none. |
| ... | @@ -815,7 +815,6 @@ impl State { | ... | @@ -815,7 +815,6 @@ impl State { |
| 815 | return Ok(()); | 815 | return Ok(()); |
| 816 | } | 816 | } |
| 817 | Choice::PageColor(color) => { | 817 | Choice::PageColor(color) => { |
| 818 | let color = color.map(|index| canvas::template::PAGE_COLORS[index].1); | ||
| 819 | return self.paper_page(color, self.view.editor.rule_lines()); | 818 | return self.paper_page(color, self.view.editor.rule_lines()); |
| 820 | } | 819 | } |
| 821 | Choice::Art(name) => { | 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,9 +254,10 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO |
| 254 | /// through it to the view it lies in. | 254 | /// through it to the view it lies in. |
| 255 | pub fn install_backdrop(window: &Window) -> bool { | 255 | pub fn install_backdrop(window: &Window) -> bool { |
| 256 | MainThreadMarker::new().expect("Views belong to the main thread"); | 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 | let Some(effect) = AnyClass::get("NSVisualEffectView") else { | 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 | let RawWindowHandle::AppKit(handle) = | 262 | let RawWindowHandle::AppKit(handle) = |
| 262 | window.window_handle().expect("Live AppKit window").as_raw() | 263 | window.window_handle().expect("Live AppKit window").as_raw() |
crates/snowbound/src/main.rs+34-29| ... | @@ -68,11 +68,10 @@ use winit::{ | ... | @@ -68,11 +68,10 @@ use winit::{ |
| 68 | /// Height of the toolbar's row, which is the title bar where the platform lets it: a | 68 | /// Height of the toolbar's row, which is the title bar where the platform lets it: a |
| 69 | /// unified compact toolbar's on macOS. | 69 | /// unified compact toolbar's on macOS. |
| 70 | const TITLE: f32 = 38.0; | 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 | const GAP: f32 = 6.0; | 72 | const GAP: f32 = 6.0; |
| 73 | const EDGE: f32 = 8.0; | ||
| 74 | const TAB_ROW: f32 = 28.0; | 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 | const FRAME: f32 = 6.0; | 75 | const FRAME: f32 = 6.0; |
| 77 | const PAGE_LIST: f32 = 240.0; | 76 | const PAGE_LIST: f32 = 240.0; |
| 78 | /// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. | 77 | /// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. |
| ... | @@ -593,6 +592,11 @@ fn strip() -> Id { | ... | @@ -593,6 +592,11 @@ fn strip() -> Id { |
| 593 | Id::ROOT.child("strip") | 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 | fn page() -> Id { | 600 | fn page() -> Id { |
| 597 | Id::ROOT.child("page") | 601 | Id::ROOT.child("page") |
| 598 | } | 602 | } |
| ... | @@ -1042,14 +1046,18 @@ impl State { | ... | @@ -1042,14 +1046,18 @@ impl State { |
| 1042 | ..Spec::default() | 1046 | ..Spec::default() |
| 1043 | }, | 1047 | }, |
| 1044 | ); | 1048 | ); |
| 1045 | let tabs = self.ui.id("tabs"); | ||
| 1046 | let height = self | 1049 | let height = self |
| 1047 | .ui | 1050 | .ui |
| 1048 | .animate(tabs, if self.full_page { 0.0 } else { TAB_ROW }); | 1051 | .animate(tab_row(), if self.full_page { 0.0 } else { TAB_ROW }); |
| 1049 | let tab_row = self.ui.open( | 1052 | let drags = self.chrome_drags(); |
| 1050 | "tabs", | 1053 | let tab_row = self.ui.open_as( |
| 1054 | tab_row(), | ||
| 1051 | Spec { | 1055 | Spec { |
| 1052 | flags: Flags::CLIP, | 1056 | flags: if drags { |
| 1057 | Flags::CLIP | Flags::CLICKABLE | ||
| 1058 | } else { | ||
| 1059 | Flags::CLIP | ||
| 1060 | }, | ||
| 1053 | size: [fill(), px(height)], | 1061 | size: [fill(), px(height)], |
| 1054 | fill: Some(theme.strip), | 1062 | fill: Some(theme.strip), |
| 1055 | pad: [FRAME, 0.0], | 1063 | pad: [FRAME, 0.0], |
| ... | @@ -1387,6 +1395,12 @@ impl State { | ... | @@ -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 | /// The toolbar: one row of groups that fold as the window narrows. Where the platform | 1404 | /// The toolbar: one row of groups that fold as the window narrows. Where the platform |
| 1391 | /// lets it, the row is the window's title bar, and on the welcome page, without `tools`, | 1405 | /// lets it, the row is the window's title bar, and on the welcome page, without `tools`, |
| 1392 | /// that is all it is. | 1406 | /// that is all it is. |
| ... | @@ -1395,8 +1409,7 @@ impl State { | ... | @@ -1395,8 +1409,7 @@ impl State { |
| 1395 | if !tools && !title { | 1409 | if !tools && !title { |
| 1396 | return Ok(()); | 1410 | return Ok(()); |
| 1397 | } | 1411 | } |
| 1398 | // Over the title's gradient, the row's empty space drags the window as the title does. | 1412 | let strip_row = self.chrome_drags(); |
| 1399 | let strip_row = title || self.surface.translucent(); | ||
| 1400 | let spec = Spec { | 1413 | let spec = Spec { |
| 1401 | flags: if strip_row { | 1414 | flags: if strip_row { |
| 1402 | Flags::CLICKABLE | 1415 | Flags::CLICKABLE |
| ... | @@ -1414,7 +1427,7 @@ impl State { | ... | @@ -1414,7 +1427,7 @@ impl State { |
| 1414 | } else { | 1427 | } else { |
| 1415 | self.ui.open("toolbar", spec); | 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 | self.ui.leaf( | 1431 | self.ui.leaf( |
| 1419 | "lead", | 1432 | "lead", |
| 1420 | Spec { | 1433 | Spec { |
| ... | @@ -1439,7 +1452,7 @@ impl State { | ... | @@ -1439,7 +1452,7 @@ impl State { |
| 1439 | self.ui.leaf( | 1452 | self.ui.leaf( |
| 1440 | "trail", | 1453 | "trail", |
| 1441 | Spec { | 1454 | Spec { |
| 1442 | size: [px(EDGE - GAP), px(1.0)], | 1455 | size: [px(FRAME - GAP), px(1.0)], |
| 1443 | ..Spec::default() | 1456 | ..Spec::default() |
| 1444 | }, | 1457 | }, |
| 1445 | ); | 1458 | ); |
| ... | @@ -1662,7 +1675,7 @@ impl State { | ... | @@ -1662,7 +1675,7 @@ impl State { |
| 1662 | choice = group( | 1675 | choice = group( |
| 1663 | ui, | 1676 | ui, |
| 1664 | "character", | 1677 | "character", |
| 1665 | 6, | 1678 | 5, |
| 1666 | |ui| { | 1679 | |ui| { |
| 1667 | divider(ui, theme); | 1680 | divider(ui, theme); |
| 1668 | let mut choice = None; | 1681 | let mut choice = None; |
| ... | @@ -1757,7 +1770,7 @@ impl State { | ... | @@ -1757,7 +1770,7 @@ impl State { |
| 1757 | choice = group( | 1770 | choice = group( |
| 1758 | ui, | 1771 | ui, |
| 1759 | "paragraph", | 1772 | "paragraph", |
| 1760 | 5, | 1773 | 4, |
| 1761 | |ui| { | 1774 | |ui| { |
| 1762 | divider(ui, theme); | 1775 | divider(ui, theme); |
| 1763 | let mut choice = None; | 1776 | let mut choice = None; |
| ... | @@ -1885,7 +1898,7 @@ impl State { | ... | @@ -1885,7 +1898,7 @@ impl State { |
| 1885 | choice = group( | 1898 | choice = group( |
| 1886 | ui, | 1899 | ui, |
| 1887 | "tags", | 1900 | "tags", |
| 1888 | 7, | 1901 | 6, |
| 1889 | |ui| { | 1902 | |ui| { |
| 1890 | divider(ui, theme); | 1903 | divider(ui, theme); |
| 1891 | let mut choice = None; | 1904 | let mut choice = None; |
| ... | @@ -1950,17 +1963,7 @@ impl State { | ... | @@ -1950,17 +1963,7 @@ impl State { |
| 1950 | }, | 1963 | }, |
| 1951 | ); | 1964 | ); |
| 1952 | if let Some(session) = session { | 1965 | if let Some(session) = session { |
| 1953 | ui.open( | 1966 | sync::control(ui, session, theme); |
| 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(); | ||
| 1964 | } | 1967 | } |
| 1965 | // Spelling awaits a platform spell checker. | 1968 | // Spelling awaits a platform spell checker. |
| 1966 | let views = [ | 1969 | let views = [ |
| ... | @@ -1991,9 +1994,11 @@ impl State { | ... | @@ -1991,9 +1994,11 @@ impl State { |
| 1991 | toolbar_popup("page color"), | 1994 | toolbar_popup("page color"), |
| 1992 | anchor, | 1995 | anchor, |
| 1993 | &mut self.thumbnails, | 1996 | &mut self.thumbnails, |
| 1997 | &mut self.templates, | ||
| 1998 | // The page's own colour aside. | ||
| 1994 | canvas::gpu::Paper { | 1999 | canvas::gpu::Paper { |
| 1995 | color: theme.paper, | 2000 | color: ui.theme.paper, |
| 1996 | ink: theme.paper_ink, | 2001 | ink: ui.theme.paper_ink, |
| 1997 | }, | 2002 | }, |
| 1998 | self.view.editor.page_color(), | 2003 | self.view.editor.page_color(), |
| 1999 | self.view.editor.rule_lines(), | 2004 | self.view.editor.rule_lines(), |
| ... | @@ -3111,7 +3116,7 @@ impl State { | ... | @@ -3111,7 +3116,7 @@ impl State { |
| 3111 | pressed: true, | 3116 | pressed: true, |
| 3112 | at, | 3117 | at, |
| 3113 | } = event | 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 | let double = self.strip_press.is_some_and(|last| { | 3121 | let double = self.strip_press.is_some_and(|last| { |
| 3117 | at.saturating_duration_since(last) <= platform::double_click_interval() | 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,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, | 74 | /// The status's icon in the toolbar, named in its tooltip, which opens the popup. |
| 75 | /// naming it in a tooltip. | 75 | pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) { |
| 76 | pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, compact: bool) { | ||
| 77 | let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline(); | 76 | let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline(); |
| 78 | let (label, icon, _) = describe(&session.sync); | 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 | ui.open_as( | 78 | ui.open_as( |
| 94 | shown, | 79 | button(), |
| 95 | if compact { | 80 | Spec { |
| 96 | Spec { | 81 | flags: Flags::CLICKABLE, |
| 97 | size: [px(TOOL), px(TOOL)], | 82 | size: [px(TOOL), px(TOOL)], |
| 98 | icon: Some(icon), | 83 | icon: Some(icon), |
| 99 | ..spec | 84 | color: Some(if strong { theme.text } else { theme.text_dim }), |
| 100 | } | 85 | hover_fill: Some(theme.hover()), |
| 101 | } else { | 86 | radius: 4.0, |
| 102 | Spec { | 87 | center: true, |
| 103 | size: [fit(), px(TOOL)], | 88 | ..Spec::default() |
| 104 | text: Some(label), | ||
| 105 | pad: [8.0, 0.0], | ||
| 106 | ..spec | ||
| 107 | } | ||
| 108 | }, | 89 | }, |
| 109 | ); | 90 | ); |
| 110 | ui.close(); | 91 | ui.close(); |
| 111 | if compact { | 92 | ui::popup::tooltip(ui, label, "", None); |
| 112 | ui::popup::tooltip(ui, label, "", None); | 93 | if ui.signal(button()).clicked { |
| 113 | } | ||
| 114 | if ui.signal(shown).clicked { | ||
| 115 | if ui.popup_open(id()) { | 94 | if ui.popup_open(id()) { |
| 116 | ui.close_popup(id()); | 95 | ui.close_popup(id()); |
| 117 | } else { | 96 | } else { |
crates/snowbound/src/templates.rs+51-4| ... | @@ -48,6 +48,8 @@ pub enum View { | ... | @@ -48,6 +48,8 @@ pub enum View { |
| 48 | /// The strip with the page colours beneath it. | 48 | /// The strip with the page colours beneath it. |
| 49 | Colors, | 49 | Colors, |
| 50 | Gallery, | 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 | /// A thumbnail's art: each raster and where it lies in the tile. | 55 | /// A thumbnail's art: each raster and where it lies in the tile. |
| ... | @@ -199,7 +201,8 @@ fn strip( | ... | @@ -199,7 +201,8 @@ fn strip( |
| 199 | chosen | 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 | fn gallery( | 206 | fn gallery( |
| 204 | ui: &mut Ui, | 207 | ui: &mut Ui, |
| 205 | theme: &Theme, | 208 | theme: &Theme, |
| ... | @@ -207,6 +210,7 @@ fn gallery( | ... | @@ -207,6 +210,7 @@ fn gallery( |
| 207 | paper: Paper, | 210 | paper: Paper, |
| 208 | dark: bool, | 211 | dark: bool, |
| 209 | size: [f32; 2], | 212 | size: [f32; 2], |
| 213 | art: bool, | ||
| 210 | ) -> Option<Choice> { | 214 | ) -> Option<Choice> { |
| 211 | let mut chosen = None; | 215 | let mut chosen = None; |
| 212 | let margin = 24.0; | 216 | let margin = 24.0; |
| ... | @@ -236,7 +240,7 @@ fn gallery( | ... | @@ -236,7 +240,7 @@ fn gallery( |
| 236 | "title", | 240 | "title", |
| 237 | Spec { | 241 | Spec { |
| 238 | size: [fill(), px(24.0)], | 242 | size: [fill(), px(24.0)], |
| 239 | text: Some("Page Templates"), | 243 | text: Some(if art { "Backgrounds" } else { "Page Templates" }), |
| 240 | ..Spec::default() | 244 | ..Spec::default() |
| 241 | }, | 245 | }, |
| 242 | ); | 246 | ); |
| ... | @@ -264,7 +268,8 @@ fn gallery( | ... | @@ -264,7 +268,8 @@ fn gallery( |
| 264 | PAGE_COLORS | 268 | PAGE_COLORS |
| 265 | .iter() | 269 | .iter() |
| 266 | .enumerate() | 270 | .enumerate() |
| 267 | .map(|(index, (name, _))| (Choice::Color(index), *name)), | 271 | .map(|(index, (name, _))| (Choice::Color(index), *name)) |
| 272 | .filter(|_| !art), | ||
| 268 | ) | 273 | ) |
| 269 | .collect(); | 274 | .collect(); |
| 270 | for (row, choices) in choices.chunks(columns).enumerate() { | 275 | for (row, choices) in choices.chunks(columns).enumerate() { |
| ... | @@ -411,6 +416,9 @@ impl crate::State { | ... | @@ -411,6 +416,9 @@ impl crate::State { |
| 411 | let Some(session) = &self.session else { | 416 | let Some(session) = &self.session else { |
| 412 | return; | 417 | return; |
| 413 | }; | 418 | }; |
| 419 | if self.templates == View::Art { | ||
| 420 | return self.art_gallery(theme); | ||
| 421 | } | ||
| 414 | let space = session.space; | 422 | let space = session.space; |
| 415 | let editor = &self.view.editor; | 423 | let editor = &self.view.editor; |
| 416 | let blank = editor | 424 | let blank = editor |
| ... | @@ -440,8 +448,17 @@ impl crate::State { | ... | @@ -440,8 +448,17 @@ impl crate::State { |
| 440 | let chosen = match self.templates { | 448 | let chosen = match self.templates { |
| 441 | View::Gallery => { | 449 | View::Gallery => { |
| 442 | let size = [rect[2] - rect[0], rect[3] - rect[1]]; | 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 | view => strip( | 462 | view => strip( |
| 446 | &mut self.ui, | 463 | &mut self.ui, |
| 447 | theme, | 464 | theme, |
| ... | @@ -475,4 +492,34 @@ impl crate::State { | ... | @@ -475,4 +492,34 @@ impl crate::State { |
| 475 | None => {} | 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,8 +3,8 @@ use std::collections::HashMap; |
| 3 | 3 | ||
| 4 | /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from | 4 | /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from |
| 5 | /// ancestors (pre-order), sizes summed from children (post-order), overflow taken back | 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 | 6 | /// (by folding a row's groups for what space sized by ancestors can't give, then from that |
| 7 | /// strict boxes first), and positions along each parent's flow. Labels too wide for their | 7 | /// space, then from the least strict boxes first), and positions along each parent's flow. Labels too wide for their |
| 8 | /// solved width wrap or shorten before heights are solved. Boxes are in build order, so | 8 | /// solved width wrap or shorten before heights are solved. Boxes are in build order, so |
| 9 | /// index order is pre-order. | 9 | /// index order is pre-order. |
| 10 | pub(crate) fn solve( | 10 | pub(crate) fn solve( |
| ... | @@ -75,10 +75,12 @@ pub(crate) fn solve( | ... | @@ -75,10 +75,12 @@ pub(crate) fn solve( |
| 75 | if along(node, axis) { | 75 | if along(node, axis) { |
| 76 | let space = | 76 | let space = |
| 77 | |child: &&usize| matches!(nodes[**child].size[axis].size, Size::Fraction(_)); | 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 | let mut excess = flow(nodes, index, axis) - room; | 79 | let mut excess = flow(nodes, index, axis) - room; |
| 80 | excess = give_back(nodes, &space, axis, excess); | 80 | // Groups fold only for what the space can't give, so the room a fold frees |
| 81 | while excess > 0.0 && axis == 0 { | 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 | let Some(group) = children | 84 | let Some(group) = children |
| 83 | .iter() | 85 | .iter() |
| 84 | .copied() | 86 | .copied() |
| ... | @@ -96,6 +98,7 @@ pub(crate) fn solve( | ... | @@ -96,6 +98,7 @@ pub(crate) fn solve( |
| 96 | excess -= nodes[group].computed[0] - width; | 98 | excess -= nodes[group].computed[0] - width; |
| 97 | nodes[group].computed[0] = width; | 99 | nodes[group].computed[0] = width; |
| 98 | } | 100 | } |
| 101 | excess = give_back(nodes, &space, axis, excess); | ||
| 99 | let mut tiers: Vec<f32> = sized | 102 | let mut tiers: Vec<f32> = sized |
| 100 | .iter() | 103 | .iter() |
| 101 | .map(|child| strictness(nodes, *child, axis)) | 104 | .map(|child| strictness(nodes, *child, axis)) |
crates/ui/src/lib.rs+3| ... | @@ -542,6 +542,8 @@ struct Popup { | ... | @@ -542,6 +542,8 @@ struct Popup { |
| 542 | /// Its filter field's text, and the key of the row the keyboard or pointer last chose. | 542 | /// Its filter field's text, and the key of the row the keyboard or pointer last chose. |
| 543 | query: String, | 543 | query: String, |
| 544 | highlight: Option<u64>, | 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 | /// The height its results ease from and to, and the seconds since they set out. | 547 | /// The height its results ease from and to, and the seconds since they set out. |
| 546 | height: Option<[f32; 3]>, | 548 | height: Option<[f32; 3]>, |
| 547 | opened: Instant, | 549 | opened: Instant, |
| ... | @@ -1000,6 +1002,7 @@ impl Ui { | ... | @@ -1000,6 +1002,7 @@ impl Ui { |
| 1000 | focus: self.focus, | 1002 | focus: self.focus, |
| 1001 | query: String::new(), | 1003 | query: String::new(), |
| 1002 | highlight: None, | 1004 | highlight: None, |
| 1005 | picked: None, | ||
| 1003 | height: None, | 1006 | height: None, |
| 1004 | opened: self.now, | 1007 | opened: self.now, |
| 1005 | }); | 1008 | }); |
crates/ui/src/popup.rs+221| ... | @@ -814,6 +814,227 @@ pub fn gallery( | ... | @@ -814,6 +814,227 @@ pub fn gallery( |
| 814 | None | 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 | /// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it. | 1038 | /// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it. |
| 818 | fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { | 1039 | fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { |
| 819 | let style = ui.theme.menu(); | 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,7 +209,8 @@ fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() { |
| 209 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | 209 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); |
| 210 | let rects = folding_row(&mut ui, 130.0, &[0, 1]); | 210 | let rects = folding_row(&mut ui, 130.0, &[0, 1]); |
| 211 | // 80 + 20 + 20 fits 130 with 10 to spare, taken by the space. | 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 | let rects = folding_row(&mut ui, 100.0, &[0, 1]); | 214 | let rects = folding_row(&mut ui, 100.0, &[0, 1]); |
| 214 | assert_eq!(rects[0], Some([0.0, 0.0, 60.0, 20.0])); | 215 | assert_eq!(rects[0], Some([0.0, 0.0, 60.0, 20.0])); |
| 215 | assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0])); | 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,3 +2517,29 @@ fn the_wheel_scrolls_overflowing_tabs_sideways_either_way_it_turns() { |
| 2516 | let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; | 2517 | let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; |
| 2517 | assert!((back - scrolled - 30.0).abs() < 0.5, "{scrolled} to {back}"); | 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 | } |