| 1 | use crate::{Axis, Event, Flags, Id, Signal, Spec, Ui, fit, px}; |
| 2 | use accesskit::Role; |
| 3 | use draw::RasterImage; |
| 4 | use draw::edit::{self, Command, Movement}; |
| 5 | use parley::{ |
| 6 | Affinity, |
| 7 | editing::{Cursor, Selection}, |
| 8 | }; |
| 9 | use std::{hash::Hash, time::Duration}; |
| 10 | use winit::{ |
| 11 | event::Ime, |
| 12 | keyboard::{Key, ModifiersState, NamedKey}, |
| 13 | window::CursorIcon, |
| 14 | }; |
| 15 | |
| 16 | /// A labelled push button that fills with the accent tint under the pointer. |
| 17 | pub fn button(ui: &mut Ui, part: impl Hash, text: &str) -> Signal { |
| 18 | let theme = &ui.theme; |
| 19 | let spec = Spec { |
| 20 | flags: Flags::CLICKABLE, |
| 21 | size: [fit(), px(theme.font_size * 2.0)], |
| 22 | text: Some(text), |
| 23 | fill: Some(theme.chip), |
| 24 | hover_fill: Some(theme.hover()), |
| 25 | hover_border: Some(theme.accent), |
| 26 | radius: 4.0, |
| 27 | pad: [theme.font_size * 0.75, 0.0], |
| 28 | center: true, |
| 29 | role: Some(Role::Button), |
| 30 | ..Spec::default() |
| 31 | }; |
| 32 | ui.leaf(part, spec) |
| 33 | } |
| 34 | |
| 35 | /// Cancel and a dialog's default button `text`, which takes no clicks while not `enabled`, |
| 36 | /// in the platform's order: the default last on macOS and GNOME, first on Windows, as their |
| 37 | /// own dialogs place them. The default wears the accent around it. Whether each was clicked, |
| 38 | /// Cancel first. |
| 39 | pub fn dialog_buttons(ui: &mut Ui, text: &str, enabled: bool) -> [bool; 2] { |
| 40 | let default = |ui: &mut Ui| { |
| 41 | let theme = &ui.theme; |
| 42 | let spec = Spec { |
| 43 | flags: if enabled { |
| 44 | Flags::CLICKABLE |
| 45 | } else { |
| 46 | Flags::default() |
| 47 | }, |
| 48 | size: [fit(), px(theme.font_size * 2.0)], |
| 49 | text: Some(text), |
| 50 | color: (!enabled).then_some(theme.text_dim), |
| 51 | fill: Some(theme.chip), |
| 52 | hover_fill: enabled.then(|| theme.hover()), |
| 53 | border: enabled.then_some(theme.accent), |
| 54 | radius: 4.0, |
| 55 | pad: [theme.font_size * 0.75, 0.0], |
| 56 | center: true, |
| 57 | role: Some(Role::Button), |
| 58 | ..Spec::default() |
| 59 | }; |
| 60 | let clicked = ui.leaf("default", spec).clicked; |
| 61 | if !enabled && let Some(node) = ui.access(ui.id("default")) { |
| 62 | node.set_disabled(); |
| 63 | } |
| 64 | clicked |
| 65 | }; |
| 66 | let windows = edit::Platform::CURRENT == edit::Platform::Windows; |
| 67 | let mut chosen = windows && default(ui); |
| 68 | let cancel = button(ui, "cancel", "Cancel").clicked; |
| 69 | if !windows { |
| 70 | chosen = default(ui); |
| 71 | } |
| 72 | [cancel, chosen] |
| 73 | } |
| 74 | |
| 75 | /// A short tag in the accent's colour, centred in a box `height` tall, as BETA marks a |
| 76 | /// feature still settling beside its name. |
| 77 | pub fn badge(ui: &mut Ui, part: impl Hash, text: &str, height: f32) { |
| 78 | let theme = &ui.theme; |
| 79 | let [red, green, blue, _] = theme.accent; |
| 80 | let tall = (theme.font_size * 1.15).min(height).round(); |
| 81 | let spec = Spec { |
| 82 | size: [fit(), px(tall)], |
| 83 | text: Some(text), |
| 84 | font_size: Some((theme.font_size * 0.7).round()), |
| 85 | bold: true, |
| 86 | color: Some(theme.accent), |
| 87 | fill: Some([red, green, blue, 0.16]), |
| 88 | radius: 3.0, |
| 89 | pad: [4.0, 0.0], |
| 90 | center: true, |
| 91 | ..Spec::default() |
| 92 | }; |
| 93 | ui.open( |
| 94 | part, |
| 95 | Spec { |
| 96 | size: [crate::children(), px(height)], |
| 97 | pad: [0.0, ((height - tall) / 2.0).max(0.0)], |
| 98 | ..Spec::default() |
| 99 | }, |
| 100 | ); |
| 101 | ui.leaf("text", spec); |
| 102 | ui.close(); |
| 103 | } |
| 104 | |
| 105 | /// The width `badge` takes for `text`. |
| 106 | pub fn badge_width(ui: &mut Ui, text: &str) -> f32 { |
| 107 | let size = (ui.theme.font_size * 0.7).round(); |
| 108 | ui.texts.label(text, size, ui.frame).size[0] + 8.0 |
| 109 | } |
| 110 | |
| 111 | /// A check box before `label`, `checked` or not; a click on either reports for the caller |
| 112 | /// to toggle. |
| 113 | pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Signal { |
| 114 | let theme = ui.theme.clone(); |
| 115 | let [height, side] = [theme.font_size * 2.0, 16.0]; |
| 116 | let margin = (height - side) / 2.0; |
| 117 | let id = ui.open( |
| 118 | part, |
| 119 | Spec { |
| 120 | flags: Flags::CLICKABLE, |
| 121 | size: [crate::children(), px(height)], |
| 122 | gap: 8.0, |
| 123 | role: Some(Role::CheckBox), |
| 124 | ..Spec::default() |
| 125 | }, |
| 126 | ); |
| 127 | if let Some(node) = ui.access(id) { |
| 128 | node.set_toggled(checked.into()); |
| 129 | } |
| 130 | // A box's signal is taken once, so its hover and click are read together. |
| 131 | let signal = ui.signal(id); |
| 132 | ui.leaf( |
| 133 | "box", |
| 134 | Spec { |
| 135 | size: [px(side), px(height)], |
| 136 | inset: [0.0, margin, 0.0, margin], |
| 137 | fill: Some(if checked { theme.accent } else { theme.base }), |
| 138 | border: Some(if checked || signal.hovered { |
| 139 | theme.accent |
| 140 | } else { |
| 141 | crate::mix(theme.chip, theme.text, 0.45) |
| 142 | }), |
| 143 | radius: 3.0, |
| 144 | icon: checked.then_some(crate::popup::CHECK), |
| 145 | color: Some([1.0; 4]), |
| 146 | ..Spec::default() |
| 147 | }, |
| 148 | ); |
| 149 | ui.leaf( |
| 150 | "label", |
| 151 | Spec { |
| 152 | size: [fit(), px(height)], |
| 153 | text: Some(label), |
| 154 | ..Spec::default() |
| 155 | }, |
| 156 | ); |
| 157 | ui.close(); |
| 158 | signal |
| 159 | } |
| 160 | |
| 161 | /// How a segmented control draws its choice, as the platform draws its own. |
| 162 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 163 | pub enum Segments { |
| 164 | /// A raised face sliding along a sunken track: macOS since 11, libadwaita's toggle |
| 165 | /// groups and Windows 11's segmented control. |
| 166 | #[default] |
| 167 | Track, |
| 168 | /// Buttons joined in a row, the chosen one pressed in: Mac OS X 10.6's segmented control |
| 169 | /// and Windows 7's toggle groups. |
| 170 | Joined, |
| 171 | } |
| 172 | |
| 173 | /// `choices` side by side, equally wide, with `chosen` picked: a radio group named `label` |
| 174 | /// for assistive technology. Tab enters at the choice, and arrows pick the one beside it. |
| 175 | /// Returns the choice a click or the keyboard picks. |
| 176 | pub fn segmented( |
| 177 | ui: &mut Ui, |
| 178 | part: impl Hash, |
| 179 | label: &str, |
| 180 | choices: &[&str], |
| 181 | chosen: usize, |
| 182 | ) -> Option<usize> { |
| 183 | let theme = ui.theme.clone(); |
| 184 | let joined = ui.segments == Segments::Joined; |
| 185 | let dark = draw::oklab(theme.text)[0] > 0.5; |
| 186 | let [red, green, blue, _] = theme.text; |
| 187 | let ink = |alpha| [red, green, blue, alpha]; |
| 188 | let height = theme.font_size * 2.0; |
| 189 | let widest = (choices.iter()).fold(0.0_f32, |widest, choice| widest.max(ui.measure(choice)[0])); |
| 190 | let width = (widest + 2.0 * theme.font_size).round(); |
| 191 | // A track shows around its face; joined buttons fill their border. |
| 192 | let inset = if joined { 1.0 } else { 2.0 }; |
| 193 | let button = |shade: f32| { |
| 194 | if dark { |
| 195 | crate::mix(theme.popup, theme.text, shade) |
| 196 | } else { |
| 197 | crate::mix([1.0; 4], theme.chip, 0.75 - shade * 4.0) |
| 198 | } |
| 199 | }; |
| 200 | let group = ui.open( |
| 201 | part, |
| 202 | Spec { |
| 203 | size: [crate::children(), px(height)], |
| 204 | fill: Some(if joined { |
| 205 | button(0.12) |
| 206 | } else { |
| 207 | ink(if dark { 0.1 } else { 0.07 }) |
| 208 | }), |
| 209 | gradient: joined.then(|| button(0.06)), |
| 210 | border: joined.then_some(theme.chip), |
| 211 | radius: if joined { 4.0 } else { 6.0 }, |
| 212 | pad: [inset, inset], |
| 213 | role: Some(Role::RadioGroup), |
| 214 | ..Spec::default() |
| 215 | }, |
| 216 | ); |
| 217 | if let Some(node) = ui.access(group) { |
| 218 | node.set_label(label); |
| 219 | node.set_orientation(accesskit::Orientation::Horizontal); |
| 220 | } |
| 221 | let target = chosen as f32 * width; |
| 222 | let slid = if joined { |
| 223 | ui.hold(group.child("slide"), target) |
| 224 | } else { |
| 225 | ui.animate(group.child("slide"), target) |
| 226 | }; |
| 227 | ui.leaf( |
| 228 | "thumb", |
| 229 | Spec { |
| 230 | flags: Flags::FLOAT, |
| 231 | size: [px(width), px(height - 2.0 * inset)], |
| 232 | position: [inset + slid, inset], |
| 233 | fill: Some(match (joined, dark) { |
| 234 | (true, _) => theme.accent, |
| 235 | (false, true) => crate::mix(theme.popup, theme.text, 0.3), |
| 236 | (false, false) => [1.0; 4], |
| 237 | }), |
| 238 | gradient: joined.then(|| crate::mix(theme.accent, [0.0, 0.0, 0.0, 1.0], 0.25)), |
| 239 | border: (!joined && !dark).then(|| ink(0.08)), |
| 240 | shadow: (!joined && !dark).then_some([0.0, 0.0, 0.0, 0.12]), |
| 241 | radius: if joined { 3.0 } else { 4.0 }, |
| 242 | ..Spec::default() |
| 243 | }, |
| 244 | ); |
| 245 | // Rules between the choices; a track leaves them out beside its face. |
| 246 | for between in 1..choices.len() { |
| 247 | if !joined && (between == chosen || between == chosen + 1) { |
| 248 | continue; |
| 249 | } |
| 250 | let length = if joined { height - 2.0 } else { height * 0.5 }; |
| 251 | ui.leaf( |
| 252 | ("rule", between), |
| 253 | Spec { |
| 254 | flags: Flags::FLOAT, |
| 255 | size: [px(1.0), px(length)], |
| 256 | position: [ |
| 257 | inset + between as f32 * width - 0.5, |
| 258 | (height - length) / 2.0, |
| 259 | ], |
| 260 | fill: Some(if joined { theme.chip } else { ink(0.15) }), |
| 261 | ..Spec::default() |
| 262 | }, |
| 263 | ); |
| 264 | } |
| 265 | let mut picked = None; |
| 266 | for (index, choice) in choices.iter().enumerate() { |
| 267 | let on = index == chosen; |
| 268 | let id = ui.open( |
| 269 | index, |
| 270 | Spec { |
| 271 | flags: Flags::CLICKABLE, |
| 272 | size: [px(width), crate::fill()], |
| 273 | text: Some(choice), |
| 274 | color: Some(if on && joined { [1.0; 4] } else { theme.text }), |
| 275 | hover_fill: (!on).then(|| ink(0.06)), |
| 276 | radius: if joined { 3.0 } else { 4.0 }, |
| 277 | center: true, |
| 278 | role: Some(Role::RadioButton), |
| 279 | ..Spec::default() |
| 280 | }, |
| 281 | ); |
| 282 | if let Some(node) = ui.access(id) { |
| 283 | node.set_toggled(on.into()); |
| 284 | } |
| 285 | ui.close(); |
| 286 | let signal = ui.signal(id); |
| 287 | // The keyboard's arrows pick the segment they move to, as a radio group's do. |
| 288 | let arrived = ui.lasted(id.child("focus"), signal.focused) == Duration::ZERO; |
| 289 | if !on && (signal.clicked || signal.focused && arrived) { |
| 290 | picked = Some(index); |
| 291 | } |
| 292 | } |
| 293 | ui.close(); |
| 294 | picked |
| 295 | } |
| 296 | |
| 297 | /// Scrollers the platform paints, fixed along the content's edges, as Mac OS X 10.6's are. |
| 298 | pub struct Scrollers { |
| 299 | /// Across a scroller, in logical pixels. |
| 300 | pub thickness: f32, |
| 301 | pub paint: Box<dyn FnMut(&Scroller) -> PaintedScroller>, |
| 302 | } |
| 303 | |
| 304 | /// A scroller as it is to be painted, in logical pixels. |
| 305 | pub struct Scroller { |
| 306 | pub axis: Axis, |
| 307 | pub length: f32, |
| 308 | pub scale: f32, |
| 309 | /// Where the offset lies in its range, from 0 to 1. |
| 310 | pub value: f32, |
| 311 | /// How much of the content shows, from 0 to 1. |
| 312 | pub proportion: f32, |
| 313 | /// The part held down. |
| 314 | pub held: Option<ScrollerPart>, |
| 315 | /// Whether the window is key, which colours the knob. |
| 316 | pub active: bool, |
| 317 | } |
| 318 | |
| 319 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 320 | pub enum ScrollerPart { |
| 321 | Decrement, |
| 322 | Increment, |
| 323 | Knob, |
| 324 | /// The track either side of the knob, which pages. |
| 325 | Slot, |
| 326 | } |
| 327 | |
| 328 | /// A painted scroller and where its parts lie along it, from its start in logical pixels. |
| 329 | #[derive(Clone)] |
| 330 | pub struct PaintedScroller { |
| 331 | /// `length` by `thickness`, at the scroller's scale. |
| 332 | pub image: RasterImage, |
| 333 | pub knob: [f32; 2], |
| 334 | pub slot: [f32; 2], |
| 335 | pub decrement: [f32; 2], |
| 336 | pub increment: [f32; 2], |
| 337 | } |
| 338 | |
| 339 | /// How long a held arrow or track waits before repeating, and then between steps. |
| 340 | const REPEAT_DELAY: Duration = Duration::from_millis(300); |
| 341 | const REPEAT: Duration = Duration::from_millis(50); |
| 342 | |
| 343 | /// A scrollbar along the far edge of the current box, for content whose scroll offset |
| 344 | /// ranges over `range` while `view` of it shows, all in one unit: the platform's scroller |
| 345 | /// where `Ui::scrollers` has one, otherwise a thumb in `color` over the content. With a |
| 346 | /// `corner`, its far end leaves room for the other axis's scrollbar. Returns the offset |
| 347 | /// the scrollbar chose. |
| 348 | #[allow(clippy::too_many_arguments)] |
| 349 | pub fn scrollbar( |
| 350 | ui: &mut Ui, |
| 351 | part: impl Hash, |
| 352 | axis: Axis, |
| 353 | offset: f32, |
| 354 | range: [f32; 2], |
| 355 | view: f32, |
| 356 | corner: bool, |
| 357 | color: [f32; 4], |
| 358 | ) -> Option<f32> { |
| 359 | if ui.scrollers.is_some() { |
| 360 | return system_scrollbar(ui, part, axis, offset, range, view, corner); |
| 361 | } |
| 362 | let along = usize::from(axis == Axis::Y); |
| 363 | let rect = ui.laid_out(ui.current())?; |
| 364 | let length = rect[along + 2] - rect[along]; |
| 365 | let cross = rect[3 - along] - rect[1 - along]; |
| 366 | let span = range[1] - range[0]; |
| 367 | // A thumb keeps 4 from each end and the box's far edge, and is 6 across. |
| 368 | let track = length - 8.0 - if corner { 10.0 } else { 0.0 }; |
| 369 | if span <= 0.0 || track <= 0.0 { |
| 370 | return None; |
| 371 | } |
| 372 | let size = (track * view / (view + span)).max(28.0).min(track); |
| 373 | let start = 4.0 + ((offset - range[0]) / span).clamp(0.0, 1.0) * (track - size); |
| 374 | let id = ui.id(&part); |
| 375 | let signal = ui.signal(id); |
| 376 | let pointer = ui.pointer().map(|pointer| pointer[along] - rect[along]); |
| 377 | if signal.pressed |
| 378 | && let Some(pointer) = pointer |
| 379 | { |
| 380 | ui.state(id).grab = pointer - start; |
| 381 | } |
| 382 | let chosen = signal.dragging.then_some(pointer).flatten().map(|pointer| { |
| 383 | let travel = (track - size).max(f32::EPSILON); |
| 384 | let fraction = ((pointer - ui.state(id).grab - 4.0) / travel).clamp(0.0, 1.0); |
| 385 | range[0] + fraction * span |
| 386 | }); |
| 387 | let mut position = [0.0; 2]; |
| 388 | position[along] = start; |
| 389 | position[1 - along] = cross - 10.0; |
| 390 | let mut extent = [px(6.0); 2]; |
| 391 | extent[along] = px(size); |
| 392 | ui.leaf( |
| 393 | &part, |
| 394 | Spec { |
| 395 | flags: Flags::CLICKABLE | Flags::FLOAT, |
| 396 | size: extent, |
| 397 | position, |
| 398 | fill: Some(color), |
| 399 | hover_fill: Some([color[0], color[1], color[2], (color[3] * 1.6).min(1.0)]), |
| 400 | radius: 3.0, |
| 401 | cursor: Some(CursorIcon::Default), |
| 402 | ..Spec::default() |
| 403 | }, |
| 404 | ); |
| 405 | chosen |
| 406 | } |
| 407 | |
| 408 | fn system_scrollbar( |
| 409 | ui: &mut Ui, |
| 410 | part: impl Hash, |
| 411 | axis: Axis, |
| 412 | offset: f32, |
| 413 | range: [f32; 2], |
| 414 | view: f32, |
| 415 | corner: bool, |
| 416 | ) -> Option<f32> { |
| 417 | let along = usize::from(axis == Axis::Y); |
| 418 | let rect = ui.laid_out(ui.current())?; |
| 419 | let span = range[1] - range[0]; |
| 420 | let thickness = ui.scrollers.as_ref()?.thickness; |
| 421 | let length = rect[along + 2] - rect[along] - if corner { thickness } else { 0.0 }; |
| 422 | if span <= 0.0 || length <= 0.0 { |
| 423 | return None; |
| 424 | } |
| 425 | let id = ui.id(&part); |
| 426 | let signal = ui.signal(id); |
| 427 | let pointer = ui.pointer().map(|pointer| pointer[along] - rect[along]); |
| 428 | let now = ui.now; |
| 429 | if !signal.pressed && !signal.dragging { |
| 430 | ui.state(id).held = None; |
| 431 | } |
| 432 | let request = Scroller { |
| 433 | axis, |
| 434 | length, |
| 435 | scale: ui.scale, |
| 436 | value: ((offset - range[0]) / span).clamp(0.0, 1.0), |
| 437 | proportion: (view / (view + span)).clamp(0.0, 1.0), |
| 438 | held: ui.state(id).held.map(|(part, _)| part), |
| 439 | active: ui.window_focused, |
| 440 | }; |
| 441 | let painted = (ui.scrollers.as_mut()?.paint)(&request); |
| 442 | let within = |[start, end]: [f32; 2], at: f32| start <= at && at < end; |
| 443 | let line = (view / 20.0).max(1.0); |
| 444 | let mut chosen = None; |
| 445 | if let Some(at) = pointer |
| 446 | && (signal.pressed || signal.dragging) |
| 447 | { |
| 448 | if signal.pressed { |
| 449 | let hit = [ |
| 450 | (ScrollerPart::Decrement, painted.decrement), |
| 451 | (ScrollerPart::Increment, painted.increment), |
| 452 | (ScrollerPart::Knob, painted.knob), |
| 453 | (ScrollerPart::Slot, painted.slot), |
| 454 | ] |
| 455 | .into_iter() |
| 456 | .find(|(_, extent)| within(*extent, at)); |
| 457 | ui.state(id).held = hit.map(|(part, _)| (part, now)); |
| 458 | ui.state(id).grab = at - painted.knob[0]; |
| 459 | } |
| 460 | match ui.state(id).held { |
| 461 | Some((ScrollerPart::Knob, _)) => { |
| 462 | let travel = |
| 463 | (painted.slot[1] - painted.slot[0]) - (painted.knob[1] - painted.knob[0]); |
| 464 | let from = at - ui.state(id).grab - painted.slot[0]; |
| 465 | let fraction = (from / travel.max(f32::EPSILON)).clamp(0.0, 1.0); |
| 466 | chosen = Some(range[0] + fraction * span); |
| 467 | } |
| 468 | Some((part, due)) if now >= due => { |
| 469 | let step = match part { |
| 470 | ScrollerPart::Decrement => -line, |
| 471 | ScrollerPart::Increment => line, |
| 472 | // The track pages towards the pointer and stops once the knob is under it. |
| 473 | _ if within(painted.knob, at) => 0.0, |
| 474 | _ if at < painted.knob[0] => line - view, |
| 475 | _ => view - line, |
| 476 | }; |
| 477 | let delay = if signal.pressed { REPEAT_DELAY } else { REPEAT }; |
| 478 | ui.state(id).held = Some((part, now + delay)); |
| 479 | chosen = Some((offset + step).clamp(range[0], range[1])); |
| 480 | } |
| 481 | _ => {} |
| 482 | } |
| 483 | // A held part repeats on later frames. |
| 484 | ui.animating = true; |
| 485 | } |
| 486 | let mut position = [0.0; 2]; |
| 487 | position[1 - along] = rect[3 - along] - rect[1 - along] - thickness; |
| 488 | let mut extent = [px(thickness); 2]; |
| 489 | extent[along] = px(length); |
| 490 | ui.leaf( |
| 491 | &part, |
| 492 | Spec { |
| 493 | flags: Flags::CLICKABLE | Flags::FLOAT, |
| 494 | size: extent, |
| 495 | position, |
| 496 | image: Some(&painted.image), |
| 497 | cursor: Some(CursorIcon::Default), |
| 498 | ..Spec::default() |
| 499 | }, |
| 500 | ); |
| 501 | if axis == Axis::Y && corner { |
| 502 | // The corner between the two scrollers shows the content's white, as AppKit's does. |
| 503 | ui.leaf( |
| 504 | ("corner", &part), |
| 505 | Spec { |
| 506 | flags: Flags::FLOAT, |
| 507 | size: [px(thickness); 2], |
| 508 | position: [position[0], length], |
| 509 | fill: Some([1.0; 4]), |
| 510 | ..Spec::default() |
| 511 | }, |
| 512 | ); |
| 513 | } |
| 514 | chosen |
| 515 | } |
| 516 | |
| 517 | /// A single-line field editing `text`, showing `placeholder` while empty. Presses, drags |
| 518 | /// and keys select and edit as on the page: a double press selects a word, a triple press |
| 519 | /// everything, and dragging extends by that unit. |
| 520 | pub fn text_field( |
| 521 | ui: &mut Ui, |
| 522 | id: Id, |
| 523 | text: &mut String, |
| 524 | placeholder: &str, |
| 525 | spec: Spec<'_>, |
| 526 | ) -> Signal { |
| 527 | field(ui, id, text, placeholder, spec, false) |
| 528 | } |
| 529 | |
| 530 | /// A text field for a password, showing and announcing a bullet for each character. |
| 531 | pub fn password_field( |
| 532 | ui: &mut Ui, |
| 533 | id: Id, |
| 534 | text: &mut String, |
| 535 | placeholder: &str, |
| 536 | spec: Spec<'_>, |
| 537 | ) -> Signal { |
| 538 | field(ui, id, text, placeholder, spec, true) |
| 539 | } |
| 540 | |
| 541 | fn field( |
| 542 | ui: &mut Ui, |
| 543 | id: Id, |
| 544 | text: &mut String, |
| 545 | placeholder: &str, |
| 546 | spec: Spec<'_>, |
| 547 | secret: bool, |
| 548 | ) -> Signal { |
| 549 | const BULLET: &str = "\u{2022}"; |
| 550 | // The text laid out, and where its offsets fall in `text`: a secret shows bullets. |
| 551 | let masked = |text: &str| { |
| 552 | if secret { |
| 553 | BULLET.repeat(text.chars().count()) |
| 554 | } else { |
| 555 | text.to_owned() |
| 556 | } |
| 557 | }; |
| 558 | let to_text = |text: &str, index: usize| match secret { |
| 559 | true => (text.char_indices().map(|(at, _)| at)) |
| 560 | .nth(index / BULLET.len()) |
| 561 | .unwrap_or(text.len()), |
| 562 | false => index, |
| 563 | }; |
| 564 | let to_shown = |text: &str, index: usize| match secret { |
| 565 | true => text[..index].chars().count() * BULLET.len(), |
| 566 | false => index, |
| 567 | }; |
| 568 | let mut shown_text = masked(text); |
| 569 | let signal = ui.signal(id); |
| 570 | let pad = spec.pad[0]; |
| 571 | let size = ui.theme.font_size; |
| 572 | let modifiers = edit_modifiers(ui.modifiers()); |
| 573 | let pointer = ui |
| 574 | .pointer() |
| 575 | .zip(ui.laid_out(id)) |
| 576 | .map(|(pointer, rect)| pointer[0] - rect[0] - pad) |
| 577 | .filter(|_| signal.pressed || signal.dragging); |
| 578 | let state = ui.state(id); |
| 579 | let (mut selection, mut press, select) = (state.selection, state.press, state.select.take()); |
| 580 | let before = [selection.anchor(), selection.focus()]; |
| 581 | let (texts, frame) = (&mut ui.texts, ui.frame); |
| 582 | let mut label = texts.label(&shown_text, size, frame); |
| 583 | if let Some(x) = pointer { |
| 584 | let layout = &label.layout; |
| 585 | let unit = if signal.pressed { signal.unit } else { press.1 }; |
| 586 | let hit = Cursor::from_point(layout, x, label.size[1] / 2.0); |
| 587 | let target = edit::selection_at(layout, hit, x, unit); |
| 588 | selection = if signal.pressed { |
| 589 | let pressed = if modifiers.shift { |
| 590 | Selection::new(selection.anchor(), target.focus()) |
| 591 | } else { |
| 592 | target |
| 593 | }; |
| 594 | press = (pressed, unit); |
| 595 | pressed |
| 596 | } else { |
| 597 | let [anchor, focus] = edit::drag( |
| 598 | [press.0.anchor(), press.0.focus()], |
| 599 | [target.anchor(), target.focus()], |
| 600 | unit, |
| 601 | |cursor| cursor.index(), |
| 602 | ); |
| 603 | Selection::new(anchor, focus) |
| 604 | }; |
| 605 | } |
| 606 | selection = if let Some([anchor, focus]) = select { |
| 607 | Selection::new( |
| 608 | Cursor::from_byte_index(&label.layout, anchor, Affinity::Downstream), |
| 609 | Cursor::from_byte_index(&label.layout, focus, Affinity::Upstream), |
| 610 | ) |
| 611 | } else { |
| 612 | selection.refresh(&label.layout) |
| 613 | }; |
| 614 | for event in &signal.events { |
| 615 | let layout = &label.layout; |
| 616 | let (range, inserted) = match event { |
| 617 | Event::Ime(Ime::Commit(committed)) => (selection.text_range(), committed.as_str()), |
| 618 | Event::Key { key, text: typed } => { |
| 619 | let key = edit_key(key); |
| 620 | // One line has no break for Control-K to join, nor room for Home, End and the |
| 621 | // page keys to scroll. |
| 622 | let command = match (Command::from_key(&key, modifiers), &key) { |
| 623 | (Some(Command::Kill), _) => Some(Command::DeleteTo(Movement::LineEnd)), |
| 624 | ( |
| 625 | Some( |
| 626 | Command::ScrollPage { .. } |
| 627 | | Command::MovePage { .. } |
| 628 | | Command::MoveParagraphs { .. }, |
| 629 | ), |
| 630 | _, |
| 631 | ) => None, |
| 632 | (None, edit::Key::Named(edit::NamedKey::Home)) => { |
| 633 | Some(Command::Move(Movement::DocumentStart)) |
| 634 | } |
| 635 | (None, edit::Key::Named(edit::NamedKey::End)) => { |
| 636 | Some(Command::Move(Movement::DocumentEnd)) |
| 637 | } |
| 638 | (command, _) => command, |
| 639 | }; |
| 640 | let collapsed = selection.is_collapsed(); |
| 641 | match (command, key) { |
| 642 | (Some(Command::Move(movement)), _) => { |
| 643 | selection = edit::step(layout, selection, movement, modifiers.shift); |
| 644 | continue; |
| 645 | } |
| 646 | (Some(Command::DeleteTo(movement)), _) if collapsed => ( |
| 647 | edit::step(layout, selection, movement, true).text_range(), |
| 648 | "", |
| 649 | ), |
| 650 | (Some(Command::Delete { backward }), _) if collapsed => { |
| 651 | let caret = selection.focus(); |
| 652 | let cluster = caret.logical_clusters(layout)[usize::from(!backward)]; |
| 653 | ( |
| 654 | cluster.map_or(caret.index()..caret.index(), |cluster| { |
| 655 | cluster.text_range() |
| 656 | }), |
| 657 | "", |
| 658 | ) |
| 659 | } |
| 660 | (Some(_), _) => (selection.text_range(), ""), |
| 661 | (None, edit::Key::Character(character)) |
| 662 | if modifiers.command && character.eq_ignore_ascii_case("a") => |
| 663 | { |
| 664 | selection = Selection::new( |
| 665 | Cursor::from_byte_index(layout, 0, Affinity::Downstream), |
| 666 | Cursor::from_byte_index(layout, usize::MAX, Affinity::Upstream), |
| 667 | ); |
| 668 | continue; |
| 669 | } |
| 670 | (None, _) => match typed.as_deref().filter(|typed| { |
| 671 | !modifiers.command |
| 672 | && !modifiers.control |
| 673 | && !typed.is_empty() |
| 674 | && !typed.chars().any(char::is_control) |
| 675 | }) { |
| 676 | Some(typed) => (selection.text_range(), typed), |
| 677 | None => continue, |
| 678 | }, |
| 679 | } |
| 680 | } |
| 681 | _ => continue, |
| 682 | }; |
| 683 | let range = to_text(text, range.start)..to_text(text, range.end); |
| 684 | text.replace_range(range.clone(), inserted); |
| 685 | shown_text = masked(text); |
| 686 | label = texts.label(&shown_text, size, frame); |
| 687 | selection = Selection::from_byte_index( |
| 688 | &label.layout, |
| 689 | to_shown(text, range.start + inserted.len()), |
| 690 | Affinity::Downstream, |
| 691 | ); |
| 692 | } |
| 693 | let moved = signal.pressed || before != [selection.anchor(), selection.focus()]; |
| 694 | let state = ui.state(id); |
| 695 | state.selection = selection; |
| 696 | state.press = press; |
| 697 | let theme = ui.theme.clone(); |
| 698 | let shown = if text.is_empty() { |
| 699 | placeholder |
| 700 | } else { |
| 701 | shown_text.as_str() |
| 702 | }; |
| 703 | let height = spec.size[1]; |
| 704 | let backdrop = spec.fill.unwrap_or(theme.base); |
| 705 | ui.open_as( |
| 706 | id, |
| 707 | Spec { |
| 708 | flags: spec.flags | Flags::CLICKABLE | Flags::FOCUSABLE, |
| 709 | text: Some(shown), |
| 710 | color: Some(if text.is_empty() { |
| 711 | theme.text_dim |
| 712 | } else { |
| 713 | theme.text |
| 714 | }), |
| 715 | cursor: Some(CursorIcon::Text), |
| 716 | role: spec.role.or(Some(if secret { |
| 717 | Role::PasswordInput |
| 718 | } else { |
| 719 | Role::TextInput |
| 720 | })), |
| 721 | ..spec |
| 722 | }, |
| 723 | ); |
| 724 | if let Some(node) = ui.access(id) { |
| 725 | node.set_value(shown_text.as_str()); |
| 726 | if !placeholder.is_empty() { |
| 727 | node.set_placeholder(placeholder); |
| 728 | } |
| 729 | } |
| 730 | if signal.focused { |
| 731 | let line = label.size[1]; |
| 732 | let top = match height.size { |
| 733 | crate::Size::Pixels(height) => (height - line) / 2.0, |
| 734 | _ => 0.0, |
| 735 | }; |
| 736 | let fill = if ui.window_focused { |
| 737 | theme.selection |
| 738 | } else { |
| 739 | theme.inactive_selection |
| 740 | }; |
| 741 | for (rect, _) in selection.geometry(&label.layout) { |
| 742 | ui.mark( |
| 743 | [pad + rect.x0 as f32, top, pad + rect.x1 as f32, top + line], |
| 744 | fill, |
| 745 | 0.0, |
| 746 | ); |
| 747 | } |
| 748 | if ui.window_focused && selection.is_collapsed() { |
| 749 | let opacity = ui.blink(id, moved); |
| 750 | let x = pad + selection.focus().geometry(&label.layout, 0.0).x0 as f32; |
| 751 | let half = edit::CARET_WIDTH / 2.0; |
| 752 | ui.mark( |
| 753 | [x - half, top, x + half, top + line], |
| 754 | edit::caret_color(theme.caret, backdrop, opacity), |
| 755 | half, |
| 756 | ); |
| 757 | } |
| 758 | } |
| 759 | ui.close(); |
| 760 | signal |
| 761 | } |
| 762 | |
| 763 | /// A winit key in the editing vocabulary text fields and the page share. |
| 764 | pub fn edit_key(key: &Key) -> edit::Key { |
| 765 | use edit::NamedKey as Edit; |
| 766 | match key { |
| 767 | Key::Character(text) => edit::Key::Character(text.to_string()), |
| 768 | Key::Named(named) => edit::Key::Named(match named { |
| 769 | NamedKey::Escape => Edit::Escape, |
| 770 | NamedKey::Tab => Edit::Tab, |
| 771 | NamedKey::Space => Edit::Space, |
| 772 | NamedKey::Enter => Edit::Enter, |
| 773 | NamedKey::Backspace => Edit::Backspace, |
| 774 | NamedKey::Delete => Edit::Delete, |
| 775 | NamedKey::ArrowLeft => Edit::ArrowLeft, |
| 776 | NamedKey::ArrowRight => Edit::ArrowRight, |
| 777 | NamedKey::ArrowUp => Edit::ArrowUp, |
| 778 | NamedKey::ArrowDown => Edit::ArrowDown, |
| 779 | NamedKey::Home => Edit::Home, |
| 780 | NamedKey::End => Edit::End, |
| 781 | NamedKey::PageUp => Edit::PageUp, |
| 782 | NamedKey::PageDown => Edit::PageDown, |
| 783 | NamedKey::F7 => Edit::F7, |
| 784 | NamedKey::F11 => Edit::F11, |
| 785 | NamedKey::Alt |
| 786 | | NamedKey::AltGraph |
| 787 | | NamedKey::Control |
| 788 | | NamedKey::Shift |
| 789 | | NamedKey::Super |
| 790 | | NamedKey::Meta => Edit::Modifier, |
| 791 | _ => Edit::Other, |
| 792 | }), |
| 793 | _ => edit::Key::Named(Edit::Other), |
| 794 | } |
| 795 | } |
| 796 | |
| 797 | pub fn edit_modifiers(modifiers: ModifiersState) -> edit::Modifiers { |
| 798 | edit::Modifiers { |
| 799 | shift: modifiers.shift_key(), |
| 800 | control: modifiers.control_key(), |
| 801 | option: modifiers.alt_key(), |
| 802 | command: if edit::Platform::CURRENT == edit::Platform::MacOs { |
| 803 | modifiers.super_key() |
| 804 | } else { |
| 805 | modifiers.control_key() |
| 806 | }, |
| 807 | } |
| 808 | } |