Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): file picker dialog widget

Lewis: May this revision serve well! <lu5a@proton.me>

+353
+353
crates/bone-ui/src/widgets/file_picker.rs
··· 1 + use crate::frame::FrameCtx; 2 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 3 + use crate::strings::StringKey; 4 + use crate::theme::Step12; 5 + use crate::widget_id::{WidgetId, WidgetKey}; 6 + 7 + use super::dialog::{Dialog, DialogButton, show_dialog}; 8 + use super::paint::{LabelText, WidgetPaint}; 9 + use super::table::{ListItem, ListView, ListViewState, show_list_view}; 10 + use super::text_input::{AlwaysValid, MemoryClipboard, TextInput, TextInputState, show_text_input}; 11 + 12 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 13 + pub enum FilePickerMode { 14 + Open, 15 + Save, 16 + } 17 + 18 + #[derive(Copy, Clone, Debug, PartialEq)] 19 + pub struct FilePickerEntry { 20 + pub id: WidgetId, 21 + pub label: StringKey, 22 + } 23 + 24 + #[derive(Clone, Debug, Default, PartialEq)] 25 + pub struct FilePickerState { 26 + pub list: ListViewState, 27 + pub filename: TextInputState, 28 + pub clipboard: MemoryClipboard, 29 + } 30 + 31 + #[derive(Debug, PartialEq)] 32 + pub struct FilePickerDialog<'a, 'state> { 33 + pub id: WidgetId, 34 + pub viewport: LayoutRect, 35 + pub size: LayoutSize, 36 + pub mode: FilePickerMode, 37 + pub current_path: StringKey, 38 + pub entries: &'a [FilePickerEntry], 39 + pub state: &'state mut FilePickerState, 40 + pub title: StringKey, 41 + } 42 + 43 + impl<'a, 'state> FilePickerDialog<'a, 'state> { 44 + #[must_use] 45 + pub fn new( 46 + id: WidgetId, 47 + viewport: LayoutRect, 48 + mode: FilePickerMode, 49 + current_path: StringKey, 50 + entries: &'a [FilePickerEntry], 51 + title: StringKey, 52 + state: &'state mut FilePickerState, 53 + ) -> Self { 54 + Self { 55 + id, 56 + viewport, 57 + size: LayoutSize::new(LayoutPx::new(540.0), LayoutPx::new(420.0)), 58 + mode, 59 + current_path, 60 + entries, 61 + state, 62 + title, 63 + } 64 + } 65 + } 66 + 67 + #[derive(Clone, Debug, PartialEq)] 68 + pub enum FilePickerOutcome { 69 + Cancelled, 70 + Open { 71 + folder: WidgetId, 72 + }, 73 + Save { 74 + folder: Option<WidgetId>, 75 + filename: String, 76 + }, 77 + } 78 + 79 + #[derive(Clone, Debug, PartialEq)] 80 + pub struct FilePickerResponse { 81 + pub outcome: Option<FilePickerOutcome>, 82 + pub navigated_to: Option<WidgetId>, 83 + pub paint: Vec<WidgetPaint>, 84 + } 85 + 86 + #[must_use] 87 + pub fn show_file_picker( 88 + ctx: &mut FrameCtx<'_>, 89 + picker: FilePickerDialog<'_, '_>, 90 + ) -> FilePickerResponse { 91 + let FilePickerDialog { 92 + id, 93 + viewport, 94 + size, 95 + mode, 96 + current_path, 97 + entries, 98 + state, 99 + title, 100 + } = picker; 101 + let confirm_id = id.child(WidgetKey::new("confirm")); 102 + let cancel_id = id.child(WidgetKey::new("cancel")); 103 + let confirm_label = match mode { 104 + FilePickerMode::Open => StringKey::new("file_picker.open"), 105 + FilePickerMode::Save => StringKey::new("file_picker.save"), 106 + }; 107 + let buttons = [ 108 + DialogButton::secondary(cancel_id, StringKey::new("file_picker.cancel")), 109 + DialogButton::primary(confirm_id, confirm_label), 110 + ]; 111 + let list_items: Vec<ListItem> = entries 112 + .iter() 113 + .map(|e| ListItem { 114 + id: e.id, 115 + label: e.label, 116 + }) 117 + .collect(); 118 + let (response, navigated_to) = show_dialog( 119 + ctx, 120 + Dialog::new(id, viewport, size, title, &buttons), 121 + |ctx, body_rect, paint| { 122 + paint.push(WidgetPaint::Label { 123 + rect: path_label_rect(body_rect), 124 + text: LabelText::Key(current_path), 125 + color: ctx.theme.colors.text_secondary(), 126 + role: ctx.theme.typography.caption, 127 + }); 128 + let list_rect = list_rect_for(body_rect, mode); 129 + paint.push(WidgetPaint::Surface { 130 + rect: list_rect, 131 + fill: ctx.theme.colors.surface(crate::theme::SurfaceLevel::L0), 132 + border: Some(crate::theme::Border { 133 + width: crate::theme::StrokeWidth::HAIRLINE, 134 + color: ctx.theme.colors.neutral.step(Step12::SUBTLE_BORDER), 135 + }), 136 + radius: ctx.theme.radius.sm, 137 + elevation: None, 138 + }); 139 + let list_response = show_list_view( 140 + ctx, 141 + ListView::new( 142 + id.child(WidgetKey::new("list")), 143 + list_rect, 144 + &list_items, 145 + &mut state.list, 146 + ), 147 + ); 148 + paint.extend(list_response.paint); 149 + if mode == FilePickerMode::Save { 150 + let widget = TextInput { 151 + id: id.child(WidgetKey::new("filename")), 152 + rect: filename_rect(body_rect), 153 + placeholder: StringKey::new("file_picker.filename_placeholder"), 154 + state: &mut state.filename, 155 + disabled: false, 156 + validator: AlwaysValid, 157 + }; 158 + let text_response = show_text_input(ctx, widget, &mut state.clipboard); 159 + paint.extend(text_response.paint); 160 + } 161 + list_response.activated 162 + }, 163 + ); 164 + let outcome = if response.dismissed || response.activated == Some(cancel_id) { 165 + Some(FilePickerOutcome::Cancelled) 166 + } else if response.activated == Some(confirm_id) { 167 + match mode { 168 + FilePickerMode::Open => navigated_to 169 + .or(state.list.focused) 170 + .map(|folder| FilePickerOutcome::Open { folder }), 171 + FilePickerMode::Save => Some(FilePickerOutcome::Save { 172 + folder: state.list.focused, 173 + filename: state.filename.text.clone(), 174 + }), 175 + } 176 + } else { 177 + None 178 + }; 179 + FilePickerResponse { 180 + outcome, 181 + navigated_to, 182 + paint: response.paint, 183 + } 184 + } 185 + 186 + fn path_label_rect(body: LayoutRect) -> LayoutRect { 187 + LayoutRect::new( 188 + LayoutPos::new( 189 + LayoutPx::new(body.origin.x.value() + 16.0), 190 + LayoutPx::new(body.origin.y.value() + 8.0), 191 + ), 192 + LayoutSize::new( 193 + LayoutPx::saturating_nonneg(body.size.width.value() - 32.0), 194 + LayoutPx::new(20.0), 195 + ), 196 + ) 197 + } 198 + 199 + fn list_rect_for(body: LayoutRect, mode: FilePickerMode) -> LayoutRect { 200 + let list_height = match mode { 201 + FilePickerMode::Open => body.size.height.value() - 36.0, 202 + FilePickerMode::Save => body.size.height.value() - 80.0, 203 + } 204 + .max(0.0); 205 + LayoutRect::new( 206 + LayoutPos::new( 207 + LayoutPx::new(body.origin.x.value() + 16.0), 208 + LayoutPx::new(body.origin.y.value() + 32.0), 209 + ), 210 + LayoutSize::new( 211 + LayoutPx::saturating_nonneg(body.size.width.value() - 32.0), 212 + LayoutPx::new(list_height), 213 + ), 214 + ) 215 + } 216 + 217 + fn filename_rect(body: LayoutRect) -> LayoutRect { 218 + LayoutRect::new( 219 + LayoutPos::new( 220 + LayoutPx::new(body.origin.x.value() + 16.0), 221 + LayoutPx::new(body.origin.y.value() + body.size.height.value() - 36.0), 222 + ), 223 + LayoutSize::new( 224 + LayoutPx::saturating_nonneg(body.size.width.value() - 32.0), 225 + LayoutPx::new(28.0), 226 + ), 227 + ) 228 + } 229 + 230 + #[cfg(test)] 231 + mod tests { 232 + use std::sync::Arc; 233 + 234 + use super::{ 235 + FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerOutcome, FilePickerState, 236 + show_file_picker, 237 + }; 238 + use crate::focus::FocusManager; 239 + use crate::frame::FrameCtx; 240 + use crate::hit_test::{HitFrame, HitState, resolve}; 241 + use crate::hotkey::HotkeyTable; 242 + use crate::input::{FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey}; 243 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 244 + use crate::strings::{StringKey, StringTable}; 245 + use crate::theme::Theme; 246 + use crate::widget_id::{WidgetId, WidgetKey}; 247 + 248 + fn picker_id() -> WidgetId { 249 + WidgetId::ROOT.child(WidgetKey::new("picker")) 250 + } 251 + 252 + fn entries() -> Vec<FilePickerEntry> { 253 + vec![ 254 + FilePickerEntry { 255 + id: picker_id().child(WidgetKey::new("doc1")), 256 + label: StringKey::new("doc.first"), 257 + }, 258 + FilePickerEntry { 259 + id: picker_id().child(WidgetKey::new("doc2")), 260 + label: StringKey::new("doc.second"), 261 + }, 262 + ] 263 + } 264 + 265 + fn viewport() -> LayoutRect { 266 + LayoutRect::new( 267 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 268 + LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(600.0)), 269 + ) 270 + } 271 + 272 + #[test] 273 + fn escape_cancels_picker() { 274 + let entries = entries(); 275 + let mut state = FilePickerState::default(); 276 + let theme = Arc::new(Theme::light()); 277 + let table = HotkeyTable::new(); 278 + let mut focus = FocusManager::new(); 279 + let mut hits = HitFrame::new(); 280 + let prev = HitState::new(); 281 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 282 + snap.keys_pressed.push(KeyEvent::new( 283 + KeyCode::Named(NamedKey::Escape), 284 + ModifierMask::NONE, 285 + )); 286 + let response = { 287 + let mut ctx = FrameCtx::new( 288 + theme, 289 + &mut snap, 290 + &mut focus, 291 + &table, 292 + StringTable::empty(), 293 + &mut hits, 294 + &prev, 295 + ); 296 + show_file_picker( 297 + &mut ctx, 298 + FilePickerDialog::new( 299 + picker_id(), 300 + viewport(), 301 + FilePickerMode::Open, 302 + StringKey::new("path.home"), 303 + &entries, 304 + StringKey::new("file_picker.title"), 305 + &mut state, 306 + ), 307 + ) 308 + }; 309 + assert_eq!(response.outcome, Some(FilePickerOutcome::Cancelled)); 310 + } 311 + 312 + #[test] 313 + fn save_mode_paints_filename_input_above_buttons() { 314 + let entries = entries(); 315 + let mut state = FilePickerState::default(); 316 + let theme = Arc::new(Theme::light()); 317 + let table = HotkeyTable::new(); 318 + let mut focus = FocusManager::new(); 319 + let mut hits = HitFrame::new(); 320 + let prev = HitState::new(); 321 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 322 + let response = { 323 + let mut ctx = FrameCtx::new( 324 + theme, 325 + &mut snap, 326 + &mut focus, 327 + &table, 328 + StringTable::empty(), 329 + &mut hits, 330 + &prev, 331 + ); 332 + show_file_picker( 333 + &mut ctx, 334 + FilePickerDialog::new( 335 + picker_id(), 336 + viewport(), 337 + FilePickerMode::Save, 338 + StringKey::new("path.home"), 339 + &entries, 340 + StringKey::new("file_picker.title"), 341 + &mut state, 342 + ), 343 + ) 344 + }; 345 + let _next = resolve(&prev, &hits, &snap, focus.focused()); 346 + let label_count = response 347 + .paint 348 + .iter() 349 + .filter(|p| matches!(p, super::WidgetPaint::Label { .. })) 350 + .count(); 351 + assert!(label_count >= 2); 352 + } 353 + }