| ... | ... | @@ -0,0 +1,351 @@ |
| 1 | //! Snowbound's own message, question and entry dialogs, for desktops with neither zenity nor |
| 2 | //! kdialog, as GNOME's image-based systems ship: the app runs itself as the dialog, which |
| 3 | //! answers on stdout and exits successfully once accepted. |
| 4 | |
| 5 | use draw::Renderer; |
| 6 | use std::{error::Error, ffi::OsString, process::Command, sync::Arc, time::Instant}; |
| 7 | use ui::{Axis, Id, Spec, Ui, children, fill, fit, px}; |
| 8 | use winit::{ |
| 9 | application::ApplicationHandler, |
| 10 | dpi::LogicalSize, |
| 11 | event::{ElementState, WindowEvent}, |
| 12 | event_loop::{ActiveEventLoop, EventLoop}, |
| 13 | keyboard::{Key, NamedKey}, |
| 14 | window::{Window, WindowId}, |
| 15 | }; |
| 16 | |
| 17 | /// The argument that runs the app as a dialog. |
| 18 | pub const DIALOG: &str = "--dialog"; |
| 19 | |
| 20 | pub enum Ask<'a> { |
| 21 | /// A message with an OK button. |
| 22 | Message, |
| 23 | /// Whether to go ahead: buttons for `cancel` and for `action`. |
| 24 | Question { cancel: &'a str, action: &'a str }, |
| 25 | /// A line of text, starting as `value`. |
| 26 | Entry { value: &'a str }, |
| 27 | /// A user name, starting as `user`, and a password, answered as zenity does: `user|password`. |
| 28 | Login { user: &'a str }, |
| 29 | } |
| 30 | |
| 31 | /// Shows `title` and `detail` as `ask` asks, then waits: the answer, empty but for an entry, |
| 32 | /// or None when cancelled. |
| 33 | pub fn ask(title: &str, detail: &str, ask: Ask) -> Option<String> { |
| 34 | let (kind, extra): (&str, &[&str]) = match &ask { |
| 35 | Ask::Message => ("message", &[]), |
| 36 | Ask::Question { cancel, action } => ("question", &[cancel, action]), |
| 37 | Ask::Entry { value } => ("entry", &[value]), |
| 38 | Ask::Login { user } => ("login", &[user]), |
| 39 | }; |
| 40 | let output = crate::loader::executable() |
| 41 | .and_then(|exe| { |
| 42 | Command::new(exe) |
| 43 | .args([DIALOG, kind, title, detail]) |
| 44 | .args(extra) |
| 45 | .stderr(std::process::Stdio::inherit()) |
| 46 | .output() |
| 47 | }) |
| 48 | .inspect_err(|error| eprintln!("Cannot show {title:?}: {error}")) |
| 49 | .ok()?; |
| 50 | output |
| 51 | .status |
| 52 | .success() |
| 53 | .then(|| String::from_utf8_lossy(&output.stdout).into_owned()) |
| 54 | } |
| 55 | |
| 56 | /// The dialog `ask` would show, given the arguments after `DIALOG`. |
| 57 | pub fn run(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn Error>> { |
| 58 | let mut next = || { |
| 59 | args.next() |
| 60 | .and_then(|arg| arg.into_string().ok()) |
| 61 | .unwrap_or_default() |
| 62 | }; |
| 63 | let [kind, title, detail, first, second] = [(); 5].map(|()| next()); |
| 64 | let mut dialog = Dialog { |
| 65 | kind, |
| 66 | title, |
| 67 | detail, |
| 68 | buttons: [first, second], |
| 69 | fields: Default::default(), |
| 70 | shown: None, |
| 71 | accepted: false, |
| 72 | }; |
| 73 | if dialog.kind == "entry" || dialog.kind == "login" { |
| 74 | dialog.fields[0] = std::mem::take(&mut dialog.buttons[0]); |
| 75 | } |
| 76 | EventLoop::new()?.run_app(&mut dialog)?; |
| 77 | if !dialog.accepted { |
| 78 | std::process::exit(1); |
| 79 | } |
| 80 | let [first, second] = &dialog.fields; |
| 81 | match dialog.kind.as_str() { |
| 82 | "entry" => print!("{first}"), |
| 83 | "login" => print!("{first}|{second}"), |
| 84 | _ => {} |
| 85 | } |
| 86 | Ok(()) |
| 87 | } |
| 88 | |
| 89 | struct Dialog { |
| 90 | kind: String, |
| 91 | title: String, |
| 92 | detail: String, |
| 93 | /// A question's cancel and action labels. |
| 94 | buttons: [String; 2], |
| 95 | /// An entry's text, or a login's user name and password. |
| 96 | fields: [String; 2], |
| 97 | shown: Option<Shown>, |
| 98 | accepted: bool, |
| 99 | } |
| 100 | |
| 101 | struct Shown { |
| 102 | window: Arc<Window>, |
| 103 | surface: crate::surface::Surface, |
| 104 | renderer: Renderer, |
| 105 | ui: Ui, |
| 106 | } |
| 107 | |
| 108 | fn field(index: usize) -> Id { |
| 109 | Id::ROOT.child(("field", index)) |
| 110 | } |
| 111 | |
| 112 | impl Dialog { |
| 113 | fn finish(&mut self, event_loop: &ActiveEventLoop, accepted: bool) { |
| 114 | self.accepted = accepted; |
| 115 | // The window's resources end while the event loop still holds its display. |
| 116 | self.shown = None; |
| 117 | event_loop.exit(); |
| 118 | } |
| 119 | |
| 120 | /// Lays out and paints the dialog; Some once a button answers it. |
| 121 | fn frame(&mut self) -> Result<Option<bool>, Box<dyn Error>> { |
| 122 | let Some(shown) = &mut self.shown else { |
| 123 | return Ok(None); |
| 124 | }; |
| 125 | let ui = &mut shown.ui; |
| 126 | let scale = shown.window.scale_factor() as f32; |
| 127 | let size = shown.window.inner_size(); |
| 128 | ui.begin( |
| 129 | [size.width as f32 / scale, size.height as f32 / scale], |
| 130 | scale, |
| 131 | Instant::now(), |
| 132 | ); |
| 133 | let theme = ui.theme.clone(); |
| 134 | ui.open( |
| 135 | "dialog", |
| 136 | Spec { |
| 137 | axis: Axis::Y, |
| 138 | size: [fill(), fill()], |
| 139 | fill: Some(theme.popup), |
| 140 | pad: [20.0, 16.0], |
| 141 | gap: 10.0, |
| 142 | role: Some(accesskit::Role::Dialog), |
| 143 | ..Spec::default() |
| 144 | }, |
| 145 | ); |
| 146 | let text = |ui: &mut Ui, part, text: &str, bold| { |
| 147 | ui.leaf( |
| 148 | part, |
| 149 | Spec { |
| 150 | size: [fill(), fit()], |
| 151 | text: Some(text), |
| 152 | bold, |
| 153 | overflow: ui::Overflow::Wrap, |
| 154 | ..Spec::default() |
| 155 | }, |
| 156 | ); |
| 157 | }; |
| 158 | text(ui, "title", &self.title, true); |
| 159 | if !self.detail.is_empty() { |
| 160 | text(ui, "detail", &self.detail, false); |
| 161 | } |
| 162 | let entry = Spec { |
| 163 | size: [fill(), px(theme.font_size * 2.0)], |
| 164 | fill: Some(theme.base), |
| 165 | border: Some(theme.accent), |
| 166 | radius: 4.0, |
| 167 | pad: [6.0, 0.0], |
| 168 | ..Spec::default() |
| 169 | }; |
| 170 | let fields = match self.kind.as_str() { |
| 171 | "entry" => 1, |
| 172 | "login" => 2, |
| 173 | _ => 0, |
| 174 | }; |
| 175 | if fields > 0 && ui.focused().is_none() { |
| 176 | ui.set_focus(Some(field(0))); |
| 177 | } |
| 178 | for (index, value) in self.fields.iter_mut().take(fields).enumerate() { |
| 179 | if index == 1 { |
| 180 | text(ui, "password label", "Password:", false); |
| 181 | ui::password_field(ui, field(index), value, "", entry.clone()); |
| 182 | } else { |
| 183 | ui::text_field(ui, field(index), value, "", entry.clone()); |
| 184 | } |
| 185 | } |
| 186 | ui.leaf( |
| 187 | "space", |
| 188 | Spec { |
| 189 | size: [fill(), fill()], |
| 190 | ..Spec::default() |
| 191 | }, |
| 192 | ); |
| 193 | ui.open( |
| 194 | "buttons", |
| 195 | Spec { |
| 196 | size: [fill(), children()], |
| 197 | gap: 8.0, |
| 198 | ..Spec::default() |
| 199 | }, |
| 200 | ); |
| 201 | ui.leaf( |
| 202 | "space", |
| 203 | Spec { |
| 204 | size: [fill(), px(0.0)], |
| 205 | ..Spec::default() |
| 206 | }, |
| 207 | ); |
| 208 | let [cancel, action] = match self.kind.as_str() { |
| 209 | "question" => [self.buttons[0].as_str(), self.buttons[1].as_str()], |
| 210 | "message" => ["", "OK"], |
| 211 | _ => ["Cancel", "OK"], |
| 212 | }; |
| 213 | let mut answer = None; |
| 214 | if !cancel.is_empty() && ui::button(ui, "cancel", cancel).clicked { |
| 215 | answer = Some(false); |
| 216 | } |
| 217 | if ui::button(ui, "action", action).clicked { |
| 218 | answer = Some(true); |
| 219 | } |
| 220 | ui.close(); |
| 221 | ui.close(); |
| 222 | ui.end(); |
| 223 | shown.window.set_cursor(ui.cursor().unwrap_or_default()); |
| 224 | if let Some(frame) = shown.surface.frame(&shown.renderer)? { |
| 225 | let interface = ui.layers(); |
| 226 | let layers: Vec<_> = interface |
| 227 | .iter() |
| 228 | .filter_map(|layer| match layer { |
| 229 | ui::Layer::Primitives(primitives) => Some(primitives.layer(scale)), |
| 230 | ui::Layer::Custom { .. } => None, |
| 231 | }) |
| 232 | .collect(); |
| 233 | shown |
| 234 | .renderer |
| 235 | .draw(&frame.target, shown.surface.size, theme.popup, &layers) |
| 236 | .map_err(|error| format!("Dialog drawing failed: {error:?}"))?; |
| 237 | shown.surface.present(&shown.renderer, frame); |
| 238 | } |
| 239 | if ui.wants_frame() { |
| 240 | shown.window.request_redraw(); |
| 241 | } |
| 242 | Ok(answer) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | impl ApplicationHandler for Dialog { |
| 247 | fn resumed(&mut self, event_loop: &ActiveEventLoop) { |
| 248 | if self.shown.is_some() { |
| 249 | return; |
| 250 | } |
| 251 | let height = match self.kind.as_str() { |
| 252 | "login" => 260.0, |
| 253 | "entry" => 200.0, |
| 254 | _ => 170.0, |
| 255 | }; |
| 256 | let shown = (|| -> Result<Shown, Box<dyn Error>> { |
| 257 | let window = Arc::new( |
| 258 | event_loop.create_window( |
| 259 | crate::platform::window_attributes() |
| 260 | .with_title(&self.title) |
| 261 | .with_inner_size(LogicalSize::new(440.0, height)) |
| 262 | .with_resizable(false), |
| 263 | )?, |
| 264 | ); |
| 265 | let (surface, renderer) = |
| 266 | pollster::block_on(crate::surface::Surface::new(window.clone(), false))?; |
| 267 | let appearance = crate::platform::appearance(&window); |
| 268 | window.set_theme(Some(appearance)); |
| 269 | let ui = Ui::new( |
| 270 | crate::theme(appearance, false, false), |
| 271 | crate::platform::double_click_interval(), |
| 272 | ); |
| 273 | Ok(Shown { |
| 274 | window, |
| 275 | surface, |
| 276 | renderer, |
| 277 | ui, |
| 278 | }) |
| 279 | })(); |
| 280 | match shown { |
| 281 | Ok(shown) => { |
| 282 | shown.window.request_redraw(); |
| 283 | self.shown = Some(shown); |
| 284 | } |
| 285 | Err(error) => { |
| 286 | eprintln!("Cannot show {:?}: {error}", self.title); |
| 287 | self.finish(event_loop, false); |
| 288 | } |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) { |
| 293 | let Some(shown) = &mut self.shown else { |
| 294 | return; |
| 295 | }; |
| 296 | let scale = shown.window.scale_factor() as f32; |
| 297 | let input = match event { |
| 298 | WindowEvent::CloseRequested => return self.finish(event_loop, false), |
| 299 | WindowEvent::RedrawRequested => { |
| 300 | match self.frame() { |
| 301 | Ok(Some(answer)) => self.finish(event_loop, answer), |
| 302 | Ok(None) => {} |
| 303 | Err(error) => { |
| 304 | eprintln!("{error}"); |
| 305 | self.finish(event_loop, false); |
| 306 | } |
| 307 | } |
| 308 | return; |
| 309 | } |
| 310 | WindowEvent::Resized(size) => { |
| 311 | shown.surface.size = [size.width, size.height]; |
| 312 | shown.surface.configure(&shown.renderer); |
| 313 | return shown.window.request_redraw(); |
| 314 | } |
| 315 | WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => { |
| 316 | match event.logical_key { |
| 317 | Key::Named(NamedKey::Enter) => return self.finish(event_loop, true), |
| 318 | Key::Named(NamedKey::Escape) => return self.finish(event_loop, false), |
| 319 | key => ui::Event::Key { |
| 320 | key, |
| 321 | text: event.text.map(|text| text.to_string()), |
| 322 | }, |
| 323 | } |
| 324 | } |
| 325 | WindowEvent::ModifiersChanged(modifiers) => ui::Event::Modifiers(modifiers.state()), |
| 326 | WindowEvent::Ime(ime) => ui::Event::Ime(ime), |
| 327 | WindowEvent::CursorMoved { position, .. } => { |
| 328 | ui::Event::PointerMoved([position.x as f32 / scale, position.y as f32 / scale]) |
| 329 | } |
| 330 | WindowEvent::CursorLeft { .. } => ui::Event::PointerLeft, |
| 331 | WindowEvent::MouseInput { state, button, .. } => { |
| 332 | // A press away from the controls moves the window, which has no title bar |
| 333 | // under winit's Adwaita frame. |
| 334 | if state == ElementState::Pressed |
| 335 | && shown.ui.cursor().is_none() |
| 336 | && let Err(error) = shown.window.drag_window() |
| 337 | { |
| 338 | eprintln!("{error}"); |
| 339 | } |
| 340 | ui::Event::Button { |
| 341 | button, |
| 342 | pressed: state == ElementState::Pressed, |
| 343 | at: Instant::now(), |
| 344 | } |
| 345 | } |
| 346 | _ => return, |
| 347 | }; |
| 348 | shown.ui.event(input); |
| 349 | shown.window.request_redraw(); |
| 350 | } |
| 351 | } |