From bbab5e238daff5efaefa6ed6cf5f3408aa5c2d95 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Sun, 10 May 2026 09:58:23 +0200 Subject: [PATCH] First commit. Version 0.1.0 --- .gitea/workflows/ci.yml | 96 + CONTRIBUTING.md | 136 ++ Cargo.toml | 46 + LICENSE | 673 ++++-- Makefile | 53 + README.md | 375 ++- SECURITY.md | 127 + TODO | 1 + benches/lookup.rs | 150 ++ code_style_guide.md | 169 ++ debian/cargo-checksum.json | 1 + debian/changelog | 5 + debian/control | 59 + debian/copyright | 198 ++ debian/ltk-theme-default.install | 1 + debian/rules | 19 + debian/source/format | 1 + docs/architecture.md | 247 ++ docs/cookbook.md | 825 +++++++ docs/onboarding.md | 385 ++++ docs/theming.md | 737 ++++++ docs/widgets.md | 851 +++++++ examples/combo.rs | 227 ++ examples/dialog.rs | 231 ++ examples/inputs.rs | 128 ++ examples/mini_shell.rs | 626 +++++ examples/pickers.rs | 166 ++ examples/scroll.rs | 154 ++ examples/showcase.rs | 252 ++ examples/sliders.rs | 155 ++ examples/widgets.rs | 217 ++ liberux.toml | 147 ++ locales/de.yaml | 26 + locales/en.yaml | 26 + locales/es.yaml | 26 + locales/fr.yaml | 26 + locales/it.yaml | 26 + locales/pt.yaml | 26 + locales/pt_BR.yaml | 26 + scripts/doctest-md.sh | 68 + src/app.rs | 737 ++++++ src/core.rs | 485 ++++ src/draw/chrome.rs | 102 + src/draw/damage.rs | 511 +++++ src/draw/gles.rs | 253 ++ src/draw/layout.rs | 379 +++ src/draw/mod.rs | 327 +++ src/draw/software.rs | 283 +++ src/egl_context.rs | 515 +++++ src/event_loop/app_data.rs | 2037 +++++++++++++++++ src/event_loop/handlers.rs | 495 ++++ src/event_loop/mod.rs | 814 +++++++ src/gles_render/clip.rs | 158 ++ src/gles_render/framebuffer.rs | 342 +++ src/gles_render/helpers.rs | 293 +++ src/gles_render/image.rs | 168 ++ src/gles_render/mod.rs | 387 ++++ src/gles_render/primitives.rs | 545 +++++ src/gles_render/raii.rs | 107 + src/gles_render/setup.rs | 657 ++++++ src/gles_render/shaders.rs | 812 +++++++ src/gles_render/text.rs | 176 ++ src/input/dispatch.rs | 251 ++ src/input/gesture.rs | 1070 +++++++++ src/input/keyboard.rs | 637 ++++++ src/input/mod.rs | 36 + src/input/pointer.rs | 495 ++++ src/input/touch.rs | 223 ++ src/layout/column.rs | 359 +++ src/layout/mod.rs | 47 + src/layout/row.rs | 299 +++ src/layout/spacer.rs | 143 ++ src/layout/stack.rs | 182 ++ src/layout/wrap_grid.rs | 347 +++ src/lib.rs | 429 ++++ src/render/clip.rs | 100 + src/render/helpers.rs | 112 + src/render/image.rs | 115 + src/render/mod.rs | 634 +++++ src/render/primitives.rs | 103 + src/render/setup.rs | 124 + src/render/text.rs | 203 ++ src/secure_mem.rs | 78 + src/system_fonts.rs | 133 ++ src/theme/document.rs | 111 + src/theme/fallback/Sora-LICENSE.txt | 134 ++ src/theme/fallback/Sora-Regular.otf | Bin 0 -> 50796 bytes src/theme/fallback/mod.rs | 112 + src/theme/font_registry.rs | 357 +++ src/theme/fonts.rs | 77 + src/theme/gradient_lut.rs | 304 +++ src/theme/mod.rs | 1103 +++++++++ src/theme/paint.rs | 207 ++ src/theme/schema.rs | 1449 ++++++++++++ src/theme/shadow.rs | 193 ++ src/theme/slots.rs | 345 +++ src/theme/surface.rs | 107 + src/theme/text_style.rs | 225 ++ src/tree.rs | 90 + src/types.rs | 432 ++++ src/wallpaper.rs | 255 +++ src/widget/anchored_overlay.rs | 124 + src/widget/button.rs | 553 +++++ src/widget/checkbox.rs | 217 ++ src/widget/color_picker.rs | 545 +++++ src/widget/combo.rs | 977 ++++++++ src/widget/container.rs | 374 +++ src/widget/date_picker.rs | 617 +++++ src/widget/dialog.rs | 451 ++++ src/widget/external.rs | 182 ++ src/widget/flex.rs | 138 ++ src/widget/image.rs | 217 ++ src/widget/list_item.rs | 268 +++ src/widget/mod.rs | 923 ++++++++ src/widget/notebook.rs | 249 ++ src/widget/pressable.rs | 296 +++ src/widget/progress_bar.rs | 143 ++ src/widget/radio.rs | 212 ++ src/widget/scroll.rs | 156 ++ src/widget/separator.rs | 137 ++ src/widget/slider.rs | 541 +++++ src/widget/spinner.rs | 223 ++ src/widget/tab_bar.rs | 208 ++ src/widget/text.rs | 384 ++++ src/widget/text_edit.rs | 2029 ++++++++++++++++ src/widget/time_picker.rs | 615 +++++ src/widget/toast.rs | 305 +++ src/widget/toggle.rs | 244 ++ src/widget/tooltip.rs | 247 ++ src/widget/viewport.rs | 183 ++ src/widget/vslider.rs | 486 ++++ src/widget/window_button.rs | 383 ++++ tests/animation.rs | 336 +++ tests/common/mod.rs | 63 + tests/core_surface.rs | 227 ++ tests/element_map.rs | 248 ++ tests/event_loop_flow.rs | 283 +++ tests/layout_stack_spacer.rs | 277 +++ tests/overlay_reconciliation.rs | 85 + tests/render_pixels.rs | 312 +++ tests/scroll.rs | 108 + tests/security_upload.rs | 112 + tests/slider_math.rs | 65 + tests/tab_navigation.rs | 107 + tests/text_edit_dispatch.rs | 207 ++ tests/theme_parsing.rs | 399 ++++ tests/tree_lookup.rs | 95 + tests/vslider_math.rs | 119 + tests/widget_dispatch.rs | 281 +++ themes/default/branding/dark/launcher.svg | 346 +++ themes/default/branding/dark/lockscreen.svg | 546 +++++ .../branding/dark/lockscreen/1280x720.webp | Bin 0 -> 16592 bytes .../branding/dark/lockscreen/1920x1080.webp | Bin 0 -> 28728 bytes .../branding/dark/lockscreen/3840x2160.webp | Bin 0 -> 75322 bytes .../default/branding/dark/logo/horizontal.svg | 72 + themes/default/branding/dark/logo/logo.svg | 50 + themes/default/branding/dark/logo/square.svg | 75 + themes/default/branding/dark/wallpaper.svg | 107 + .../branding/dark/wallpaper/1280x720.webp | Bin 0 -> 9774 bytes .../branding/dark/wallpaper/1920x1080.webp | Bin 0 -> 17510 bytes .../branding/dark/wallpaper/3840x2160.webp | Bin 0 -> 45398 bytes themes/default/branding/light/launcher.svg | 346 +++ themes/default/branding/light/lockscreen.svg | 349 +++ .../branding/light/lockscreen/1280x720.webp | Bin 0 -> 11106 bytes .../branding/light/lockscreen/1920x1080.webp | Bin 0 -> 20044 bytes .../branding/light/lockscreen/3840x2160.webp | Bin 0 -> 60008 bytes .../branding/light/logo/horizontal.svg | 25 + themes/default/branding/light/logo/logo.svg | 20 + themes/default/branding/light/logo/square.svg | 27 + themes/default/branding/light/wallpaper.svg | 68 + .../branding/light/wallpaper/1280x720.webp | Bin 0 -> 7862 bytes .../branding/light/wallpaper/1920x1080.webp | Bin 0 -> 14244 bytes .../branding/light/wallpaper/3840x2160.webp | Bin 0 -> 42758 bytes themes/default/icons/apps/appstore.svg | 48 + themes/default/icons/apps/calculator.svg | 206 ++ themes/default/icons/apps/calendar.svg | 14 + themes/default/icons/apps/camera.svg | 140 ++ themes/default/icons/apps/clock.svg | 199 ++ themes/default/icons/apps/contacts.svg | 279 +++ themes/default/icons/apps/default.svg | 9 + themes/default/icons/apps/drive.svg | 14 + themes/default/icons/apps/files.svg | 113 + themes/default/icons/apps/firefox.svg | 14 + themes/default/icons/apps/gallery.svg | 98 + themes/default/icons/apps/gimp.svg | 14 + themes/default/icons/apps/mail.svg | 14 + themes/default/icons/apps/maps.svg | 51 + themes/default/icons/apps/messaging.svg | 105 + themes/default/icons/apps/notes.svg | 207 ++ themes/default/icons/apps/paint.svg | 164 ++ themes/default/icons/apps/pdfviewer.svg | 133 ++ themes/default/icons/apps/phone.svg | 56 + themes/default/icons/apps/screen.svg | 93 + themes/default/icons/apps/settings.svg | 134 ++ themes/default/icons/apps/slides.svg | 105 + themes/default/icons/apps/spreadsheet.svg | 133 ++ themes/default/icons/apps/terminal.svg | 55 + themes/default/icons/apps/texteditor.svg | 47 + themes/default/icons/apps/trash.svg | 212 ++ themes/default/icons/apps/weather.svg | 117 + themes/default/icons/apps/wordprocessor.svg | 133 ++ themes/default/icons/catalogue/LICENSE.md | 70 + .../filled/accessibility/high-contrast.svg | 5 + .../filled/accessibility/languages.svg | 5 + .../filled/accessibility/magnifier.svg | 5 + .../filled/accessibility/subtitles-cc.svg | 5 + .../filled/accessibility/text-to-speech.svg | 5 + .../icons/catalogue/filled/actions/add.svg | 5 + .../icons/catalogue/filled/actions/copy.svg | 5 + .../icons/catalogue/filled/actions/cut.svg | 5 + .../catalogue/filled/actions/delete-full.svg | 5 + .../icons/catalogue/filled/actions/delete.svg | 5 + .../icons/catalogue/filled/actions/edit.svg | 5 + .../catalogue/filled/actions/favourite.svg | 5 + .../icons/catalogue/filled/actions/filter.svg | 5 + .../catalogue/filled/actions/invisible.svg | 5 + .../icons/catalogue/filled/actions/link.svg | 5 + .../icons/catalogue/filled/actions/paste.svg | 5 + .../icons/catalogue/filled/actions/redo.svg | 5 + .../icons/catalogue/filled/actions/remove.svg | 5 + .../icons/catalogue/filled/actions/save.svg | 5 + .../icons/catalogue/filled/actions/search.svg | 5 + .../catalogue/filled/actions/select-all.svg | 5 + .../icons/catalogue/filled/actions/share.svg | 5 + .../icons/catalogue/filled/actions/undo.svg | 5 + .../catalogue/filled/actions/visible.svg | 5 + .../icons/catalogue/filled/archives/audio.svg | 5 + .../catalogue/filled/archives/cloud-sync.svg | 5 + .../filled/archives/compressed-file.svg | 6 + .../filled/archives/document-text.svg | 6 + .../catalogue/filled/archives/document.svg | 5 + .../filled/archives/folder-downloads.svg | 5 + .../filled/archives/folder-empty.svg | 5 + .../filled/archives/folder-files.svg | 6 + .../filled/archives/folder-locked.svg | 6 + .../filled/archives/folder-music.svg | 5 + .../filled/archives/folder-photos.svg | 5 + .../filled/archives/folder-shared.svg | 5 + .../filled/archives/folder-videos.svg | 5 + .../icons/catalogue/filled/archives/image.svg | 5 + .../filled/archives/installation-file-app.svg | 5 + .../icons/catalogue/filled/archives/pdf.svg | 8 + .../filled/archives/presentation.svg | 5 + .../catalogue/filled/archives/spreadsheet.svg | 5 + .../icons/catalogue/filled/archives/video.svg | 5 + .../catalogue/filled/communication/attach.svg | 5 + .../filled/communication/contact-group.svg | 5 + .../filled/communication/contact.svg | 5 + .../filled/communication/dial-pad.svg | 5 + .../filled/communication/email-open.svg | 5 + .../catalogue/filled/communication/email.svg | 5 + .../filled/communication/message.svg | 5 + .../communication/notifications-status.svg | 5 + .../filled/communication/notifications.svg | 5 + .../filled/communication/phone-reject.svg | 5 + .../catalogue/filled/communication/phone.svg | 5 + .../catalogue/filled/communication/send.svg | 5 + .../filled/communication/video-call.svg | 5 + .../filled/communication/voice-notes.svg | 5 + .../filled/controls/slider-handle-circle.svg | 5 + .../filled/controls/slider-handle-square.svg | 5 + .../filled/customisation/colour-palette.svg | 5 + .../filled/customisation/equaliser.svg | 5 + .../filled/customisation/settings.svg | 5 + .../filled/customisation/wallpaper.svg | 5 + .../filled/energy/battery-charging.svg | 5 + .../filled/energy/battery-critical.svg | 5 + .../catalogue/filled/energy/battery-empty.svg | 5 + .../filled/energy/battery-fast-charging.svg | 5 + .../catalogue/filled/energy/battery-full.svg | 5 + .../catalogue/filled/energy/battery-low.svg | 5 + .../filled/energy/battery-medium.svg | 5 + .../filled/energy/battery-saving-mode.svg | 6 + .../catalogue/filled/energy/plugged-in.svg | 5 + .../catalogue/filled/features/app-store.svg | 5 + .../catalogue/filled/features/calculator.svg | 5 + .../catalogue/filled/features/calendar.svg | 5 + .../catalogue/filled/features/camera-app.svg | 5 + .../icons/catalogue/filled/features/clock.svg | 5 + .../catalogue/filled/features/console.svg | 5 + .../filled/features/gallery-photos.svg | 6 + .../catalogue/filled/features/maps-gps.svg | 5 + .../catalogue/filled/features/weather.svg | 5 + .../catalogue/filled/features/web-browser.svg | 5 + .../icons/catalogue/filled/feedback/error.svg | 5 + .../icons/catalogue/filled/feedback/help.svg | 5 + .../catalogue/filled/feedback/hourglass.svg | 5 + .../catalogue/filled/feedback/information.svg | 5 + .../catalogue/filled/feedback/loading.svg | 9 + .../icons/catalogue/filled/feedback/ok.svg | 5 + .../catalogue/filled/feedback/spinner.svg | 5 + .../catalogue/filled/feedback/success.svg | 5 + .../catalogue/filled/feedback/warning.svg | 5 + .../catalogue/filled/general/down-circle.svg | 5 + .../catalogue/filled/general/down-simple.svg | 5 + .../icons/catalogue/filled/general/down.svg | 5 + .../catalogue/filled/general/left-circle.svg | 5 + .../catalogue/filled/general/left-simple.svg | 5 + .../icons/catalogue/filled/general/left.svg | 5 + .../catalogue/filled/general/right-circle.svg | 5 + .../catalogue/filled/general/right-simple.svg | 5 + .../icons/catalogue/filled/general/right.svg | 5 + .../catalogue/filled/general/up-circle.svg | 5 + .../catalogue/filled/general/up-simple.svg | 5 + .../icons/catalogue/filled/general/up.svg | 5 + .../filled/hardware/brightness-maximum.svg | 5 + .../filled/hardware/brightness-minimum.svg | 5 + .../icons/catalogue/filled/hardware/chip.svg | 8 + .../icons/catalogue/filled/hardware/eject.svg | 5 + .../filled/hardware/external-hard-drive.svg | 5 + .../filled/hardware/external-monitor.svg | 5 + .../catalogue/filled/hardware/harddisk.svg | 8 + .../catalogue/filled/hardware/harddrive.svg | 8 + .../filled/hardware/headphones-bluetooth.svg | 5 + .../catalogue/filled/hardware/headphones.svg | 5 + .../catalogue/filled/hardware/keyboard.svg | 5 + .../catalogue/filled/hardware/light-mode.svg | 5 + .../filled/hardware/microphone-muted.svg | 5 + .../catalogue/filled/hardware/microphone.svg | 5 + .../icons/catalogue/filled/hardware/mouse.svg | 5 + .../catalogue/filled/hardware/night-mode.svg | 5 + .../catalogue/filled/hardware/printer.svg | 5 + .../filled/hardware/rotation-locked.svg | 6 + .../catalogue/filled/hardware/rotation.svg | 5 + .../catalogue/filled/hardware/scanner.svg | 5 + .../filled/hardware/screen-recording.svg | 5 + .../catalogue/filled/hardware/screenshot.svg | 5 + .../catalogue/filled/hardware/sdcard.svg | 8 + .../filled/hardware/speaker-high.svg | 5 + .../catalogue/filled/hardware/speaker-low.svg | 5 + .../filled/hardware/speaker-medium.svg | 5 + .../filled/hardware/speaker-mute.svg | 5 + .../icons/catalogue/filled/hardware/usb.svg | 8 + .../catalogue/filled/keyboard/assignment.svg | 5 + .../catalogue/filled/keyboard/backspace.svg | 5 + .../icons/catalogue/filled/keyboard/emoji.svg | 5 + .../icons/catalogue/filled/keyboard/enter.svg | 5 + .../icons/catalogue/filled/keyboard/gif.svg | 5 + .../icons/catalogue/filled/keyboard/shift.svg | 5 + .../catalogue/filled/keyboard/sticker.svg | 5 + .../catalogue/filled/keyboard/toggle-off.svg | 5 + .../catalogue/filled/keyboard/toggle-on.svg | 5 + .../catalogue/filled/multimedia/air-play.svg | 5 + .../catalogue/filled/multimedia/loop-one.svg | 6 + .../catalogue/filled/multimedia/loop.svg | 5 + .../catalogue/filled/multimedia/next.svg | 5 + .../catalogue/filled/multimedia/pause.svg | 5 + .../catalogue/filled/multimedia/play.svg | 5 + .../catalogue/filled/multimedia/previous.svg | 5 + .../catalogue/filled/multimedia/shuffle.svg | 5 + .../catalogue/filled/multimedia/stop.svg | 5 + .../filled/navigation/app-selector.svg | 5 + .../filled/navigation/close-small.svg | 6 + .../catalogue/filled/navigation/close.svg | 5 + .../filled/navigation/full-screen-exit.svg | 5 + .../filled/navigation/full-screen-expand.svg | 6 + .../catalogue/filled/navigation/home.svg | 5 + .../catalogue/filled/navigation/maximise.svg | 5 + .../filled/navigation/menu-burger.svg | 6 + .../filled/navigation/menu-kebab.svg | 5 + .../filled/navigation/menu-meatballs.svg | 5 + .../filled/navigation/menubar-back.svg | 5 + .../filled/navigation/menubar-home.svg | 5 + .../filled/navigation/menubar-multiarea.svg | 6 + .../catalogue/filled/navigation/minimise.svg | 5 + .../filled/navigation/pin-window.svg | 5 + .../catalogue/filled/navigation/restore.svg | 5 + .../filled/safety/administrator-shield.svg | 5 + .../icons/catalogue/filled/safety/face-id.svg | 5 + .../filled/safety/fingerprint-error.svg | 6 + .../filled/safety/fingerprint-success.svg | 5 + .../catalogue/filled/safety/fingerprint.svg | 5 + .../filled/safety/keyboard-access.svg | 5 + .../filled/safety/location-gps-active.svg | 5 + .../filled/safety/location-gps-scanning.svg | 5 + .../catalogue/filled/safety/lock-screen.svg | 5 + .../catalogue/filled/safety/microphone.svg | 5 + .../icons/catalogue/filled/safety/padlock.svg | 5 + .../catalogue/filled/safety/password.svg | 5 + .../filled/safety/safety-pattern.svg | 13 + .../safety/security-and-emergencies.svg | 5 + .../catalogue/filled/safety/unlocked.svg | 5 + .../icons/catalogue/filled/safety/webcam.svg | 5 + .../catalogue/filled/session/log-out.svg | 5 + .../icons/catalogue/filled/session/power.svg | 5 + .../catalogue/filled/session/restart.svg | 5 + .../icons/catalogue/filled/session/sleep.svg | 5 + .../catalogue/filled/session/switch-user.svg | 5 + .../icons/catalogue/filled/system/3g.svg | 6 + .../icons/catalogue/filled/system/4g.svg | 5 + .../icons/catalogue/filled/system/5g.svg | 5 + .../catalogue/filled/system/airplane-mode.svg | 5 + .../filled/system/bluetooth-connected.svg | 5 + .../filled/system/bluetooth-external.svg | 5 + .../filled/system/bluetooth-not-connected.svg | 5 + .../catalogue/filled/system/ethernet.svg | 5 + .../filled/system/hotspot-access-point.svg | 5 + .../icons/catalogue/filled/system/lte.svg | 5 + .../icons/catalogue/filled/system/nfc.svg | 5 + .../icons/catalogue/filled/system/roaming.svg | 6 + .../filled/system/signal-level-1.svg | 5 + .../filled/system/signal-level-2.svg | 5 + .../filled/system/signal-level-3.svg | 5 + .../catalogue/filled/system/signal-none.svg | 5 + .../catalogue/filled/system/sim-card.svg | 5 + .../icons/catalogue/filled/system/traffic.svg | 6 + .../catalogue/filled/system/vpn-connected.svg | 5 + .../catalogue/filled/system/wifi-disabled.svg | 5 + .../filled/system/wifi-signal-full.svg | 5 + .../filled/system/wifi-signal-low.svg | 5 + .../filled/system/wifi-signal-medium.svg | 5 + .../filled/system/wifi-signal-none.svg | 5 + .../system/wifi-without-an-internet.svg | 6 + .../filled/system/without-a-sim-card.svg | 6 + .../icons/catalogue/filled/window/close.svg | 3 + .../catalogue/filled/window/maximize.svg | 3 + .../catalogue/filled/window/minimize.svg | 3 + .../icons/catalogue/filled/window/restore.svg | 21 + .../line/accessibility/high-contrast.svg | 7 + .../line/accessibility/languages.svg | 5 + .../line/accessibility/magnifier.svg | 7 + .../line/accessibility/subtitles-cc.svg | 7 + .../line/accessibility/text-to-speech.svg | 5 + .../icons/catalogue/line/actions/add.svg | 5 + .../icons/catalogue/line/actions/copy.svg | 11 + .../icons/catalogue/line/actions/cut.svg | 7 + .../catalogue/line/actions/delete-full.svg | 7 + .../icons/catalogue/line/actions/delete.svg | 5 + .../icons/catalogue/line/actions/edit.svg | 5 + .../catalogue/line/actions/favourite.svg | 5 + .../icons/catalogue/line/actions/filter.svg | 5 + .../catalogue/line/actions/invisible.svg | 7 + .../icons/catalogue/line/actions/link.svg | 7 + .../icons/catalogue/line/actions/paste.svg | 6 + .../icons/catalogue/line/actions/redo.svg | 5 + .../icons/catalogue/line/actions/remove.svg | 5 + .../icons/catalogue/line/actions/save.svg | 6 + .../icons/catalogue/line/actions/search.svg | 5 + .../catalogue/line/actions/select-all.svg | 13 + .../icons/catalogue/line/actions/share.svg | 7 + .../icons/catalogue/line/actions/undo.svg | 5 + .../icons/catalogue/line/actions/visible.svg | 6 + .../icons/catalogue/line/archives/audio.svg | 6 + .../catalogue/line/archives/cloud-sync.svg | 7 + .../line/archives/compressed-file.svg | 12 + .../catalogue/line/archives/document-text.svg | 8 + .../catalogue/line/archives/document.svg | 5 + .../line/archives/folder-downloads.svg | 6 + .../catalogue/line/archives/folder-empty.svg | 5 + .../catalogue/line/archives/folder-files.svg | 8 + .../catalogue/line/archives/folder-locked.svg | 7 + .../catalogue/line/archives/folder-music.svg | 6 + .../catalogue/line/archives/folder-photos.svg | 6 + .../catalogue/line/archives/folder-shared.svg | 7 + .../catalogue/line/archives/folder-videos.svg | 6 + .../icons/catalogue/line/archives/image.svg | 6 + .../line/archives/installation-file-app.svg | 8 + .../icons/catalogue/line/archives/pdf.svg | 6 + .../catalogue/line/archives/presentation.svg | 5 + .../catalogue/line/archives/spreadsheet.svg | 5 + .../icons/catalogue/line/archives/video.svg | 5 + .../catalogue/line/communication/attach.svg | 5 + .../line/communication/contact-group.svg | 8 + .../catalogue/line/communication/contact.svg | 6 + .../catalogue/line/communication/dial-pad.svg | 12 + .../line/communication/email-open.svg | 7 + .../catalogue/line/communication/email.svg | 6 + .../catalogue/line/communication/message.svg | 8 + .../communication/notifications-status.svg | 7 + .../line/communication/notifications.svg | 8 + .../line/communication/phone-reject.svg | 6 + .../catalogue/line/communication/phone.svg | 5 + .../catalogue/line/communication/send.svg | 5 + .../line/communication/video-call.svg | 6 + .../line/communication/voice-notes.svg | 5 + .../line/controls/slider-handle-circle.svg | 7 + .../line/controls/slider-handle-square.svg | 7 + .../line/customisation/colour-palette.svg | 7 + .../line/customisation/equaliser.svg | 7 + .../catalogue/line/customisation/settings.svg | 5 + .../line/customisation/wallpaper.svg | 6 + .../line/energy/battery-charging.svg | 6 + .../line/energy/battery-critical.svg | 7 + .../catalogue/line/energy/battery-empty.svg | 5 + .../line/energy/battery-fast-charging.svg | 7 + .../catalogue/line/energy/battery-full.svg | 8 + .../catalogue/line/energy/battery-low.svg | 6 + .../catalogue/line/energy/battery-medium.svg | 7 + .../line/energy/battery-saving-mode.svg | 6 + .../catalogue/line/energy/plugged-in.svg | 6 + .../catalogue/line/features/app-store.svg | 12 + .../catalogue/line/features/calculator.svg | 10 + .../catalogue/line/features/calendar.svg | 7 + .../catalogue/line/features/camera-app.svg | 7 + .../icons/catalogue/line/features/clock.svg | 6 + .../icons/catalogue/line/features/console.svg | 8 + .../line/features/gallery-photos.svg | 8 + .../catalogue/line/features/maps-gps.svg | 7 + .../icons/catalogue/line/features/weather.svg | 6 + .../catalogue/line/features/web-browser.svg | 5 + .../icons/catalogue/line/feedback/error.svg | 6 + .../icons/catalogue/line/feedback/help.svg | 7 + .../catalogue/line/feedback/hourglass.svg | 5 + .../catalogue/line/feedback/information.svg | 7 + .../icons/catalogue/line/feedback/loading.svg | 6 + .../icons/catalogue/line/feedback/ok.svg | 6 + .../icons/catalogue/line/feedback/spinner.svg | 12 + .../icons/catalogue/line/feedback/success.svg | 5 + .../icons/catalogue/line/feedback/warning.svg | 7 + .../catalogue/line/general/down-circle.svg | 6 + .../catalogue/line/general/down-simple.svg | 5 + .../icons/catalogue/line/general/down.svg | 5 + .../catalogue/line/general/left-circle.svg | 6 + .../catalogue/line/general/left-simple.svg | 5 + .../icons/catalogue/line/general/left.svg | 5 + .../catalogue/line/general/right-circle.svg | 6 + .../catalogue/line/general/right-simple.svg | 5 + .../icons/catalogue/line/general/right.svg | 5 + .../catalogue/line/general/up-circle.svg | 7 + .../catalogue/line/general/up-simple.svg | 5 + .../icons/catalogue/line/general/up.svg | 5 + .../line/hardware/brightness-maximum.svg | 13 + .../line/hardware/brightness-minimum.svg | 13 + .../icons/catalogue/line/hardware/chip.svg | 15 + .../icons/catalogue/line/hardware/eject.svg | 6 + .../line/hardware/external-hard-drive.svg | 7 + .../line/hardware/external-monitor.svg | 9 + .../catalogue/line/hardware/harddisk.svg | 11 + .../catalogue/line/hardware/harddrive.svg | 11 + .../line/hardware/headphones-bluetooth.svg | 6 + .../catalogue/line/hardware/headphones.svg | 6 + .../catalogue/line/hardware/keyboard.svg | 12 + .../catalogue/line/hardware/light-mode.svg | 5 + .../line/hardware/microphone-muted.svg | 9 + .../catalogue/line/hardware/microphone.svg | 6 + .../icons/catalogue/line/hardware/mouse.svg | 6 + .../catalogue/line/hardware/night-mode.svg | 5 + .../icons/catalogue/line/hardware/printer.svg | 8 + .../line/hardware/rotation-locked.svg | 7 + .../catalogue/line/hardware/rotation.svg | 5 + .../icons/catalogue/line/hardware/scanner.svg | 9 + .../line/hardware/screen-recording.svg | 8 + .../catalogue/line/hardware/screenshot.svg | 8 + .../icons/catalogue/line/hardware/sdcard.svg | 11 + .../catalogue/line/hardware/speaker-high.svg | 7 + .../catalogue/line/hardware/speaker-low.svg | 5 + .../line/hardware/speaker-medium.svg | 6 + .../catalogue/line/hardware/speaker-mute.svg | 7 + .../icons/catalogue/line/hardware/usb.svg | 9 + .../catalogue/line/keyboard/assignment.svg | 5 + .../catalogue/line/keyboard/backspace.svg | 5 + .../icons/catalogue/line/keyboard/emoji.svg | 8 + .../icons/catalogue/line/keyboard/enter.svg | 5 + .../icons/catalogue/line/keyboard/gif.svg | 5 + .../icons/catalogue/line/keyboard/shift.svg | 5 + .../icons/catalogue/line/keyboard/sticker.svg | 5 + .../catalogue/line/keyboard/toggle-off.svg | 5 + .../catalogue/line/keyboard/toggle-on.svg | 5 + .../catalogue/line/multimedia/air-play.svg | 5 + .../catalogue/line/multimedia/loop-one.svg | 8 + .../icons/catalogue/line/multimedia/loop.svg | 8 + .../icons/catalogue/line/multimedia/next.svg | 6 + .../icons/catalogue/line/multimedia/pause.svg | 6 + .../icons/catalogue/line/multimedia/play.svg | 5 + .../catalogue/line/multimedia/previous.svg | 6 + .../catalogue/line/multimedia/shuffle.svg | 9 + .../icons/catalogue/line/multimedia/stop.svg | 5 + .../line/navigation/app-selector.svg | 8 + .../catalogue/line/navigation/close-small.svg | 6 + .../icons/catalogue/line/navigation/close.svg | 5 + .../line/navigation/full-screen-exit.svg | 11 + .../line/navigation/full-screen-expand.svg | 14 + .../icons/catalogue/line/navigation/home.svg | 6 + .../catalogue/line/navigation/maximise.svg | 7 + .../catalogue/line/navigation/menu-burger.svg | 7 + .../catalogue/line/navigation/menu-kebab.svg | 7 + .../line/navigation/menu-meatballs.svg | 7 + .../line/navigation/menubar-back.svg | 5 + .../line/navigation/menubar-home.svg | 5 + .../line/navigation/menubar-multiarea.svg | 7 + .../catalogue/line/navigation/minimise.svg | 7 + .../catalogue/line/navigation/pin-window.svg | 5 + .../catalogue/line/navigation/restore.svg | 12 + .../line/safety/administrator-shield.svg | 6 + .../icons/catalogue/line/safety/face-id.svg | 5 + .../line/safety/fingerprint-error.svg | 9 + .../line/safety/fingerprint-success.svg | 10 + .../catalogue/line/safety/fingerprint.svg | 10 + .../catalogue/line/safety/keyboard-access.svg | 13 + .../line/safety/location-gps-active.svg | 6 + .../line/safety/location-gps-scanning.svg | 6 + .../catalogue/line/safety/lock-screen.svg | 5 + .../catalogue/line/safety/microphone.svg | 6 + .../icons/catalogue/line/safety/padlock.svg | 5 + .../icons/catalogue/line/safety/password.svg | 5 + .../catalogue/line/safety/safety-pattern.svg | 13 + .../line/safety/security-and-emergencies.svg | 5 + .../icons/catalogue/line/safety/unlocked.svg | 5 + .../icons/catalogue/line/safety/webcam.svg | 7 + .../icons/catalogue/line/session/log-out.svg | 7 + .../icons/catalogue/line/session/power.svg | 6 + .../icons/catalogue/line/session/restart.svg | 7 + .../icons/catalogue/line/session/sleep.svg | 5 + .../catalogue/line/session/switch-user.svg | 9 + .../icons/catalogue/line/system/3g.svg | 8 + .../icons/catalogue/line/system/4g.svg | 8 + .../icons/catalogue/line/system/5g.svg | 8 + .../catalogue/line/system/airplane-mode.svg | 5 + .../line/system/bluetooth-connected.svg | 5 + .../line/system/bluetooth-external.svg | 7 + .../line/system/bluetooth-not-connected.svg | 7 + .../icons/catalogue/line/system/ethernet.svg | 5 + .../line/system/hotspot-access-point.svg | 9 + .../icons/catalogue/line/system/lte.svg | 9 + .../icons/catalogue/line/system/nfc.svg | 5 + .../icons/catalogue/line/system/roaming.svg | 8 + .../catalogue/line/system/signal-level-1.svg | 7 + .../catalogue/line/system/signal-level-2.svg | 7 + .../catalogue/line/system/signal-level-3.svg | 6 + .../catalogue/line/system/signal-none.svg | 7 + .../icons/catalogue/line/system/sim-card.svg | 5 + .../icons/catalogue/line/system/traffic.svg | 5 + .../catalogue/line/system/vpn-connected.svg | 8 + .../catalogue/line/system/wifi-disabled.svg | 10 + .../line/system/wifi-signal-full.svg | 8 + .../catalogue/line/system/wifi-signal-low.svg | 6 + .../line/system/wifi-signal-medium.svg | 7 + .../line/system/wifi-signal-none.svg | 5 + .../line/system/wifi-without-an-internet.svg | 10 + .../line/system/without-a-sim-card.svg | 6 + .../icons/catalogue/line/window/close.svg | 3 + .../icons/catalogue/line/window/maximize.svg | 3 + .../icons/catalogue/line/window/minimize.svg | 3 + .../icons/catalogue/line/window/restore.svg | 21 + themes/default/theme.json | 307 +++ 635 files changed, 53627 insertions(+), 175 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.toml create mode 100644 Makefile create mode 100644 SECURITY.md create mode 100644 TODO create mode 100644 benches/lookup.rs create mode 100755 code_style_guide.md create mode 100644 debian/cargo-checksum.json create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/ltk-theme-default.install create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 docs/architecture.md create mode 100644 docs/cookbook.md create mode 100644 docs/onboarding.md create mode 100644 docs/theming.md create mode 100644 docs/widgets.md create mode 100644 examples/combo.rs create mode 100644 examples/dialog.rs create mode 100644 examples/inputs.rs create mode 100644 examples/mini_shell.rs create mode 100644 examples/pickers.rs create mode 100644 examples/scroll.rs create mode 100644 examples/showcase.rs create mode 100644 examples/sliders.rs create mode 100644 examples/widgets.rs create mode 100644 liberux.toml create mode 100644 locales/de.yaml create mode 100644 locales/en.yaml create mode 100644 locales/es.yaml create mode 100644 locales/fr.yaml create mode 100644 locales/it.yaml create mode 100644 locales/pt.yaml create mode 100644 locales/pt_BR.yaml create mode 100755 scripts/doctest-md.sh create mode 100644 src/app.rs create mode 100644 src/core.rs create mode 100644 src/draw/chrome.rs create mode 100644 src/draw/damage.rs create mode 100644 src/draw/gles.rs create mode 100644 src/draw/layout.rs create mode 100644 src/draw/mod.rs create mode 100644 src/draw/software.rs create mode 100644 src/egl_context.rs create mode 100644 src/event_loop/app_data.rs create mode 100644 src/event_loop/handlers.rs create mode 100644 src/event_loop/mod.rs create mode 100644 src/gles_render/clip.rs create mode 100644 src/gles_render/framebuffer.rs create mode 100644 src/gles_render/helpers.rs create mode 100644 src/gles_render/image.rs create mode 100644 src/gles_render/mod.rs create mode 100644 src/gles_render/primitives.rs create mode 100644 src/gles_render/raii.rs create mode 100644 src/gles_render/setup.rs create mode 100644 src/gles_render/shaders.rs create mode 100644 src/gles_render/text.rs create mode 100644 src/input/dispatch.rs create mode 100644 src/input/gesture.rs create mode 100644 src/input/keyboard.rs create mode 100644 src/input/mod.rs create mode 100644 src/input/pointer.rs create mode 100644 src/input/touch.rs create mode 100644 src/layout/column.rs create mode 100644 src/layout/mod.rs create mode 100644 src/layout/row.rs create mode 100644 src/layout/spacer.rs create mode 100644 src/layout/stack.rs create mode 100644 src/layout/wrap_grid.rs create mode 100755 src/lib.rs create mode 100644 src/render/clip.rs create mode 100644 src/render/helpers.rs create mode 100644 src/render/image.rs create mode 100644 src/render/mod.rs create mode 100644 src/render/primitives.rs create mode 100644 src/render/setup.rs create mode 100644 src/render/text.rs create mode 100644 src/secure_mem.rs create mode 100644 src/system_fonts.rs create mode 100644 src/theme/document.rs create mode 100644 src/theme/fallback/Sora-LICENSE.txt create mode 100644 src/theme/fallback/Sora-Regular.otf create mode 100644 src/theme/fallback/mod.rs create mode 100644 src/theme/font_registry.rs create mode 100644 src/theme/fonts.rs create mode 100644 src/theme/gradient_lut.rs create mode 100644 src/theme/mod.rs create mode 100644 src/theme/paint.rs create mode 100644 src/theme/schema.rs create mode 100644 src/theme/shadow.rs create mode 100644 src/theme/slots.rs create mode 100644 src/theme/surface.rs create mode 100644 src/theme/text_style.rs create mode 100755 src/tree.rs create mode 100644 src/types.rs create mode 100644 src/wallpaper.rs create mode 100644 src/widget/anchored_overlay.rs create mode 100644 src/widget/button.rs create mode 100644 src/widget/checkbox.rs create mode 100644 src/widget/color_picker.rs create mode 100644 src/widget/combo.rs create mode 100644 src/widget/container.rs create mode 100644 src/widget/date_picker.rs create mode 100644 src/widget/dialog.rs create mode 100644 src/widget/external.rs create mode 100644 src/widget/flex.rs create mode 100644 src/widget/image.rs create mode 100644 src/widget/list_item.rs create mode 100755 src/widget/mod.rs create mode 100644 src/widget/notebook.rs create mode 100644 src/widget/pressable.rs create mode 100644 src/widget/progress_bar.rs create mode 100644 src/widget/radio.rs create mode 100644 src/widget/scroll.rs create mode 100644 src/widget/separator.rs create mode 100644 src/widget/slider.rs create mode 100644 src/widget/spinner.rs create mode 100644 src/widget/tab_bar.rs create mode 100644 src/widget/text.rs create mode 100644 src/widget/text_edit.rs create mode 100644 src/widget/time_picker.rs create mode 100644 src/widget/toast.rs create mode 100644 src/widget/toggle.rs create mode 100644 src/widget/tooltip.rs create mode 100644 src/widget/viewport.rs create mode 100644 src/widget/vslider.rs create mode 100644 src/widget/window_button.rs create mode 100644 tests/animation.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/core_surface.rs create mode 100644 tests/element_map.rs create mode 100644 tests/event_loop_flow.rs create mode 100644 tests/layout_stack_spacer.rs create mode 100644 tests/overlay_reconciliation.rs create mode 100644 tests/render_pixels.rs create mode 100644 tests/scroll.rs create mode 100644 tests/security_upload.rs create mode 100644 tests/slider_math.rs create mode 100644 tests/tab_navigation.rs create mode 100644 tests/text_edit_dispatch.rs create mode 100644 tests/theme_parsing.rs create mode 100644 tests/tree_lookup.rs create mode 100644 tests/vslider_math.rs create mode 100644 tests/widget_dispatch.rs create mode 100644 themes/default/branding/dark/launcher.svg create mode 100644 themes/default/branding/dark/lockscreen.svg create mode 100644 themes/default/branding/dark/lockscreen/1280x720.webp create mode 100644 themes/default/branding/dark/lockscreen/1920x1080.webp create mode 100644 themes/default/branding/dark/lockscreen/3840x2160.webp create mode 100644 themes/default/branding/dark/logo/horizontal.svg create mode 100644 themes/default/branding/dark/logo/logo.svg create mode 100644 themes/default/branding/dark/logo/square.svg create mode 100644 themes/default/branding/dark/wallpaper.svg create mode 100644 themes/default/branding/dark/wallpaper/1280x720.webp create mode 100644 themes/default/branding/dark/wallpaper/1920x1080.webp create mode 100644 themes/default/branding/dark/wallpaper/3840x2160.webp create mode 100644 themes/default/branding/light/launcher.svg create mode 100644 themes/default/branding/light/lockscreen.svg create mode 100644 themes/default/branding/light/lockscreen/1280x720.webp create mode 100644 themes/default/branding/light/lockscreen/1920x1080.webp create mode 100644 themes/default/branding/light/lockscreen/3840x2160.webp create mode 100644 themes/default/branding/light/logo/horizontal.svg create mode 100644 themes/default/branding/light/logo/logo.svg create mode 100644 themes/default/branding/light/logo/square.svg create mode 100644 themes/default/branding/light/wallpaper.svg create mode 100644 themes/default/branding/light/wallpaper/1280x720.webp create mode 100644 themes/default/branding/light/wallpaper/1920x1080.webp create mode 100644 themes/default/branding/light/wallpaper/3840x2160.webp create mode 100644 themes/default/icons/apps/appstore.svg create mode 100644 themes/default/icons/apps/calculator.svg create mode 100644 themes/default/icons/apps/calendar.svg create mode 100644 themes/default/icons/apps/camera.svg create mode 100644 themes/default/icons/apps/clock.svg create mode 100644 themes/default/icons/apps/contacts.svg create mode 100644 themes/default/icons/apps/default.svg create mode 100644 themes/default/icons/apps/drive.svg create mode 100644 themes/default/icons/apps/files.svg create mode 100644 themes/default/icons/apps/firefox.svg create mode 100644 themes/default/icons/apps/gallery.svg create mode 100644 themes/default/icons/apps/gimp.svg create mode 100644 themes/default/icons/apps/mail.svg create mode 100644 themes/default/icons/apps/maps.svg create mode 100644 themes/default/icons/apps/messaging.svg create mode 100644 themes/default/icons/apps/notes.svg create mode 100644 themes/default/icons/apps/paint.svg create mode 100644 themes/default/icons/apps/pdfviewer.svg create mode 100644 themes/default/icons/apps/phone.svg create mode 100644 themes/default/icons/apps/screen.svg create mode 100644 themes/default/icons/apps/settings.svg create mode 100644 themes/default/icons/apps/slides.svg create mode 100644 themes/default/icons/apps/spreadsheet.svg create mode 100644 themes/default/icons/apps/terminal.svg create mode 100644 themes/default/icons/apps/texteditor.svg create mode 100644 themes/default/icons/apps/trash.svg create mode 100644 themes/default/icons/apps/weather.svg create mode 100644 themes/default/icons/apps/wordprocessor.svg create mode 100644 themes/default/icons/catalogue/LICENSE.md create mode 100644 themes/default/icons/catalogue/filled/accessibility/high-contrast.svg create mode 100644 themes/default/icons/catalogue/filled/accessibility/languages.svg create mode 100644 themes/default/icons/catalogue/filled/accessibility/magnifier.svg create mode 100644 themes/default/icons/catalogue/filled/accessibility/subtitles-cc.svg create mode 100644 themes/default/icons/catalogue/filled/accessibility/text-to-speech.svg create mode 100644 themes/default/icons/catalogue/filled/actions/add.svg create mode 100644 themes/default/icons/catalogue/filled/actions/copy.svg create mode 100644 themes/default/icons/catalogue/filled/actions/cut.svg create mode 100644 themes/default/icons/catalogue/filled/actions/delete-full.svg create mode 100644 themes/default/icons/catalogue/filled/actions/delete.svg create mode 100644 themes/default/icons/catalogue/filled/actions/edit.svg create mode 100644 themes/default/icons/catalogue/filled/actions/favourite.svg create mode 100644 themes/default/icons/catalogue/filled/actions/filter.svg create mode 100644 themes/default/icons/catalogue/filled/actions/invisible.svg create mode 100644 themes/default/icons/catalogue/filled/actions/link.svg create mode 100644 themes/default/icons/catalogue/filled/actions/paste.svg create mode 100644 themes/default/icons/catalogue/filled/actions/redo.svg create mode 100644 themes/default/icons/catalogue/filled/actions/remove.svg create mode 100644 themes/default/icons/catalogue/filled/actions/save.svg create mode 100644 themes/default/icons/catalogue/filled/actions/search.svg create mode 100644 themes/default/icons/catalogue/filled/actions/select-all.svg create mode 100644 themes/default/icons/catalogue/filled/actions/share.svg create mode 100644 themes/default/icons/catalogue/filled/actions/undo.svg create mode 100644 themes/default/icons/catalogue/filled/actions/visible.svg create mode 100644 themes/default/icons/catalogue/filled/archives/audio.svg create mode 100644 themes/default/icons/catalogue/filled/archives/cloud-sync.svg create mode 100644 themes/default/icons/catalogue/filled/archives/compressed-file.svg create mode 100644 themes/default/icons/catalogue/filled/archives/document-text.svg create mode 100644 themes/default/icons/catalogue/filled/archives/document.svg create mode 100644 themes/default/icons/catalogue/filled/archives/folder-downloads.svg create mode 100644 themes/default/icons/catalogue/filled/archives/folder-empty.svg create mode 100644 themes/default/icons/catalogue/filled/archives/folder-files.svg create mode 100644 themes/default/icons/catalogue/filled/archives/folder-locked.svg create mode 100644 themes/default/icons/catalogue/filled/archives/folder-music.svg create mode 100644 themes/default/icons/catalogue/filled/archives/folder-photos.svg create mode 100644 themes/default/icons/catalogue/filled/archives/folder-shared.svg create mode 100644 themes/default/icons/catalogue/filled/archives/folder-videos.svg create mode 100644 themes/default/icons/catalogue/filled/archives/image.svg create mode 100644 themes/default/icons/catalogue/filled/archives/installation-file-app.svg create mode 100644 themes/default/icons/catalogue/filled/archives/pdf.svg create mode 100644 themes/default/icons/catalogue/filled/archives/presentation.svg create mode 100644 themes/default/icons/catalogue/filled/archives/spreadsheet.svg create mode 100644 themes/default/icons/catalogue/filled/archives/video.svg create mode 100644 themes/default/icons/catalogue/filled/communication/attach.svg create mode 100644 themes/default/icons/catalogue/filled/communication/contact-group.svg create mode 100644 themes/default/icons/catalogue/filled/communication/contact.svg create mode 100644 themes/default/icons/catalogue/filled/communication/dial-pad.svg create mode 100644 themes/default/icons/catalogue/filled/communication/email-open.svg create mode 100644 themes/default/icons/catalogue/filled/communication/email.svg create mode 100644 themes/default/icons/catalogue/filled/communication/message.svg create mode 100644 themes/default/icons/catalogue/filled/communication/notifications-status.svg create mode 100644 themes/default/icons/catalogue/filled/communication/notifications.svg create mode 100644 themes/default/icons/catalogue/filled/communication/phone-reject.svg create mode 100644 themes/default/icons/catalogue/filled/communication/phone.svg create mode 100644 themes/default/icons/catalogue/filled/communication/send.svg create mode 100644 themes/default/icons/catalogue/filled/communication/video-call.svg create mode 100644 themes/default/icons/catalogue/filled/communication/voice-notes.svg create mode 100644 themes/default/icons/catalogue/filled/controls/slider-handle-circle.svg create mode 100644 themes/default/icons/catalogue/filled/controls/slider-handle-square.svg create mode 100644 themes/default/icons/catalogue/filled/customisation/colour-palette.svg create mode 100644 themes/default/icons/catalogue/filled/customisation/equaliser.svg create mode 100644 themes/default/icons/catalogue/filled/customisation/settings.svg create mode 100644 themes/default/icons/catalogue/filled/customisation/wallpaper.svg create mode 100644 themes/default/icons/catalogue/filled/energy/battery-charging.svg create mode 100644 themes/default/icons/catalogue/filled/energy/battery-critical.svg create mode 100644 themes/default/icons/catalogue/filled/energy/battery-empty.svg create mode 100644 themes/default/icons/catalogue/filled/energy/battery-fast-charging.svg create mode 100644 themes/default/icons/catalogue/filled/energy/battery-full.svg create mode 100644 themes/default/icons/catalogue/filled/energy/battery-low.svg create mode 100644 themes/default/icons/catalogue/filled/energy/battery-medium.svg create mode 100644 themes/default/icons/catalogue/filled/energy/battery-saving-mode.svg create mode 100644 themes/default/icons/catalogue/filled/energy/plugged-in.svg create mode 100644 themes/default/icons/catalogue/filled/features/app-store.svg create mode 100644 themes/default/icons/catalogue/filled/features/calculator.svg create mode 100644 themes/default/icons/catalogue/filled/features/calendar.svg create mode 100644 themes/default/icons/catalogue/filled/features/camera-app.svg create mode 100644 themes/default/icons/catalogue/filled/features/clock.svg create mode 100644 themes/default/icons/catalogue/filled/features/console.svg create mode 100644 themes/default/icons/catalogue/filled/features/gallery-photos.svg create mode 100644 themes/default/icons/catalogue/filled/features/maps-gps.svg create mode 100644 themes/default/icons/catalogue/filled/features/weather.svg create mode 100644 themes/default/icons/catalogue/filled/features/web-browser.svg create mode 100644 themes/default/icons/catalogue/filled/feedback/error.svg create mode 100644 themes/default/icons/catalogue/filled/feedback/help.svg create mode 100644 themes/default/icons/catalogue/filled/feedback/hourglass.svg create mode 100644 themes/default/icons/catalogue/filled/feedback/information.svg create mode 100644 themes/default/icons/catalogue/filled/feedback/loading.svg create mode 100644 themes/default/icons/catalogue/filled/feedback/ok.svg create mode 100644 themes/default/icons/catalogue/filled/feedback/spinner.svg create mode 100644 themes/default/icons/catalogue/filled/feedback/success.svg create mode 100644 themes/default/icons/catalogue/filled/feedback/warning.svg create mode 100644 themes/default/icons/catalogue/filled/general/down-circle.svg create mode 100644 themes/default/icons/catalogue/filled/general/down-simple.svg create mode 100644 themes/default/icons/catalogue/filled/general/down.svg create mode 100644 themes/default/icons/catalogue/filled/general/left-circle.svg create mode 100644 themes/default/icons/catalogue/filled/general/left-simple.svg create mode 100644 themes/default/icons/catalogue/filled/general/left.svg create mode 100644 themes/default/icons/catalogue/filled/general/right-circle.svg create mode 100644 themes/default/icons/catalogue/filled/general/right-simple.svg create mode 100644 themes/default/icons/catalogue/filled/general/right.svg create mode 100644 themes/default/icons/catalogue/filled/general/up-circle.svg create mode 100644 themes/default/icons/catalogue/filled/general/up-simple.svg create mode 100644 themes/default/icons/catalogue/filled/general/up.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/brightness-maximum.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/brightness-minimum.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/chip.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/eject.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/external-hard-drive.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/external-monitor.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/harddisk.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/harddrive.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/headphones-bluetooth.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/headphones.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/keyboard.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/light-mode.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/microphone-muted.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/microphone.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/mouse.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/night-mode.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/printer.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/rotation-locked.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/rotation.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/scanner.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/screen-recording.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/screenshot.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/sdcard.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/speaker-high.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/speaker-low.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/speaker-medium.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/speaker-mute.svg create mode 100644 themes/default/icons/catalogue/filled/hardware/usb.svg create mode 100644 themes/default/icons/catalogue/filled/keyboard/assignment.svg create mode 100644 themes/default/icons/catalogue/filled/keyboard/backspace.svg create mode 100644 themes/default/icons/catalogue/filled/keyboard/emoji.svg create mode 100644 themes/default/icons/catalogue/filled/keyboard/enter.svg create mode 100644 themes/default/icons/catalogue/filled/keyboard/gif.svg create mode 100644 themes/default/icons/catalogue/filled/keyboard/shift.svg create mode 100644 themes/default/icons/catalogue/filled/keyboard/sticker.svg create mode 100644 themes/default/icons/catalogue/filled/keyboard/toggle-off.svg create mode 100644 themes/default/icons/catalogue/filled/keyboard/toggle-on.svg create mode 100644 themes/default/icons/catalogue/filled/multimedia/air-play.svg create mode 100644 themes/default/icons/catalogue/filled/multimedia/loop-one.svg create mode 100644 themes/default/icons/catalogue/filled/multimedia/loop.svg create mode 100644 themes/default/icons/catalogue/filled/multimedia/next.svg create mode 100644 themes/default/icons/catalogue/filled/multimedia/pause.svg create mode 100644 themes/default/icons/catalogue/filled/multimedia/play.svg create mode 100644 themes/default/icons/catalogue/filled/multimedia/previous.svg create mode 100644 themes/default/icons/catalogue/filled/multimedia/shuffle.svg create mode 100644 themes/default/icons/catalogue/filled/multimedia/stop.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/app-selector.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/close-small.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/close.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/full-screen-exit.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/full-screen-expand.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/home.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/maximise.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/menu-burger.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/menu-kebab.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/menu-meatballs.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/menubar-back.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/menubar-home.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/menubar-multiarea.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/minimise.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/pin-window.svg create mode 100644 themes/default/icons/catalogue/filled/navigation/restore.svg create mode 100644 themes/default/icons/catalogue/filled/safety/administrator-shield.svg create mode 100644 themes/default/icons/catalogue/filled/safety/face-id.svg create mode 100644 themes/default/icons/catalogue/filled/safety/fingerprint-error.svg create mode 100644 themes/default/icons/catalogue/filled/safety/fingerprint-success.svg create mode 100644 themes/default/icons/catalogue/filled/safety/fingerprint.svg create mode 100644 themes/default/icons/catalogue/filled/safety/keyboard-access.svg create mode 100644 themes/default/icons/catalogue/filled/safety/location-gps-active.svg create mode 100644 themes/default/icons/catalogue/filled/safety/location-gps-scanning.svg create mode 100644 themes/default/icons/catalogue/filled/safety/lock-screen.svg create mode 100644 themes/default/icons/catalogue/filled/safety/microphone.svg create mode 100644 themes/default/icons/catalogue/filled/safety/padlock.svg create mode 100644 themes/default/icons/catalogue/filled/safety/password.svg create mode 100644 themes/default/icons/catalogue/filled/safety/safety-pattern.svg create mode 100644 themes/default/icons/catalogue/filled/safety/security-and-emergencies.svg create mode 100644 themes/default/icons/catalogue/filled/safety/unlocked.svg create mode 100644 themes/default/icons/catalogue/filled/safety/webcam.svg create mode 100644 themes/default/icons/catalogue/filled/session/log-out.svg create mode 100644 themes/default/icons/catalogue/filled/session/power.svg create mode 100644 themes/default/icons/catalogue/filled/session/restart.svg create mode 100644 themes/default/icons/catalogue/filled/session/sleep.svg create mode 100644 themes/default/icons/catalogue/filled/session/switch-user.svg create mode 100644 themes/default/icons/catalogue/filled/system/3g.svg create mode 100644 themes/default/icons/catalogue/filled/system/4g.svg create mode 100644 themes/default/icons/catalogue/filled/system/5g.svg create mode 100644 themes/default/icons/catalogue/filled/system/airplane-mode.svg create mode 100644 themes/default/icons/catalogue/filled/system/bluetooth-connected.svg create mode 100644 themes/default/icons/catalogue/filled/system/bluetooth-external.svg create mode 100644 themes/default/icons/catalogue/filled/system/bluetooth-not-connected.svg create mode 100644 themes/default/icons/catalogue/filled/system/ethernet.svg create mode 100644 themes/default/icons/catalogue/filled/system/hotspot-access-point.svg create mode 100644 themes/default/icons/catalogue/filled/system/lte.svg create mode 100644 themes/default/icons/catalogue/filled/system/nfc.svg create mode 100644 themes/default/icons/catalogue/filled/system/roaming.svg create mode 100644 themes/default/icons/catalogue/filled/system/signal-level-1.svg create mode 100644 themes/default/icons/catalogue/filled/system/signal-level-2.svg create mode 100644 themes/default/icons/catalogue/filled/system/signal-level-3.svg create mode 100644 themes/default/icons/catalogue/filled/system/signal-none.svg create mode 100644 themes/default/icons/catalogue/filled/system/sim-card.svg create mode 100644 themes/default/icons/catalogue/filled/system/traffic.svg create mode 100644 themes/default/icons/catalogue/filled/system/vpn-connected.svg create mode 100644 themes/default/icons/catalogue/filled/system/wifi-disabled.svg create mode 100644 themes/default/icons/catalogue/filled/system/wifi-signal-full.svg create mode 100644 themes/default/icons/catalogue/filled/system/wifi-signal-low.svg create mode 100644 themes/default/icons/catalogue/filled/system/wifi-signal-medium.svg create mode 100644 themes/default/icons/catalogue/filled/system/wifi-signal-none.svg create mode 100644 themes/default/icons/catalogue/filled/system/wifi-without-an-internet.svg create mode 100644 themes/default/icons/catalogue/filled/system/without-a-sim-card.svg create mode 100644 themes/default/icons/catalogue/filled/window/close.svg create mode 100644 themes/default/icons/catalogue/filled/window/maximize.svg create mode 100644 themes/default/icons/catalogue/filled/window/minimize.svg create mode 100644 themes/default/icons/catalogue/filled/window/restore.svg create mode 100644 themes/default/icons/catalogue/line/accessibility/high-contrast.svg create mode 100644 themes/default/icons/catalogue/line/accessibility/languages.svg create mode 100644 themes/default/icons/catalogue/line/accessibility/magnifier.svg create mode 100644 themes/default/icons/catalogue/line/accessibility/subtitles-cc.svg create mode 100644 themes/default/icons/catalogue/line/accessibility/text-to-speech.svg create mode 100644 themes/default/icons/catalogue/line/actions/add.svg create mode 100644 themes/default/icons/catalogue/line/actions/copy.svg create mode 100644 themes/default/icons/catalogue/line/actions/cut.svg create mode 100644 themes/default/icons/catalogue/line/actions/delete-full.svg create mode 100644 themes/default/icons/catalogue/line/actions/delete.svg create mode 100644 themes/default/icons/catalogue/line/actions/edit.svg create mode 100644 themes/default/icons/catalogue/line/actions/favourite.svg create mode 100644 themes/default/icons/catalogue/line/actions/filter.svg create mode 100644 themes/default/icons/catalogue/line/actions/invisible.svg create mode 100644 themes/default/icons/catalogue/line/actions/link.svg create mode 100644 themes/default/icons/catalogue/line/actions/paste.svg create mode 100644 themes/default/icons/catalogue/line/actions/redo.svg create mode 100644 themes/default/icons/catalogue/line/actions/remove.svg create mode 100644 themes/default/icons/catalogue/line/actions/save.svg create mode 100644 themes/default/icons/catalogue/line/actions/search.svg create mode 100644 themes/default/icons/catalogue/line/actions/select-all.svg create mode 100644 themes/default/icons/catalogue/line/actions/share.svg create mode 100644 themes/default/icons/catalogue/line/actions/undo.svg create mode 100644 themes/default/icons/catalogue/line/actions/visible.svg create mode 100644 themes/default/icons/catalogue/line/archives/audio.svg create mode 100644 themes/default/icons/catalogue/line/archives/cloud-sync.svg create mode 100644 themes/default/icons/catalogue/line/archives/compressed-file.svg create mode 100644 themes/default/icons/catalogue/line/archives/document-text.svg create mode 100644 themes/default/icons/catalogue/line/archives/document.svg create mode 100644 themes/default/icons/catalogue/line/archives/folder-downloads.svg create mode 100644 themes/default/icons/catalogue/line/archives/folder-empty.svg create mode 100644 themes/default/icons/catalogue/line/archives/folder-files.svg create mode 100644 themes/default/icons/catalogue/line/archives/folder-locked.svg create mode 100644 themes/default/icons/catalogue/line/archives/folder-music.svg create mode 100644 themes/default/icons/catalogue/line/archives/folder-photos.svg create mode 100644 themes/default/icons/catalogue/line/archives/folder-shared.svg create mode 100644 themes/default/icons/catalogue/line/archives/folder-videos.svg create mode 100644 themes/default/icons/catalogue/line/archives/image.svg create mode 100644 themes/default/icons/catalogue/line/archives/installation-file-app.svg create mode 100644 themes/default/icons/catalogue/line/archives/pdf.svg create mode 100644 themes/default/icons/catalogue/line/archives/presentation.svg create mode 100644 themes/default/icons/catalogue/line/archives/spreadsheet.svg create mode 100644 themes/default/icons/catalogue/line/archives/video.svg create mode 100644 themes/default/icons/catalogue/line/communication/attach.svg create mode 100644 themes/default/icons/catalogue/line/communication/contact-group.svg create mode 100644 themes/default/icons/catalogue/line/communication/contact.svg create mode 100644 themes/default/icons/catalogue/line/communication/dial-pad.svg create mode 100644 themes/default/icons/catalogue/line/communication/email-open.svg create mode 100644 themes/default/icons/catalogue/line/communication/email.svg create mode 100644 themes/default/icons/catalogue/line/communication/message.svg create mode 100644 themes/default/icons/catalogue/line/communication/notifications-status.svg create mode 100644 themes/default/icons/catalogue/line/communication/notifications.svg create mode 100644 themes/default/icons/catalogue/line/communication/phone-reject.svg create mode 100644 themes/default/icons/catalogue/line/communication/phone.svg create mode 100644 themes/default/icons/catalogue/line/communication/send.svg create mode 100644 themes/default/icons/catalogue/line/communication/video-call.svg create mode 100644 themes/default/icons/catalogue/line/communication/voice-notes.svg create mode 100644 themes/default/icons/catalogue/line/controls/slider-handle-circle.svg create mode 100644 themes/default/icons/catalogue/line/controls/slider-handle-square.svg create mode 100644 themes/default/icons/catalogue/line/customisation/colour-palette.svg create mode 100644 themes/default/icons/catalogue/line/customisation/equaliser.svg create mode 100644 themes/default/icons/catalogue/line/customisation/settings.svg create mode 100644 themes/default/icons/catalogue/line/customisation/wallpaper.svg create mode 100644 themes/default/icons/catalogue/line/energy/battery-charging.svg create mode 100644 themes/default/icons/catalogue/line/energy/battery-critical.svg create mode 100644 themes/default/icons/catalogue/line/energy/battery-empty.svg create mode 100644 themes/default/icons/catalogue/line/energy/battery-fast-charging.svg create mode 100644 themes/default/icons/catalogue/line/energy/battery-full.svg create mode 100644 themes/default/icons/catalogue/line/energy/battery-low.svg create mode 100644 themes/default/icons/catalogue/line/energy/battery-medium.svg create mode 100644 themes/default/icons/catalogue/line/energy/battery-saving-mode.svg create mode 100644 themes/default/icons/catalogue/line/energy/plugged-in.svg create mode 100644 themes/default/icons/catalogue/line/features/app-store.svg create mode 100644 themes/default/icons/catalogue/line/features/calculator.svg create mode 100644 themes/default/icons/catalogue/line/features/calendar.svg create mode 100644 themes/default/icons/catalogue/line/features/camera-app.svg create mode 100644 themes/default/icons/catalogue/line/features/clock.svg create mode 100644 themes/default/icons/catalogue/line/features/console.svg create mode 100644 themes/default/icons/catalogue/line/features/gallery-photos.svg create mode 100644 themes/default/icons/catalogue/line/features/maps-gps.svg create mode 100644 themes/default/icons/catalogue/line/features/weather.svg create mode 100644 themes/default/icons/catalogue/line/features/web-browser.svg create mode 100644 themes/default/icons/catalogue/line/feedback/error.svg create mode 100644 themes/default/icons/catalogue/line/feedback/help.svg create mode 100644 themes/default/icons/catalogue/line/feedback/hourglass.svg create mode 100644 themes/default/icons/catalogue/line/feedback/information.svg create mode 100644 themes/default/icons/catalogue/line/feedback/loading.svg create mode 100644 themes/default/icons/catalogue/line/feedback/ok.svg create mode 100644 themes/default/icons/catalogue/line/feedback/spinner.svg create mode 100644 themes/default/icons/catalogue/line/feedback/success.svg create mode 100644 themes/default/icons/catalogue/line/feedback/warning.svg create mode 100644 themes/default/icons/catalogue/line/general/down-circle.svg create mode 100644 themes/default/icons/catalogue/line/general/down-simple.svg create mode 100644 themes/default/icons/catalogue/line/general/down.svg create mode 100644 themes/default/icons/catalogue/line/general/left-circle.svg create mode 100644 themes/default/icons/catalogue/line/general/left-simple.svg create mode 100644 themes/default/icons/catalogue/line/general/left.svg create mode 100644 themes/default/icons/catalogue/line/general/right-circle.svg create mode 100644 themes/default/icons/catalogue/line/general/right-simple.svg create mode 100644 themes/default/icons/catalogue/line/general/right.svg create mode 100644 themes/default/icons/catalogue/line/general/up-circle.svg create mode 100644 themes/default/icons/catalogue/line/general/up-simple.svg create mode 100644 themes/default/icons/catalogue/line/general/up.svg create mode 100644 themes/default/icons/catalogue/line/hardware/brightness-maximum.svg create mode 100644 themes/default/icons/catalogue/line/hardware/brightness-minimum.svg create mode 100644 themes/default/icons/catalogue/line/hardware/chip.svg create mode 100644 themes/default/icons/catalogue/line/hardware/eject.svg create mode 100644 themes/default/icons/catalogue/line/hardware/external-hard-drive.svg create mode 100644 themes/default/icons/catalogue/line/hardware/external-monitor.svg create mode 100644 themes/default/icons/catalogue/line/hardware/harddisk.svg create mode 100644 themes/default/icons/catalogue/line/hardware/harddrive.svg create mode 100644 themes/default/icons/catalogue/line/hardware/headphones-bluetooth.svg create mode 100644 themes/default/icons/catalogue/line/hardware/headphones.svg create mode 100644 themes/default/icons/catalogue/line/hardware/keyboard.svg create mode 100644 themes/default/icons/catalogue/line/hardware/light-mode.svg create mode 100644 themes/default/icons/catalogue/line/hardware/microphone-muted.svg create mode 100644 themes/default/icons/catalogue/line/hardware/microphone.svg create mode 100644 themes/default/icons/catalogue/line/hardware/mouse.svg create mode 100644 themes/default/icons/catalogue/line/hardware/night-mode.svg create mode 100644 themes/default/icons/catalogue/line/hardware/printer.svg create mode 100644 themes/default/icons/catalogue/line/hardware/rotation-locked.svg create mode 100644 themes/default/icons/catalogue/line/hardware/rotation.svg create mode 100644 themes/default/icons/catalogue/line/hardware/scanner.svg create mode 100644 themes/default/icons/catalogue/line/hardware/screen-recording.svg create mode 100644 themes/default/icons/catalogue/line/hardware/screenshot.svg create mode 100644 themes/default/icons/catalogue/line/hardware/sdcard.svg create mode 100644 themes/default/icons/catalogue/line/hardware/speaker-high.svg create mode 100644 themes/default/icons/catalogue/line/hardware/speaker-low.svg create mode 100644 themes/default/icons/catalogue/line/hardware/speaker-medium.svg create mode 100644 themes/default/icons/catalogue/line/hardware/speaker-mute.svg create mode 100644 themes/default/icons/catalogue/line/hardware/usb.svg create mode 100644 themes/default/icons/catalogue/line/keyboard/assignment.svg create mode 100644 themes/default/icons/catalogue/line/keyboard/backspace.svg create mode 100644 themes/default/icons/catalogue/line/keyboard/emoji.svg create mode 100644 themes/default/icons/catalogue/line/keyboard/enter.svg create mode 100644 themes/default/icons/catalogue/line/keyboard/gif.svg create mode 100644 themes/default/icons/catalogue/line/keyboard/shift.svg create mode 100644 themes/default/icons/catalogue/line/keyboard/sticker.svg create mode 100644 themes/default/icons/catalogue/line/keyboard/toggle-off.svg create mode 100644 themes/default/icons/catalogue/line/keyboard/toggle-on.svg create mode 100644 themes/default/icons/catalogue/line/multimedia/air-play.svg create mode 100644 themes/default/icons/catalogue/line/multimedia/loop-one.svg create mode 100644 themes/default/icons/catalogue/line/multimedia/loop.svg create mode 100644 themes/default/icons/catalogue/line/multimedia/next.svg create mode 100644 themes/default/icons/catalogue/line/multimedia/pause.svg create mode 100644 themes/default/icons/catalogue/line/multimedia/play.svg create mode 100644 themes/default/icons/catalogue/line/multimedia/previous.svg create mode 100644 themes/default/icons/catalogue/line/multimedia/shuffle.svg create mode 100644 themes/default/icons/catalogue/line/multimedia/stop.svg create mode 100644 themes/default/icons/catalogue/line/navigation/app-selector.svg create mode 100644 themes/default/icons/catalogue/line/navigation/close-small.svg create mode 100644 themes/default/icons/catalogue/line/navigation/close.svg create mode 100644 themes/default/icons/catalogue/line/navigation/full-screen-exit.svg create mode 100644 themes/default/icons/catalogue/line/navigation/full-screen-expand.svg create mode 100644 themes/default/icons/catalogue/line/navigation/home.svg create mode 100644 themes/default/icons/catalogue/line/navigation/maximise.svg create mode 100644 themes/default/icons/catalogue/line/navigation/menu-burger.svg create mode 100644 themes/default/icons/catalogue/line/navigation/menu-kebab.svg create mode 100644 themes/default/icons/catalogue/line/navigation/menu-meatballs.svg create mode 100644 themes/default/icons/catalogue/line/navigation/menubar-back.svg create mode 100644 themes/default/icons/catalogue/line/navigation/menubar-home.svg create mode 100644 themes/default/icons/catalogue/line/navigation/menubar-multiarea.svg create mode 100644 themes/default/icons/catalogue/line/navigation/minimise.svg create mode 100644 themes/default/icons/catalogue/line/navigation/pin-window.svg create mode 100644 themes/default/icons/catalogue/line/navigation/restore.svg create mode 100644 themes/default/icons/catalogue/line/safety/administrator-shield.svg create mode 100644 themes/default/icons/catalogue/line/safety/face-id.svg create mode 100644 themes/default/icons/catalogue/line/safety/fingerprint-error.svg create mode 100644 themes/default/icons/catalogue/line/safety/fingerprint-success.svg create mode 100644 themes/default/icons/catalogue/line/safety/fingerprint.svg create mode 100644 themes/default/icons/catalogue/line/safety/keyboard-access.svg create mode 100644 themes/default/icons/catalogue/line/safety/location-gps-active.svg create mode 100644 themes/default/icons/catalogue/line/safety/location-gps-scanning.svg create mode 100644 themes/default/icons/catalogue/line/safety/lock-screen.svg create mode 100644 themes/default/icons/catalogue/line/safety/microphone.svg create mode 100644 themes/default/icons/catalogue/line/safety/padlock.svg create mode 100644 themes/default/icons/catalogue/line/safety/password.svg create mode 100644 themes/default/icons/catalogue/line/safety/safety-pattern.svg create mode 100644 themes/default/icons/catalogue/line/safety/security-and-emergencies.svg create mode 100644 themes/default/icons/catalogue/line/safety/unlocked.svg create mode 100644 themes/default/icons/catalogue/line/safety/webcam.svg create mode 100644 themes/default/icons/catalogue/line/session/log-out.svg create mode 100644 themes/default/icons/catalogue/line/session/power.svg create mode 100644 themes/default/icons/catalogue/line/session/restart.svg create mode 100644 themes/default/icons/catalogue/line/session/sleep.svg create mode 100644 themes/default/icons/catalogue/line/session/switch-user.svg create mode 100644 themes/default/icons/catalogue/line/system/3g.svg create mode 100644 themes/default/icons/catalogue/line/system/4g.svg create mode 100644 themes/default/icons/catalogue/line/system/5g.svg create mode 100644 themes/default/icons/catalogue/line/system/airplane-mode.svg create mode 100644 themes/default/icons/catalogue/line/system/bluetooth-connected.svg create mode 100644 themes/default/icons/catalogue/line/system/bluetooth-external.svg create mode 100644 themes/default/icons/catalogue/line/system/bluetooth-not-connected.svg create mode 100644 themes/default/icons/catalogue/line/system/ethernet.svg create mode 100644 themes/default/icons/catalogue/line/system/hotspot-access-point.svg create mode 100644 themes/default/icons/catalogue/line/system/lte.svg create mode 100644 themes/default/icons/catalogue/line/system/nfc.svg create mode 100644 themes/default/icons/catalogue/line/system/roaming.svg create mode 100644 themes/default/icons/catalogue/line/system/signal-level-1.svg create mode 100644 themes/default/icons/catalogue/line/system/signal-level-2.svg create mode 100644 themes/default/icons/catalogue/line/system/signal-level-3.svg create mode 100644 themes/default/icons/catalogue/line/system/signal-none.svg create mode 100644 themes/default/icons/catalogue/line/system/sim-card.svg create mode 100644 themes/default/icons/catalogue/line/system/traffic.svg create mode 100644 themes/default/icons/catalogue/line/system/vpn-connected.svg create mode 100644 themes/default/icons/catalogue/line/system/wifi-disabled.svg create mode 100644 themes/default/icons/catalogue/line/system/wifi-signal-full.svg create mode 100644 themes/default/icons/catalogue/line/system/wifi-signal-low.svg create mode 100644 themes/default/icons/catalogue/line/system/wifi-signal-medium.svg create mode 100644 themes/default/icons/catalogue/line/system/wifi-signal-none.svg create mode 100644 themes/default/icons/catalogue/line/system/wifi-without-an-internet.svg create mode 100644 themes/default/icons/catalogue/line/system/without-a-sim-card.svg create mode 100644 themes/default/icons/catalogue/line/window/close.svg create mode 100644 themes/default/icons/catalogue/line/window/maximize.svg create mode 100644 themes/default/icons/catalogue/line/window/minimize.svg create mode 100644 themes/default/icons/catalogue/line/window/restore.svg create mode 100644 themes/default/theme.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8f28d1b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,96 @@ +# Gitea Actions CI for ltk. +# +# Runs on every push and pull request against `master`. Two independent +# jobs share the cache layer but otherwise execute in parallel: +# +# - `test` builds the workspace and runs the full test corpus. +# - `audit` runs `cargo audit` against the RustSec advisory database to +# catch dependency CVEs without waiting for a human to remember to run +# it locally. +# +# Note: ltk uses Modified Allman style (not rustfmt's default), so there +# is no `cargo fmt --check` step here on purpose. If rustfmt ever ships +# a stable Allman config, add a third `fmt` job. +# +# Compatible syntax with GitHub Actions, so swapping host providers does +# not require touching this file. Gitea also reads `.github/workflows/` +# by default, but `.gitea/workflows/` is preferred for clarity. + +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +env: + CARGO_TERM_COLOR: always + # Surface the MSRV that `Cargo.toml` declares so the runner pins the + # toolchain to the same version the package is published against. + RUST_TOOLCHAIN: "1.85" + +jobs: + test: + name: build + test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + # ltk links against Wayland / EGL / xkbcommon at compile time + # through smithay-client-toolkit, wayland-egl and khronos-egl. + # The runner image does not ship these headers by default. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwayland-dev libegl-dev libxkbcommon-dev pkg-config + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - name: Cache cargo registry and target + uses: Swatinem/rust-cache@v2 + + - name: Build + run: cargo build --workspace --all-targets + + - name: Test + run: cargo test --workspace --all-targets + + # External-Markdown doctests. `cargo test --doc` only sees doctests + # inside `src/`; the cookbook and widget reference under `docs/` + # are stand-alone Markdown files that rustdoc has to be invoked + # against directly. The script discovers the freshly-built rlib + # from the `Build` step's `target/debug/` and runs `rustdoc --test` + # over each `.md`. Snippets are `no_run` so this only typechecks — + # the goal is to catch API drift, not integration-test the runtime. + - name: Markdown doctests + run: ./scripts/doctest-md.sh + + audit: + name: cargo audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: Swatinem/rust-cache@v2 + + - name: Install cargo-audit + # `--locked` so the audit tool itself does not pick up an + # advisory-affected transitive dep at install time. + run: cargo install cargo-audit --locked + + - name: Run cargo audit + # Default behaviour: fail on any active advisory. Add + # `--ignore RUSTSEC-...` here for advisories that have a + # documented mitigation upstream we cannot avoid yet. + run: cargo audit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3ad28d3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,136 @@ +# Contributing to ltk + +Thanks for considering a contribution. This document covers the practical +mechanics: how to set up your environment, how to run tests, what shape a +patch should take, and where to send it. + +For background on the toolkit itself, read +[`docs/onboarding.md`](docs/onboarding.md) and +[`docs/architecture.md`](docs/architecture.md) first. + +## Reporting bugs and proposing features + +Open an issue on the project repository before sending a non-trivial +patch. We want to align on scope before you spend time on an +implementation. For security-relevant issues see +[`SECURITY.md`](SECURITY.md) — those should *not* go through the public +issue tracker. + +When reporting a bug, include: + +- the `ltk` version (commit hash if you built from source), +- the Rust toolchain (`rustc --version`), +- the compositor and OS, +- a minimal reproducer (Rust source preferred over screenshots), +- whether the issue happens on both the GLES and the software backend + (set `LTK_FORCE_SOFTWARE=1` to force software). + +## Building and testing + +The project requires the Rust toolchain shipped with Debian stable +(currently 1.85). On Debian / Ubuntu: + +```bash +sudo apt-get install \ + libwayland-dev libegl-dev libxkbcommon-dev pkg-config + +git clone +cd ltk +cargo build +cargo test +``` + +The `Makefile` wraps the common targets: + +```bash +make all # cargo build --release +make test # cargo test +make audit # cargo audit (installs cargo-audit on first run) +make doc # cargo doc --no-deps +make example # run every example under examples/ in turn +make clean +``` + +Running the examples requires a Wayland session and the default theme +on disk: + +```bash +export LTK_THEMES_DIR="$PWD/themes" +cargo run --example showcase +``` + +## Code style + +`ltk` uses a custom **Modified Allman** style. `rustfmt`'s default +settings do not match it; do not run `cargo fmt`. The full rules live +in [`code_style_guide.md`](code_style_guide.md), but the headline points +are: + +- **tabs** for indentation (never spaces), +- opening `{` on its **own new line** for `fn`, `impl`, `struct`, + `enum`, `mod`, `if`, `for`, `while`, `match`, `loop`, +- `} else {` and `} else if … {` on the same line as the closing brace + (compact else), +- spaces inside non-empty parentheses: `fn foo( x: i32 )`, + `bar( arg )`, `Some( x )`, `Ok( v )`, +- spaces inside non-empty attribute brackets: `#[ derive( Clone, Debug ) ]`, +- no spaces inside `<>` generics: `Vec`, `Option`, +- comments in **English** — never another language. + +Match the surrounding code when in doubt. + +## Patch shape + +- Keep changes focused: one logical change per pull request. A bug fix + and a refactor go in separate PRs. +- Add tests. The repository has ~400 tests covering the existing + surface, and a contribution that adds behaviour without test + coverage will get review pushback. See [`tests/`](tests/) for + examples of integration tests via `UiSurface`, and the inline + `#[cfg(test)] mod tests` blocks under `src/` for unit tests. +- Document new public API. Every `pub` item exported from the crate + root is expected to have a `///` rustdoc comment with at minimum a + one-paragraph description and an example. Module-level `//!` + comments are required for new submodules. +- Don't break the public API surface without coordinating. Until the + crate hits `1.0`, breaking changes go in minor versions + (`0.x.0 → 0.(x+1).0`); patch versions (`0.x.y → 0.x.(y+1)`) keep + source compatibility. +- Run `cargo test` and `make audit` before sending. CI will run them + again, but it is faster for both of us if your local run is clean. + +## Architectural decisions worth knowing + +A few patterns recur across the codebase: + +- **Builder methods consume `self`** (`pub fn padding( mut self, p: f32 ) -> Self`). + Chaining works because every builder returns `Self`. Don't introduce + setters that take `&mut self`. +- **Layouts and widgets share `Element`.** Anything that converts + to `Element` can be pushed into any layout. The split between + [`crate::layout`] and [`crate::widget`] is documentation, not + architecture. +- **The runtime is single-threaded.** Use `RefCell` for caches inside + `App` state, never `Mutex`. Cross-thread communication goes through + [`ChannelSender`](src/app.rs). +- **`view()` must be pure.** No I/O, no allocation-heavy work, no + state mutation. Cache derived data on the app struct (behind + `RefCell` if needed) and look it up. +- **Theming is process-global.** There is no per-app theme; the active + document and mode live in a `RwLock>`. `view()` reads the + state, never writes it. Mode flips and document swaps go through + [`crate::set_active_mode`] and [`crate::set_active_document`]. +- **Per-frame allocations are fine.** Building the `Element` tree on + every render is the supported model. Don't try to retain widgets + across frames. + +## License + +By submitting a patch you agree it is licensed under +[`LGPL-2.1-only`](LICENSE), the same as the rest of the project. Do not +add `Co-Authored-By` lines or other AI attribution to commit messages. + +## Code of conduct + +Be respectful. Disagreements are welcome; personal attacks are not. +Maintainers will moderate threads that go off the rails. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..39d1a9d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "ltk" +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +license = "LGPL-2.1-only" +description = "Lightweight declarative Wayland UI toolkit. Elm-shaped App / view / update model with GLES and software rendering backends, layer-shell support, runtime theming and a runtime-free core surface for embedding." +repository = "https://github.com/liberux/ltk" +documentation = "https://docs.rs/ltk" +homepage = "https://liberux.net" +readme = "README.md" +keywords = [ "wayland", "ui", "gui", "toolkit", "layer-shell" ] +categories = [ "gui", "rendering", "os::linux-apis" ] +authors = [ "Liberux Labs, S. L. " ] +exclude = [ + "target/*", + "debian/*", + "themes/*", + "liberux.toml", + "TODO", +] + +[dependencies] +chrono = { version = "0.4", features = ["clock"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +smithay-client-toolkit = { version = "0.20", features = ["calloop", "calloop-wayland-source", "xkbcommon"] } +calloop = "0.14" +calloop-wayland-source = "0.4" +tiny-skia = "0.12" +fontdue = "0.9" +image = { version = "=0.25.2", default-features = false, features = ["png", "jpeg", "webp"] } +resvg = "0.44" +rust-i18n = "3" +wayland-protocols = { version = "0.32", features = ["client", "unstable"] } +wayland-egl = "0.32" +khronos-egl = { version = "6", features = ["dynamic"] } +glow = "0.17" +raw-window-handle = "0.6" + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "lookup" +harness = false diff --git a/LICENSE b/LICENSE index c6487f4..f6683e7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,176 +1,501 @@ -GNU LESSER GENERAL PUBLIC LICENSE + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Moe Ghoul, President of Vice -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice That's all there is to it! diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..40822cc --- /dev/null +++ b/Makefile @@ -0,0 +1,53 @@ +REGISTRY ?= /usr/share/cargo/registry/ltk +DOCDIR ?= /usr/share/doc/libltk-doc/html + +# Targets are recipes, not files. Listing them as PHONY tells make to +# ignore filesystem entries with the same name — without this `examples` +# silently no-ops because the `examples/` directory exists, and `doc` +# would do the same once `target/doc` is around. +.PHONY: all test doctest-md audit doc install examples clean distclean + +all: + cargo build --release + +test: + cargo test + +# Typecheck the `no_run` snippets in docs/*.md by feeding each markdown +# file to `rustdoc --test`. Catches API drift in cookbook + widget +# reference: drops a renamed builder, removed function, etc. surface +# here just like `cargo test --doc` does for in-source doctests. +doctest-md: + ./scripts/doctest-md.sh + +audit: + @command -v cargo-audit >/dev/null 2>&1 || cargo install cargo-audit --locked + cargo audit + +doc: + cargo doc --no-deps + +install: doc + install -d $(REGISTRY) + cp -r src Cargo.toml liberux.toml $(REGISTRY)/ + cp debian/cargo-checksum.json $(REGISTRY)/.cargo-checksum.json + install -d $(DOCDIR) + cp -r target/doc/* $(DOCDIR)/ + +examples: + LTK_THEMES_DIR=themes cargo run --release --example showcase + LTK_THEMES_DIR=themes cargo run --release --example inputs + LTK_THEMES_DIR=themes cargo run --release --example scroll + LTK_THEMES_DIR=themes cargo run --release --example sliders + LTK_THEMES_DIR=themes cargo run --release --example combo + LTK_THEMES_DIR=themes cargo run --release --example mini_shell + LTK_THEMES_DIR=themes cargo run --release --example widgets + LTK_THEMES_DIR=themes cargo run --release --example pickers + +clean: + dh_clean + cargo clean + rm -rf target + +distclean: clean + rm -f Cargo.lock diff --git a/README.md b/README.md index c014fba..b87985d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,376 @@ # ltk -Liberux ToolKit \ No newline at end of file +`ltk` is a public Rust UI toolkit for Wayland applications. + +It is developed by Liberux as part of the Eydos stack, where it powers +shell and application surfaces, but it is published as a reusable library for +third-party developers building their own Wayland software. + +Being written in Rust is also part of the project's value proposition: + +- memory safety without a garbage collector +- predictable resource lifetimes through ownership and borrowing +- good control over allocations and data movement in rendering-heavy code +- a strong fit for low-level UI, graphics, and system-integration work + +For a Wayland toolkit, that combination is useful in practice: it reduces an +entire class of memory-management bugs common in lower-level UI stacks while +still allowing tight control over performance-sensitive paths. + +## What It Is + +`ltk` is a lightweight, declarative toolkit with an Elm-shaped model: + +- implement `App` +- return an `Element` tree from `view()` +- update your state in `update()` +- run the event loop with `ltk::run(app)` + +The runtime handles layout, drawing, input dispatch, focus, overlays, and +backend selection between GLES and software rendering. + +## What It Is Not + +`ltk` is not: + +- a browser UI toolkit +- a cross-platform desktop toolkit +- a general-purpose web-style framework + +Today it is specifically a Wayland toolkit. If you are building native Wayland +applications, panels, launchers, lock screens, or other shell-adjacent +surfaces, it is in scope. If you need Windows, macOS, or browser targets, it +is not. + +## Project Status + +`ltk` is a public library intended for third-party use, but it is still shaped +by real production needs inside the Liberux / Eydos ecosystem. + +That means: + +- the API is usable for external applications today +- the project is optimized first for native Wayland workloads +- some advanced APIs are still more shell-oriented than app-oriented +- public documentation and examples are present, but the project is not trying + to present itself as a cross-platform beginner toolkit + +If you are evaluating `ltk` for a third-party application, the right mental +model is "public Wayland toolkit with production consumers" rather than +"experimental demo crate". + +## Why Third Parties Might Use It + +`ltk` is designed around a few practical goals: + +- low idle wakeups and event-driven redraws +- partial redraws and damage tracking +- a simple declarative tree instead of retained widgets +- direct support for normal windows and layer-shell surfaces +- a runtime-free core (`ltk::core::UiSurface`) for embedding layout and drawing + without `ltk::run()` + +This makes it especially relevant for: + +- Wayland applications +- mobile-first Linux shells +- launchers and dashboards +- greeters and lock screens +- compositor-side or embedded UI surfaces + +## Quick Start + +Add `ltk` to your `Cargo.toml`: + +```toml +[dependencies] +ltk = { path = "../ltk" } +``` + +Minimal app: + +```rust +use ltk::{ App, Element, button, column, spacer, text }; + +#[derive(Clone)] +enum Msg +{ + Increment, +} + +struct CounterApp +{ + value: u32, +} + +impl App for CounterApp +{ + type Message = Msg; + + fn view( &self ) -> Element + { + column::() + .padding( 32.0 ) + .spacing( 16.0 ) + .center_y( true ) + .push( text( "Hello from ltk" ).size( 28.0 ) ) + .push( text( format!( "Count: {}", self.value ) ).size( 18.0 ) ) + .push( spacer() ) + .push( button( "Increment" ).on_press( Msg::Increment ) ) + .into() + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::Increment => self.value += 1, + } + } +} + +fn main() +{ + ltk::run( CounterApp { value: 0 } ); +} +``` + +## Requirements + +`ltk` currently assumes: + +- **Rust 1.85** or newer (the toolchain shipped with Debian stable; declared + as `rust-version` in `Cargo.toml`). +- A running **Wayland** session — there is no X11 backend. +- System headers for `libwayland`, `libegl` and `libxkbcommon` at compile + time. On Debian / Ubuntu: + ```bash + sudo apt-get install libwayland-dev libegl-dev libxkbcommon-dev pkg-config + ``` +- A usable system font (`fonts-sora`, `fonts-liberation`, `fonts-dejavu`, + …). If none is installed `ltk` falls back to an embedded Sora Regular + build with a stderr warning. +- A theme named `default`, installed system-wide (the + `ltk-theme-default` Debian package drops it under + `/usr/share/ltk/themes/default/`) or exposed through + `LTK_THEMES_DIR` for development. + +Rendering backend selection is automatic: + +- **GLES** when EGL is available (every modern Wayland compositor). +- **Software** fallback otherwise. +- Set `LTK_FORCE_SOFTWARE=1` to force the software path even when EGL is + available — useful for headless test runs and for diagnosing + driver-specific bugs. + +For development inside this repository: + +```bash +export LTK_THEMES_DIR="$PWD/themes" +cargo run --example showcase +``` + +## Examples + +Useful entry points in this repository: + +- `cargo run --example showcase` +- `cargo run --example widgets` +- `cargo run --example inputs` +- `cargo run --example scroll` +- `cargo run --example mini_shell` + +In general: + +- start with `showcase` for a regular app window +- use `widgets` to see the core controls +- use `mini_shell` if you need overlays, theme switching, or shell-style + composition + +## Public API Overview + +Most applications should start with this subset: + +- `App` +- `Element` +- widgets such as `button`, `text`, `text_edit`, `image` +- layouts such as `column`, `row`, `stack`, `grid`, `spacer` +- `Color` +- `run` + +More advanced APIs are available when needed: + +- `overlays()` +- `shell_mode()` and layer-shell controls +- `set_channel_sender()` and `poll_external()` +- gesture hooks such as `on_swipe_*` +- `core::UiSurface` +- runtime theme APIs + +## Windows and Shell Surfaces + +By default, `ltk` creates a regular `xdg-shell` window. + +That is the right starting point for: + +- normal applications +- internal tools +- prototypes + +Switch to layer-shell only when you are building shell surfaces such as: + +- top bars +- docks +- homescreens +- notifications +- greeters +- lock screens + +## Performance Notes + +`ltk` is designed to sleep when idle and redraw only on real work. + +The main rules for downstream applications are: + +- keep `view()` pure and cheap +- do not perform I/O inside `view()` +- use `poll_interval()` sparingly +- return `true` from `is_animating()` only while something is actually moving +- cache decoded images and expensive derived state in your app + +The library already provides: + +- event-driven redraw scheduling +- per-surface invalidation +- partial redraws for interaction-only changes +- GPU and software backends behind the same widget API + +## Backend Differences + +The public API is the same across backends, but visual parity is not perfect +yet. The widget tree, layout, hit-testing, text, images, fills, strokes, +clipping and gradients all paint identically on both paths. The gap is in the +shadow / backdrop pipeline. + +Effects that currently render only on the **GLES** backend, and are silent +no-ops on the **Software** backend: + +- **Outer drop shadows** (`Canvas::fill_shadow_outer`) — themed surfaces that + declare a `Shadow` slot show the soft halo on GLES and a flat fill on + software. +- **Inner / inset shadows** (`Canvas::fill_shadow_inset`) — `InsetShadow` + slots paint nothing on software. +- **Inset shadow blend modes** — `PlusLighter`, `Multiply`, `Screen` and + `Overlay` are GLES-only; the GLES `Overlay` path snapshots the framebuffer + and computes the CSS Overlay formula in-shader, which has no software + equivalent today. + +Calls to these APIs are safe on both backends — they simply produce a flatter +appearance under software. No widget panics, returns an error, or skips +unrelated drawing. + +If your application leans heavily on shadows or inset effects, validate both +rendering paths before shipping. Force the software path with: + +```bash +LTK_FORCE_SOFTWARE=1 cargo run --example showcase +``` + +Closing this gap (porting the shadow / inset-shadow pipeline to tiny-skia) is +on the post-v0.1 roadmap. + +## Documentation + +| File | When to read it | +| --- | --- | +| [`docs/onboarding.md`](docs/onboarding.md) | First hour with the library — environment, first app, what to ignore at first. | +| [`docs/architecture.md`](docs/architecture.md) | Runtime model, overlays, animation, theming, performance and where the cost of a frame lives. | +| [`docs/widgets.md`](docs/widgets.md) | Per-widget catalogue: what each one is, when to use it, minimal example, see-also. | +| [`docs/theming.md`](docs/theming.md) | JSON theme schema, slot conventions, runtime APIs. | +| [`docs/cookbook.md`](docs/cookbook.md) | Concrete recipes — slide-in panels, password fields, runtime theme toggle, channel-driven state, embedding without `ltk::run`. | +| `cargo doc --open` | Per-item rustdoc for the public API. | +| [`SECURITY.md`](SECURITY.md) | How to report a vulnerability and what is in / out of scope. | +| [`CONTRIBUTING.md`](CONTRIBUTING.md) | Build, test, code style, patch shape. | + +Recommended reading order for a new contributor: + +1. run `examples/showcase.rs` +2. read `docs/onboarding.md` +3. browse `docs/widgets.md` for the catalogue +4. dip into `docs/cookbook.md` when you hit a specific shape +5. open `docs/architecture.md` once you need overlays, animations, or + runtime theming. + +## Relationship to Liberux and Eydos + +Liberux is the promoter and primary maintainer of `ltk`. + +The project exists because Eydos needs a native Wayland toolkit for its +own shell and application stack, but `ltk` is intentionally published as a +public library rather than kept as a private internal component. Third-party +developers are part of the intended audience. + +That origin matters because it explains the current priorities: + +- strong Wayland focus +- support for layer-shell and shell-style overlays +- attention to mobile power usage +- theming and runtime surfaces that fit an operating system environment + +## License + +This project is licensed under `LGPL-2.1-only`. + +That means third parties can use `ltk` in their own applications, including +proprietary ones, subject to the obligations of the GNU Lesser General Public +License v2.1. If you are planning a commercial or closed-source product, read +the license text carefully and make sure your distribution model complies with +it. + +See [LICENSE](./LICENSE). + +## Third-party assets + +`ltk`'s default theme bundles two third-party asset sets that travel under +their own licences. Anyone redistributing the toolkit (or a binary that +embeds the default theme) must propagate the attributions below. + +- **Symbolic icons** under `themes/default/icons/catalogue/` come from + Streamline's *Core Line Free* set, distributed under + [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/) + (CC BY 4.0). © Streamline. Some files have been modified for the + symbolic-tinting pipeline; details in + [`themes/default/icons/catalogue/LICENSE.md`](./themes/default/icons/catalogue/LICENSE.md). + Upstream: . +- **Sora Regular** (`src/theme/fallback/Sora-Regular.otf`) is the + embedded font fallback, distributed under + [SIL Open Font Licence 1.1](https://scripts.sil.org/OFL). + © 2019-2020 The Sora Project Authors, Jonathan Barnbrook, Julián Moncada. + Upstream: . + +The remaining artwork in the default theme — wallpapers, lockscreens, +launcher logo, brand-mark variants and per-application icons — is +original to Liberux Labs and travels under the toolkit's own +`LGPL-2.1-only` licence. + +The full Debian-style declaration of every asset and its licence lives +in [`debian/copyright`](./debian/copyright); that is the file the `.deb` +ships under `/usr/share/doc/libltk*/copyright`. + +## Contributing + +Patches and bug reports are welcome. Read +[`CONTRIBUTING.md`](CONTRIBUTING.md) for the practical mechanics: build +prerequisites, how to run tests, the project's Modified Allman code +style, and what shape a pull request should take. + +For security-sensitive issues see [`SECURITY.md`](SECURITY.md) — please +do not file those through the public issue tracker. + +If you are evaluating `ltk` for a third-party product and are unsure +whether your use case is in scope, open a discussion before writing +code. That is especially useful when you are: + +- missing an app-facing example, +- blocked by a shell-oriented assumption in the API, +- trying to understand whether a given platform target is realistic. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4e93074 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,127 @@ +# Security policy + +`ltk` is a UI toolkit for Wayland clients. It runs inside the trust boundary +of the application that links it: the toolkit decodes images, parses theme +JSON, lays out widget trees and drives a GLES / software renderer. A bug in +any of those paths can crash the host application or, in worst-case, expose +information that a co-tenant Wayland client should not have. + +This document describes how to report a vulnerability and what we consider in +or out of scope. + +## Supported versions + +`ltk` is in active development. Security fixes land on the latest released +minor version on crates.io and on the `master` branch in this repository. +Older minor versions are not patched. + +## Reporting a vulnerability + +Email **info@liberux.net** with: + +- a description of the issue, +- the smallest reproducer you can share (Rust source preferred), +- the `ltk` version and Rust toolchain you used, +- whether the issue is exploitable today or requires a follow-up condition. + +Please do **not** open a public GitHub issue, pull request, or discussion for +unpatched vulnerabilities. We aim to acknowledge reports within 5 working +days and ship a fix within 30 days for high-severity issues. If you do not +hear back, escalate by sending a follow-up email. + +You may use PGP if you prefer — request the current public key in your first +message and we will reply with it before sharing any sensitive details. + +## In scope + +- memory-safety bugs reachable from any public API on the `ltk` crate root + or its `core::UiSurface` runtime-free surface, +- panics or undefined behaviour triggered by a theme JSON document, a font + file, or an image buffer that an application would reasonably accept from + user content (e.g. a wallpaper picker, a notification icon), +- denial-of-service through unbounded memory growth in caches or layout + recursion that the toolkit performs on behalf of the application, +- credential leaks in widgets that expose a `secure` mode (currently + `TextEdit`) — for example, plaintext password residue in heap dumps after + the widget is dropped. + +## Out of scope + +- bugs that require a malicious Wayland compositor: ltk treats the + compositor as part of the trusted computing base and does not defend + against compositor-level spoofing, layer-shell privilege misuse, or + protocol-level attacks, +- bugs that require an attacker to set environment variables on the user's + process (`LTK_THEMES_DIR`, `LTK_FORCE_SOFTWARE`, `XDG_DATA_HOME`): + ltk is not designed to run inside setuid binaries or anywhere the env is + not under the user's control, +- bugs in third-party dependencies (`tiny-skia`, `fontdue`, `image`, + `smithay-client-toolkit`, …): report those upstream. We will track the + fix and bump the dependency in a release note. + +## Hardening features + +- `TextEdit::secure( true )` zeroizes the underlying string buffer on drop + via `secure_mem::secure_zero` (volatile writes + a `compiler_fence` so + the optimiser cannot elide the wipe). The guarantee covers two copies + the runtime owns: the `TextEdit` itself and the per-frame + `WidgetHandlers::TextEdit` snapshot the layout pass produces for input + dispatch — both implement `Drop` and run the wipe before the + allocation returns to the allocator. The next `view()` rebuild + replaces the previous frame's snapshot, so the typical lifecycle is + "one frame on the heap, then overwritten on drop". Single-line and + multiline are mutually exclusive with secure (passwords have no line + breaks); the text-input-v3 IME path is also skipped in secure mode so + preedit / commit strings never reach the compositor's IME stack. + + ### What `TextEdit::secure` does **not** cover + + The wipe only reaches buffers ltk allocated. Callers retain + responsibility for everything outside the widget tree: + + - **Application-owned `String`s.** `text_edit( "Pwd", &self.password )` + only borrows `self.password`; the credential lives on the consumer's + struct. Wipe it explicitly (via `secure_mem::secure_zero` on + `password.as_bytes_mut()`) once the auth handshake completes, or put + the credential in a wrapper type with a `Drop` impl that does the + wipe. + - **`on_change` callback copies.** Each keystroke fires a closure with + a fresh `String` clone of the current value. If the closure stores + or forwards that value (e.g. to a worker thread for PAM), each + stored copy is the consumer's responsibility — ltk no longer owns + it past the closure call. + - **OS-level disclosure surfaces.** Swap, hibernation images, and + core dumps are outside any user-space wipe's reach. For threat + models that require resistance to these, link an `mlock`-aware + allocator, restrict the process's `PR_SET_DUMPABLE` to `0`, and + consider running on a no-swap mount. ltk itself does not call + `mlock` because the buffers it owns are short-lived (single frame) + and `mlock`-ing them per-frame would dominate the cost without + materially improving the threat model. + - **Compositor-side state.** The compositor sees pointer / keyboard + events for the surface but never the rendered glyphs (since + `secure` paints bullets). Custom IMEs that the user has installed + can still observe keystrokes — that is an OS-level concern outside + ltk's trust boundary. + + Login / lock-screen consumers should pair `TextEdit::secure` with an + explicit wipe of their own state on the success path. The + [`docs/cookbook.md`](./docs/cookbook.md) "Password field with PAM + submit" recipe shows the canonical shape: clear `self.password` after + spawning the worker thread so the in-flight clone in the worker is + the only remaining copy, and let the worker's drop-on-completion run + its own wipe. +- `draw_image_data` (both backends) refuses to upload a buffer whose length + does not match `width × height × 4`. The mismatch path logs once and + draws nothing. +- Glyph caches and gradient stops have soft caps to bound memory growth + under unusual content. Exceeding the cap drains older entries (glyphs) + or truncates the input (gradient stops) with a stderr warning. + +## Supply chain + +ltk targets the Rust toolchain shipped with Debian stable (currently 1.85). +Direct dependencies are pinned at the minor-version level except `image`, +which is pinned to an exact version because the crate's MSRV and feature +surface change between patch releases. We monitor RustSec advisories +manually; running `cargo audit` in your own CI is recommended. diff --git a/TODO b/TODO new file mode 100644 index 0000000..640a245 --- /dev/null +++ b/TODO @@ -0,0 +1 @@ +- Backend winit + wgpu diff --git a/benches/lookup.rs b/benches/lookup.rs new file mode 100644 index 0000000..ab5aac2 --- /dev/null +++ b/benches/lookup.rs @@ -0,0 +1,150 @@ +//! Per-frame dispatch hot path benches. +//! +//! The renderer rebuilds the widget tree on every frame, then hit-tests +//! pointer events against the resulting flat `Vec` and +//! looks up handlers by `flat_idx`. Both calls are O(N) in the slice +//! length. Run with `cargo bench --bench lookup`. + +use criterion::{ black_box, criterion_group, criterion_main, BenchmarkId, Criterion }; + +use ltk::test_support::{ find_handlers, find_widget, find_widget_at, LaidOutWidget, WidgetHandlers }; +use ltk::{ Point, Rect }; + +// ── Fixture builders ────────────────────────────────────────────────────────── + +/// Build N widgets laid out in a vertical strip. Each widget is 100×30 with a +/// 1px gap, so `find_widget_at(x=50, y=k*31+15)` lands inside widget `k`. Half +/// of them carry a Button handler so `find_handlers` exercises the +/// match-and-clone path on roughly every other lookup. +fn build_widgets( n: usize ) -> Vec> +{ + ( 0 .. n ).map( |i| + { + let rect = Rect + { + x: 0.0, + y: ( i as f32 ) * 31.0, + width: 100.0, + height: 30.0, + }; + let handlers = if i % 2 == 0 + { + WidgetHandlers::Button { on_press: Some( () ), on_long_press: None, on_drag_start: None, on_escape: None, repeating: false } + } else { + WidgetHandlers::None + }; + LaidOutWidget + { + rect, + flat_idx: i, + id: None, + paint_rect: rect, + handlers, + keyboard_focusable: true, + } + } ).collect() +} + +// ── Benches ─────────────────────────────────────────────────────────────────── + +/// Pointer hit testing on a hot path. Three sub-benches: +/// - last: hit the last widget (worst case under reverse iteration is the +/// *first* widget; the helper iterates from the end to honour +/// "topmost wins", so the last widget in slice order is the cheap +/// one and the first is the expensive one). +/// - first: hit the first widget — full-slice walk. +/// - miss: point that lies outside every rect — also a full-slice walk +/// but never returns early. +fn bench_find_widget_at( c: &mut Criterion ) +{ + let mut group = c.benchmark_group( "find_widget_at" ); + + for &n in &[ 10usize, 100, 1000 ] + { + let widgets = build_widgets( n ); + + // Hit on the last widget in slice order — `find_widget_at` walks in + // reverse, so this returns on the first iteration. + let last_y = ( n as f32 - 1.0 ) * 31.0 + 15.0; + group.bench_with_input( BenchmarkId::new( "hit_last", n ), &n, |b, _| + { + b.iter( || + { + let p = Point { x: 50.0, y: last_y }; + black_box( find_widget_at( black_box( &widgets ), black_box( p ) ) ) + } ); + } ); + + // Hit on the first widget — full reverse walk. + group.bench_with_input( BenchmarkId::new( "hit_first", n ), &n, |b, _| + { + b.iter( || + { + let p = Point { x: 50.0, y: 15.0 }; + black_box( find_widget_at( black_box( &widgets ), black_box( p ) ) ) + } ); + } ); + + // Miss — point sits to the right of every rect. + group.bench_with_input( BenchmarkId::new( "miss", n ), &n, |b, _| + { + b.iter( || + { + let p = Point { x: 9_999.0, y: 9_999.0 }; + black_box( find_widget_at( black_box( &widgets ), black_box( p ) ) ) + } ); + } ); + } + + group.finish(); +} + +/// `flat_idx` lookup. Linear scan; we measure the worst case (last entry) +/// since it's what bounds latency on real frames. +fn bench_find_widget( c: &mut Criterion ) +{ + let mut group = c.benchmark_group( "find_widget" ); + + for &n in &[ 10usize, 100, 1000 ] + { + let widgets = build_widgets( n ); + let target = n - 1; + + group.bench_with_input( BenchmarkId::new( "last", n ), &n, |b, _| + { + b.iter( || + { + black_box( find_widget( black_box( &widgets ), black_box( target ) ) ) + } ); + } ); + } + + group.finish(); +} + +/// Same shape as `find_widget` but exercises the handler-cloning path on hit. +/// Useful as a separate measurement because the clone cost grows with the +/// handler payload, even though the scan itself does not. +fn bench_find_handlers( c: &mut Criterion ) +{ + let mut group = c.benchmark_group( "find_handlers" ); + + for &n in &[ 10usize, 100, 1000 ] + { + let widgets = build_widgets( n ); + let target = n - 1; + + group.bench_with_input( BenchmarkId::new( "last", n ), &n, |b, _| + { + b.iter( || + { + black_box( find_handlers( black_box( &widgets ), black_box( target ) ) ) + } ); + } ); + } + + group.finish(); +} + +criterion_group!( benches, bench_find_widget_at, bench_find_widget, bench_find_handlers ); +criterion_main!( benches ); diff --git a/code_style_guide.md b/code_style_guide.md new file mode 100755 index 0000000..1f7e45f --- /dev/null +++ b/code_style_guide.md @@ -0,0 +1,169 @@ +# Code Style Guide + +This project follows a **Modified Allman** brace and formatting style. +It prioritizes **visual clarity, symmetry, and logical separation of blocks** over strict line compactness. + +--- + +## Overview + +### Key Principles +- Indentation with **tabs** (width = 4). +- **Opening braces `{`** always start on a new line. +- **`} else {`** and **`} catch {`** appear on the same line for compact flow. +- **Spaces inside parentheses only when non-empty:** + - `( arg1, arg2 )` + - `main()` +- **Classes and structs:** PascalCase +- **Variables, objects, and functions:** camelCase +- **Constants/macros:** UPPER_CASE_WITH_UNDERSCORES + +--- + +## Examples + +### `if / else` structure +```cpp +if ( condition ) +{ + doSomething(); +} else { + doSomethingElse( arg1, arg2 ); +} +``` + +### `try / catch` structure +```cpp +try +{ + runOperation(); +} catch ( const std::exception& ex ) { + handleError( ex ); +} +``` + +### Class and method naming +```cpp +class DataAnalyzer +{ +public: + DataAnalyzer( const std::string& filePath ) + : filePath( filePath ) + { + } + + void processDataAndGenerateReport() + { + try + { + loadDataFromFile( filePath ); + analyzeResultsAndPrintSummary(); + } catch ( const std::runtime_error& error ) { + std::cerr << "Error: " << error.what() << std::endl; + } + } + +private: + std::string filePath; + + void loadDataFromFile( const std::string& path ) + { + if ( path.empty() ) + { + throw std::runtime_error( "File path cannot be empty." ); + } else { + std::cout << "Loading data from: " << path << std::endl; + } + } + + void analyzeResultsAndPrintSummary() + { + std::cout << "Analyzing data..." << std::endl; + std::cout << "Summary: OK" << std::endl; + } +}; +``` + +### `main()` function +```cpp +int main() +{ + DataAnalyzer analyzer( "results.csv" ); + + try + { + analyzer.processDataAndGenerateReport(); + } catch ( const std::exception& ex ) { + std::cerr << "Unhandled exception: " << ex.what() << std::endl; + } + + return 0; +} +``` + +--- + +## `.clang-format` configuration + +Save this file as `.clang-format` in your project root: + +```yaml +# === Style: Modified Allman (tabs, compact else/catch, and selective parentheses spacing) === +# Naming conventions: +# · Classes and structs: PascalCase (e.g., MyClass, PlayerController) +# · Variables, objects, and functions: camelCase (e.g., myVariable, calculateScore) +# · Constants or macros: UPPER_CASE_WITH_UNDERSCORES +# · if/else and try/catch share the same brace layout: +# if ( condition ) +# { +# ... +# } else { +# ... +# } +# try +# { +# ... +# } catch ( const std::exception& e ) { +# ... +# } + +BasedOnStyle: LLVM + +IndentWidth: 4 +UseTab: Always + +BraceWrapping: + AfterClass: true + AfterControlStatement: true + AfterEnum: true + AfterFunction: true + AfterNamespace: true + AfterStruct: true + AfterUnion: true + BeforeCatch: false + BeforeElse: false + IndentBraces: false + SplitEmptyFunction: false + SplitEmptyRecord: false + SplitEmptyNamespace: false + +AllowShortIfStatementsOnASingleLine: false +AllowShortLoopsOnASingleLine: false +AllowShortFunctionsOnASingleLine: false + +SpaceBeforeParens: Always +SpaceInEmptyParentheses: false +SpaceInParens: true +SpacesInAngles: false +SpacesInCStyleCastParentheses: true +SpacesInSquareBrackets: false + +KeepEmptyLinesAtTheStartOfBlocks: true +ColumnLimit: 0 +AlignConsecutiveAssignments: true +AlignConsecutiveDeclarations: true +AlignEscapedNewlines: Left +AlignOperands: true +AlignTrailingComments: true + +BreakBeforeBraces: Allman diff --git a/debian/cargo-checksum.json b/debian/cargo-checksum.json new file mode 100644 index 0000000..0f8c576 --- /dev/null +++ b/debian/cargo-checksum.json @@ -0,0 +1 @@ +{"package":null,"files":{}} diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..e9a533a --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +ltk (0.1.0-1) unstable; urgency=low + + * Initial Debian packaging. + + -- Pedro M. de Echanove Pasquin Tue, 10 Mar 2026 00:00:00 +0200 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..5a5d06a --- /dev/null +++ b/debian/control @@ -0,0 +1,59 @@ +Source: ltk +Section: libdevel +Priority: optional +Maintainer: Pedro M. de Echanove Pasquin +Build-Depends: debhelper-compat (= 13), libxkbcommon-dev +Rules-Requires-Root: no +Standards-Version: 4.7.0 +Homepage: https://liberux.net + +Package: libltk-dev +Architecture: all +Multi-Arch: foreign +Depends: ${misc:Depends} +Suggests: libltk-doc +Description: Liberux ToolKit — Rust UI toolkit (development files) + ltk is the UI toolkit for the Liberux desktop. It provides widgets + (buttons, text inputs, labels, images), layout containers (column, row, + stack) and a Wayland event loop built on smithay-client-toolkit, + tiny-skia and fontdue. + . + This package contains the Rust crate sources required to build projects + that depend on ltk. + +Package: libltk-doc +Architecture: all +Multi-Arch: foreign +Section: doc +Depends: ${misc:Depends} +Description: Liberux ToolKit — API documentation + HTML reference documentation for the ltk Rust crate, generated by + cargo doc. Covers the App trait, all widgets, layout containers and + primitive types. + . + Browse the docs at /usr/share/doc/libltk-doc/html/ltk/index.html. + +Package: ltk-theme-default +Architecture: all +Multi-Arch: foreign +Section: misc +Provides: ltk-theme +Depends: ${misc:Depends}, fonts-sora, fonts-noto-core, fonts-noto-cjk +Description: Liberux ToolKit — default Eydos theme assets + Wallpapers, lockscreen images and theme.json shipped as the Eydos + shell's default theme. Installed under /usr/share/ltk/themes/default/ + where the Liberux shells (crustace, loginmanager, …) pick them up at + startup via ltk::ThemeDocument::find("default"). Override the search + root with the LTK_THEMES_DIR environment variable for in-tree + development. + . + Depends on fonts-sora rather than re-shipping the Sora typeface so we + stay aligned with Debian policy on duplicate file content. The + theme.json references the OTFs at their system-wide install path + (/usr/share/fonts/opentype/sora/Sora-{Light,Regular,SemiBold,Bold}.otf); + fontdue reads them directly so widgets get the four canonical weights + (300, 400, 600, 700) without going through fontconfig. + . + Provides the virtual package ltk-theme, so any Liberux app that + Depends on ltk-theme can be satisfied by installing this package (or + any other implementer, e.g. ltk-theme-). diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..69f3ae2 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,198 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: ltk +Upstream-Contact: Liberux Labs, S. L. +Source: https://liberux.net + +Files: * +Copyright: 2026 Liberux Labs, S. L. +License: LGPL-2.1-only + +Files: debian/* +Copyright: 2026 Liberux Labs, S. L. +License: LGPL-2.1-only + +Files: themes/default/branding/* + themes/default/icons/apps/* + themes/default/icons/app-default.svg +Copyright: 2026 Liberux Labs, S. L. +License: LGPL-2.1-only +Comment: + Wallpapers, lockscreens, launcher logos (under + themes/default/branding/) and per-application icons (under + themes/default/icons/apps/, plus the `app-default.svg` fallback) + are original artwork by Liberux Labs, distributed under the same + LGPL-2.1-only terms as the rest of the toolkit. Raster variants + (`*.webp` under branding/{light,dark}/{wallpaper,lockscreen}/) + are pre-rendered from the canonical SVGs in the same directory + and inherit the same licence. + +Files: themes/default/icons/catalogue/* +Copyright: 2024 Streamline (https://streamlinehq.com) +License: CC-BY-4.0 +Comment: + Symbolic glyphs under themes/default/icons/catalogue/{filled,line}/ + are sourced from Streamline's *Core Line Free* set, distributed + under Creative Commons Attribution 4.0 International. Some of the + SVG files have been modified from the upstream collection (typical + edits include viewBox normalisation, removal / normalisation of + inline fill / stroke so `theme::tint_symbolic` controls the final + colour at runtime, ID renames, and path simplification at small + chrome sizes); which files were touched and which were not is not + tracked per icon — treat any file in this directory as potentially + differing from the upstream release and fetch from upstream if a + pristine copy is required. This directory-wide declaration satisfies + CC BY 4.0 §3(a)(1)(B); the full text is in + themes/default/icons/catalogue/LICENSE.md. The runtime + `theme::tint_symbolic` pass is a separate transformation of the + rendered RGBA output and is not a modification of the on-disk SVGs. + Upstream collection: https://www.streamlinehq.com/icons/core-line-free + Attribution shorthand: "Icons by Streamline, CC BY 4.0". + +Files: src/theme/fallback/Sora-Regular.otf + src/theme/fallback/Sora-LICENSE.txt +Copyright: 2019-2020 The Sora Project Authors + 2020 Jonathan Barnbrook + 2020 Julián Moncada +License: OFL-1.1 +Comment: + Sora Regular is bundled as the last-resort font fallback used by + `Canvas::new` / `Canvas::new_gles` when no system font from the + candidate chain (`fonts-sora`, `fonts-liberation`, `fonts-dejavu`, + …) is installed. The full OFL 1.1 text is reproduced in the + sibling `Sora-LICENSE.txt`. Upstream: + https://github.com/sora-xor/sora-font + +License: LGPL-2.1-only + This library is free software; you can redistribute it and/or modify it + under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation; version 2.1 of the License. + . + This library is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser + General Public License for more details. + . + On Debian systems, the full text of the GNU Lesser General Public + License version 2.1 can be found in the file + `/usr/share/common-licenses/LGPL-2.1'. + +License: OFL-1.1 + ----------------------------------------------------------- + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + ----------------------------------------------------------- + . + PREAMBLE + The goals of the Open Font License (OFL) are to stimulate worldwide + development of collaborative font projects, to support the font creation + efforts of academic and linguistic communities, and to provide a free and + open framework in which fonts may be shared and improved in partnership + with others. + . + The OFL allows the licensed fonts to be used, studied, modified and + redistributed freely as long as they are not sold by themselves. The + fonts, including any derivative works, can be bundled, embedded, + redistributed and/or sold with any software provided that any reserved + names are not used by derivative works. The fonts and derivatives, + however, cannot be released under any other type of license. The + requirement for fonts to remain under this license does not apply + to any document created using the fonts or their derivatives. + . + DEFINITIONS + "Font Software" refers to the set of files released by the Copyright + Holder(s) under this license and clearly marked as such. This may + include source files, build scripts and documentation. + . + "Reserved Font Name" refers to any names specified as such after the + copyright statement(s). + . + "Original Version" refers to the collection of Font Software components as + distributed by the Copyright Holder(s). + . + "Modified Version" refers to any derivative made by adding to, deleting, + or substituting -- in part or in whole -- any of the components of the + Original Version, by changing formats or by porting the Font Software to a + new environment. + . + "Author" refers to any designer, engineer, programmer, technical + writer or other person who contributed to the Font Software. + . + PERMISSION & CONDITIONS + Permission is hereby granted, free of charge, to any person obtaining + a copy of the Font Software, to use, study, copy, merge, embed, modify, + redistribute, and sell modified and unmodified copies of the Font + Software, subject to the following conditions: + . + 1) Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + . + 2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + . + 3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + . + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + . + 5) The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + . + TERMINATION + This license becomes null and void if any of the above conditions are + not met. + . + DISCLAIMER + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + OTHER DEALINGS IN THE FONT SOFTWARE. + +License: CC-BY-4.0 + Creative Commons Attribution 4.0 International Public Licence (CC BY 4.0). + . + You are free to: + . + * Share — copy and redistribute the material in any medium or format. + * Adapt — remix, transform, and build upon the material for any + purpose, even commercially. + . + The licensor cannot revoke these freedoms as long as you follow the + licence terms. + . + Under the following terms: + . + * Attribution — You must give appropriate credit, provide a link to + the licence, and indicate if changes were made. You may do so in + any reasonable manner, but not in any way that suggests the + licensor endorses you or your use. + * No additional restrictions — You may not apply legal terms or + technological measures that legally restrict others from doing + anything the licence permits. + . + The full text of this licence is reproduced verbatim at: + https://creativecommons.org/licenses/by/4.0/legalcode + . + A human-readable summary (which is not a substitute for the licence) is + available at: + https://creativecommons.org/licenses/by/4.0/ + . + The licence text above is a summary; the canonical legal text at the URL + is what governs use, distribution and modification of the Licensed + Material referenced under `Files: themes/default/icons/catalogue/*`. diff --git a/debian/ltk-theme-default.install b/debian/ltk-theme-default.install new file mode 100644 index 0000000..5b3828a --- /dev/null +++ b/debian/ltk-theme-default.install @@ -0,0 +1 @@ +themes/default usr/share/ltk/themes/ diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..86b01a0 --- /dev/null +++ b/debian/rules @@ -0,0 +1,19 @@ +#!/usr/bin/make -f +include /usr/share/dpkg/pkg-info.mk + +REGISTRY = debian/libltk-dev/usr/share/cargo/registry/ltk-$(DEB_VERSION_UPSTREAM) +DOCDIR = debian/libltk-doc/usr/share/doc/libltk-doc/html + +%: + dh $@ + +override_dh_auto_configure: +override_dh_auto_build: +override_dh_auto_test: + +override_dh_auto_install: + $(MAKE) install REGISTRY=$(REGISTRY) DOCDIR=$(DOCDIR) + +override_dh_auto_clean: + $(MAKE) clean + dh_clean diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..d3827e7 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +1.0 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f94faa8 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,247 @@ +# ltk architecture + +If you are new to the library, start with [`docs/onboarding.md`](./onboarding.md) +first. This document assumes you already know how to run an example and what +kind of application surface you are trying to build. + +This document covers the patterns that the small `examples/` files cannot show: how a real application is structured on top of the [`App`] trait, how multiple surfaces coordinate, how theming is consumed, how to build animations, and where the cost of a frame actually lives. + +For copy-pasteable patterns the canonical references are the two downstream consumers in the Eydos workspace: + +- **crustace** (`crustace/src/`) — the Eydos shell. Layer-shell background surface + 8 overlays, system polling, MPRIS, notifications, animated OSD. +- **loginmanager** (`loginmanager/src/`) — greeter. `keyboard_exclusive`, single overlay, focus management, async PAM via `set_channel_sender`. + +The rest of this document explains *why* those repos look the way they do. + +If you are coming from `cargo doc`, keep the public API split in mind: + +- `ltk::window` — normal application windows +- `ltk::shell` — layer-shell and overlays +- `ltk::runtime` — advanced runtime hooks and runtime-free embedding + +This document mostly lives in the overlap between `ltk::shell` and +`ltk::runtime`. If you only want to build a plain app window, stay with +`docs/onboarding.md` and the `ltk::window` surface first. + +## Mental model + +ltk is Elm-shaped. The application is a value implementing [`App`]; ltk drives the loop and the application reacts. + +Every frame: ltk calls `view()` and `overlays()`, lays out the returned tree(s), draws them, and dispatches input events back as `Message` values which are fed to `update()`. There are no retained widgets. `Element` is rebuilt from scratch every frame from the application's own state. + +This sounds expensive and is actually fine. The widget tree is plain enums, the layout pass is a single recursive walk that already has to happen anyway, and as of the `WidgetHandlers` snapshot work the input dispatch path no longer rebuilds the tree per event. The only thing the app must avoid in `view()` is *I/O* (reading files, scanning directories, walking icon caches) — keep those in `poll_external` or behind a `RefCell` cache. + +In practice, that model is easiest to adopt in three steps: + +1. Start with the `ltk::window` mental model: one app state, one `view()`, one `update()`, one normal window. +2. Add `ltk::shell` concepts only if you need layer-shell or overlays. +3. Reach for `ltk::runtime` hooks only when you need async wakeups, invalidation narrowing, or embedding outside `ltk::run()`. + +## The trait surface, by purpose + +`App` looks intimidating — most of it is opt-in. Group the methods by what you actually need: + +**Always implement** + +- `type Message` — your message enum. +- `view(&self) -> Element` — main surface contents. +- `update(&mut self, msg: Msg)` — state transitions. + +**Implement when your app is multi-surface** + +- `overlays(&self) -> Vec>` — see *Surface composition* below. + +**Implement when your app is a shell component, not a window** + +- `shell_mode()` → `ShellMode::Layer( Layer::Background | Bottom | Top | Overlay )`. +- `layer_anchor()`, `layer_size()`, `exclusive_zone()`, `keyboard_exclusive()` — the layer-shell knobs. +- `background_color()` → `Color::rgba( 0, 0, 0, 0 )` for transparent surfaces (panels, OSDs). + +**Implement when external state matters** + +- `set_channel_sender(sender)` — saved once at startup; clone into background threads to push messages into the loop without polling. +- `poll_external() -> Vec` — called after every Wayland event *and* every `poll_interval()` tick. Drain receivers here. +- `poll_interval()` — `None` (event-driven only) or `Some( Duration )` (timer wakeups for clocks, expiry, etc.). + +**Implement when input gestures matter** + +- `on_swipe_up`, `on_swipe_down`, `on_swipe_progress`, `on_swipe_down_progress` (follow-the-finger). +- `on_tap` — taps that miss every widget. +- `on_key` / `on_key_with_modifiers` — global hotkeys. +- `swipe_threshold`, `swipe_down_threshold` — gesture sensitivity. + +**Implement for animations and focus** + +- `is_animating()` — return `true` while a tween is running; the loop redraws at ~60 Hz. +- `take_focus_request()` → `Option` — pull-once focus retargeting. +- `on_text_input_focused(active)` — surface IME state. + +The defaults for everything else are sensible enough that a minimal app overrides only the four methods in the first group. + +Another way to read the trait is by API layer: + +- `ltk::window`: `view`, `update`, plus the widgets/layouts you use to build the tree. +- `ltk::shell`: `shell_mode`, `layer_anchor`, `layer_size`, `exclusive_zone`, `keyboard_exclusive`, `overlays`. +- `ltk::runtime`: `set_channel_sender`, `poll_external`, `poll_interval`, `invalidate_after`, `take_focus_request`, `is_animating`, and `core::UiSurface`. + +That is the intended order of adoption for third-party users. + +## Surface composition + +The main surface is what `view()` paints. `overlays()` returns a `Vec>` describing additional layer-shell surfaces that should exist this frame. The runtime diffs that list against the previous frame using [`OverlayId`]: + +- Same id present last frame and this frame → keep the surface alive, only re-render its `view`. +- New id → create a new layer-shell surface. +- Id missing → destroy the surface. + +This is why crustace declares stable `const OVERLAY_LAUNCHER: OverlayId = OverlayId(1)` etc. at the top of `app.rs`. Don't allocate ids dynamically — diffing relies on stability. + +Each overlay carries its own `view`, `anchor`, `size`, `layer`, `keyboard_exclusive`, `input_region`, and `on_dismiss`. The `Message` type is shared with the main app: a button inside an overlay produces the same `Msg` that a button on the main surface would, and `update()` handles both. There is no per-overlay state machine — overlays are pure projections of `App` state. + +`on_dismiss` is fired by three independent paths: a `popup_done` event from the compositor (xdg-popup mode); a pointer / touch press on the main surface that does not land on the trigger pointed at by `anchor_widget_id` while the overlay is mapped (covers compositors that route the button to the parent surface instead of breaking the popup grab); and Escape pressed while at least one xdg-popup overlay is open. The application only has to flip its `is_open` flag to `false` in `update()`; the runtime tolerates the message arriving more than once for the same open / close cycle. + +Common patterns: + +- *Modal panel*: `layer: Overlay`, `anchor: ALL`, `keyboard_exclusive: false`, `on_dismiss: Some( CloseMsg )`. Tap-outside dismisses; the panel itself centers via `column().push(spacer()).push(panel).push(spacer())`. +- *Pass-through OSD*: same as above but `input_region: Some(Vec::new())` so pointer events fall through to whatever is below. +- *Top bar / dock*: `layer: Top` or `Bottom`, `anchor: TOP`/`BOTTOM`, fixed `size`, non-zero `exclusive_zone` so app windows reflow around it. Usually returned from `view()` (single-purpose shell), not from `overlays()`. +- *Greeter / lock screen*: `shell_mode: Layer(Overlay)`, `keyboard_exclusive: true`. Loginmanager is the reference. + +Overlays do not nest. A "submenu inside the quick settings panel" is just a second overlay with a different id whose `view()` builds the submenu. Crustace uses this for the WiFi and Bluetooth pickers. + +If your application does not need overlays or layer-shell, you can ignore this +entire section and stay in the `ltk::window` subset. + +## Theming + +`ltk::theme` exposes a process-wide active theme. Three layers: + +1. **Document** — a [`ThemeDocument`] loaded from disk (`/usr/share/ltk/themes//theme.json`). Each document carries a `light` and `dark` [`Mode`] with a typed [`SlotStore`] (colors, paints, shadows, surfaces, text styles), wallpaper/lockscreen/launcher specs and a shared `fonts` block. When the `default` document cannot be located ltk falls back to an embedded B/W theme + embedded Sora Regular font, logs a stderr warning, and stamps every frame with a red banner pointing at the `ltk-theme-default` Debian package so the missing-theme signal is visible without the process aborting. `ltk::is_fallback_active()` exposes the state for apps that want to react programmatically. +2. **Mode** — [`ThemeMode::Light`] or `Dark`; flips which mode of the document is active. +3. **Active state** — `ltk::active_document()` / `ltk::active_mode()` return the current pair. Per-slot shorthands (`ltk::theme_color`, `theme_paint`, `theme_shadows`, `theme_surface`, `theme_text_style`, `theme_palette`, `theme_window_controls`, `theme_wallpaper`, `theme_lockscreen`) cover the common patterns. + +Inside a widget tree, read the palette through the per-slot helper: + +```rust,no_run +# fn _ex() { +let _label = ltk::text( "Hello" ) + .color( ltk::theme_palette().text_primary ); +# } +``` + +To switch theme at runtime, dispatch a message that calls `ltk::set_active_mode( ThemeMode::Dark )` from `update()` and let the next frame re-resolve. There is no manual invalidation step. + +Loading a different document: + +```rust +let doc = ltk::ThemeDocument::find( "default" ) + .expect( "default theme not installed (ltk-theme-default)" ); +ltk::set_active_document( doc ); +``` + +For dev iteration set `LTK_THEMES_DIR=/path/to/ltk/themes` so the lookup picks files in the working tree before the system path. The full search order is: + +1. `LTK_THEMES_DIR//` when the env var is set +2. `$XDG_DATA_HOME/ltk/themes//` (defaults to `~/.local/share/ltk/themes//`) +3. `/usr/share/ltk/themes//` + +Wallpapers ship as a single landscape PNG per variant. `ltk::WallpaperBundle::from_path_or_bytes( path, bundled_fallback )` handles the disk-or-builtin fallback, and `bundle.for_size( sw, sh )` returns the right crop for landscape *or* portrait surfaces — no need to ship two PNGs. + +For many third-party apps, theming is optional at first. It is reasonable to +start with the default theme and come back to the runtime theme APIs later as +part of the `ltk::runtime` layer. + +## Animations + +The render loop is event-driven by default: it sleeps until input arrives, a `poll_interval` ticks, or `set_channel_sender` is woken from a thread. To run a tween, override `is_animating()`: + +```rust,no_run +# struct App { toast: Option<()>, nav_progress: f32 } +# impl App { +fn is_animating( &self ) -> bool +{ + self.toast.is_some() // an OSD is fading + || self.nav_progress < 1.0 // a screen is sliding +} +# } +``` + +While `is_animating()` returns `true`, ltk redraws at ~60 Hz. Do *not* mutate state in `view()`; instead read `Instant::now()` against a stored start time and compute the tween value: + +```rust,no_run +# use std::time::Instant; +# use ltk::Element; +# const TOAST_DURATION: f32 = 3.0; +# #[ derive( Clone ) ] enum Msg {} +# struct App { toast_started: Option } +# impl App { +fn view( &self ) -> Element +{ + let progress = match self.toast_started + { + Some( t ) => ( t.elapsed().as_secs_f32() / TOAST_DURATION ).min( 1.0 ), + None => 0.0, + }; + // … fade alpha = 1.0 - progress +# ltk::text( "" ).into() +} +# } +``` + +The end-of-animation cleanup belongs in `poll_external()`: when `progress >= 1.0` clear `self.toast_started` so `is_animating()` returns `false` and the loop sleeps again. + +For follow-the-finger gestures use `on_swipe_progress(progress)` / `on_swipe_down_progress(progress)`. Those fire continuously during the drag with a `0.0..=1.0` value and don't require `is_animating` — the gesture itself drives the redraw. + +For a basic application window, defer this whole area until the rest of the UI +is already working. Animation is part of the advanced runtime surface, not the +core onboarding path. + +## Larger state patterns + +A four-button demo can keep all state in one struct and one flat `Msg` enum. Anything bigger needs structure. Conventions used by crustace and loginmanager: + +**One module per screen / panel.** Each module owns its sub-state struct and its sub-message enum, and exposes `fn view(...) -> Element` and `fn update(&mut self, msg: SubMsg)` (or the parent inlines those calls). See `crustace/src/homescreen.rs`, `launcher/`, `notifications.rs`, `powermenu.rs`. + +**Wrap sub-messages in the top-level enum.** `enum AppMsg { Home(HomeMsg), Settings(SettingsMsg), Nav(Route), Tick }`. `update()` matches the outer variant, then forwards to the right sub-module. This avoids one-giant-message-enum bloat once the app passes ~30 variants. + +**Ephemeral caches behind `RefCell` (single-threaded).** `view(&self)` is `&self`; if you need a mutable icon cache, scaled-image cache, layout cache, etc., wrap it in `RefCell<...>` on the app struct and `borrow_mut()` inside `view()`. Crustace's `IconCache` does exactly this. Don't reach for `Mutex` — the event loop is single-threaded. + +**External state via channel + poll.** Anything that blocks (D-Bus, files, network, IPC) lives on a background thread. At startup save the `ChannelSender` from `set_channel_sender`, hand a clone to the worker, and have the worker push messages back. `poll_external()` is the place for non-blocking `try_recv()` against in-process receivers (e.g. `mpsc`/`crossbeam` channels) or for expiry checks like "is this notification past its TTL". + +**Stable widget ids only when you need to programmatically focus them.** `WidgetId` is an opt-in tag on a widget that pairs with `App::take_focus_request()`. Don't decorate every widget; tag the one input you want to autofocus on screen entry. + +Again, the simplest progression is: + +1. one flat app state in `ltk::window` +2. sub-state and overlays once the app becomes shell-like +3. caches, channels, focus retargeting, and cross-surface invalidation only when scale requires them + +## Performance + +The cheap things and the expensive things, in rough order: + +- *Cheap*: building the `Element` tree. It's plain enums and `Vec`s. crustace rebuilds the entire shell every frame and stays idle when nothing changes. +- *Cheap*: input dispatch. Per-leaf handler snapshots are captured during the layout pass; pointer/key events are O(N_focusable_leaves) lookups, not tree walks. +- *Cheap*: `active_document()` / `theme_palette()`. The first returns a clone of an `Arc` from a `RwLock`-protected cell; the second projects the active mode's slot table onto the eight canonical palette fields. +- *Avoid in `view()`*: filesystem walks, image decoding, `serde` parsing, regex compilation. Cache the result on the app struct (behind `RefCell` if needed) and look it up. +- *Avoid in `view()`*: cloning large `Vec` image buffers. `img_widget` takes an `Arc>`; build the `Arc` once at load time and clone *the Arc*, not the bytes. +- *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at 60 Hz and burns battery on the mobile target. +- *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`. +- *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper. +- *GPU vs software*: the GLES path is selected automatically when EGL is available; both render the same pixels (see the recent commits for the alpha/SDF parity work). There is no API-level difference for the application. + +When a redraw feels sluggish: add a one-line print at the top of `view()` and confirm it's not being called more often than expected. The single most common mistake is leaving `is_animating()` returning `true` after the animation finished. + +## Where to look in the consumer repos + +| Pattern | File | +| --- | --- | +| Multi-overlay coordination, overlay id constants | `crustace/src/app.rs` (`overlays()`, lines ~250–380) | +| Background poller + channel sender | `crustace/src/app.rs` (`set_channel_sender`, `poll_external`) | +| Sub-module per screen | `crustace/src/{homescreen.rs, notifications.rs, powermenu.rs, launcher/}` | +| Cached icon loading via `RefCell` | `crustace/src/launcher/icon_cache.rs` and use sites in `app.rs` | +| OSD overlay with auto-expiry | `crustace/src/app.rs` (`show_osd`, `build_osd`, `OSD_TIMEOUT_SECS`) | +| `keyboard_exclusive` + `take_focus_request` | `loginmanager/src/main.rs` | +| Theme on disk (slot-typed JSON) | `ltk/themes/default/theme.json`, `ltk::ThemeDocument::find` | + +For a self-contained example that exercises overlays, theme switching, and animation in one ~300-line file, see `examples/mini_shell.rs`. diff --git a/docs/cookbook.md b/docs/cookbook.md new file mode 100644 index 0000000..49583af --- /dev/null +++ b/docs/cookbook.md @@ -0,0 +1,825 @@ +# ltk cookbook + +Concrete recipes for patterns that come up often when building real +applications and shells with `ltk`. Each recipe pairs a sketch of the +problem with a copy-pasteable shape of the solution and pointers to the +relevant APIs. + +If you are looking for the mental model, read +[`docs/onboarding.md`](./onboarding.md) and +[`docs/architecture.md`](./architecture.md) first. For per-widget +reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see +[`docs/theming.md`](./theming.md). + +## Table of contents + +- [Slide-in panel](#slide-in-panel) +- [Password field with PAM submit](#password-field-with-pam-submit) +- [Swipe-to-dismiss overlay](#swipe-to-dismiss-overlay) +- [Runtime light / dark theme toggle](#runtime-light--dark-theme-toggle) +- [Icon launcher with WrapGrid](#icon-launcher-with-wrapgrid) +- [Channel-driven external state](#channel-driven-external-state) +- [Toast / OSD with auto-expiry](#toast--osd-with-auto-expiry) +- [Tab navigation between widgets](#tab-navigation-between-widgets) +- [Multi-screen app via sub-state pattern](#multi-screen-app-via-sub-state-pattern) +- [Embedding ltk without `ltk::run`](#embedding-ltk-without-ltkrun) + +--- + +## Slide-in panel + +A quick-settings or notification panel that slides down from the top of +the screen and dissolves into transparency at its leading edge so the +edge does not knife-cut against the layer below. + +```rust,no_run +# use std::time::Instant; +# use ltk::{ container, text, viewport, Anchor, Element, Layer, OverlayId, OverlaySpec }; +# const OVERLAY_QS: OverlayId = OverlayId( 1 ); +# const SLIDE_DURATION: f32 = 0.25; +# #[ derive( Clone ) ] enum Msg { CloseQs } +# struct App { qs_started: Option, surface_width: u32, surface_height: u32 } +# impl App { +# fn quick_settings_view( &self ) -> Element { text( "qs" ).into() } +fn build_quick_settings_overlay( &self ) -> OverlaySpec +{ + // Compute the slide progress based on a stored start instant. While + // animating, `is_animating()` returns `true` so the runtime redraws + // at ~60 Hz and reads the new progress every frame. + let progress = match self.qs_started + { + Some( t ) => ( t.elapsed().as_secs_f32() / SLIDE_DURATION ).min( 1.0 ), + None => 1.0, + }; + + let panel_height = self.surface_height as f32 * 0.85; + let visible_h = panel_height * progress; + + // Feather the bottom edge during the slide; drop the fade once the + // panel is fully open so the bottom of a settled panel is hard. + let fade_px = if progress < 1.0 { 16.0 } else { 0.0 }; + + let panel: Element = container( self.quick_settings_view() ) + .surface( "surface-card" ) + .padding( 24.0 ) + .into(); + + OverlaySpec + { + id: OVERLAY_QS, + layer: Layer::Overlay, + anchor: Anchor::TOP, + size: ( self.surface_width, visible_h as u32 ), + exclusive_zone: 0, + keyboard_exclusive: false, + input_region: None, + view: viewport( panel ) + .height( panel_height ) + .fade_bottom( fade_px ) + .into(), + on_dismiss: Some( Msg::CloseQs ), + anchor_widget_id: None, + } +} + +fn is_animating( &self ) -> bool +{ + self.qs_started + .map( |t| t.elapsed().as_secs_f32() < SLIDE_DURATION ) + .unwrap_or( false ) +} +# } +``` + +The `fade_bottom( px )` builder is a GLES-only effect; the software +backend renders a hard edge. If your shell must look identical on both +backends, branch on [`ltk::is_software_render()`] and skip the fade +when the software path is active. + +**See also**: [`Viewport`](./widgets.md#viewport), +[`OverlaySpec`](../src/app.rs). + +--- + +## Password field with PAM submit + +A login screen where the user types a password, presses Enter, and the +app forwards the submission to a background thread that runs PAM. + +```rust,no_run +# use ltk::{ button, column, text, text_edit, App, ChannelSender, Element }; +# #[ derive( Clone ) ] enum Msg { +# UsernameChanged( String ), PasswordChanged( String ), +# Submit, AuthResult( bool ), +# } +# fn pam_authenticate( _user: &str, _pass: &str ) -> bool { true } +struct LoginApp +{ + username: String, + password: String, + sender: Option>, +} + +impl App for LoginApp +{ + type Message = Msg; + + fn view( &self ) -> Element + { + column() + .padding( 32.0 ) + .spacing( 16.0 ) + .push( text( "Sign in" ).size( 28.0 ) ) + .push( + text_edit( "Username", &self.username ) + .on_change( |s| Msg::UsernameChanged( s ) ), + ) + .push( + text_edit( "Password", &self.password ) + .secure( true ) // mask glyphs + zeroize on drop + .on_change( |s| Msg::PasswordChanged( s ) ) + .on_submit( Msg::Submit ), // Enter fires this + ) + .push( button( "Log in" ).on_press( Msg::Submit ) ) + .into() + } + + fn set_channel_sender( &mut self, s: ChannelSender ) + { + // Saved once at startup; cloned into worker threads so they can + // wake the loop without polling. + self.sender = Some( s ); + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::UsernameChanged( s ) => self.username = s, + Msg::PasswordChanged( s ) => self.password = s, + Msg::Submit => + { + let username = self.username.clone(); + let password = self.password.clone(); + let sender = self.sender.clone().unwrap(); + + std::thread::spawn( move || + { + let result = pam_authenticate( &username, &password ); + let _ = sender.send( Msg::AuthResult( result ) ); + } ); + + // Clear the visible field so the user has feedback; + // `secure( true )` zeroizes the buffer when the next + // view() rebuild drops the old TextEdit. + self.password.clear(); + } + Msg::AuthResult( true ) => std::process::exit( 0 ), + Msg::AuthResult( false ) => { /* show error */ } + } + } +} +``` + +`secure( true )` does two things: +1. Renders bullets instead of the actual characters. +2. Wipes the underlying byte buffer with zeroes when the + [`TextEdit`](./widgets.md#text_edit) (and the per-frame handler + snapshot) is dropped — the heap allocation that used to hold the + credential is overwritten before it returns to the allocator. + +`text_edit::on_submit` fires when the user presses Enter inside the +field; it is wired to the same `Msg::Submit` the explicit button uses +so both keyboard and pointer paths converge on the same `update` arm. + +The cleanup of `self.password` is the application's responsibility once +authentication completes. `ltk` cannot guarantee a clean memory image +on its own — for the strongest guarantees clear long-lived state in +your `Drop` impl too. + +**See also**: [`SECURITY.md`](../SECURITY.md), the +[`text_edit`](./widgets.md#text_edit) widget, and +[`crate::ChannelSender`](../src/app.rs). + +--- + +## Swipe-to-dismiss overlay + +A modal panel that closes when the user swipes down past a threshold or +taps outside the panel. + +```rust,no_run +# use ltk::{ column, container, spacer, text, Anchor, Element, Layer, OverlayId, OverlaySpec }; +# const OVERLAY_MODAL: OverlayId = OverlayId( 2 ); +# #[ derive( Clone ) ] enum Msg { CloseModal } +# struct App { modal_open: bool, modal_drag_progress: f32 } +# impl App { +# fn modal_body( &self ) -> Element { text( "modal" ).into() } +fn overlays( &self ) -> Vec> +{ + if !self.modal_open { return vec![]; } + + // The modal body sits inside a column capped at 400 px so it stays + // legible on wide displays; the outer column with two spacers + // centres it vertically. + let modal: Element = column() + .max_width( 400.0 ) + .push( + container( self.modal_body() ) + .surface( "surface-card" ) + .padding( 24.0 ), + ) + .into(); + + vec![ + OverlaySpec + { + id: OVERLAY_MODAL, + layer: Layer::Overlay, + anchor: Anchor::ALL, + size: ( 0, 0 ), + exclusive_zone: 0, + keyboard_exclusive: false, + input_region: None, // accept input + view: column() + .center_y( true ) + .push( spacer() ) + .push( modal ) + .push( spacer() ) + .into(), + on_dismiss: Some( Msg::CloseModal ), // tap outside dismisses + anchor_widget_id: None, + }, + ] +} + +// Swipe-down gesture (only fires inside the overlay because the main +// surface does not declare a down-swipe target). +fn on_swipe_down( &mut self ) -> Option +{ + Some( Msg::CloseModal ) +} + +fn on_swipe_down_progress( &mut self, progress: f32 ) +{ + // Optional follow-the-finger feedback: store the in-progress value + // and use it in view() to translate or fade the modal contents. + self.modal_drag_progress = progress; +} +# } +``` + +`on_dismiss` is the runtime's "tap outside the panel" hook — it fires +for any release on the overlay surface that did not land on an +interactive widget. The runtime also fires it on a press on the main +surface that does not hit the trigger (relevant for xdg-popup overlays +under compositors that do not break the popup grab on parent-surface +clicks) and on Escape with an xdg-popup open. Pair with `on_swipe_down` +for a follow-the-finger gesture, and `on_swipe_down_progress` if you +want the modal to track the finger before commit. + +**See also**: [`OverlaySpec`](../src/app.rs), +[`docs/architecture.md`](./architecture.md#surface-composition). + +--- + +## Runtime light / dark theme toggle + +A "switch theme" affordance that flips the active mode without a +restart. + +```rust,no_run +# use ltk::{ button, Element, ThemeMode }; +# #[ derive( Clone ) ] enum Msg { ToggleTheme } +# struct App; +# impl App { +fn view( &self ) -> Element +{ + let label = match ltk::active_mode() + { + ThemeMode::Light => "Switch to dark", + ThemeMode::Dark => "Switch to light", + }; + button( label ).on_press( Msg::ToggleTheme ).into() +} + +fn update( &mut self, msg: Msg ) +{ + match msg + { + Msg::ToggleTheme => + { + let new = match ltk::active_mode() + { + ThemeMode::Light => ThemeMode::Dark, + ThemeMode::Dark => ThemeMode::Light, + }; + ltk::set_active_mode( new ); + // No further action — the next render reads the new mode + // through the slot helpers and recomposes the surface. + } + } +} +# } +``` + +`set_active_mode` mutates a process-global cell; the next `view()` +rebuild reads the new mode through the per-slot helpers +([`theme_palette`], [`theme_surface`], [`theme_paint`], etc.) and +renders against the new colours. There is no manual invalidation step. + +For a full theme swap, load a different `ThemeDocument` and apply it: + +```rust,no_run +# fn _ex() { +let doc = ltk::ThemeDocument::find( "midnight" ) + .expect( "midnight theme not installed" ); +ltk::set_active_document( doc ); +# } +``` + +**See also**: [`docs/theming.md`](./theming.md#using-the-theme-from-app-code). + +--- + +## Icon launcher with WrapGrid + +An app drawer that displays an N-column grid of icon buttons, scrolls +when content overflows, and caches decoded icons across frames. + +```rust,no_run +# use std::cell::RefCell; +# use std::collections::HashMap; +# use std::sync::Arc; +# use ltk::{ grid, icon_button, scroll, App, Element }; +# #[ derive( Clone ) ] enum Msg { Launch( String ) } +# struct DesktopEntry { id: String, icon_path: String } +# fn decode_icon( _path: &str ) -> ( Arc>, u32, u32 ) { +# ( Arc::new( vec![ 0; 4 ] ), 1, 1 ) +# } +struct LauncherApp +{ + apps: Vec, + icon_cache: RefCell>, u32, u32 )>>, +} + +impl App for LauncherApp +{ + type Message = Msg; + + fn view( &self ) -> Element + { + let mut grid = grid::( 4 ) + .padding( 16.0 ) + .spacing( 12.0 ); + + for app in &self.apps + { + // Decode icons once on first reference; reuse the Arc on + // every subsequent frame for a pointer copy. + let ( bytes, w, h ) = self + .icon_cache + .borrow_mut() + .entry( app.id.clone() ) + .or_insert_with( || decode_icon( &app.icon_path ) ) + .clone(); + + grid = grid.push( + icon_button( bytes, w, h ) + .on_press( Msg::Launch( app.id.clone() ) ), + ); + } + + scroll( grid ).into() + } + + fn update( &mut self, _msg: Msg ) { /* ... */ } +} +``` + +The `RefCell>` cache is single-threaded — the runtime is +single-threaded, so `Mutex` would only add lock overhead. Decoded +icons are kept as `Arc>` so building the widget is a pointer +clone instead of a full byte clone. + +**See also**: [`grid`](./widgets.md#grid), +[`scroll`](./widgets.md#scroll), +[`docs/architecture.md`](./architecture.md#larger-state-patterns). + +--- + +## Channel-driven external state + +Surface external events (D-Bus signals, file watches, timers) into the +event loop without busy-polling. + +```rust,no_run +# use std::time::{ Duration, Instant }; +# use ltk::ChannelSender; +# #[ derive( Clone ) ] struct BatteryEvent; +# fn wait_for_battery_event() -> BatteryEvent { BatteryEvent } +# struct OsdToast { expires_at: Instant } +# #[ derive( Clone ) ] enum Msg { BatteryChanged( BatteryEvent ), HideToast } +# struct App { sender: Option>, toast: Option } +# impl App { +fn set_channel_sender( &mut self, sender: ChannelSender ) +{ + // Saved once and never changed. + self.sender = Some( sender.clone() ); + + // Spawn the worker that watches for external events and forwards + // them as messages. Substitute `wait_for_battery_event` with whatever + // blocks for your real source — a D-Bus signal, a file watch, a + // socket read, a timer, etc. + std::thread::spawn( move || + { + loop + { + // Block until the external source produces an event. When it + // arrives, post a message into the loop. + let event = wait_for_battery_event(); + let _ = sender.send( Msg::BatteryChanged( event ) ); + } + } ); +} + +fn poll_external( &mut self ) -> Vec +{ + // For state that doesn't need a dedicated thread (file mtime checks, + // expiry sweeps), drain it here. Called after every Wayland event + // and every poll_interval tick. + let mut msgs = vec![]; + if let Some( osd ) = self.toast.as_ref() + { + if osd.expires_at <= Instant::now() + { + msgs.push( Msg::HideToast ); + } + } + msgs +} + +fn poll_interval( &self ) -> Option +{ + // Wake every minute to re-check the clock display. Keep this `None` + // unless you actually need a wall-clock tick — it costs battery + // life on mobile targets. + Some( Duration::from_secs( 60 ) ) +} +# } +``` + +The `set_channel_sender` hook gives you a clone-able sender that wakes +the event loop from another thread without busy-waiting. `poll_external` +runs in the loop's own thread after each tick, so anything that costs +CPU but does not block (TTL checks, RefCell snapshot diffs) is fine +there. + +**See also**: [`docs/architecture.md`](./architecture.md#larger-state-patterns). + +--- + +## Toast / OSD with auto-expiry + +A short-lived overlay that fades out after a fixed duration without +blocking other UI. + +```rust,no_run +# use std::time::Instant; +# use ltk::{ container, text, App, Anchor, Color, Element, Layer, OverlayId, OverlaySpec }; +# const OVERLAY_TOAST: OverlayId = OverlayId( 3 ); +# #[ derive( Clone ) ] enum Msg {} +struct AppState +{ + toast: Option, + // ... +} + +struct Toast +{ + text: String, + started: Instant, +} + +const TOAST_DURATION: f32 = 2.0; +const TOAST_FADE: f32 = 0.25; + +impl AppState +{ +# fn main_view( &self ) -> Element { text( "main" ).into() } + // ... +} + +impl App for AppState +{ + type Message = Msg; + + fn view( &self ) -> Element { self.main_view() } + + fn overlays( &self ) -> Vec> + { + let toast = match &self.toast + { + Some( t ) => t, + None => return vec![], + }; + + let elapsed = toast.started.elapsed().as_secs_f32(); + let alpha = if elapsed >= TOAST_DURATION + { + // Fade-out window: 0.25 s after expiry the alpha hits 0. + ( 1.0 - ( elapsed - TOAST_DURATION ) / TOAST_FADE ).clamp( 0.0, 1.0 ) + } else { 1.0 }; + + vec![ + OverlaySpec + { + id: OVERLAY_TOAST, + layer: Layer::Overlay, + anchor: Anchor::BOTTOM, + size: ( 0, 0 ), + exclusive_zone: 0, + keyboard_exclusive: false, + input_region: Some( vec![] ), // pass-through + view: container( text( &toast.text ).color( Color::WHITE ) ) + .surface( "surface-panel" ) + .padding( 12.0 ) + .opacity( alpha ) + .into(), + on_dismiss: None, + anchor_widget_id: None, + }, + ] + } + + fn update( &mut self, _msg: Msg ) {} + + fn is_animating( &self ) -> bool + { + // Redraw at 60 Hz while the toast is visible or fading. + self.toast.is_some() + } + + fn poll_external( &mut self ) -> Vec + { + // Drop the toast once the fade window completes. + if let Some( t ) = &self.toast + { + if t.started.elapsed().as_secs_f32() >= TOAST_DURATION + TOAST_FADE + { + self.toast = None; + } + } + vec![] + } +} +``` + +Two ideas worth noting: + +- `input_region: Some( vec![] )` makes the overlay pass-through: pointer + events fall through to whatever surface is below. The toast does not + steal taps from the main UI. +- The fade-out cleanup belongs in `poll_external`, not `view()`. `view` + must stay pure — read state, build a tree, no mutation. + +**See also**: [`docs/architecture.md`](./architecture.md#animations). + +--- + +## Tab navigation between widgets + +The runtime ships Tab / Shift+Tab traversal automatically; you only need +to set up programmatic focus when an external event should land focus on +a specific widget. + +```rust,no_run +# use ltk::{ column, text_edit, App, Element, WidgetId }; +# #[ derive( Clone ) ] enum Msg { +# UsernameChanged( String ), PasswordChanged( String ), AuthFailed, +# } +const FIELD_USERNAME: WidgetId = WidgetId( "username" ); +const FIELD_PASSWORD: WidgetId = WidgetId( "password" ); + +struct LoginApp +{ + username: String, + password: String, + pending_focus: Option, + // ... +} + +impl App for LoginApp +{ + type Message = Msg; + + fn view( &self ) -> Element + { + column() + .push( + text_edit( "Username", &self.username ) + .id( FIELD_USERNAME ) + .on_change( |s| Msg::UsernameChanged( s ) ), + ) + .push( + text_edit( "Password", &self.password ) + .id( FIELD_PASSWORD ) + .secure( true ) + .on_change( |s| Msg::PasswordChanged( s ) ), + ) + .into() + } + + fn take_focus_request( &mut self ) -> Option + { + // Returned once; the runtime focuses that widget on the next + // frame. Subsequent calls return None. + self.pending_focus.take() + } + + fn update( &mut self, msg: Msg ) + { + if matches!( msg, Msg::AuthFailed ) + { + // Clear the password and put focus back on the field so the + // user can retype without a click. + self.password.clear(); + self.pending_focus = Some( FIELD_PASSWORD ); + } + } +} +``` + +`take_focus_request` is consumed once: the runtime dispatches focus to +the returned id and the next call returns `None` because the slot was +drained. The application owns "when should focus move". + +Tab and Shift+Tab traverse focusable widgets in declaration order — no +opt-in needed beyond the widget being interactive. + +**See also**: [`WidgetId`](../src/types.rs), +[`tests/tab_navigation.rs`](../tests/tab_navigation.rs). + +--- + +## Multi-screen app via sub-state pattern + +When the app has more than ~30 message variants it is time to split by +screen. Each screen owns its sub-state and sub-message, and the +top-level enum wraps them. + +```rust,no_run +# use ltk::{ column, text, App, Element }; +# #[ derive( Clone, Copy ) ] enum Screen { Home, Settings, About } +# #[ derive( Clone ) ] enum HomeMsg {} +# #[ derive( Clone ) ] enum SettingsMsg {} +# struct HomeState; +# struct SettingsState; +# fn home_view( _: &HomeState ) -> Element { text( "home" ).into() } +# fn home_update( _: &mut HomeState, _: HomeMsg ) {} +# fn settings_view( _: &SettingsState ) -> Element { text( "settings" ).into() } +# fn settings_update( _: &mut SettingsState, _: SettingsMsg ) {} +# fn about_view() -> Element { text( "about" ).into() } +# fn nav_bar( _: Screen ) -> Element { text( "nav" ).into() } +#[derive(Clone)] +enum AppMsg +{ + Nav( Screen ), + Home( HomeMsg ), + Settings( SettingsMsg ), +} + +struct AppState +{ + current: Screen, + home: HomeState, + settings: SettingsState, +} + +impl App for AppState +{ + type Message = AppMsg; + + fn view( &self ) -> Element + { + let body = match self.current + { + Screen::Home => home_view( &self.home ).map( AppMsg::Home ), + Screen::Settings => settings_view( &self.settings ).map( AppMsg::Settings ), + Screen::About => about_view(), + }; + + column() + .push( nav_bar( self.current ) ) + .push( body ) + .into() + } + + fn update( &mut self, msg: AppMsg ) + { + match msg + { + AppMsg::Nav( s ) => self.current = s, + AppMsg::Home( m ) => home_update( &mut self.home, m ), + AppMsg::Settings( m ) => settings_update( &mut self.settings, m ), + } + } +} +``` + +Each screen owns its `view( state ) -> Element` and +`update( state, msg )` functions; the parent wraps the sub-message in +the outer enum on the way out and unwraps it on the way in. There is no +runtime-level "screen" abstraction in `ltk` because the widget tree is +already cheap to rebuild every frame — the dispatch above is just three +function calls. + +In a real project each screen lives in its own file (`src/home.rs`, +`src/settings.rs`, `src/about.rs`) declared as `pub mod home;` etc. in +`lib.rs`, and the call sites become `home::view( &self.home )` and +`home::update( &mut self.home, m )` instead of the flat +`home_view` / `home_update` shape used above. The snippet is flat so +that `doctest-md` can typecheck the dispatch without needing one file +per screen. + +**See also**: [`docs/architecture.md`](./architecture.md#larger-state-patterns). + +--- + +## Embedding ltk without `ltk::run` + +A compositor or embedder that already owns the Wayland connection and +just wants ltk's layout, rendering and hit-testing. + +```rust,no_run +# use ltk::{ button, Color, Element, Rect }; +# use ltk::core::{ Canvas, RenderOptions, UiSurface }; +# #[ derive( Clone ) ] enum Msg { Tick } +# struct App; +# impl App { fn view( &self ) -> Element { button( "x" ).into() } +# fn update( &mut self, _: Msg ) {} } +# struct Event; +# impl Event { fn into_msg( self ) -> Msg { Msg::Tick } } +# struct Queue( Vec ); +# impl Queue { fn drain( &mut self ) -> std::vec::Drain<'_, Event> { self.0.drain( .. ) } } +# fn present_argb8888( _: &[u8] ) {} +# fn wl_damage( _: &Rect ) {} +# fn _ex( width: u32, height: u32, mut app: App, mut input_queue: Queue, pos_x: f32, pos_y: f32 ) { +let mut surface = UiSurface::::new( width, height ); + +loop +{ + // 1. Drain pending app events from your own input source. + for ev in input_queue.drain() { app.update( ev.into_msg() ); } + + // 2. Build the tree and render. + let view = app.view(); + let out = surface.render( + &view, + RenderOptions::full_canvas( width, height ) + .background( Color::TRANSPARENT ), + ); + + // 3. Pull pixels (software backend) or present the FBO (GLES). + match surface.canvas() + { + Canvas::Software( _ ) => + { + let mut buf = vec![ 0u8; ( width * height * 4 ) as usize ]; + surface.canvas().write_to_wayland_buf( &mut buf, false ); + present_argb8888( &buf ); + } + Canvas::Gles( _ ) => + { + // Already drawn into the FBO the embedder owns; commit + // through your own EGL context. + } + } + + // 4. Use damage rects to feed wl_surface.damage_buffer if you are + // on the software path. + for rect in &out.damage_rects { wl_damage( rect ); } + + // Pointer dispatch: turn a screen-space point into the widget under it. + let hit = surface.hit_test( ltk::Point { x: pos_x, y: pos_y } ); + if let Some( idx ) = hit + { + if let Some( msg ) = surface.handlers( idx ).and_then( |h| h.press_msg() ) + { + app.update( msg ); + } + } +# break; +} +# } +``` + +`UiSurface` keeps the focus / hover / pressed state, the cursor map, +the scroll-offset table, and the per-frame widget rects. Set +`set_focused( Some( idx ) )` etc. from your own input handler and the +next render automatically uses the partial-damage path when only +interaction state changed. + +**See also**: [`tests/core_surface.rs`](../tests/core_surface.rs) for +the full set of supported operations, +[`docs/onboarding.md`](./onboarding.md#when-to-use-coreuisurface). diff --git a/docs/onboarding.md b/docs/onboarding.md new file mode 100644 index 0000000..2b6fcd6 --- /dev/null +++ b/docs/onboarding.md @@ -0,0 +1,385 @@ +# ltk onboarding + +This guide is for the first hour with `ltk`: what environment you need, how to +run the examples, how to build a minimal app, when to use layer-shell vs a +regular window, and what theme/font assumptions the toolkit currently makes. + +If you already know the basics and want the deeper rationale, read +[`docs/architecture.md`](./architecture.md) next. + +## What `ltk` is + +`ltk` is a Rust UI toolkit for Wayland. It is aimed first at the Eydos shell +stack, but it can also be used to build normal client applications and +runtime-free UI surfaces. + +At a high level: + +- Implement the [`App`] trait. +- Return an [`Element`] tree from `view()`. +- React to user input by handling messages in `update()`. +- Start the event loop with `ltk::run(app)`. + +The model is declarative and Elm-shaped: the widget tree is rebuilt from your +state, then `ltk` handles layout, drawing and input dispatch. + +If you are browsing the crate through `cargo doc`, the public API is also +grouped conceptually into three entry points: + +- `ltk::window` — basic application windows +- `ltk::shell` — layer-shell and overlays +- `ltk::runtime` — advanced runtime hooks and runtime-free embedding + +Most users should start with `ltk::window` and ignore the other two until they +have a normal app window running. + +## Before you start + +`ltk` is not a browser toolkit and not a cross-platform desktop toolkit. Today +it assumes: + +- a running **Wayland** session +- Wayland client libraries available through Rust dependencies +- a usable system font such as `google-sora-fonts`, `liberation-fonts` or + `dejavu-fonts` +- an installed `default` theme, or a development theme directory exposed + through `LTK_THEMES_DIR` + +The rendering backend is selected automatically: + +- **GLES** when EGL/GLES is available +- **software** fallback otherwise, or when `LTK_FORCE_SOFTWARE=1` + +## Fastest way to see it working + +From the repo root: + +```bash +cargo run --example showcase +``` + +Other useful examples: + +- `cargo run --example widgets` — broad widget survey +- `cargo run --example inputs` — text entry +- `cargo run --example scroll` — scroll viewport patterns +- `cargo run --example mini_shell` — overlays, animation and theme switching + +All examples require a running Wayland compositor. + +## Theme and font setup + +`ltk` currently expects a theme named `default`. Lookup order is: + +1. `LTK_THEMES_DIR//` +2. `$XDG_DATA_HOME/ltk/themes//` +3. `/usr/share/ltk/themes//` + +For development inside this repository, the simplest setup is: + +```bash +export LTK_THEMES_DIR="$PWD/themes" +``` + +That makes `ThemeDocument::find("default")` resolve to +`$PWD/themes/default/theme.json`. + +Font loading is separate from theme lookup. `Canvas` walks a chain of +common system font paths (`fonts-sora`, `fonts-liberation`, `fonts-dejavu`, +`fonts-freefont`, …) and uses the first one it finds. If nothing matches, +it falls back to an embedded Sora Regular (~50 KB, SIL OFL 1.1) shipped +inside the crate, so canvas construction never panics on a system without +the expected fonts. Installing one of the listed packages is still +recommended for richer glyph coverage. + +## Your first app + +The smallest useful `ltk` app implements `App`, returns a tree from `view()`, +updates its state in `update()`, and calls `ltk::run(...)`. + +```rust,no_run +use ltk::{ App, Element, Keysym, button, column, spacer, text }; + +#[derive(Clone)] +enum Msg +{ + Increment, +} + +struct CounterApp +{ + value: u32, +} + +impl App for CounterApp +{ + type Message = Msg; + + fn view( &self ) -> Element + { + column::() + .padding( 32.0 ) + .spacing( 16.0 ) + .center_y( true ) + .push( text( "Hello from ltk" ).size( 28.0 ) ) + .push( text( format!( "Count: {}", self.value ) ).size( 18.0 ) ) + .push( spacer() ) + .push( button( "Increment" ).on_press( Msg::Increment ) ) + .into() + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::Increment => self.value += 1, + } + } + + fn on_key( &mut self, keysym: Keysym ) -> Option + { + if keysym == Keysym::Escape + { + std::process::exit( 0 ); + } + None + } +} + +fn main() +{ + ltk::run( CounterApp { value: 0 } ); +} +``` + +### Minimal `Cargo.toml` + +```toml +[package] +name = "my-ltk-app" +version = "0.1.0" +edition = "2021" + +[dependencies] +ltk = { path = "../ltk" } +``` + +If you vend `ltk` from crates.io later, replace the `path` dependency with a +versioned one. + +## Public API Layers + +`ltk` exposes most items at the crate root, but for documentation and discovery +it is useful to think of the library in three layers. + +### 1. `ltk::window` + +This is the default entry point for third-party applications. + +Use it for: + +- normal application windows +- tools and prototypes +- most widget/layout work + +The APIs you will usually touch first live here conceptually: + +- `App` +- `Element` +- `button`, `text`, `text_edit`, `image` +- `column`, `row`, `stack`, `grid`, `spacer` +- `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio` +- `Color` +- `run` + +### 2. `ltk::shell` + +This layer groups the APIs that matter when your surface is part of the shell +rather than a normal app window. + +Use it for: + +- bars and docks +- homescreens +- notifications +- greeters and lock screens +- transient overlays + +The most important APIs in this layer are: + +- `ShellMode` +- `Layer` +- `Anchor` +- `OverlaySpec` +- `OverlayId` +- `overlays()` + +### 3. `ltk::runtime` + +This layer is for advanced integration points. + +Use it when you need: + +- external wakeups via `set_channel_sender()` +- timer-driven or async state via `poll_external()` / `poll_interval()` +- redraw narrowing via `invalidate_after()` +- runtime theme state access +- runtime-free embedding through `core::UiSurface` + +Most applications do not need to start here. + +## Regular app window vs shell surface + +Most consumers should start with a **regular window**. + +Default behaviour: + +- `shell_mode()` defaults to `ShellMode::Window` +- `ltk::run(app)` creates an xdg-shell toplevel + +Use this for: + +- normal applications +- internal tools +- prototypes while learning the toolkit + +Switch to **layer-shell** only when you are building a shell component: + +- top bar +- dock +- homescreen +- notification surface +- lock screen / greeter + +The knobs you will usually override are: + +- `shell_mode()` +- `layer_anchor()` +- `layer_size()` +- `exclusive_zone()` +- `keyboard_exclusive()` +- `background_color()` + +For a non-trivial layer-shell example, use `examples/mini_shell.rs` as the +reference entry point. + +## The APIs you will touch first + +In practice, most first apps only need a small subset of the surface area. + +Start here: + +- `App` +- `Element` +- `button`, `text`, `text_edit`, `image` +- `column`, `row`, `stack`, `grid`, `spacer` +- `container`, `scroll`, `slider`, `toggle`, `checkbox`, `radio` +- `Color` +- `run` + +Do not start with these unless you need them: + +- `ltk::shell` +- `ltk::runtime` +- `overlays()` +- gesture hooks such as `on_swipe_*` +- `set_channel_sender()` / `poll_external()` +- `core::UiSurface` +- custom theming APIs + +## Message flow and state + +The expected shape is: + +1. user interaction emits a `Message` +2. `update()` mutates app state +3. `view()` rebuilds the UI from that state + +Example: + +```rust +#[derive(Clone)] +enum Msg +{ + NameChanged( String ), + Submit, +} +``` + +For small apps, one top-level `enum Msg` is enough. Once the app grows, split +state by screen/panel and wrap sub-messages in the top-level enum: + +```rust,no_run +# #[ derive( Clone ) ] pub enum HomeMsg {} +# #[ derive( Clone ) ] pub enum SettingsMsg {} +enum AppMsg +{ + Home( HomeMsg ), + Settings( SettingsMsg ), + Quit, +} +``` + +This is the pattern used by `examples/mini_shell.rs`. + +## Recommended learning order + +If you are new to the library, this order minimizes confusion: + +1. Run `examples/showcase.rs`. +2. Read the crate-level docs in `src/lib.rs`, especially `ltk::window`. +3. Build a plain xdg-shell window with `button`, `text`, `column`. +4. Add input handling with `text_edit` or `slider`. +5. Only then look at `ltk::shell` for overlays and layer-shell. +6. Move to `ltk::runtime` only when you need advanced hooks or embedding. + +## Performance rules of thumb + +`ltk` is designed to sleep when idle and redraw only on real changes, but the +application can still make bad choices. Keep these rules in mind: + +- keep `view()` pure and cheap +- do not do filesystem I/O, parsing or image decoding inside `view()` +- cache expensive derived data on your app struct +- leave `poll_interval()` as `None` unless you genuinely need periodic wakeups +- only return `true` from `is_animating()` while something is actually moving + +On mobile targets, the last two matter directly for battery life. + +## When to use `core::UiSurface` + +Most apps should ignore `core` at first. + +Use `core::UiSurface` when you want `ltk`'s layout/drawing/hit-testing without +`ltk::run()`. Typical cases: + +- compositor-side decorations +- embedding `ltk` widgets in another render loop +- offscreen rendering or previews + +There is coverage for that path in `tests/core_surface.rs`. + +## Current assumptions and rough edges + +This repo is usable, but a few current behaviours are worth knowing up front: + +- examples and docs assume Wayland, not X11 +- theming is process-global +- theme discovery currently expects a `default` theme on disk (a B/W + fallback document kicks in when missing, with a red banner on every + frame so the gap is impossible to miss) +- the architecture docs mention downstream consumer repos that are not part of + this repository + +None of that blocks learning the toolkit, but it matters when you evaluate +`ltk` as a third-party dependency. + +## What to read next + +- [`docs/architecture.md`](./architecture.md) — multi-surface patterns, + theming, animation and performance +- [`examples/showcase.rs`](../examples/showcase.rs) — smallest visual tour +- [`examples/widgets.rs`](../examples/widgets.rs) — broader widget coverage +- [`examples/mini_shell.rs`](../examples/mini_shell.rs) — overlays and shell + patterns +- [`tests/core_surface.rs`](../tests/core_surface.rs) — runtime-free rendering diff --git a/docs/theming.md b/docs/theming.md new file mode 100644 index 0000000..414c8de --- /dev/null +++ b/docs/theming.md @@ -0,0 +1,737 @@ +# ltk theming + +`ltk` reads a JSON theme document at startup and exposes a process-wide +active state for widgets and applications to query. This document +describes the on-disk format, the runtime APIs, and the slot conventions +that built-in widgets expect. + +For background on *why* theming is process-global and how it interacts +with the runtime, see [`docs/architecture.md`](./architecture.md). This +file is the schema reference. + +## File layout + +A theme is a directory under one of: + +1. `LTK_THEMES_DIR//` — only when the env var is set; intended + for development. +2. `$XDG_DATA_HOME/ltk/themes//` (defaults to + `~/.local/share/ltk/themes//`). +3. `/usr/share/ltk/themes//` — system-wide install path + (`ltk-theme-default` Debian package). + +The directory tree: + +```text +/ +├── theme.json required — the schema below +├── branding/ mode-aware branded assets +│ ├── light/ +│ │ ├── launcher.svg launcher logo +│ │ ├── wallpaper.svg homescreen wallpaper +│ │ ├── lockscreen.svg greeter / lockscreen image +│ │ └── logo/ brand wordmark / icon variants +│ │ ├── logo.svg primary logo (about / splash) +│ │ ├── square.svg 1:1 variant (avatars, app icons) +│ │ └── horizontal.svg wordmark (header / sign-in bars) +│ └── dark/ +│ └── (same set, dark variants) +└── icons/ symbolic + app icons + ├── app-default.svg fallback icon for unknown app ids + ├── apps/ per-application icons (firefox.svg, …) + └── catalogue/ symbolic glyph catalogue + ├── filled/ solid silhouettes — preferred by default + │ └── / general, system, window, … + └── line/ outlined variants (same names) +``` + +Branded assets in `branding/` and icons in `icons/` are picked up by +convention — see [Branding assets](#branding-assets) and +[Icons](#icons) below. Asset paths declared inside `theme.json` (e.g. a +custom wallpaper override) resolve relative to the theme directory; a +bare `"path": "custom.png"` is portable across install prefixes, +absolute paths work for system fonts but break relocatable installs. + +## Top-level structure + +```json +{ + "theme": { "id": "default", "name": "Default" }, + "fonts": { ... }, + "colors": { ... }, + "gradients": { ... }, + "inset_stacks": { ... }, + "modes": { + "light": { ... }, + "dark": { ... } + } +} +``` + +`theme.id` must match the directory name. `theme.name` is shown in any +theme-picker UI a shell builds on top of `ltk`. + +The other six sections are described below in order. The parser is strict +(`deny_unknown_fields`): unknown keys at any level are an error so typos +surface immediately. + +## `fonts` + +```json +"fonts": { + "sora": { + "name": "Sora", + "fallbacks": ["system-ui", "sans-serif"], + "sources": [ + { "weight": 300, "path": "/usr/share/fonts/opentype/sora/Sora-Light.otf" }, + { "weight": 400, "path": "/usr/share/fonts/opentype/sora/Sora-Regular.otf" }, + { "weight": 600, "path": "/usr/share/fonts/opentype/sora/Sora-SemiBold.otf" }, + { "weight": 700, "path": "/usr/share/fonts/opentype/sora/Sora-Bold.otf" } + ] + } +} +``` + +Each entry defines a font *family* with one source per OpenType / TrueType +weight. The map key (`"sora"`) is the family alias used inside the theme +(`"font_family": "sora"`); `name` is the human-readable label. + +`fallbacks` is consulted when a glyph is missing from the primary font +files at render time. The chain is walked in order; the first family that +can rasterise the codepoint wins. + +If none of the listed `sources` exist on disk, `ltk` falls back to its +embedded Sora Regular (~50 KB, OFL 1.1) and stamps a red banner on every +frame pointing at the missing-theme problem. + +## `colors` + +A flat dictionary of named hex literals, used as building blocks +elsewhere in the document via the [`@name` reference syntax](#references): + +```json +"colors": { + "navy": "#0A032E", + "white": "#FFFFFF", + "cyan": "#04D9FE", + "danger": "#E5484D", + "glass-hi": "#555555", + "ink": "#000000" +} +``` + +Values are 6- or 8-digit hex (`#RRGGBB` or `#RRGGBBAA`). Names use +kebab-case. There is no distinction between "palette" colours (used in +many places) and "raw" colours (single-use) — any hex can live here, and +single-use literals are equally valid inline. + +## `gradients` + +Named paints. Two variants: `linear` and `radial`. Used both as fills for +surfaces and as values referenced from a slot. Stops carry a `pos` (in +`[0, 1]`, but stop positions outside that range are accepted and used for +extrapolation) and a `color` (literal hex or `@reference`). + +```json +"gradients": { + "fill-cyan": { + "type": "linear", + "angle_deg": 270, + "space": "linear-rgb", + "stops": [ + { "pos": 0, "color": "@cyan" }, + { "pos": 1, "color": "@cyan-soft" } + ] + } +} +``` + +`space` is either `"srgb"` (perceptually quick, the default) or +`"linear-rgb"` (interpolate in linear-light space, more uniform mid-tones +for high-saturation gradients). `angle_deg` is the conventional CSS +gradient angle (`0` = up, `90` = right, `180` = down, `270` = left). + +Radial gradients use `center: [x, y]` (relative to the painted rect, both +in `[0, 1]`) and `radius: r` (also relative). + +Soft cap: a single gradient may carry **64 stops**. Beyond that the +parser truncates the tail with a stderr warning, so a hostile or +mistakenly-large gradient cannot blow up CPU + memory at parse time. +Realistic designs use 2 – 6 stops. + +## `inset_stacks` + +Named lists of inset shadows reused across surfaces. Convenient for the +"glass" stack used by every translucent slot in the default theme: + +```json +"inset_stacks": { + "glass-insets": [ + { "offset": [0, 1], "blur": 4, "color": "@ink/0F", "blend": "normal" } + ] +} +``` + +Each entry has `offset: [x, y]` (logical pixels), `blur` (Gaussian sigma +× 2, matching the CSS convention), optional `spread` (default `0`), +`color` (literal or `@ref`), and `blend` (`normal`, `plus-lighter`, or +`overlay`). + +Reference an entire stack from a slot with `"inset_shadows": +"@glass-insets"`, or inline the array if you only use it once. + +## `modes` + +Two required entries: `light` and `dark`. Each carries the look the +theme applies in that lighting mode plus its own `slots` table. + +### `wallpaper` / `lockscreen` + +Both are optional. The runtime resolves them in two steps: + +1. **Convention** — looks for `branding/{mode}/wallpaper.svg` and + `branding/{mode}/lockscreen.svg`, with the standard mode → + opposite-mode → no-mode fallback (see + [Branding assets](#branding-assets)). +2. **Override** — if `theme.json` declares an explicit block, that path + wins over the convention. Useful for raster wallpapers or + non-conventional locations: + +```json +"wallpaper": { "path": "custom/path.png", "fit": "cover" } +``` + +`path` resolves against the theme directory. `fit` is one of `"cover"`, +`"contain"`, `"stretch"`, `"center"` (default `"cover"`). The wallpaper +bundle helper ([`ltk::WallpaperBundle::for_size`]) returns the right +crop for landscape or portrait surfaces, so a single landscape SVG / PNG +covers both. + +### `launcher` + +```json +"launcher": { "background": "@white/E6", "border_radius": 24.0 } +``` + +`background` is any color reference (`@name[/AA]`). `border_radius` is +the corner radius for the launcher panel, in logical pixels. + +### `window_controls` + +Per-mode tokens for the title-bar control buttons: + +```json +"window_controls": { + "icon": "#5F5F68", + "hover_bg": "@navy/14", + "pressed_bg": "@navy/24", + "close_hover_bg": "@danger", + "close_icon": "@white", + "focus_ring": "@teal" +} +``` + +Consumed by the [`window_button`](../src/widget/window_button.rs) widget +through [`theme_window_controls()`]. + +The actual SVG glyphs (`close`, `maximize`, `minimize`, `restore`) live +in `icons/catalogue/filled/window/` and are tinted at runtime with the +`icon` colour from this block (and `close_icon` on close-hover). Themes +don't need to ship per-mode variants of these glyphs — the symbolic +tinting handles light vs dark colouring. They also don't need a +`line/window/` variant; chrome controls should look the same regardless +of the theme's overall icon style preference, and the existing `filled` +fallback in [`icon_path`](#icons) already covers the case. + +### `slots` + +The mode's slot table. Each entry is keyed by a stable id; widgets look +their slot up by id and the slot's `meta.semantic` field supplies a +human-readable hint that's useful in theme inspectors. + +Three slot variants: + +#### `color` + +```json +"text-primary": { + "type": "color", + "value": "@navy", + "meta": { "semantic": "palette/text_primary" } +} +``` + +Plain colour values. `value` is a literal hex or `@reference`. `meta` is +optional but conventional: `palette/` for the palette layer and +`effect//` for everything else. + +#### `shadows` + +```json +"shadows-glass": { + "type": "shadows", + "shadows": [ + { "offset": [0, 0], "blur": 9, "color": "@glass-elev/1F" } + ] +} +``` + +Outer drop shadows applied via [`theme_shadows(id)`]. Same field shape as +`inset_stacks` entries. + +#### `surface` + +```json +"surface-card": { + "type": "surface", + "fill": "@surface-glass-dark", + "shadows": "shadows-glass", + "inset_shadows": "@glass-insets", + "backdrop": { "blur_px": 22.5 }, + "meta": { "semantic": "effect/glass/card" } +} +``` + +The most expressive slot kind. Composes: + +- `fill` — a paint reference (`@gradient-name`) or inline gradient / + solid colour. Required. +- `shadows` — id of a `shadows` slot or an inline list. Optional. +- `inset_shadows` — id of an `inset_stacks` entry (`@glass-insets`) or + inline list. Optional. +- `backdrop` — `{ "blur_px": <σ × 2> }` for backdrop blur. Optional; + GLES backend renders it, software backend ignores it (documented + parity gap). + +## References + +The `@name` syntax substitutes a palette / gradient / inset-stack value +in place of an inline literal: + +- `@cyan` — looks up `colors.cyan`, `gradients.cyan` or + `inset_stacks.cyan` (collisions across sections are an error). When the + resolved value is a colour, the alpha channel comes from the original. +- `@cyan/80` — the `/AA` suffix is a colour-only alpha override (two hex + digits). Lets a single base `@navy` serve `@navy/14`, `@navy/24`, + `@navy/99`, `@navy/D9` etc. without a separate entry per alpha. + +References inside gradient stops or inset shadows are resolved at parse +time, so each downstream substitution at a slot call site is a flat +clone with no recursion at runtime. + +Unknown references fail loud: parsing aborts with `ThemeError:: +UnknownColorRef("foo")`. + +## Canonical slot ids + +The default widgets look up these slot ids; a custom theme that omits any +of them falls back to embedded defaults. + +**Palette (every mode must define):** + +| id | role | type | +| --- | --- | --- | +| `bg-page` | window background | color | +| `surface` | card / panel surface | color | +| `surface-alt` | text-input field background | color | +| `text-primary` | regular text colour | color | +| `text-secondary` | muted / placeholder text | color | +| `accent` | toggle on, slider fill, focus ring | color | +| `divider` | separator, toggle off, list item border | color | +| `icon` | icon-button glyph colour | color | + +**Effects (optional but used by built-in widgets):** + +| id | consumer | type | +| --- | --- | --- | +| `shadows-glass` | every surface that opts into elevation | shadows | +| `surface-card` | `Container::surface("surface-card")` | surface | +| `surface-card-flat` | flat variant for software backend | surface | +| `surface-panel` | overlay panels | surface | +| `surface-slider-track` | `Slider` track background | surface | +| `surface-slider-fill` | `Slider` filled portion | surface | +| `surface-slider-track-flat` | software-backend slider track | surface | +| `surface-slider-fill-flat` | software-backend slider fill | surface | +| `surface-toggle-active` | `Toggle` on-state surface | surface | + +The `-flat` variants are used by the software backend, which lacks +backdrop blur; the GLES backend uses the non-flat ones. + +## Using the theme from app code + +The active theme is process-global mutable state: a `(ThemeDocument, +ThemeMode)` pair guarded by a `RwLock` behind the `ltk::theme` API. +Widgets and apps read it through cheap accessors that clone an `Arc` +or project a small struct out of the slot table — designed so it's +fine to call them dozens of times per frame from inside `view()`. + +### The canonical pattern: read in `view()`, never cache + +Read the theme at the top of every `view()` and let the next frame +re-resolve automatically. Storing a `Color` in your app state freezes +it at the moment you captured it: a later `set_active_mode( Dark )` +will repaint everyone who reads palette per-frame and skip the widgets +that read a stale field. + +```rust,no_run +# use ltk::{ column, container, text, Element }; +# #[ derive( Clone ) ] enum Msg {} +# struct MyApp; +# impl MyApp { +fn view( &self ) -> Element +{ + let palette = ltk::theme_palette(); + + column::() + .push( text( "Hola" ).color( palette.text_primary ) ) + .push( text( "subtítulo" ).color( palette.text_secondary ) ) + .push( + container( text( "tarjeta" ).color( palette.text_primary ) ) + .background( palette.surface ) + .radius( 12.0 ), + ) + .into() +} +# } +``` + +### Helpers reference + +| Helper | Returns | When to use | +| --- | --- | --- | +| `theme_palette()` | `Palette` | Common case — named colour fields (`bg`, `surface`, `surface_alt`, `text_primary`, `text_secondary`, `accent`, `divider`, `icon`, `danger`, `danger_bg`). Cheap projection, ideal at the top of `view()`. | +| `theme_color( id )` | `Option` | Pull a single colour slot by id when it's not in the palette (`"surface-card-border"`, custom theme tokens). | +| `theme_color_or( id, fallback )` | `Color` | Same, with a baked-in default — ergonomic in widget defaults so missing slots don't return `None`. | +| `theme_paint( id )` | `Option` | Slot may be a colour or a gradient — promotes a colour to `Paint::Solid` automatically. | +| `theme_surface( id )` | `Option` | Surface slot (fill + shadows + insets + backdrop). | +| `theme_resolve_surface( id )` | `Option<( Surface, Vec )>` | Same, but pre-resolves a `ShadowsRef::Named` reference to a flat `Vec`. Use this when you call `canvas.fill_surface` directly. | +| `theme_shadows( id )` | `Option>` | Outer shadow stack. | +| `theme_text_style( id )` | `Option` | Typography slot (size, weight, line-height). | +| `theme_window_controls()` | `WindowControlsSpec` | Per-mode chrome tokens for the title-bar buttons. | +| `theme_wallpaper()` / `theme_lockscreen()` | `Option` | Full-screen branded images (SVG), with the convention fallback chain. | +| `theme_branding_image( name, sw, sh )` | `Option` | Sized branded image: smallest covering raster (WebP / PNG / JPEG) under `branding/{mode}/{name}/`, or the largest available raster if none cover, falling back to the SVG only when no rasters exist. Pass `(0, 0)` for the smallest available (startup before surface-configure). | +| `theme_branding_raster( name, sw, sh )` | `Option` | Raster-only variant of the above; returns `None` only when no rasters exist at all. | +| `theme_branding_asset( name, ext )` | `Option` | Generic branded asset lookup (any extension). Powers `theme_launcher_icon`, `theme_wallpaper`, `theme_lockscreen`. | +| `theme_launcher_icon()` | `Option` | Launcher logo SVG path, with the convention fallback. | +| `theme_logo()` | `Option` | Primary brand logo SVG (`branding/{mode}/logo/logo.svg`) — about dialogs, splash screens. | +| `theme_logo_square()` | `Option` | Square 1:1 logo variant (`logo/square.svg`) — app icons, login avatars, lockscreen badges. | +| `theme_logo_horizontal()` | `Option` | Wordmark logo variant (`logo/horizontal.svg`) — header bars, sign-in screens. | +| `theme_app_icon( name )` / `theme_app_default_icon()` | `Option` | Per-app icons under `icons/apps/`. | +| `theme_icon_path( "category/name" )` | `Option` | Catalogue icon path (filled-then-line lookup). | +| `theme_icon_rgba( "category/name", size )` | `Option<( Arc>, u32, u32 )>` | Rasterised + cached RGBA. Pair with `theme::tint_symbolic` for chrome glyphs. | +| `is_fallback_active()` | `bool` | `true` when the embedded B/W fallback theme is in force (no theme on disk). Useful to disable a theme-picker UI or warn the user. | + +### Switching mode or document at runtime + +Mutators live next to the readers. They take effect on the next render: + +```rust,no_run +// Light → dark. +ltk::set_active_mode( ltk::ThemeMode::Dark ); + +// Replace the whole document (user picked a different theme id in a +// settings panel, etc.). +let doc = ltk::ThemeDocument::find( "midnight" ) + .expect( "midnight theme not installed" ); +ltk::set_active_document( doc ); +``` + +The conventional wiring is to dispatch a message from the UI: + +```rust,no_run +# struct MyApp; +enum Msg { ToggleTheme } + +# impl MyApp { +fn update( &mut self, msg: Msg ) +{ + match msg + { + Msg::ToggleTheme => + { + let next = match ltk::active_mode() + { + ltk::ThemeMode::Light => ltk::ThemeMode::Dark, + ltk::ThemeMode::Dark => ltk::ThemeMode::Light, + }; + ltk::set_active_mode( next ); + } + } +} +# } +``` + +Every widget that read `theme_palette()` / `theme_surface(...)` / +... in `view()` automatically gets the new colours on the next +frame — there is no manual invalidation step, no observer +registration, no list of subscribed widgets. The entire reactivity +story is "ltk re-runs `view()` every frame and you read fresh values". + +### Do / don't + +- **Do** read palette / surfaces / icons inside `view()`, every frame. +- **Do** use `theme_color_or( id, fallback )` for non-palette slots so + a custom theme that omits a slot still paints something sane. +- **Do** use `theme_palette().` for the eight common roles — + the palette is precomputed and cheap; named slot lookups only beat + it when you genuinely need a non-canonical token. +- **Don't** store `Color`, `Surface`, `Paint`, or icon `PathBuf`s in + your app state. They snapshot the moment of capture and stop + responding to mode changes. +- **Don't** hard-code `Color::hex( ... )` for chrome that should adapt + to mode — route it through a palette token or a custom slot. +- **Don't** call `set_active_mode` from `view()` (it's a side effect; + do it in `update()` from a `Msg`). + +## Branding assets + +The `branding/` directory holds the theme's identity assets — launcher +logo, wallpaper, lockscreen, brand wordmark — keyed by mode. The +runtime loader looks them up by convention so themes don't need to +declare them in `theme.json`. + +### Layout + +```text +branding/ +├── light/ +│ ├── launcher.svg +│ ├── wallpaper.svg +│ ├── wallpaper/ optional pre-rendered raster variants +│ │ ├── 1280x720.webp +│ │ ├── 1920x1080.webp +│ │ └── 3840x2160.webp +│ ├── lockscreen.svg +│ ├── lockscreen/ +│ │ └── (same WIDTHxHEIGHT.webp set) +│ └── logo/ brand wordmark / icon variants +│ ├── logo.svg primary (about, splash) +│ ├── square.svg 1:1 (app icon, avatar) +│ └── horizontal.svg wordmark (header bar, sign-in) +└── dark/ + └── (same structure) +``` + +The SVG is the canonical asset. Sized raster variants in the same-named +subdirectory are an optimisation: the loader prefers a pre-rendered +WebP / PNG / JPEG that already covers the surface (no upscale, no +runtime SVG rasterisation), falling back to the SVG when no raster +fits. Filenames must be `WIDTHxHEIGHT.` (literal numeric form, +lower-case `x`); the parser is strict so a bad name is silently +ignored rather than misclassified. + +### Fallback chain — SVG + +When a branded SVG asset is requested for the active mode, the runtime +tries three locations in order: + +1. `branding/{active_mode}/{name}.svg` — preferred variant. +2. `branding/{opposite_mode}/{name}.svg` — graceful degradation when + the theme only ships one mode of the asset. +3. `branding/{name}.svg` — mode-agnostic asset, for themes that don't + bother with light/dark variants. + +Returns `None` when none of the candidates exist; consumers fall back +to whatever default they prefer (solid `bg-page` for wallpapers, no +launcher decoration, etc.). + +### Resolution — raster + +When the surface size is known, [`theme_branding_raster`] looks for a +pre-rendered raster under `branding/{mode}/{name}/`. The same three-step +mode chain applies to the *directory*: tries the active mode first, then +the opposite mode, then the mode-less directory. Within the *first +existing* directory it parses every `WIDTHxHEIGHT.` filename +(`` ∈ {`webp`, `png`, `jpg`, `jpeg`}) and picks: + +- the smallest entry whose two dimensions cover the surface, if any; +- otherwise the largest entry available — a fast-decoding upscaled + raster beats paying the SVG rasterisation cost. + +The directory does *not* cross over to the opposite-mode tree once an +existing directory is found: a colour-wrong raster (light-mode asset +served in dark mode or vice versa) would be more jarring than an +upscaled same-mode raster. Cross-mode degradation only happens at the +SVG layer, where the vector form re-paints crisply at any size. + +`theme_branding_image(name, sw, sh)` composes raster-then-SVG: tries +`theme_branding_raster` first, falls back to `theme_branding_asset(name, +"svg")` only when no raster files exist anywhere in the chain. Pass +`(0, 0)` to get the smallest available raster — every entry trivially +covers a zero-sized surface. Useful at startup before the +surface-configure event arrives, so the first frame paints from a +fast-decoded lightweight raster (typically a few ms) instead of the +SVG (typically 1-2 s for a gradient-heavy abstract wallpaper). + +### Runtime API + +```rust +// SVG resolution — mode-aware fallback chain on the file path. +let launcher = ltk::theme_launcher_icon(); // Option +let wallpaper = ltk::theme_wallpaper(); // Option +let lockscreen = ltk::theme_lockscreen(); // Option + +// Sized raster (WebP / PNG / JPEG) — pick the smallest covering variant. +let raster = ltk::theme_branding_raster( "wallpaper", 1920, 1080 ); +// -> Option + +// Combined: raster first, SVG fallback. Recommended for wallpaper / +// lockscreen consumers that have the surface size at decode time. +let path = ltk::theme_branding_image( "wallpaper", 1920, 1080 ); + +// Brand wordmark / icon — three named variants under branding/{mode}/logo/. +let about_logo = ltk::theme_logo(); // Option — primary +let app_icon = ltk::theme_logo_square(); // Option — 1:1 +let header_lg = ltk::theme_logo_horizontal(); // Option — wordmark + +// Generic helper for arbitrary branded SVG assets (splash, watermarks, …). +let splash = ltk::theme_branding_asset( "splash", "svg" ); +``` + +`branding_asset(name, ext)` is the underlying SVG helper; `branding_raster` +and `branding_image` add the size-aware layer on top. The three `theme_logo*` +helpers are thin wrappers over `branding_asset( "logo/", "svg" )` — +the `name` argument freely accepts a `subdir/file` shape, so any other +sub-grouped asset family follows the same pattern. + +## Icons + +Two parallel trees under `icons/`: + +- **`icons/apps/`** — per-application icons (`firefox.svg`, + `calculator.svg`, …). Each app's brand identity, mode-agnostic. + Looked up via [`theme::app_icon( "firefox" )`]. +- **`icons/catalogue/`** — symbolic glyph catalogue intended for tinting + at runtime. Two style variants: + - `filled/` — solid silhouettes. Preferred by default. + - `line/` — outlined variants. Used as fallback when `filled` doesn't + ship a given icon. A theme that prefers a line aesthetic puts its + SVGs in `filled/` (where the lookup goes first); the directory + name reflects the *style of the asset*, not the lookup precedence. + +Categories under `catalogue/{filled,line}/`: `accessibility`, `actions`, +`archives`, `communication`, `controls`, `customisation`, `energy`, +`features`, `feedback`, `general`, `hardware`, `keyboard`, `multimedia`, +`navigation`, `safety`, `session`, `system`, `window`. + +### Adding an icon + +1. Drop a monochrome SVG into + `catalogue/filled//.svg`. Convention: `` at the root and an explicit fill on the path (e.g. + ``). The rasteriser keeps only the alpha channel + for symbolic tinting, so the actual RGB of the source doesn't + matter — pick `#000000` for consistency with the rest of the + catalogue. +2. Optionally ship the `line/` variant in + `catalogue/line//.svg`. +3. Reference it from widget code by its slash-separated stem, no + extension: `theme::icon_path( "general/down-simple" )`. + +### Runtime API + +```rust,no_run +# fn _ex() -> Option<()> { +// Path resolution. Tries filled/.svg first, then line/.svg. +let path = ltk::theme::icon_path( "window/close" ); +// -> Option + +// Rasterise to RGBA8 (cached per (path, size) for the lifetime of the +// active document — set_active_document flushes the cache). +let ( rgba, w, h ) = ltk::theme::icon_rgba( "window/close", 16 )?; + +// Tint a symbolic icon: keep the alpha, replace the RGB with `tint`. +// `palette.icon` is the catalogue tint; for window-chrome glyphs use +// `ltk::theme_window_controls().icon` instead. +let palette = ltk::theme_palette(); +let tinted = ltk::theme::tint_symbolic( &rgba, palette.icon ); +# Some( () ) +# } +``` + +The `icon_rgba` + `tint_symbolic` pair is the standard pipeline for +catalogue and chrome icons: rasterise once, recolour per mode via +palette tokens. Themes ship a single SVG per glyph and the per-mode +look comes from code, not from duplicated assets. + +## Localisation + +ltk integrates `rust-i18n` for built-in widget strings (context-menu +labels, calendar month / day-of-week names, …). Locale files live in +`ltk/locales/.yaml`. English is the fallback. Currently shipped: +`en`, `es`, `fr`, `it`, `de`, `pt`, `pt_BR`. + +### Existing keys + +```yaml +context_menu: + copy: "Copy" + cut: "Cut" + paste: "Paste" + delete: "Delete" + +date_picker: + month_1: "January" + ... + month_12: "December" + dow_short_0: "S" + ... + dow_short_6: "S" +``` + +`dow_short_` is indexed from each locale's `first_dow` (Sunday-first +in `en`, Monday-first in `es` / `fr` / `it` / `de` / `pt` / `pt_BR`). +The `Locale` struct ships `first_dow: u8` and the date picker indexes +`dow_short_*` accordingly. + +Built-in widgets read these via `rust_i18n::t!( "context_menu.copy" )` +at render time, so switching locale at runtime via +`rust_i18n::set_locale( "es" )` flips the UI on the next frame without +reconstructing widgets. + +### Adding a string + +1. Pick a key like `my_widget.label`. +2. Add it to **every** file under `ltk/locales/` so each language has a + translation (English at minimum is required — it's the fallback). +3. Read it from widget code with `rust_i18n::t!( "my_widget.label" )`. + +### Adding a language + +1. Create `ltk/locales/.yaml` with all existing keys translated. +2. The `i18n!("locales", fallback = "en")` macro at the crate root picks + it up automatically — no registration step needed. +3. Apps select the locale at startup via + `rust_i18n::set_locale( "" )`. + +## Common errors + +| Symptom | Cause | Fix | +| --- | --- | --- | +| Red banner on every frame | No theme found at any of the three search paths | Install `ltk-theme-default` Debian package or set `LTK_THEMES_DIR` | +| `unknown reference @foo` at startup | Typo in `@name` reference | Check the `colors` / `gradients` / `inset_stacks` section spelling | +| `unknown field 'foo'` | Stale schema after a `ltk` upgrade | Compare against this document and the default theme | +| Slot is rendering with the wrong colour after `set_active_mode` | App caches a `Color` from a previous frame | Read palette / surface inside `view()` each frame; the per-frame `Arc` clone is cheap | + +## Custom themes + +A custom theme directory can live anywhere under +`$XDG_DATA_HOME/ltk/themes/`. Three practical recipes: + +- **Repaint for a brand**: copy `themes/default/`, then change: + - the `colors` palette in `theme.json` (everything else cascades), + - the up-to-six SVGs in `branding/{light,dark}/` (launcher, + wallpaper, lockscreen), + - leave the gradients, inset stacks, slot wiring and the entire + `icons/` tree untouched. +- **Override a single icon**: drop a replacement SVG at the same + catalogue path under your theme's + `icons/catalogue/filled//.svg`. The `icon_path` + lookup resolves against whichever theme is active, so a partial + catalogue overlays the default cleanly without forking the rest. +- **Build a flat-only theme**: drop every `backdrop` block from the + slots and route every `surface-*-flat` slot to a solid `colors` + reference. Visual parity with the software backend is automatic. + +All three recipes keep the slot ids and reference shapes intact, so the +built-in widgets continue to work without code changes. diff --git a/docs/widgets.md b/docs/widgets.md new file mode 100644 index 0000000..8fc010c --- /dev/null +++ b/docs/widgets.md @@ -0,0 +1,851 @@ +# ltk widget catalogue + +A flat reference of every widget and layout exported at the crate root. +Each entry has the same four sections: **what it is**, **when to use it**, +**minimal example**, **see also**. The full builder API and per-method +docs live on `cargo doc`; this file is the at-a-glance index. + +For mental model and architecture, read [`docs/onboarding.md`](./onboarding.md) +and [`docs/architecture.md`](./architecture.md) first. For copy-pasteable +patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md). + +## Table of contents + +- [Buttons and activations](#buttons-and-activations) + - [`button`](#button) · [`icon_button`](#icon_button) · [`pressable`](#pressable) · [`window_button`](#window_button) · [`list_item`](#list_item) +- [Stateful binary controls](#stateful-binary-controls) + - [`toggle`](#toggle) · [`checkbox`](#checkbox) · [`radio`](#radio) +- [Continuous controls](#continuous-controls) + - [`slider`](#slider) · [`vslider`](#vslider) · [`progress_bar`](#progress_bar) +- [Text input and display](#text-input-and-display) + - [`text`](#text) · [`text_edit`](#text_edit) +- [Decoration and chrome](#decoration-and-chrome) + - [`container`](#container) · [`separator`](#separator) · [`img_widget`](#img_widget) +- [Clipping wrappers](#clipping-wrappers) + - [`scroll`](#scroll) · [`viewport`](#viewport) · [`flex`](#flex) +- [Overlays and feedback](#overlays-and-feedback) + - [`spinner`](#spinner) · [`toast`](#toast) · [`tooltip`](#tooltip) · [`combo`](#combo) · [`tabs`](#tabs) · [`notebook`](#notebook) · [`dialog`](#dialog) +- [Pickers](#pickers) + - [`date_picker`](#date_picker) · [`time_picker`](#time_picker) · [`color_picker`](#color_picker) +- [Layouts](#layouts) + - [`column`](#column) · [`row`](#row) · [`stack`](#stack) · [`grid`](#grid) · [`spacer`](#spacer) + +--- + +## Buttons and activations + +### `button` + +A standard text button. Activates on tap, Enter, or Space when focused. + +**When**: any place a normal app would have a "Save" / "Cancel" / "Send" +control. + +```rust,no_run +# use ltk::{ button, Element }; +# #[ derive( Clone ) ] enum Msg { Save } +# fn _ex() -> Element { +button( "Save" ).on_press( Msg::Save ) +.into() +# } +``` + +**See also**: [`pressable`](#pressable) for activation on a richer +custom-shaped surface, [`icon_button`](#icon_button) for image-only +buttons. + +### `icon_button` + +A button whose visual is an RGBA bitmap instead of text. Same dispatch +shape as [`button`](#button). + +**When**: toolbar icons, system tray glyphs, anywhere the visual is +icon-first. + +```rust,no_run +# use std::sync::Arc; +# use ltk::{ icon_button, Element }; +# #[ derive( Clone ) ] enum Msg { OpenSearch } +# fn _ex( rgba_bytes: Arc>, w: u32, h: u32 ) -> Element { +icon_button( rgba_bytes, w, h ).on_press( Msg::OpenSearch ) +.into() +# } +``` + +**See also**: [`button`](#button), [`window_button`](#window_button) +(specialised icon button for window decorations). + +### `pressable` + +Wraps any [`Element`](../src/widget/mod.rs) so it dispatches a press +message. Invisible to drawing — the wrapped child paints itself. + +**When**: a card, a custom-styled list row, or any non-trivial visual +that should behave like a button. Inner widgets that are themselves +interactive (a button nested inside) keep priority. + +```rust,no_run +# use ltk::{ column, container, pressable, row, Element, Pressable }; +# #[ derive( Clone ) ] enum Msg { OpenWifiPicker } +# fn _ex( +# icon: Element, +# title: Element, +# subtitle: Element, +# ) -> Pressable { +pressable( + container( row() + .push( icon ) + .push( column().push( title ).push( subtitle ) ) ) + .surface( "surface-card" ) +) +.on_press( Msg::OpenWifiPicker ) +# } +``` + +**See also**: [`button`](#button) when a plain text button is enough, +[`list_item`](#list_item) for the standard "label + subtitle + trailing" +pattern. + +### `window_button` + +A title-bar control button (minimize / maximize / restore / close). +Comes with a special hover-tint for `Close`. + +**When**: building custom server-side window decorations. + +```rust,no_run +# use ltk::{ window_button, window_controls, Element, WindowButton, WindowButtonKind }; +# #[ derive( Clone ) ] enum Msg { CloseWindow, Minimize, Maximize, Close } +# fn _ex() -> ( WindowButton, Element ) { +let close = window_button( WindowButtonKind::Close ).on_press( Msg::CloseWindow ); + +// Or the full standard set: +let bar = window_controls( + Some( Msg::Minimize ), + WindowButtonKind::Maximize, + Some( Msg::Maximize ), + Some( Msg::Close ), +); +# ( close, bar.into() ) +# } +``` + +`focusable( true )` opts the button into the Tab cycle (off by default +to match desktop convention). + +**See also**: [`crate::theme_window_controls`] for the colour tokens +each mode supplies. + +### `list_item` + +A row with a primary label, optional subtitle, optional right-aligned +trailing text, and a tappable surface. + +**When**: settings menus, navigation lists, contact rows. Doubles its +height when a subtitle is set. + +```rust,no_run +# use ltk::{ list_item, ListItem }; +# #[ derive( Clone ) ] enum Msg { OpenWifi } +# fn _ex() -> ListItem { +list_item( "Wi-Fi" ) + .subtitle( "Eduroam" ) + .trailing( "›" ) + .on_press( Msg::OpenWifi ) +# } +``` + +**See also**: [`pressable`](#pressable) for free-form tappable rows, +[`scroll`](#scroll) to wrap a list of items in a scrollable container. + +--- + +## Stateful binary controls + +### `toggle` + +A two-state on / off switch. Renders as a horizontal pill with a sliding +thumb. + +**When**: prominent settings toggles ("Wi-Fi", "Do not disturb"). + +```rust,no_run +# use ltk::{ toggle, Toggle }; +# #[ derive( Clone ) ] enum Msg { ToggleWifi } +# struct App { wifi_enabled: bool } +# impl App { fn _ex( &self ) -> Toggle { +toggle( self.wifi_enabled ) + .label( "Wi-Fi" ) + .on_toggle( Msg::ToggleWifi ) +# }} +``` + +**See also**: [`checkbox`](#checkbox) for less prominent opt-ins, +[`radio`](#radio) for mutually-exclusive groups. + +### `checkbox` + +A two-state opt-in with a square box and a check glyph. + +**When**: form fields, terms acceptance, multi-select lists. + +```rust,no_run +# use ltk::{ checkbox, Checkbox }; +# #[ derive( Clone ) ] enum Msg { ToggleTerms } +# struct App { accept_terms: bool } +# impl App { fn _ex( &self ) -> Checkbox { +checkbox( self.accept_terms ) + .label( "I accept the terms" ) + .on_toggle( Msg::ToggleTerms ) +# }} +``` + +**See also**: [`toggle`](#toggle), [`radio`](#radio). + +### `radio` + +A single option inside a mutually-exclusive group. Build one per +variant; the application owns "which is selected". + +**When**: priority pickers, layout choices, anywhere exactly one of N +is selected. + +```rust,no_run +# use ltk::{ column, radio, Element }; +# #[ derive( Clone, PartialEq ) ] enum Priority { Low, Medium, High } +# #[ derive( Clone ) ] enum Msg { SetPriority( Priority ) } +# struct App { priority: Priority } +# impl App { fn _ex( &self ) -> Element { +column() + .push( radio( self.priority == Priority::Low ).label( "Low" ).on_select( Msg::SetPriority( Priority::Low ) ) ) + .push( radio( self.priority == Priority::Medium ).label( "Medium" ).on_select( Msg::SetPriority( Priority::Medium ) ) ) + .push( radio( self.priority == Priority::High ).label( "High" ).on_select( Msg::SetPriority( Priority::High ) ) ) +.into() +# }} +``` + +**See also**: [`checkbox`](#checkbox), [`toggle`](#toggle). + +--- + +## Continuous controls + +### `slider` + +A horizontal slider for selecting a value in `[0.0, 1.0]`. + +**When**: brightness / volume / scrub bars. Drag the thumb or tap a +position; the change message fires continuously during drag. + +```rust,no_run +# use ltk::{ slider, Slider }; +# #[ derive( Clone ) ] enum Msg { SetBrightness( f32 ) } +# struct App { brightness: f32 } +# impl App { fn _ex( &self ) -> Slider { +slider( self.brightness ) + .on_change( |v| Msg::SetBrightness( v ) ) +# }} +``` + +`accent_thumb( true )` swaps the default thumb for the two-circle +brand-coloured variant. `track_surface( id )` and `fill_surface( id )` +override the default theme slots. + +**See also**: [`vslider`](#vslider) for the vertical axis, +[`progress_bar`](#progress_bar) for read-only progress display. + +### `vslider` + +A vertical slider. Same value model as [`slider`](#slider) — `0.0` at +the bottom, `1.0` at the top. + +**When**: column-shaped equalisers, compact volume / brightness picks +inside narrow side panels. + +```rust,no_run +# use ltk::{ vslider, VSlider }; +# #[ derive( Clone ) ] enum Msg { SetVolume( f32 ) } +# struct App { volume: f32 } +# impl App { fn _ex( &self ) -> VSlider { +vslider( self.volume ).on_change( |v| Msg::SetVolume( v ) ) +# }} +``` + +**See also**: [`slider`](#slider). + +### `progress_bar` + +A read-only linear progress indicator. + +**When**: determinate operations with a known fraction +(downloads, file copies, install steps). + +```rust,no_run +# use ltk::{ progress_bar, ProgressBar }; +# struct App { download_fraction: f32 } +# impl App { fn _ex( &self ) -> ProgressBar { +progress_bar( self.download_fraction ) +# }} +``` + +**See also**: [`spinner`](#spinner) for indeterminate operations +without a known fraction. + +--- + +## Text input and display + +### `text` + +A single-line label. Truncates with an ellipsis when wider than its +allocated rect. + +**When**: titles, captions, anything non-interactive. + +```rust,no_run +# use ltk::{ text, Color, Element }; +# #[ derive( Clone ) ] enum Msg {} +# fn _ex() -> ( Element, Element ) { +let title = text( "Title" ).size( 24.0 ).color( Color::WHITE ); +let centred = text( "Centred" ).align_center(); +# ( title.into(), centred.into() ) +# } +``` + +**See also**: [`text_edit`](#text_edit) for editable text. + +### `text_edit` + +A single-line text input with cursor, Backspace, Enter / Submit, +clipboard support, a `secure( true )` password mode that masks the +visible characters and zeroizes the buffer on drop, and a +`password_toggle( visible, on_toggle )` builder that pins a show / +hide-password eye icon to the right edge of the field. + +**When**: login fields, chat inputs, search boxes. + +```rust,no_run +# use ltk::{ text_edit, Element }; +# #[ derive( Clone ) ] enum Msg { +# UsernameChanged( String ), PasswordChanged( String ), +# TogglePassword, Submit, +# } +# struct App { username: String, password: String, show_password: bool } +# impl App { fn _ex( &self ) -> ( Element, Element ) { +let user = text_edit( "Username", &self.username ) + .on_change( |s| Msg::UsernameChanged( s ) ) + .on_submit( Msg::Submit ); + +// Password field with the built-in show / hide eye. The widget +// owns the icon hit-testing — taps inside the eye zone fire +// `TogglePassword` instead of moving the caret. Flip the bool in +// your `update`; the widget reads it back on the next render. +let pw = text_edit( "Password", &self.password ) + .on_change( |s| Msg::PasswordChanged( s ) ) + .password_toggle( self.show_password, Msg::TogglePassword ); +# ( user.into(), pw.into() ) +# }} +``` + +`password_toggle` keeps the wipe-on-drop and IME-bypass guarantees +of `secure( true )` regardless of the current visibility, so +flipping the eye does not weaken the field's threat model at +runtime — only what the user sees on screen changes. + +**See also**: the password recipe in +[`docs/cookbook.md`](./cookbook.md#password-field-with-pam-submit). + +--- + +## Decoration and chrome + +### `container` + +A wrapper that adds padding, a background colour or themed surface, a +border radius, and (via theme slots) shadows / inset shadows / backdrop +blur. + +**When**: cards, panels, callouts, anywhere a child needs a backdrop or +elevation. + +```rust,no_run +# use ltk::{ column, container, Element }; +# #[ derive( Clone ) ] enum Msg {} +# fn _ex( title: Element, subtitle: Element ) -> Element { +container( column().push( title ).push( subtitle ) ) + .surface( "surface-card" ) + .padding( 16.0 ) + .radius( 24.0 ) +.into() +# } +``` + +Per-edge padding is available with `padding_top` / `padding_right` / +`padding_bottom` / `padding_left`. Per-corner radius takes a [`Corners`] +struct or a tuple. + +**See also**: [`pressable`](#pressable) wrapping a `container` makes a +card interactive. + +### `separator` + +A horizontal divider line with theme-default colour and 1 px thickness. + +**When**: visual breaks between settings groups, list categories, +content blocks. + +```rust,no_run +# use ltk::{ column, separator, text, Element }; +# #[ derive( Clone ) ] enum Msg {} +# fn _ex() -> Element { +column() + .push( text( "General" ) ) + .push( separator() ) + .push( text( "Network" ) ) +.into() +# } +``` + +### `img_widget` + +A static image rendered from RGBA pixel data shared via `Arc`. + +**When**: wallpapers, icons, illustrations. The shared `Arc` makes +re-using the same buffer across frames a pointer copy. + +```rust,no_run +# use std::sync::Arc; +# use ltk::{ img_widget, Element }; +# #[ derive( Clone ) ] enum Msg {} +# fn _ex( rgba_bytes: Arc>, width: u32, height: u32 ) -> Element { +img_widget( rgba_bytes, width, height ) + .opacity( 0.8 ) +.into() +# } +``` + +`cover()` scales to fill the rect preserving aspect; `size( w, h )` sets +explicit display dimensions. + +**See also**: [`Image::from_path`](../src/widget/image.rs) helper for +disk-loaded files (PNG, JPEG via the `image` crate). + +--- + +## Clipping wrappers + +### `scroll` + +A vertically-scrollable viewport. Drag the content to scroll; clipping +is automatic. + +**When**: lists or grids that may overflow the available height. + +```rust,no_run +# use ltk::{ column, list_item, scroll, Element }; +# #[ derive( Clone ) ] enum Msg {} +# fn _ex() -> Element { +scroll( + column() + .push( list_item::( "Item 1" ) ) + .push( list_item( "Item 2" ) ) + .push( list_item( "Item 3" ) ) +) +.into() +# } +``` + +The scroll widget owns its own gesture handling — drags inside it do +not trigger the app-level `on_swipe_*` callbacks. + +**See also**: [`viewport`](#viewport) for passive clipping without +gestures, [`grid`](#grid) inside a `scroll` for app drawers. + +### `viewport` + +A passive clipping wrapper. Clips its child to the assigned rect; no +scrolling, no gesture handling. + +**When**: panels that animate in via a parent translation, fade-in +content, anywhere you want a hard clip without scrolling. The +`fade_bottom( px )` builder feathers the bottom edge to transparent +during slide-in animations (GLES backend; software backend renders a +hard edge). + +```rust,no_run +# use ltk::{ viewport, Element }; +# #[ derive( Clone ) ] enum Msg {} +# fn _ex( panel_view: Element, panel_height: f32 ) -> Element { +viewport( panel_view ) + .height( panel_height ) + .fade_bottom( 16.0 ) +.into() +# } +``` + +**See also**: the slide-in panel recipe in +[`docs/cookbook.md`](./cookbook.md#slide-in-panel). + +### `flex` + +A row-only filler wrapper. Treats its non-spacer child like a +[`spacer`](#spacer) for leftover-width distribution but draws the child +inside the allocated rect. + +**When**: a row where one non-trivial child should fill the remaining +width (a card next to a fixed-size icon). + +```rust,no_run +# use ltk::{ column, flex, row, Element }; +# #[ derive( Clone ) ] enum Msg {} +# fn _ex( +# icon: Element, +# title: Element, +# subtitle: Element, +# ) -> Element { +row() + .push( icon ) + .push( flex( column().push( title ).push( subtitle ) ) ) +.into() +# } +``` + +`weight( n )` sets the relative share when there are several flex / +spacer siblings. + +**See also**: [`spacer`](#spacer) for invisible fillers, +[`column`](#column) and [`row`](#row). + +--- + +## Overlays and feedback + +### `spinner` + +Indeterminate progress indicator. Animates while in the tree. + +**When**: long-running operations with no known fraction +(network calls, indexing, "waiting for compositor"). + +```rust,no_run +# use ltk::{ spinner, Spinner }; +# fn _ex() -> Spinner { +spinner().size( 24.0 ) +# } +``` + +**See also**: [`progress_bar`](#progress_bar) for determinate progress. + +### `toast` + +Transient notification that floats over the surface and dismisses +itself after a timeout. + +**When**: confirmation snackbars ("Saved"), non-blocking errors, +status flashes. + +```rust,no_run +# use ltk::{ toast, Toast }; +# #[ derive( Clone ) ] enum Msg {} +# fn _ex() -> Toast { +toast( "Saved" ).duration( 3.0 ) +# } +``` + +**See also**: [`tooltip`](#tooltip) for hover-anchored hints. + +### `tooltip` + +Anchored hint that appears next to a target widget on hover or focus. + +**When**: discoverable affordances on icon-only controls, keyboard +shortcut reminders, helper text that should not occupy permanent +layout space. + +```rust,no_run +# use ltk::{ tooltip, Tooltip, WidgetId }; +# #[ derive( Clone ) ] enum Msg {} +# fn _ex() -> Tooltip { +tooltip( "Ctrl+S", WidgetId( "btn/save" ) ).max_width( 240 ) +# } +``` + +The widget paints next to the widget that registered the matching +`WidgetId`. See [`docs/cookbook.md`](./cookbook.md) for the full +overlay wiring. + +**See also**: [`toast`](#toast) for transient self-dismissing +notifications. + +### `combo` + +Editable single-select with a popup list. Supports type-to-filter +and free-text entry. + +**When**: pick-one fields with too many options for a row of +[`radio`](#radio) buttons but where a free typed answer is also +valid (autocomplete, country list, theme picker). + +```rust,no_run +# use ltk::{ combo, Combo, ComboState }; +# #[ derive( Clone ) ] enum Msg { Pick( usize ) } +# fn _ex( state: ComboState ) -> Combo { +let items = vec![ + "One".to_string(), + "Two".to_string(), + "Three".to_string(), +]; +combo( state, items ).on_select_idx( Msg::Pick ) +# } +``` + +**See also**: [`radio`](#radio) for small mutually-exclusive sets, +[`notebook`](#notebook) for tabbed mode switches. + +### `tabs` + +A bare tab bar — visual selection without owning the page content. +Use this when you want full control over what each tab shows. + +**When**: paged settings screens where the panes are large and you +want to keep them as siblings in the tree, not as +[`notebook`](#notebook) children. + +```rust,no_run +# use ltk::{ tabs, TabBar }; +# #[ derive( Clone ) ] enum Msg { TabChanged( usize ) } +# fn _ex( active: usize ) -> TabBar { +tabs::( [ "Profile", "Network", "Privacy" ] ) + .selected( active ) + .on_select( Msg::TabChanged ) +# } +``` + +**See also**: [`notebook`](#notebook) for tabs that own their pages. + +### `notebook` + +Tabbed container. Owns the pages and shows only the active one. + +**When**: settings dialogs, multi-step forms, anywhere a flat +[`tabs`](#tabs) bar over hand-managed pages would be more code than +benefit. + +```rust,no_run +# use ltk::{ notebook, text, Notebook }; +# #[ derive( Clone ) ] enum Msg { TabChanged( usize ) } +# fn _ex( active: usize ) -> Notebook { +notebook::() + .page( "General", text( "general body" ) ) + .page( "Advanced", text( "advanced body" ) ) + .selected( active ) + .on_select( Msg::TabChanged ) +# } +``` + +**See also**: [`tabs`](#tabs) for a bare tab bar without page +ownership. + +### `dialog` + +Modal or non-modal centered confirmation card with a built-in scrim, +optional title, subtitle, custom body, and a right-aligned action +row. Pressing `Esc` fires the configured `cancel` message. + +**When**: destructive confirmations ("Delete file?"), guided pickers +that block other interaction until resolved, "are you sure" gates +before a long-running operation. + +```rust,no_run +# use ltk::{ button, dialog, ButtonVariant, Element }; +# #[ derive( Clone ) ] enum Msg { Cancel, Confirm } +# fn _ex() -> Element { +dialog() + .title( "Delete partition?" ) + .subtitle( "This will erase every file on /dev/sda2." ) + .cancel( Msg::Cancel ) + .action( button::( "Cancel" ).variant( ButtonVariant::Tertiary ).on_press( Msg::Cancel ) ) + .action( button::( "Delete" ).variant( ButtonVariant::Primary ).on_press( Msg::Confirm ) ) + .into() +# } +``` + +`modal( false ) + dismiss_on_scrim( msg )` makes a tap on the dim +background fire `msg`; combining `dismiss_on_scrim` with `modal( true )` +panics at lower time (the contracts contradict). `body( elem )` +swaps a custom element in between the subtitle and the action row — +the example app at `examples/dialog.rs` uses this for an in-dialog +slider. + +**See also**: [`toast`](#toast) for non-blocking transient +notifications, [`combo`](#combo) for a single-pick popup that does +not require modal blocking. + +--- + +## Pickers + +### `date_picker` + +Calendar-grid date selector with month / year navigation. + +**When**: birthdays, deadlines, any single-date input. + +```rust,no_run +# use ltk::{ date_picker, Date, DatePicker }; +# #[ derive( Clone ) ] enum Msg { Picked( Date ) } +# fn _ex() -> DatePicker { +date_picker( Date::new( 2026, 5, 7 ) ).on_change( Msg::Picked ) +# } +``` + +### `time_picker` + +Hour / minute selector. Wheel-style scrubbing on touch. + +**When**: alarms, scheduling, time-of-day input. + +```rust,no_run +# use ltk::{ time_picker, Time, TimePicker }; +# #[ derive( Clone ) ] enum Msg { Picked( Time ) } +# fn _ex( now: Time ) -> TimePicker { +time_picker( now ).on_change( Msg::Picked ) +# } +``` + +### `color_picker` + +Hue + saturation/value picker with a hex/RGB readout. + +**When**: theming UIs, paint tools, accent customisation. + +```rust,no_run +# use ltk::{ color_picker, Color, ColorPicker }; +# #[ derive( Clone ) ] enum Msg { Picked( Color ) } +# fn _ex( current: Color ) -> ColorPicker { +color_picker( current ).on_change( Msg::Picked ) +# } +``` + +--- + +## Layouts + +### `column` + +Vertical flow. Children stack top-to-bottom with optional padding, +spacing, alignment. + +```rust,no_run +# use ltk::{ button, column, spacer, text, Element }; +# #[ derive( Clone ) ] enum Msg { Ok } +# fn _ex() -> Element { +column() + .padding( 24.0 ) + .spacing( 12.0 ) + .push( text( "Title" ) ) + .push( spacer() ) + .push( button( "OK" ).on_press( Msg::Ok ) ) +.into() +# } +``` + +`max_width(px)` caps the inner content width; `fit_content()` reports +the natural content width to the parent (otherwise the column claims +the full available width). `center_y( true )` centres the content +block vertically when there are no spacers. + +### `row` + +Horizontal flow. Mirror of [`column`](#column) on the X axis. + +```rust,no_run +# use ltk::{ flex, row, Element }; +# #[ derive( Clone ) ] enum Msg {} +# fn _ex( +# label: Element, +# field: Element, +# submit_button: Element, +# ) -> Element { +row() + .spacing( 8.0 ) + .push( label ) + .push( flex( field ) ) + .push( submit_button ) +.into() +# } +``` + +### `stack` + +Z-order overlay. Each child gets explicit horizontal and vertical +alignment (`HAlign` / `VAlign`) plus an optional margin and pixel +translation. Children are drawn in declaration order — the last child +sits on top. + +```rust,no_run +# use std::sync::Arc; +# use ltk::{ button, column, img_widget, stack, text, Element, HAlign, VAlign }; +# #[ derive( Clone ) ] enum Msg { Add } +# fn _ex( background: Arc>, w: u32, h: u32 ) -> Element { +stack() + .push( img_widget( background, w, h ) ) + .push_aligned( + column().push( text( "Heading" ) ), + HAlign::Center, VAlign::Center, + ) + .push_aligned_margin( + button( "+" ).on_press( Msg::Add ), + HAlign::End, VAlign::Bottom, + 16.0, + ) +.into() +# } +``` + +**See also**: [`column`](#column) and [`row`](#row) for flow layout. + +### `grid` + +Fixed-column-count grid that wraps its children into rows. + +**When**: icon launchers, photo galleries, app drawers. + +```rust,no_run +# use std::sync::Arc; +# use ltk::{ grid, icon_button, Element }; +# #[ derive( Clone ) ] enum Msg { OpenApp1, OpenApp2 } +# fn _ex( app1_rgba: Arc>, app2_rgba: Arc>, w: u32, h: u32 ) -> Element { +grid( 4 ) + .padding( 16.0 ) + .spacing( 12.0 ) + .push( icon_button( app1_rgba, w, h ).on_press( Msg::OpenApp1 ) ) + .push( icon_button( app2_rgba, w, h ).on_press( Msg::OpenApp2 ) ) + // ... +.into() +# } +``` + +Wrap inside [`scroll`](#scroll) when the grid may overflow. + +### `spacer` + +An invisible flexible filler. Inside a column / row, absorbs leftover +space along the parent's main axis. + +```rust,no_run +# use ltk::{ column, spacer, Element }; +# #[ derive( Clone ) ] enum Msg {} +# fn _ex( header: Element, footer: Element ) -> Element { +column() + .push( header ) + .push( spacer() ) // pushes footer to the bottom + .push( footer ) +.into() +# } +``` + +`weight( n )` sets the relative share; `height( px )` / `width( px )` +pin the spacer to a fixed size on its respective axis. + +**See also**: [`flex`](#flex) for a non-empty filler. diff --git a/examples/combo.rs b/examples/combo.rs new file mode 100644 index 0000000..f70ced8 --- /dev/null +++ b/examples/combo.rs @@ -0,0 +1,227 @@ +//! `cargo run --example combo` +//! +//! Showcases the `combo` widget — a select / dropdown with editable +//! query, multi-select chips and a scrollable popup. +//! +//! The trigger lives in the main `view()` tree; the popup is returned +//! from [`App::overlays`] as a real Wayland **xdg-popup** child of the +//! main window, so it can extend outside the parent surface (the +//! canonical select / dropdown behaviour). The compositor positions the +//! popup adjacent to the trigger pill and dismisses it via +//! `popup_done` when the user clicks outside, which fires the spec's +//! `on_dismiss` message. +//! +//! Tap on an item to add it to the selection; tap on the `×` next to a +//! chip to remove it; tap outside the popup to dismiss. Esc exits. + +use ltk:: +{ + combo, column, row, separator, spacer, text, + App, Color, ComboState, Element, Keysym, OverlaySpec, WidgetId, +}; + +// Distinct anchor ids so each combo's popup can find its own trigger +// pill in the previous-frame layout snapshot. +const FRUIT_ANCHOR: WidgetId = WidgetId( "demo-fruit-trigger" ); +const REGION_ANCHOR: WidgetId = WidgetId( "demo-region-trigger" ); + +#[ derive( Clone, Debug ) ] +enum Msg +{ + // Multi-select fruit picker. + FruitToggle, + FruitQuery( String ), + FruitSelect( usize ), + FruitUnselect( usize ), + FruitDismiss, + // Single-select region picker (non-searchable). + RegionToggle, + RegionSelect( usize ), + RegionDismiss, +} + +struct Demo +{ + fruits: ComboState, + fruit_items: Vec, + region: ComboState, + region_items: Vec, +} + +impl Demo +{ + fn new() -> Self + { + Self + { + fruits: ComboState::new(), + fruit_items: [ + "Apple", "Apricot", "Avocado", "Banana", "Blackberry", + "Blueberry", "Cherry", "Date", "Fig", "Grape", "Kiwi", + "Lemon", "Lime", "Mango", "Orange", "Papaya", "Peach", + "Pear", "Pineapple", "Plum", "Raspberry", "Strawberry", + ].iter().map( |s| s.to_string() ).collect(), + + region: ComboState::new(), + region_items: [ + "Galicia", "Asturias", "Cantabria", "País Vasco", + "Navarra", "La Rioja", "Aragón", "Cataluña", + ].iter().map( |s| s.to_string() ).collect(), + } + } + + fn fruit_combo( &self ) -> ltk::Combo + { + combo( self.fruits.clone(), self.fruit_items.clone() ) + .label( "Fruits" ) + .description( "Pick the ones you want in the smoothie." ) + .placeholder( "Type to filter…" ) + .helper( "Selected fruits show up as chips above the field." ) + .multi_select( true ) + .searchable( true ) + .anchor_id( FRUIT_ANCHOR ) + .popup_max_height( 280.0 ) + .on_query_change( Msg::FruitQuery ) + .on_toggle_open( Msg::FruitToggle ) + .on_select_idx( Msg::FruitSelect ) + .on_unselect_idx( Msg::FruitUnselect ) + .on_dismiss( Msg::FruitDismiss ) + } + + fn region_combo( &self ) -> ltk::Combo + { + combo( self.region.clone(), self.region_items.clone() ) + .label( "Region" ) + .description( "Single-select, non-searchable." ) + .placeholder( "Choose a region…" ) + .anchor_id( REGION_ANCHOR ) + .popup_max_height( 240.0 ) + .on_toggle_open( Msg::RegionToggle ) + .on_select_idx( Msg::RegionSelect ) + .on_dismiss( Msg::RegionDismiss ) + } + + fn body( &self ) -> Element + { + let palette = ltk::theme_palette(); + + column::() + .padding( 32.0 ) + .spacing( 24.0 ) + .center_y( false ) + .push( + text( "ltk — combo / select demo" ) + .size( 24.0 ) + .color( palette.text_primary ) + .align_center(), + ) + .push( separator() ) + .push( self.fruit_combo().trigger() ) + .push( separator() ) + .push( self.region_combo().trigger() ) + .push( spacer() ) + .push( + row::() + .padding( 0.0 ) + .spacing( 8.0 ) + .push( + text( format!( "Fruits selected: {}", self.fruits.selected.len() ) ) + .size( 14.0 ) + .color( palette.text_secondary ), + ) + .push( spacer() ) + .push( + text( + self.region.selected.first() + .and_then( |&i| self.region_items.get( i ) ) + .map( |s| format!( "Region: {s}" ) ) + .unwrap_or_else( || "Region: —".to_string() ), + ) + .size( 14.0 ) + .color( palette.text_secondary ), + ), + ) + .push( + text( "Esc = quit" ) + .size( 12.0 ) + .color( palette.text_secondary ) + .align_center(), + ) + .into() + } +} + +impl App for Demo +{ + type Message = Msg; + + fn view( &self ) -> Element + { + self.body() + } + + fn overlays( &self ) -> Vec> + { + // Each open combo contributes one xdg-popup overlay anchored + // to its trigger pill; closed combos contribute nothing. + let mut out = Vec::new(); + if let Some( o ) = self.fruit_combo().overlay() { out.push( o ); } + if let Some( o ) = self.region_combo().overlay() { out.push( o ); } + out + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::FruitToggle => self.fruits.toggle_open(), + Msg::FruitQuery( q ) => self.fruits.query = q, + Msg::FruitSelect( i ) => + { + if self.fruits.selected.contains( &i ) + { + self.fruits.unselect( i ); + } + else + { + self.fruits.select( i ); + } + } + Msg::FruitUnselect( i ) => self.fruits.unselect( i ), + Msg::FruitDismiss => + { + self.fruits.is_open = false; + self.fruits.query.clear(); + } + + Msg::RegionToggle => self.region.toggle_open(), + Msg::RegionSelect( i ) => + { + // Single-select: replace the whole `selected` vector + // with just this index, then close. + self.region.selected = vec![ i ]; + self.region.is_open = false; + } + Msg::RegionDismiss => self.region.is_open = false, + } + } + + fn on_key( &mut self, keysym: Keysym ) -> Option + { + if keysym == Keysym::Escape + { + std::process::exit( 0 ); + } + None + } + + fn background_color( &self ) -> Color + { + ltk::theme_palette().bg + } +} + +fn main() +{ + ltk::run( Demo::new() ); +} diff --git a/examples/dialog.rs b/examples/dialog.rs new file mode 100644 index 0000000..8433b04 --- /dev/null +++ b/examples/dialog.rs @@ -0,0 +1,231 @@ +//! Example for the `dialog` widget. +//! +//! Demonstrates the three shapes the widget covers: +//! +//! * **Modal confirm** — title + subtitle + "Cancel" / "Delete" +//! actions. Backed by `cancel( Msg::Cancel )` so pressing `Esc` +//! matches tapping the Cancel button. Pointer events outside the +//! card are silently absorbed. +//! * **Non-modal pick** — `modal( false ) + dismiss_on_scrim( … )`. A +//! tap on the scrim outside the card emits the dismiss message; ESC +//! still fires `cancel`. +//! * **Custom body** — a body element (here, a slider) injected +//! between the subtitle and the action row. + +use ltk::{ + App, ButtonVariant, Element, + button, column, dialog, row, slider, spacer, stack, text, +}; + +#[ derive( Clone, Copy, PartialEq, Eq ) ] +enum Open +{ + None, + Modal, + NonModal, + CustomBody, +} + +#[ derive( Clone ) ] +enum Msg +{ + Open( Open ), + Close, + Confirm, + PickAlpha, + PickBeta, + PickGamma, + BrightnessChanged( f32 ), +} + +struct DialogApp +{ + open: Open, + last_event: String, + brightness: f32, +} + +impl DialogApp +{ + fn new() -> Self + { + Self + { + open: Open::None, + last_event: "(no dialog interactions yet)".to_string(), + brightness: 0.5, + } + } +} + +impl App for DialogApp +{ + type Message = Msg; + + fn view( &self ) -> Element + { + let palette = ltk::theme_palette(); + let primary = palette.text_primary; + let secondary = palette.text_secondary; + + let header = column::() + .spacing( 8.0 ) + .push( text( "ltk · dialog widget" ).size( 24.0 ).color( primary ) ) + .push( text( "Open one of the dialogs below. ESC always fires the dialog's `cancel` message." ) + .size( 14.0 ) + .color( secondary ) + .wrap( true ) ); + + let openers = row::() + .spacing( 12.0 ) + .push( + button::( "Modal confirm" ) + .variant( ButtonVariant::Primary ) + .on_press( Msg::Open( Open::Modal ) ), + ) + .push( + button::( "Non-modal pick" ) + .variant( ButtonVariant::Secondary ) + .on_press( Msg::Open( Open::NonModal ) ), + ) + .push( + button::( "Custom body" ) + .variant( ButtonVariant::Tertiary ) + .on_press( Msg::Open( Open::CustomBody ) ), + ); + + let event_label = text( format!( "Last event: {}", self.last_event ) ) + .size( 13.0 ) + .color( secondary ) + .wrap( true ); + + let main = column::() + .spacing( 24.0 ) + .padding( 32.0 ) + .push( header ) + .push( openers ) + .push( spacer() ) + .push( event_label ); + + // Build the active dialog (if any) and stack it on top of the + // main column. Stack draws children in order, so the dialog + // always sits over the main UI. + let overlay: Option> = match self.open + { + Open::None => None, + Open::Modal => Some( modal_confirm() ), + Open::NonModal => Some( non_modal_pick() ), + Open::CustomBody => Some( custom_body_dialog( self.brightness ) ), + }; + + match overlay + { + Some( o ) => + { + stack::() + .push( main ) + .push( o ) + .into() + } + None => main.into(), + } + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::Open( o ) => { self.open = o; self.last_event = "opened".into(); } + Msg::Close => { self.open = Open::None; self.last_event = "cancelled (button or ESC or scrim)".into(); } + Msg::Confirm => { self.open = Open::None; self.last_event = "confirmed".into(); } + Msg::PickAlpha => { self.open = Open::None; self.last_event = "picked Alpha".into(); } + Msg::PickBeta => { self.open = Open::None; self.last_event = "picked Beta".into(); } + Msg::PickGamma => { self.open = Open::None; self.last_event = "picked Gamma".into(); } + Msg::BrightnessChanged( v ) => { self.brightness = v; } + } + } +} + +fn modal_confirm() -> Element +{ + dialog::() + .title( "Delete partition?" ) + .subtitle( "This will permanently erase every file in /dev/sda2 and cannot be undone. Make sure you have a backup before continuing." ) + .cancel( Msg::Close ) + .action( + button::( "Cancel" ) + .variant( ButtonVariant::Tertiary ) + .on_press( Msg::Close ), + ) + .action( + button::( "Delete" ) + .variant( ButtonVariant::Primary ) + .on_press( Msg::Confirm ), + ) + .into() +} + +fn non_modal_pick() -> Element +{ + dialog::() + .modal( false ) + .title( "Pick a flavour" ) + .subtitle( "Tap the dim background or press ESC to cancel." ) + .cancel( Msg::Close ) + .dismiss_on_scrim( Msg::Close ) + .action( + button::( "Alpha" ) + .variant( ButtonVariant::Tertiary ) + .on_press( Msg::PickAlpha ), + ) + .action( + button::( "Beta" ) + .variant( ButtonVariant::Tertiary ) + .on_press( Msg::PickBeta ), + ) + .action( + button::( "Gamma" ) + .variant( ButtonVariant::Primary ) + .on_press( Msg::PickGamma ), + ) + .into() +} + +fn custom_body_dialog( brightness: f32 ) -> Element +{ + let palette = ltk::theme_palette(); + let secondary = palette.text_secondary; + + let body = column::() + .spacing( 8.0 ) + .push( text( format!( "Brightness: {:>3.0} %", brightness * 100.0 ) ) + .size( 14.0 ) + .color( secondary ) ) + .push( + slider::( brightness ) + .on_change( |v| Msg::BrightnessChanged( v ) ) + .accent_thumb( true ), + ); + + dialog::() + .title( "Display brightness" ) + .subtitle( "Drag the slider, then accept or cancel." ) + .cancel( Msg::Close ) + .body( body ) + .action( + button::( "Cancel" ) + .variant( ButtonVariant::Tertiary ) + .on_press( Msg::Close ), + ) + .action( + button::( "Accept" ) + .variant( ButtonVariant::Primary ) + .on_press( Msg::Confirm ), + ) + .into() +} + +fn main() +{ + ltk::run( DialogApp::new() ); +} diff --git a/examples/inputs.rs b/examples/inputs.rs new file mode 100644 index 0000000..5946dbd --- /dev/null +++ b/examples/inputs.rs @@ -0,0 +1,128 @@ +//! `cargo run --example inputs` +//! +//! Shows a plain username field and a secure password field with a +//! built-in show / hide-password eye toggle. Tap the eye to flip +//! the bullets ↔ plaintext rendering — the buffer keeps its +//! wipe-on-drop guarantee either way. Tab / Shift+Tab moves focus +//! between fields. Enter or the Login button submits. Esc exits. +//! +//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running +//! Wayland compositor (e.g. sway, labwc, or a full desktop session). + +use ltk::{ App, Element, Keysym, ButtonVariant, button, column, text, text_edit, spacer }; + +// ── Messages ────────────────────────────────────────────────────────────────── + +#[ derive( Clone ) ] +enum Message +{ + UsernameChanged( String ), + PasswordChanged( String ), + TogglePasswordVisibility, + Submit, +} + +// ── App state ───────────────────────────────────────────────────────────────── + +struct InputsApp +{ + username: String, + password: String, + password_visible: bool, + submitted: bool, +} + +impl InputsApp +{ + fn new() -> Self + { + Self + { + username: String::new(), + password: String::new(), + password_visible: false, + submitted: false, + } + } +} + +// ── App trait ───────────────────────────────────────────────────────────────── + +impl App for InputsApp +{ + type Message = Message; + + fn view( &self ) -> Element + { + let palette = ltk::theme_palette(); + let primary = palette.text_primary; + let secondary = palette.text_secondary; + + let status = if self.submitted + { + format!( "Submitted as: {}", self.username ) + } else { + String::from( "Fill in the fields and press Login" ) + }; + + column::() + .padding( 32.0 ) + .spacing( 16.0 ) + .max_width( 380.0 ) + .center_y( true ) + .push( text( "ltk — text input showcase" ).size( 24.0 ).color( primary ).align_center() ) + .push( text( status ).size( 14.0 ).color( secondary ).align_center() ) + .push( spacer() ) + .push( + text_edit::( "Username", &self.username ) + .on_change( Message::UsernameChanged ) + .on_submit( Message::Submit ), + ) + .push( + text_edit::( "Password", &self.password ) + .on_change( Message::PasswordChanged ) + .on_submit( Message::Submit ) + .password_toggle( self.password_visible, Message::TogglePasswordVisibility ), + ) + .push( + button::( "Login" ) + .variant( ButtonVariant::Primary ) + .on_press( Message::Submit ), + ) + .push( spacer() ) + .push( + text( "Tab = next field · Enter = submit · Esc = quit" ) + .size( 12.0 ) + .color( secondary ) + .align_center(), + ) + .into() + } + + fn update( &mut self, msg: Message ) + { + match msg + { + Message::UsernameChanged( v ) => self.username = v, + Message::PasswordChanged( v ) => self.password = v, + Message::TogglePasswordVisibility => self.password_visible = !self.password_visible, + Message::Submit => self.submitted = true, + } + } + + fn on_key( &mut self, keysym: Keysym ) -> Option + { + if keysym == Keysym::Escape + { + std::process::exit( 0 ); + } + None + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +fn main() +{ + ltk::run( InputsApp::new() ); +} diff --git a/examples/mini_shell.rs b/examples/mini_shell.rs new file mode 100644 index 0000000..2e3ef9b --- /dev/null +++ b/examples/mini_shell.rs @@ -0,0 +1,626 @@ +//! `cargo run --example mini_shell` +//! +//! A self-contained mini-shell that exercises the patterns described in +//! `docs/architecture.md`: +//! +//! - Multi-screen routing on the main surface (Home / Settings) +//! - Two coordinated overlays (Quick Settings + Confirm dialog) +//! - A pass-through OSD overlay with a fade-in/fade-out animation driven by +//! `is_animating()` +//! - Live dark/light theme switching from a UI toggle +//! - Nested message enums (`AppMsg::Home(HomeMsg)`, `AppMsg::Settings(...)`) +//! - One module per screen / overlay so the file scales like a real app +//! +//! Controls: +//! Esc — quit +//! Swipe down — open Quick Settings +//! Tap outside — dismiss any overlay +//! +//! NOTE: ltk is a Wayland toolkit. Run inside a Wayland session +//! (sway, labwc, the Liberux desktop, …). + +use std::time::{ Duration, Instant }; + +use ltk:: +{ + App, ButtonVariant, Color, Element, Keysym, OverlayId, OverlaySpec, + ThemeMode, + button, column, container, progress_bar, row, slider, spacer, text, toggle, +}; + +// ── Stable overlay ids ──────────────────────────────────────────────────────── +// +// Declare ids as constants so the runtime can diff the overlay list across +// frames and keep the underlying surfaces alive. + +const OVERLAY_QUICK_SETTINGS: OverlayId = OverlayId( 1 ); +const OVERLAY_CONFIRM: OverlayId = OverlayId( 2 ); +const OVERLAY_TOAST: OverlayId = OverlayId( 3 ); + +const TOAST_LIFETIME: Duration = Duration::from_millis( 1800 ); +const TOAST_FADE_IN: Duration = Duration::from_millis( 200 ); +const TOAST_FADE_OUT: Duration = Duration::from_millis( 600 ); + +// ── Routing ─────────────────────────────────────────────────────────────────── + +#[ derive( Clone, Copy, PartialEq, Eq ) ] +enum Route { Home, Settings } + +// ── Confirm dialog payload ──────────────────────────────────────────────────── +// +// Models a user-initiated action that needs confirmation before it takes +// effect. The dialog itself is just a view of `AppState::pending_action`. + +#[ derive( Clone, Copy ) ] +enum Action +{ + PowerOff, + Reset, +} + +impl Action +{ + fn label( self ) -> &'static str + { + match self + { + Action::PowerOff => "Power off this device?", + Action::Reset => "Reset all settings to defaults?", + } + } +} + +// ── Message tree ────────────────────────────────────────────────────────────── +// +// The top-level enum stays small and forwards to per-screen sub-enums — the +// recommended pattern once a shell has more than a handful of messages. + +#[ derive( Clone ) ] +enum AppMsg +{ + Nav( Route ), + Home( HomeMsg ), + Settings( SettingsMsg ), + + // Overlay coordination + OpenQuickSettings, + CloseQuickSettings, + SetBrightness( f32 ), + + ShowConfirm( Action ), + DismissConfirm, + ConfirmYes, + + // Cross-cutting + SetThemeMode( ThemeMode ), + ShowToast( String ), + Quit, +} + +#[ derive( Clone ) ] +enum HomeMsg { LaunchApp( &'static str ) } +#[ derive( Clone ) ] +enum SettingsMsg { ToggleNotifications } + +// ── App state ───────────────────────────────────────────────────────────────── + +struct AppState +{ + route: Route, + + // Per-screen state (in a real app each lives in its own module struct). + home: home::Home, + settings: settings::Settings, + + // Overlay state + quick_settings_visible: bool, + pending_action: Option, + brightness: f32, + + // Animated OSD toast + toast_text: Option, + toast_started: Option, +} + +impl AppState +{ + fn new() -> Self + { + Self + { + route: Route::Home, + home: home::Home::new(), + settings: settings::Settings::new(), + quick_settings_visible: false, + pending_action: None, + brightness: 0.7, + toast_text: None, + toast_started: None, + } + } + + fn show_toast( &mut self, msg: String ) + { + self.toast_text = Some( msg ); + self.toast_started = Some( Instant::now() ); + } + + /// 0.0 = invisible, 1.0 = fully opaque. Used by the OSD overlay's view. + fn toast_alpha( &self ) -> f32 + { + let Some( start ) = self.toast_started else { return 0.0 }; + let elapsed = start.elapsed(); + if elapsed < TOAST_FADE_IN + { + elapsed.as_secs_f32() / TOAST_FADE_IN.as_secs_f32() + } else if elapsed < TOAST_LIFETIME - TOAST_FADE_OUT { + 1.0 + } else if elapsed < TOAST_LIFETIME { + let t = ( TOAST_LIFETIME - elapsed ).as_secs_f32() / TOAST_FADE_OUT.as_secs_f32(); + t.max( 0.0 ) + } else { + 0.0 + } + } +} + +// ── App trait ───────────────────────────────────────────────────────────────── + +impl App for AppState +{ + type Message = AppMsg; + + fn view( &self ) -> Element + { + match self.route + { + Route::Home => self.home.view(), + Route::Settings => self.settings.view(), + } + } + + fn overlays( &self ) -> Vec> + { + let mut out = Vec::new(); + + if self.quick_settings_visible + { + out.push( OverlaySpec + { + id: OVERLAY_QUICK_SETTINGS, + layer: ltk::Layer::Overlay, + anchor: ltk::Anchor::ALL, + size: ( 0, 0 ), + exclusive_zone: 0, + keyboard_exclusive: false, + input_region: None, + view: overlays::quick_settings( self ), + on_dismiss: Some( AppMsg::CloseQuickSettings ), + anchor_widget_id: None, + } ); + } + + if let Some( action ) = self.pending_action + { + out.push( OverlaySpec + { + id: OVERLAY_CONFIRM, + layer: ltk::Layer::Overlay, + anchor: ltk::Anchor::ALL, + size: ( 0, 0 ), + exclusive_zone: 0, + keyboard_exclusive: false, + input_region: None, + view: overlays::confirm( action ), + on_dismiss: Some( AppMsg::DismissConfirm ), + anchor_widget_id: None, + } ); + } + + if self.toast_text.is_some() + { + out.push( OverlaySpec + { + id: OVERLAY_TOAST, + layer: ltk::Layer::Overlay, + anchor: ltk::Anchor::ALL, + size: ( 0, 0 ), + exclusive_zone: 0, + keyboard_exclusive: false, + // Pass-through: the OSD must not steal taps from anything below. + input_region: Some( Vec::new() ), + view: overlays::toast( self ), + on_dismiss: None, + anchor_widget_id: None, + } ); + } + + out + } + + fn update( &mut self, msg: AppMsg ) + { + match msg + { + AppMsg::Nav( r ) => self.route = r, + AppMsg::Home( m ) => + { + if let Some( follow ) = self.home.update( m ) + { + self.update( follow ); + } + } + AppMsg::Settings( m ) => self.settings.update( m ), + + AppMsg::OpenQuickSettings => self.quick_settings_visible = true, + AppMsg::CloseQuickSettings => self.quick_settings_visible = false, + AppMsg::SetBrightness( v ) => self.brightness = v, + + AppMsg::ShowConfirm( a ) => self.pending_action = Some( a ), + AppMsg::DismissConfirm => self.pending_action = None, + AppMsg::ConfirmYes => + { + if let Some( action ) = self.pending_action.take() + { + match action + { + Action::PowerOff => self.show_toast( "Pretending to power off…".into() ), + Action::Reset => + { + self.settings = settings::Settings::new(); + self.brightness = 0.7; + self.show_toast( "Settings reset".into() ); + } + } + } + } + + AppMsg::SetThemeMode( m ) => ltk::set_active_mode( m ), + AppMsg::ShowToast( s ) => self.show_toast( s ), + AppMsg::Quit => std::process::exit( 0 ), + } + } + + // Animation: keep redrawing while the toast is on screen so it can fade. + fn is_animating( &self ) -> bool + { + self.toast_started.is_some() + } + + // Auto-expire the toast once its lifetime is up. Putting this in + // `poll_external` (rather than `update`) means the loop wakes itself when + // `is_animating()` is driving redraws and cleans up without user input. + fn poll_external( &mut self ) -> Vec + { + if let Some( start ) = self.toast_started + { + if start.elapsed() >= TOAST_LIFETIME + { + self.toast_text = None; + self.toast_started = None; + } + } + Vec::new() + } + + fn on_swipe_down( &mut self ) -> Option + { + Some( AppMsg::OpenQuickSettings ) + } + + fn swipe_down_threshold( &self ) -> f32 { 0.10 } + + fn on_key( &mut self, k: Keysym ) -> Option + { + match k + { + Keysym::Escape => Some( AppMsg::Quit ), + _ => None, + } + } + + fn background_color( &self ) -> Color + { + // Use the active theme's background so dark/light flips immediately. + ltk::theme_palette().bg + } +} + +// ── Per-screen modules ──────────────────────────────────────────────────────── +// +// In a real app each of these lives in its own file (`src/home.rs`, +// `src/settings.rs`, …). Inlined here so the example stays a single file. + +mod home +{ + use super::*; + + pub struct Home + { + pub last_launched: Option<&'static str>, + } + + impl Home + { + pub fn new() -> Self { Self { last_launched: None } } + + /// Cmd-style: returns an optional follow-up message instead of mutating + /// shared state. The parent `update` runs the follow-up, which keeps + /// the borrow checker happy. + pub fn update( &mut self, msg: HomeMsg ) -> Option + { + match msg + { + HomeMsg::LaunchApp( name ) => + { + self.last_launched = Some( name ); + Some( AppMsg::ShowToast( format!( "Launched {}", name ) ) ) + } + } + } + + pub fn view( &self ) -> Element + { + let palette = ltk::theme_palette(); + + let title = text( "Mini Shell" ) + .size( 28.0 ) + .color( palette.text_primary ) + .align_center(); + + let status = text( match self.last_launched + { + Some( n ) => format!( "Last launched: {}", n ), + None => "No app launched yet".into(), + } ) + .size( 13.0 ) + .color( palette.text_secondary ) + .align_center(); + + let mut grid = ltk::grid::( 2 ).spacing( 12.0 ).padding( 0.0 ); + for name in &["Browser", "Mail", "Camera", "Music"] + { + grid = grid.push( + button::( *name ) + .variant( ButtonVariant::Secondary ) + .on_press( AppMsg::Home( HomeMsg::LaunchApp( name ) ) ), + ); + } + + column::() + .padding( 32.0 ) + .spacing( 16.0 ) + .push( title ) + .push( status ) + .push( spacer() ) + .push( grid ) + .push( spacer() ) + .push( + button::( "Settings" ) + .on_press( AppMsg::Nav( Route::Settings ) ), + ) + .into() + } + } +} + +mod settings +{ + use super::*; + + pub struct Settings + { + pub notifications_enabled: bool, + } + + impl Settings + { + pub fn new() -> Self { Self { notifications_enabled: true } } + + pub fn update( &mut self, msg: SettingsMsg ) + { + match msg + { + SettingsMsg::ToggleNotifications => self.notifications_enabled = !self.notifications_enabled, + } + } + + pub fn view( &self ) -> Element + { + let palette = ltk::theme_palette(); + let is_dark = ltk::active_mode() == ThemeMode::Dark; + let next = if is_dark { ThemeMode::Light } else { ThemeMode::Dark }; + let label = if is_dark { "Switch to Light" } else { "Switch to Dark" }; + + column::() + .padding( 32.0 ) + .spacing( 16.0 ) + .push( text( "Settings" ).size( 24.0 ).color( palette.text_primary ) ) + .push( + toggle::( self.notifications_enabled ) + .label( "Notifications" ) + .on_toggle( AppMsg::Settings( SettingsMsg::ToggleNotifications ) ), + ) + .push( spacer() ) + .push( text( "Theme" ).size( 14.0 ).color( palette.text_secondary ) ) + .push( + button::( label ) + .variant( ButtonVariant::Secondary ) + .on_press( AppMsg::SetThemeMode( next ) ), + ) + .push( spacer() ) + .push( + row::() + .spacing( 12.0 ) + .push( + button::( "Show toast" ) + .variant( ButtonVariant::Secondary ) + .on_press( AppMsg::ShowToast( "Hello from Settings".into() ) ), + ) + .push( + button::( "Reset settings" ) + .variant( ButtonVariant::Secondary ) + .on_press( AppMsg::ShowConfirm( Action::Reset ) ), + ), + ) + .push( + button::( "Power off" ) + .on_press( AppMsg::ShowConfirm( Action::PowerOff ) ), + ) + .push( spacer() ) + .push( + button::( "Back" ) + .variant( ButtonVariant::Tertiary ) + .on_press( AppMsg::Nav( Route::Home ) ), + ) + .into() + } + } +} + +mod overlays +{ + use super::*; + + /// Quick Settings: brightness slider + theme toggle + close button. + pub fn quick_settings( app: &AppState ) -> Element + { + let palette = ltk::theme_palette(); + let is_dark = ltk::active_mode() == ThemeMode::Dark; + let next = if is_dark { ThemeMode::Light } else { ThemeMode::Dark }; + + let panel = container::( + column::() + .padding( 0.0 ) + .spacing( 12.0 ) + .push( + row::() + .spacing( 8.0 ) + .push( text( "Quick Settings" ).size( 18.0 ).color( palette.text_primary ) ) + .push( spacer() ) + .push( + button::( "Close" ) + .variant( ButtonVariant::Tertiary ) + .on_press( AppMsg::CloseQuickSettings ), + ), + ) + .push( text( format!( "Brightness {:.0}%", app.brightness * 100.0 ) ) + .size( 13.0 ) + .color( palette.text_primary ) ) + .push( slider::( app.brightness ).on_change( AppMsg::SetBrightness ) ) + .push( + button::( if is_dark { "Light theme" } else { "Dark theme" } ) + .variant( ButtonVariant::Secondary ) + .on_press( AppMsg::SetThemeMode( next ) ), + ), + ) + .background( palette.surface_alt ) + .radius( 16.0 ) + .padding( 20.0 ); + + // Center the panel on the overlay surface using spacers. + column::() + .padding( 24.0 ) + .spacing( 0.0 ) + .max_width( 360.0 ) + .push( panel ) + .push( spacer() ) + .into() + } + + /// Confirm dialog. Same overlay pattern, different content. + pub fn confirm( action: Action ) -> Element + { + let palette = ltk::theme_palette(); + + let panel = container::( + column::() + .padding( 0.0 ) + .spacing( 16.0 ) + .push( text( action.label() ) + .size( 16.0 ) + .color( palette.text_primary ) + .align_center() ) + .push( + row::() + .spacing( 12.0 ) + .push( + button::( "Cancel" ) + .variant( ButtonVariant::Tertiary ) + .on_press( AppMsg::DismissConfirm ), + ) + .push( spacer() ) + .push( + button::( "Confirm" ) + .on_press( AppMsg::ConfirmYes ), + ), + ), + ) + .background( palette.surface_alt ) + .radius( 16.0 ) + .padding( 24.0 ); + + column::() + .padding( 0.0 ) + .spacing( 0.0 ) + .max_width( 320.0 ) + .push( spacer() ) + .push( panel ) + .push( spacer() ) + .into() + } + + /// Pass-through OSD. The fade is computed from elapsed time, not stored. + pub fn toast( app: &AppState ) -> Element + { + let palette = ltk::theme_palette(); + let alpha = app.toast_alpha(); + let txt = app.toast_text.as_deref().unwrap_or( "" ); + + // Apply the fade by attenuating the surface alpha. Text/progress stay + // readable; the container background fades. + let mut bg = palette.surface_alt; + bg.a *= alpha; + + let mut fg = palette.text_primary; + fg.a *= alpha; + + let panel = container::( + column::() + .padding( 0.0 ) + .spacing( 8.0 ) + .push( text( txt ).size( 15.0 ).color( fg ).align_center() ) + .push( progress_bar( alpha ) ), + ) + .background( bg ) + .radius( 12.0 ) + .padding( 16.0 ); + + column::() + .padding( 0.0 ) + .spacing( 0.0 ) + .max_width( 280.0 ) + .push( spacer() ) + .push( spacer() ) + .push( spacer() ) + .push( panel ) + .into() + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +fn main() +{ + // Load the on-disk default theme. ltk has no in-code fallback: the + // `ltk-theme-default` Debian package (or a dev-time + // `LTK_THEMES_DIR=/themes`) must provide + // `default/theme.json`. `ltk::run` would otherwise panic on first + // theme access with the same message as the one below. + let doc = ltk::ThemeDocument::find( "default" ) + .expect( "ltk: the `default` theme is required (install `ltk-theme-default` or set `LTK_THEMES_DIR`)" ); + ltk::set_active_document( doc ); + ltk::set_active_mode( ThemeMode::Dark ); + + ltk::run( AppState::new() ); +} diff --git a/examples/pickers.rs b/examples/pickers.rs new file mode 100644 index 0000000..45fb7bb --- /dev/null +++ b/examples/pickers.rs @@ -0,0 +1,166 @@ +//! `cargo run --example pickers` +//! +//! Showcases the four composition widgets added in this pass: +//! `notebook` (paginated tabs with a content area), `date_picker`, +//! `time_picker`, and `color_picker`. Each picker lives on its own +//! notebook page so the example doubles as a notebook demo. +//! +//! Esc exits. + +use ltk:: +{ + App, Element, Color, Keysym, + column, text, scroll, + notebook, date_picker, time_picker, color_picker, + Date, Time, +}; + +#[ derive( Clone, Debug ) ] +enum Msg +{ + SelectTab( usize ), + DateChanged( Date ), + DateView( i32, u8 ), + TimeChanged( Time ), + ColorChanged( Color ), +} + +struct PickerApp +{ + tab: usize, + date: Date, + view_year: i32, + view_month: u8, + time: Time, + color: Color, +} + +impl PickerApp +{ + fn new() -> Self + { + let today = Date::new( 2026, 5, 2 ); + Self + { + tab: 0, + date: today, + view_year: today.year, + view_month: today.month, + time: Time::new( 9, 30 ), + color: Color::hex( 0x14, 0xB8, 0xA6 ), + } + } + + fn date_page( &self ) -> Element + { + let primary = ltk::theme_palette().text_primary; + let summary = format!( + "Selected: {}-{:02}-{:02}", + self.date.year, self.date.month, self.date.day, + ); + column::() + .padding( 0.0 ) + .spacing( 16.0 ) + .push( text( summary ).size( 14.0 ).color( primary ) ) + .push( + date_picker( self.date ) + .view( self.view_year, self.view_month ) + .today( Date::new( 2026, 5, 2 ) ) + .on_change( Msg::DateChanged ) + .on_navigate( Msg::DateView ) + ) + .into() + } + + fn time_page( &self ) -> Element + { + let primary = ltk::theme_palette().text_primary; + let summary = format!( + "Selected: {:02}:{:02}", + self.time.hour, self.time.minute, + ); + column::() + .padding( 0.0 ) + .spacing( 16.0 ) + .push( text( summary ).size( 14.0 ).color( primary ) ) + .push( + time_picker( self.time ) + .minute_step( 1 ) + .twelve_hour( true ) + .on_change( Msg::TimeChanged ) + ) + .into() + } + + fn color_page( &self ) -> Element + { + let primary = ltk::theme_palette().text_primary; + let summary = format!( + "R={:.2} G={:.2} B={:.2} A={:.2}", + self.color.r, self.color.g, self.color.b, self.color.a, + ); + column::() + .padding( 0.0 ) + .spacing( 16.0 ) + .push( text( summary ).size( 14.0 ).color( primary ) ) + .push( + color_picker( self.color ) + .show_alpha( true ) + .on_change( Msg::ColorChanged ) + ) + .into() + } +} + +impl App for PickerApp +{ + type Message = Msg; + + fn view( &self ) -> Element + { + let header = text( "ltk pickers" ) + .size( 24.0 ) + .color( ltk::theme_palette().text_primary ) + .align_center(); + + let body = column::() + .padding( 24.0 ) + .spacing( 16.0 ) + .push( header ) + .push( + notebook::() + .page( "Date", self.date_page() ) + .page( "Time", self.time_page() ) + .page( "Color", self.color_page() ) + .selected( self.tab ) + .on_select( Msg::SelectTab ) + ); + + // Wrap in a scroll so the calendar's 6×7 grid fits even on + // modest window heights. + scroll( body ).into() + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::SelectTab( i ) => self.tab = i, + Msg::DateChanged( d ) => { self.date = d; self.view_year = d.year; self.view_month = d.month; } + Msg::DateView( y, m ) => { self.view_year = y; self.view_month = m; } + Msg::TimeChanged( t ) => self.time = t, + Msg::ColorChanged( c ) => self.color = c, + } + } + + fn on_key( &mut self, keysym: Keysym ) -> Option + { + if keysym == Keysym::Escape { std::process::exit( 0 ); } + None + } +} + +fn main() +{ + ltk::run( PickerApp::new() ); +} diff --git a/examples/scroll.rs b/examples/scroll.rs new file mode 100644 index 0000000..ba05457 --- /dev/null +++ b/examples/scroll.rs @@ -0,0 +1,154 @@ +//! `cargo run --example scroll` +//! +//! Demonstrates the two main scroll use cases: +//! +//! - **List mode** — a long vertical list inside a `scroll(column(...))`. +//! - **Grid mode** — a grid of buttons inside a `scroll(grid(4))` (app-drawer style). +//! +//! Press the "List" / "Grid" toggle at the top to switch between modes. +//! Drag the content area to scroll. Esc exits. +//! +//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running +//! Wayland compositor (e.g. sway, labwc, or a full desktop session). + +use ltk::{ + App, Element, Keysym, ButtonVariant, + button, column, row, text, scroll, grid, spacer, +}; + +// ── Messages ────────────────────────────────────────────────────────────────── + +#[ derive( Clone ) ] +enum Message +{ + ShowList, + ShowGrid, + ItemPressed( usize ), +} + +// ── App state ───────────────────────────────────────────────────────────────── + +#[ derive( PartialEq ) ] +enum Mode { List, Grid } + +struct ScrollApp +{ + mode: Mode, + last_pressed: Option, +} + +impl ScrollApp +{ + fn new() -> Self + { + Self { mode: Mode::List, last_pressed: None } + } +} + +// ── App trait ───────────────────────────────────────────────────────────────── + +const ITEM_COUNT: usize = 64; + +impl App for ScrollApp +{ + type Message = Message; + + fn view( &self ) -> Element + { + let palette = ltk::theme_palette(); + let primary = palette.text_primary; + let secondary = palette.text_secondary; + + let status = match self.last_pressed + { + Some( i ) => format!( "Pressed item {}", i + 1 ), + None => String::from( "Drag to scroll · click an item" ), + }; + + // Top bar: toggle buttons + status + let toggle_list = button::( "List" ) + .variant( if self.mode == Mode::List { ButtonVariant::Primary } else { ButtonVariant::Secondary } ) + .on_press( Message::ShowList ); + + let toggle_grid = button::( "Grid" ) + .variant( if self.mode == Mode::Grid { ButtonVariant::Primary } else { ButtonVariant::Secondary } ) + .on_press( Message::ShowGrid ); + + let top_bar = row::() + .spacing( 8.0 ) + .push( toggle_list ) + .push( toggle_grid ) + .push( spacer() ) + .push( text( &status ).size( 14.0 ).color( secondary ) ); + + // Scrollable content area + let content: Element = match self.mode + { + Mode::List => + { + // A long list of labeled buttons in a scrollable column + let mut col = column::().padding( 8.0 ).spacing( 6.0 ); + for i in 0..ITEM_COUNT + { + col = col.push( + button::( format!( "List item {} — click me", i + 1 ) ) + .variant( ButtonVariant::Secondary ) + .on_press( Message::ItemPressed( i ) ), + ); + } + scroll( col ).into() + } + Mode::Grid => + { + // A grid of labeled buttons in a scrollable 4-column grid + let mut g = grid::( 4 ).padding( 8.0 ).spacing( 6.0 ); + for i in 0..ITEM_COUNT + { + g = g.push( + button::( format!( "Item {}", i + 1 ) ) + .variant( ButtonVariant::Secondary ) + .on_press( Message::ItemPressed( i ) ), + ); + } + scroll( g ).into() + } + }; + + column::() + .padding( 16.0 ) + .spacing( 12.0 ) + .push( text( "ltk — scroll showcase" ).size( 22.0 ).color( primary ).align_center() ) + .push( top_bar ) + .push( content ) + .push( + text( "Drag = scroll · Esc = quit" ) + .size( 12.0 ) + .color( secondary ) + .align_center(), + ) + .into() + } + + fn update( &mut self, msg: Message ) + { + match msg + { + Message::ShowList => self.mode = Mode::List, + Message::ShowGrid => self.mode = Mode::Grid, + Message::ItemPressed( i ) => self.last_pressed = Some( i ), + } + } + + fn on_key( &mut self, keysym: Keysym ) -> Option + { + if keysym == Keysym::Escape { std::process::exit( 0 ); } + None + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +fn main() +{ + ltk::run( ScrollApp::new() ); +} diff --git a/examples/showcase.rs b/examples/showcase.rs new file mode 100644 index 0000000..3b3bfeb --- /dev/null +++ b/examples/showcase.rs @@ -0,0 +1,252 @@ +//! `cargo run --example showcase` +//! +//! Shows button variants, container with background, a slider, plus the +//! newer additions: a tab strip, a spinner driven by the wall clock, a +//! multiline text edit, and on-demand toast / tooltip overlays. +//! Tab / Shift+Tab cycles keyboard focus. Enter or Space activates the +//! focused button. Esc exits. +//! +//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running +//! Wayland compositor (e.g. sway, labwc, or a full desktop session). + +use std::time::{ Duration, Instant }; + +use ltk::{ + App, Element, Keysym, ButtonVariant, WidgetId, + OverlaySpec, + button, column, row, stack, text, text_edit, spacer, container, slider, scroll, + spinner, tabs, toast, tooltip, +}; + +// ── Messages ────────────────────────────────────────────────────────────────── + +#[ derive( Clone ) ] +enum Message +{ + Pressed( &'static str ), + SliderChanged( f32 ), + SelectTab( usize ), + NoteChanged( String ), + ShowToast, + ToggleTooltip, + HideToast, +} + +// ── App state ───────────────────────────────────────────────────────────────── + +struct ShowcaseApp +{ + last_pressed: String, + slider_value: f32, + tab: usize, + note: String, + started_at: Instant, + toast_until: Option, + tooltip_visible: bool, +} + +impl ShowcaseApp +{ + fn new() -> Self + { + Self + { + last_pressed: String::from( "—" ), + slider_value: 0.5, + tab: 0, + note: String::new(), + started_at: Instant::now(), + toast_until: None, + tooltip_visible: false, + } + } + + const SAVE_BUTTON_ID: WidgetId = WidgetId( "showcase/save" ); +} + +// ── App trait ───────────────────────────────────────────────────────────────── + +impl App for ShowcaseApp +{ + type Message = Message; + + fn view( &self ) -> Element + { + // Pull text colours from the active theme — the runtime + // background defaults to `palette.bg`, so hard-coded white + // text would be unreadable in light mode. + let palette = ltk::theme_palette(); + let primary = palette.text_primary; + let secondary = palette.text_secondary; + + let status = format!( "Last pressed: {}", self.last_pressed ); + + // Tabs strip — the active tab just relabels a banner below. + let tabs_strip: Element = tabs( [ "Buttons", "Inputs", "Hints" ] ) + .selected( self.tab ) + .on_select( Message::SelectTab ) + .into(); + let tab_banner = format!( + "Active tab: {}", + [ "Buttons", "Inputs", "Hints" ][ self.tab.min( 2 ) ], + ); + + let buttons = row::() + .spacing( 16.0 ) + .push( + button( "Primary" ) + .variant( ButtonVariant::Primary ) + .id( Self::SAVE_BUTTON_ID ) + .on_press( Message::Pressed( "Primary" ) ), + ) + .push( + button( "Secondary" ) + .variant( ButtonVariant::Secondary ) + .on_press( Message::Pressed( "Secondary" ) ), + ) + .push( + button( "Tertiary" ) + .variant( ButtonVariant::Tertiary ) + .on_press( Message::Pressed( "Tertiary" ) ), + ); + + let card = container::( + column::() + .spacing( 8.0 ) + .push( text( "container() demo" ).size( 14.0 ).color( primary ) ) + .push( + text( "Background color + padding + corner radius" ) + .size( 12.0 ) + .color( secondary ), + ), + ) + .background( palette.surface_alt ) + .radius( 12.0 ) + .padding( 16.0 ); + + let vol_label = format!( "Volume: {:.0}%", self.slider_value * 100.0 ); + + // Spinner — phase comes straight from the wall clock so the arc + // completes one revolution per second. + let phase = self.started_at.elapsed().as_secs_f32(); + let spin_row: Element = row::() + .spacing( 12.0 ) + .push( spinner().phase( phase ) ) + .push( text( "Working…" ).size( 14.0 ).color( primary ) ) + .into(); + + // Multiline text area. + let textarea = text_edit( "Type a note (multi-line)", &self.note ) + .multiline( true ) + .rows( 3 ) + .on_change( Message::NoteChanged ); + + // Buttons that exercise the overlay-returning widgets. + let overlay_row = row::() + .spacing( 12.0 ) + .push( button( "Show toast" ).on_press( Message::ShowToast ) ) + .push( + button( if self.tooltip_visible { "Hide tooltip" } else { "Show tooltip" } ) + .on_press( Message::ToggleTooltip ) + ); + + let body = column::() + .padding( 32.0 ) + .spacing( 16.0 ) + .push( text( "ltk showcase" ).size( 24.0 ).color( primary ).align_center() ) + .push( text( status ).size( 14.0 ).color( secondary ).align_center() ) + .push( tabs_strip ) + .push( text( tab_banner ).size( 12.0 ).color( secondary ).align_center() ) + .push( spacer() ) + .push( buttons ) + .push( card ) + .push( text( vol_label ).size( 13.0 ).color( secondary ) ) + .push( slider( self.slider_value ).on_change( Message::SliderChanged ) ) + .push( spin_row ) + .push( textarea ) + .push( overlay_row ) + .push( spacer() ) + .push( + text( "Tab = focus next · Enter / Space = press · Esc = quit" ) + .size( 12.0 ) + .color( secondary ) + .align_center(), + ); + + // Toast lives inside the main view (works on every + // compositor — no `wlr-layer-shell` required). The Tooltip + // stays in `overlays()` because xdg-popup is universal. + let mut s = stack::().push( scroll( body ) ); + if self.toast_until.is_some() + { + s = s.push( toast::( "Saved" ).view() ); + } + s.into() + } + + fn update( &mut self, msg: Message ) + { + match msg + { + Message::Pressed( name ) => self.last_pressed = name.to_string(), + Message::SliderChanged( val ) => self.slider_value = val, + Message::SelectTab( i ) => self.tab = i, + Message::NoteChanged( s ) => self.note = s, + Message::ShowToast => + { + self.toast_until = Some( Instant::now() + Duration::from_secs( 2 ) ); + } + Message::HideToast => self.toast_until = None, + Message::ToggleTooltip => self.tooltip_visible = !self.tooltip_visible, + } + } + + fn overlays( &self ) -> Vec> + { + // Tooltip is an xdg-popup → works on any xdg-shell compositor. + // Toast is rendered inline in `view()` (see the Stack at the + // bottom of the function) so it does not depend on + // `wlr-layer-shell` either. + if self.tooltip_visible + { + vec![ tooltip::( "Saves the document and closes the dialog", Self::SAVE_BUTTON_ID ).overlay() ] + } else { + vec![] + } + } + + fn on_key( &mut self, keysym: Keysym ) -> Option + { + if keysym == Keysym::Escape + { + std::process::exit( 0 ); + } + None + } + + // Keep redrawing while the spinner is on screen and while a toast + // is pending so its expiry timestamp is checked every frame. + fn is_animating( &self ) -> bool { true } + + // Auto-clear an expired toast. + fn poll_interval( &self ) -> Option + { + Some( Duration::from_millis( 250 ) ) + } + + fn poll_external( &mut self ) -> Vec + { + match self.toast_until + { + Some( deadline ) if Instant::now() >= deadline => vec![ Message::HideToast ], + _ => vec![], + } + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +fn main() +{ + ltk::run( ShowcaseApp::new() ); +} diff --git a/examples/sliders.rs b/examples/sliders.rs new file mode 100644 index 0000000..40d2ef3 --- /dev/null +++ b/examples/sliders.rs @@ -0,0 +1,155 @@ +//! `cargo run --example sliders` +//! +//! Showcases the **Glass** effect on both axes of the slider widget. The +//! horizontal `slider` at the top and the two vertical `vslider` pills +//! below share the same composite surface — outer glow + four inset +//! shadows (two `PlusLighter` light-catchers, one `Overlay` rim, one +//! soft dark contact shadow) — over a translucent white-to-grey track +//! and a flat cyan accent fill. +//! +//! Drag or tap anywhere inside the controls to change their values; Esc +//! exits. +//! +//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a +//! running Wayland compositor (e.g. sway, labwc, or a full desktop +//! session). The Glass effect is rendered by the GPU (GLES2) backend — +//! the software backend falls back to a flat fill. + +use ltk:: +{ + App, Element, Color, Keysym, + column, row, text, spacer, slider, vslider, +}; + +#[ derive( Clone ) ] +enum Msg +{ + SetHue( f32 ), + SetVolume( f32 ), + SetBrightness( f32 ), +} + +struct SlidersApp +{ + hue: f32, + volume: f32, + brightness: f32, +} + +impl SlidersApp +{ + fn new() -> Self + { + Self { hue: 0.30, volume: 0.48, brightness: 0.92 } + } +} + +impl App for SlidersApp +{ + type Message = Msg; + + fn view( &self ) -> Element + { + // Pill dimensions chosen so the Glass insets read at their + // intended proportions. The insets (0.45–3.6 px offsets, + // 0.9–13.5 px blur radii) are absolute, so smaller pills + // produce relatively tighter highlights. + const PILL_W: f32 = 80.0; + const PILL_H: f32 = 176.0; + + // Track text against the active theme so switching to dark mode + // (via `ltk::set_active_mode`) flips the labels automatically. + let palette = ltk::theme_palette(); + let primary = palette.text_primary; + let secondary = palette.text_secondary; + + // Horizontal slider — claims its row's full width. + let hue_slider = slider( self.hue ).on_change( Msg::SetHue ); + + // Two vertical pills. + let brgt = vslider( self.brightness ).size( PILL_W, PILL_H ).on_change( Msg::SetBrightness ); + let vol = vslider( self.volume ).size( PILL_W, PILL_H ).on_change( Msg::SetVolume ); + + let pills = row::() + .spacing( 40.0 ) + .padding( 0.0 ) + .push( brgt ) + .push( vol ); + + let pill_labels = row::() + .spacing( 40.0 ) + .padding( 0.0 ) + .push( + text( format!( "Brightness {:>3.0}%", self.brightness * 100.0 ) ) + .size( 14.0 ) + .color( primary ), + ) + .push( + text( format!( "Volume {:>3.0}%", self.volume * 100.0 ) ) + .size( 14.0 ) + .color( primary ), + ); + + column::() + .padding( 32.0 ) + .spacing( 16.0 ) + .center_y( true ) + .push( + text( "ltk — Glass sliders, both axes" ) + .size( 24.0 ) + .color( primary ) + .align_center(), + ) + .push( spacer() ) + .push( + text( format!( "Hue {:>3.0}%", self.hue * 100.0 ) ) + .size( 14.0 ) + .color( primary ), + ) + .push( hue_slider ) + .push( spacer() ) + .push( pills ) + .push( pill_labels ) + .push( spacer() ) + .push( + text( "Drag or tap inside a control · Esc = quit" ) + .size( 12.0 ) + .color( secondary ) + .align_center(), + ) + .into() + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::SetHue( v ) => self.hue = v, + Msg::SetVolume( v ) => self.volume = v, + Msg::SetBrightness( v ) => self.brightness = v, + } + } + + fn on_key( &mut self, keysym: Keysym ) -> Option + { + if keysym == Keysym::Escape + { + std::process::exit( 0 ); + } + None + } + + // Track the active theme's background so the Glass highlights and + // the translucent track read correctly against it, and dark-mode + // runs (via `ltk::set_active_mode(ThemeMode::Dark)`) flip + // automatically. + fn background_color( &self ) -> Color + { + ltk::theme_palette().bg + } +} + +fn main() +{ + ltk::run( SlidersApp::new() ); +} diff --git a/examples/widgets.rs b/examples/widgets.rs new file mode 100644 index 0000000..4a646a2 --- /dev/null +++ b/examples/widgets.rs @@ -0,0 +1,217 @@ +use std::time::Instant; + +use ltk::{ + App, Element, Keysym, WidgetId, + column, row, text, text_edit, spacer, separator, slider, + toggle, checkbox, radio, progress_bar, scroll, + spinner, tabs, +}; + +#[derive( Clone )] +enum Msg +{ + ToggleWifi, + ToggleBluetooth, + ToggleDarkMode, + CheckNotifications, + CheckUpdates, + SelectThemeLight, + SelectThemeDark, + SelectThemeAuto, + SliderChanged( f32 ), + SelectTab( usize ), + NoteChanged( String ), + Tick, +} + +struct WidgetsApp +{ + wifi: bool, + bluetooth: bool, + dark_mode: bool, + notifications: bool, + updates: bool, + theme: usize, + progress: f32, + volume: f32, + tab: usize, + note: String, + started_at: Instant, +} + +impl WidgetsApp +{ + fn new() -> Self + { + Self + { + wifi: true, + bluetooth: false, + dark_mode: true, + notifications: true, + updates: false, + theme: 2, + progress: 0.65, + volume: 0.7, + tab: 0, + note: String::new(), + started_at: Instant::now(), + } + } +} + +impl App for WidgetsApp +{ + type Message = Msg; + + fn view( &self ) -> Element + { + // Pull text colours from the active theme so the example + // reads correctly under both light and dark mode without + // touching the example code. + let palette = ltk::theme_palette(); + let primary = palette.text_primary; + let secondary = palette.text_secondary; + + let title = text( "ltk widgets" ).size( 24.0 ).color( primary ).align_center(); + + // Segmented tab strip — selection just rendered as a banner below. + let tabs_strip: Element = tabs( [ "General", "Audio", "Network" ] ) + .selected( self.tab ) + .on_select( Msg::SelectTab ) + .into(); + let tab_label = format!( + "Active tab: {}", + [ "General", "Audio", "Network" ][ self.tab.min( 2 ) ], + ); + + let toggles = column::() + .spacing( 0.0 ) + .push( text( "Toggle" ).size( 13.0 ).color( secondary ) ) + .push( toggle( self.wifi ).label( "Wi-Fi" ).on_toggle( Msg::ToggleWifi ) ) + .push( toggle( self.bluetooth ).label( "Bluetooth" ).on_toggle( Msg::ToggleBluetooth ) ) + .push( toggle( self.dark_mode ).label( "Dark mode" ).on_toggle( Msg::ToggleDarkMode ) ); + + let checkboxes = column::() + .spacing( 0.0 ) + .push( text( "Checkbox" ).size( 13.0 ).color( secondary ) ) + .push( checkbox( self.notifications ).label( "Notifications" ).on_toggle( Msg::CheckNotifications ) ) + .push( checkbox( self.updates ).label( "Auto updates" ).on_toggle( Msg::CheckUpdates ) ); + + let radios = column::() + .spacing( 0.0 ) + .push( text( "Radio" ).size( 13.0 ).color( secondary ) ) + .push( radio( self.theme == 0 ).label( "Light" ).on_select( Msg::SelectThemeLight ) ) + .push( radio( self.theme == 1 ).label( "Dark" ).on_select( Msg::SelectThemeDark ) ) + .push( radio( self.theme == 2 ).label( "Auto" ).on_select( Msg::SelectThemeAuto ) ); + + let vol_label = format!( "Volume: {:.0}%", self.volume * 100.0 ); + let prog_label = format!( "Progress: {:.0}%", self.progress * 100.0 ); + + // Spinner — animation phase derived from the wall clock so the + // arc rotates at one revolution per second. + let phase = self.started_at.elapsed().as_secs_f32(); + let spin_row: Element = row::() + .spacing( 12.0 ) + .push( spinner().phase( phase ) ) + .push( text( "Loading…" ).size( 14.0 ).color( primary ) ) + .into(); + + // Multiline text area — Enter inserts a newline, Tab leaves it. + let textarea = text_edit( "Type a note (multi-line)", &self.note ) + .multiline( true ) + .rows( 4 ) + .id( WidgetId( "widgets/note" ) ) + .on_change( Msg::NoteChanged ); + + let content = column::() + .padding( 32.0 ) + .spacing( 12.0 ) + .push( title ) + .push( tabs_strip ) + .push( text( tab_label ).size( 13.0 ).color( secondary ) ) + .push( separator() ) + .push( toggles ) + .push( separator() ) + .push( checkboxes ) + .push( separator() ) + .push( radios ) + .push( separator() ) + .push( text( vol_label ).size( 13.0 ).color( secondary ) ) + .push( slider( self.volume ).on_change( Msg::SliderChanged ) ) + .push( text( prog_label ).size( 13.0 ).color( secondary ) ) + .push( progress_bar( self.progress ) ) + .push( separator() ) + .push( text( "Spinner" ).size( 13.0 ).color( secondary ) ) + .push( spin_row ) + .push( separator() ) + .push( text( "Multiline text edit" ).size( 13.0 ).color( secondary ) ) + .push( textarea ) + .push( spacer() ) + .push( + text( "Esc = quit" ) + .size( 12.0 ) + .color( secondary ) + .align_center(), + ); + + scroll( content ).into() + } + + fn update( &mut self, msg: Msg ) + { + match msg + { + Msg::ToggleWifi => self.wifi = !self.wifi, + Msg::ToggleBluetooth => self.bluetooth = !self.bluetooth, + Msg::ToggleDarkMode => self.dark_mode = !self.dark_mode, + Msg::CheckNotifications => self.notifications = !self.notifications, + Msg::CheckUpdates => self.updates = !self.updates, + Msg::SelectThemeLight => self.theme = 0, + Msg::SelectThemeDark => self.theme = 1, + Msg::SelectThemeAuto => self.theme = 2, + Msg::SliderChanged( v ) => self.volume = v, + Msg::SelectTab( i ) => self.tab = i, + Msg::NoteChanged( s ) => self.note = s, + Msg::Tick => {} + } + } + + fn on_key( &mut self, keysym: Keysym ) -> Option + { + if keysym == Keysym::Escape { std::process::exit( 0 ); } + None + } + + // Drive the spinner — the runtime keeps redrawing while this returns + // `true`, and `view()` reads the wall clock to compute the phase, so + // no explicit Tick message is even needed for the visual to advance. + fn is_animating( &self ) -> bool { true } + + fn poll_interval( &self ) -> Option + { + // Suppress the dead-code warning on Msg::Tick; a real app would + // also use the timer to drive non-visual periodic work here. + Some( std::time::Duration::from_millis( 250 ) ) + } + + fn poll_external( &mut self ) -> Vec + { + // Consumed: the timer above keeps the runtime warm, but the + // spinner reads the wall clock directly so no Tick is required. + // We still emit one occasionally so the message variant is + // exercised — keeps the example honest about the pattern apps + // usually use for clock work. + if self.started_at.elapsed().as_millis() % 1_000 < 250 + { + vec![ Msg::Tick ] + } else { + vec![] + } + } +} + +fn main() +{ + ltk::run( WidgetsApp::new() ); +} diff --git a/liberux.toml b/liberux.toml new file mode 100644 index 0000000..de6a7e1 --- /dev/null +++ b/liberux.toml @@ -0,0 +1,147 @@ +# ───────────────────────────────────────────────────────────────────────────── +# liberux.toml — Liberux ToolKit external theme file +# Edit freely; the application reloads this file at startup. +# All colors must be hex strings: "#RRGGBB" or "#RRGGBBAA". +# All sizes are in logical pixels (f32). +# ───────────────────────────────────────────────────────────────────────────── + +# ── Global button geometry ──────────────────────────────────────────────────── + +[button] +# Fully rounded pill shape; set to e.g. 8.0 for rectangular corners. +border_radius = 100.0 + +[button.spacing] +# Gap between icon and label text (px) +icon_gap = 8.0 + +# ── Button heights per size variant ────────────────────────────────────────── + +[button.sizes] +sm = 40.0 +default = 48.0 +lg = 56.0 + +# ── Horizontal padding [vertical, horizontal] per size variant ─────────────── + +[button.padding] +sm = [0.0, 20.0] +default = [0.0, 24.0] +lg = [0.0, 28.0] + +# ── Primary button (cyan fill) ─────────────────────────────────────────────── + +[button.primary] +default_bg = "#00CFE8" +default_text = "#0B1B38" + +hover_bg = "#0B1B38" +hover_text = "#FFFFFF" + +pressed_bg = "#7EE8F2" +pressed_text = "#0B1B38" + +# Focus ring drawn as a border on top of the default state +focus_outline = "#00CFE8" +focus_width = 3.0 + +disabled_bg = "#E5E7EB" +disabled_text = "#9CA3AF" + +# ── Secondary button (outlined, white fill) ────────────────────────────────── + +[button.secondary] +default_bg = "#FFFFFF" +default_text = "#0B1B38" +default_border = "#0B1B38" +border_width = 2.0 + +hover_bg = "#6B7280" +hover_text = "#FFFFFF" + +pressed_bg = "#FFFFFF" +pressed_text = "#0B1B38" +pressed_border = "#00CFE8" + +focus_bg = "#00CFE8" +focus_text = "#0B1B38" + +disabled_bg = "#F3F4F6" +disabled_text = "#9CA3AF" +disabled_border = "#D1D5DB" + +# ── Tertiary button (transparent, underlined text) ─────────────────────────── + +[button.tertiary] +default_text = "#0B1B38" + +hover_bg = "#0B1B38" +hover_text = "#FFFFFF" + +focus_text = "#0B1B38" + +disabled_text = "#9CA3AF" + +# ── Typography ──────────────────────────────────────────────────────────────── + +[typography] +font_size_sm = 14.0 +font_size_default = 16.0 +font_size_lg = 18.0 + +# ── Input text ──────────────────────────────────────────────────────────────── + +[input] +border_radius = 100.0 +height_default = 48.0 +height_lg = 56.0 +label_size = 16.0 +helper_size = 14.0 +error_color = "#DC2626" +spacing = 4.0 + +[input.padding] +default = [13.0, 20.0] +lg = [17.0, 24.0] + +# ── Default variant: fondo blanco con borde fino ────────────────────────────── + +[input.default] +bg = "#FFFFFF" +border_color = "#D1D5DB" +border_width = 1.5 +text_color = "#0B1B38" +placeholder_color = "#9CA3AF" +selection_color = "#00CFE840" + +focus_border_color = "#0B1B38" +focus_border_width = 2.0 + +disabled_bg = "#F9FAFB" +disabled_border = "#E5E7EB" +disabled_text = "#9CA3AF" + +error_bg = "#FFF0F0" +error_border = "#F87171" +error_border_width = 1.5 + +# ── Filled variant: fondo gris sin borde inicial ───────────────────────────── + +[input.filled] +bg = "#F3F4F6" +border_color = "#F3F4F6" +border_width = 0.0 +text_color = "#0B1B38" +placeholder_color = "#9CA3AF" +selection_color = "#00CFE840" + +focus_border_color = "#0B1B38" +focus_border_width = 2.0 + +disabled_bg = "#E5E7EB" +disabled_border = "#E5E7EB" +disabled_text = "#9CA3AF" + +error_bg = "#FFF0F0" +error_border = "#F87171" +error_border_width = 1.5 diff --git a/locales/de.yaml b/locales/de.yaml new file mode 100644 index 0000000..784b85e --- /dev/null +++ b/locales/de.yaml @@ -0,0 +1,26 @@ +date_picker: + month_1: "Januar" + month_2: "Februar" + month_3: "März" + month_4: "April" + month_5: "Mai" + month_6: "Juni" + month_7: "Juli" + month_8: "August" + month_9: "September" + month_10: "Oktober" + month_11: "November" + month_12: "Dezember" + dow_short_0: "S" + dow_short_1: "M" + dow_short_2: "D" + dow_short_3: "M" + dow_short_4: "D" + dow_short_5: "F" + dow_short_6: "S" + +context_menu: + copy: "Kopieren" + cut: "Ausschneiden" + paste: "Einfügen" + delete: "Löschen" diff --git a/locales/en.yaml b/locales/en.yaml new file mode 100644 index 0000000..42894f8 --- /dev/null +++ b/locales/en.yaml @@ -0,0 +1,26 @@ +date_picker: + month_1: "January" + month_2: "February" + month_3: "March" + month_4: "April" + month_5: "May" + month_6: "June" + month_7: "July" + month_8: "August" + month_9: "September" + month_10: "October" + month_11: "November" + month_12: "December" + dow_short_0: "S" + dow_short_1: "M" + dow_short_2: "T" + dow_short_3: "W" + dow_short_4: "T" + dow_short_5: "F" + dow_short_6: "S" + +context_menu: + copy: "Copy" + cut: "Cut" + paste: "Paste" + delete: "Delete" diff --git a/locales/es.yaml b/locales/es.yaml new file mode 100644 index 0000000..4a20c63 --- /dev/null +++ b/locales/es.yaml @@ -0,0 +1,26 @@ +date_picker: + month_1: "Enero" + month_2: "Febrero" + month_3: "Marzo" + month_4: "Abril" + month_5: "Mayo" + month_6: "Junio" + month_7: "Julio" + month_8: "Agosto" + month_9: "Septiembre" + month_10: "Octubre" + month_11: "Noviembre" + month_12: "Diciembre" + dow_short_0: "D" + dow_short_1: "L" + dow_short_2: "M" + dow_short_3: "X" + dow_short_4: "J" + dow_short_5: "V" + dow_short_6: "S" + +context_menu: + copy: "Copiar" + cut: "Cortar" + paste: "Pegar" + delete: "Borrar" diff --git a/locales/fr.yaml b/locales/fr.yaml new file mode 100644 index 0000000..15f5a25 --- /dev/null +++ b/locales/fr.yaml @@ -0,0 +1,26 @@ +date_picker: + month_1: "Janvier" + month_2: "Février" + month_3: "Mars" + month_4: "Avril" + month_5: "Mai" + month_6: "Juin" + month_7: "Juillet" + month_8: "Août" + month_9: "Septembre" + month_10: "Octobre" + month_11: "Novembre" + month_12: "Décembre" + dow_short_0: "D" + dow_short_1: "L" + dow_short_2: "M" + dow_short_3: "M" + dow_short_4: "J" + dow_short_5: "V" + dow_short_6: "S" + +context_menu: + copy: "Copier" + cut: "Couper" + paste: "Coller" + delete: "Supprimer" diff --git a/locales/it.yaml b/locales/it.yaml new file mode 100644 index 0000000..7e16a09 --- /dev/null +++ b/locales/it.yaml @@ -0,0 +1,26 @@ +date_picker: + month_1: "Gennaio" + month_2: "Febbraio" + month_3: "Marzo" + month_4: "Aprile" + month_5: "Maggio" + month_6: "Giugno" + month_7: "Luglio" + month_8: "Agosto" + month_9: "Settembre" + month_10: "Ottobre" + month_11: "Novembre" + month_12: "Dicembre" + dow_short_0: "D" + dow_short_1: "L" + dow_short_2: "M" + dow_short_3: "M" + dow_short_4: "G" + dow_short_5: "V" + dow_short_6: "S" + +context_menu: + copy: "Copia" + cut: "Taglia" + paste: "Incolla" + delete: "Elimina" diff --git a/locales/pt.yaml b/locales/pt.yaml new file mode 100644 index 0000000..99c64fc --- /dev/null +++ b/locales/pt.yaml @@ -0,0 +1,26 @@ +date_picker: + month_1: "Janeiro" + month_2: "Fevereiro" + month_3: "Março" + month_4: "Abril" + month_5: "Maio" + month_6: "Junho" + month_7: "Julho" + month_8: "Agosto" + month_9: "Setembro" + month_10: "Outubro" + month_11: "Novembro" + month_12: "Dezembro" + dow_short_0: "D" + dow_short_1: "S" + dow_short_2: "T" + dow_short_3: "Q" + dow_short_4: "Q" + dow_short_5: "S" + dow_short_6: "S" + +context_menu: + copy: "Copiar" + cut: "Cortar" + paste: "Colar" + delete: "Eliminar" diff --git a/locales/pt_BR.yaml b/locales/pt_BR.yaml new file mode 100644 index 0000000..5d17c2a --- /dev/null +++ b/locales/pt_BR.yaml @@ -0,0 +1,26 @@ +date_picker: + month_1: "Janeiro" + month_2: "Fevereiro" + month_3: "Março" + month_4: "Abril" + month_5: "Maio" + month_6: "Junho" + month_7: "Julho" + month_8: "Agosto" + month_9: "Setembro" + month_10: "Outubro" + month_11: "Novembro" + month_12: "Dezembro" + dow_short_0: "D" + dow_short_1: "S" + dow_short_2: "T" + dow_short_3: "Q" + dow_short_4: "Q" + dow_short_5: "S" + dow_short_6: "S" + +context_menu: + copy: "Copiar" + cut: "Recortar" + paste: "Colar" + delete: "Excluir" diff --git a/scripts/doctest-md.sh b/scripts/doctest-md.sh new file mode 100755 index 0000000..5813573 --- /dev/null +++ b/scripts/doctest-md.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Run `rustdoc --test` over every Markdown file under `docs/` so the +# `no_run` snippets in the widget catalogue and cookbook get the same +# typecheck guard `cargo test --doc` provides for in-source doctests. +# Files with no Rust code blocks are a no-op for rustdoc. +# +# `no_run` blocks compile but do not execute — that is what we want here +# because none of the snippets has a meaningful side effect outside a +# real Wayland session and the goal is to catch API drift, not to +# integration-test the runtime. +# +# Output mirrors `cargo test`'s shape: one PASS / FAIL line per file +# with rustdoc's compile output streamed through. The script exits +# non-zero on the first file that fails so CI fails the job cleanly. + +set -euo pipefail + +REPO_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )" +cd "$REPO_ROOT" + +# Build the library so an rlib lives under target/debug/ for --extern. +# `--lib` skips the examples / tests; the rlib is the only artefact we +# need. +cargo build --lib --quiet + +# Cargo names the rlib `libltk-.rlib` under `target/debug/deps/`. +# The hash is unstable across rebuilds (it embeds the dependency graph), +# so we discover it instead of hard-coding it. `ls -t` orders by mtime +# so a stale rlib from a previous build cannot win. +RLIB="$( ls -t target/debug/deps/libltk-*.rlib 2>/dev/null | head -1 )" +if [ -z "$RLIB" ]; then + echo "doctest-md: no libltk-*.rlib found in target/debug/deps/" >&2 + echo "doctest-md: did the cargo build above succeed?" >&2 + exit 1 +fi + +# Pick the edition from Cargo.toml so we stay in lockstep with the +# package and a future bump from 2021 → 2024 just works. +EDITION="$( grep -E '^edition[[:space:]]*=' Cargo.toml | head -1 | awk -F\" '{ print $2 }' )" +if [ -z "$EDITION" ]; then + EDITION="2021" +fi + +failures=0 +for f in docs/*.md; do + # Each file gets its own synthetic crate name so rustdoc's diagnostics + # are easy to attribute. `tr -c '[:alnum:]_' '_'` rewrites any + # character a Rust crate name cannot contain (`-`, `.`, etc.). + crate_name="$( basename "$f" .md | tr -c '[:alnum:]_' '_' )_md" + + echo "doctest-md: $f" + if ! rustdoc --test "$f" \ + --edition "$EDITION" \ + --extern "ltk=$RLIB" \ + -L "target/debug" \ + -L "target/debug/deps" \ + --crate-name "$crate_name" + then + failures=$(( failures + 1 )) + fi +done + +if [ "$failures" -gt 0 ]; then + echo "doctest-md: $failures markdown file(s) with failing snippets" >&2 + exit 1 +fi + +echo "doctest-md: all docs/*.md snippets compile" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..e3914ae --- /dev/null +++ b/src/app.rs @@ -0,0 +1,737 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +pub use smithay_client_toolkit::seat::keyboard::Keysym; +pub use calloop::channel::Sender as ChannelSender; + +use crate::widget::Element; + +/// Wayland shell mode for the application surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShellMode +{ + /// Normal application window using xdg-shell protocol. + /// This is the default for regular applications. + Window, + + /// Layer-shell surface at the specified layer. + /// Used for shell components like panels, backgrounds, overlays. + Layer( Layer ), +} + +/// Layer-shell layer position. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layer +{ + /// Below normal windows (wallpapers, desktop backgrounds). + Background, + /// Below normal windows but above background. + Bottom, + /// Above normal windows (panels, docks). + Top, + /// Above everything (notifications, on-screen displays). + Overlay, +} + +impl Layer +{ + /// Convert to smithay's wlr-layer Layer type. + pub( crate ) fn to_wlr_layer( self ) -> smithay_client_toolkit::shell::wlr_layer::Layer + { + use smithay_client_toolkit::shell::wlr_layer::Layer as WlrLayer; + match self + { + Layer::Background => WlrLayer::Background, + Layer::Bottom => WlrLayer::Bottom, + Layer::Top => WlrLayer::Top, + Layer::Overlay => WlrLayer::Overlay, + } + } +} + +/// Layer-shell anchor edges. +/// Determines which screen edges the surface is attached to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Anchor +{ + pub top: bool, + pub bottom: bool, + pub left: bool, + pub right: bool, +} + +impl Anchor +{ + /// Anchor to all edges (fill screen). + pub const ALL: Self = Self { top: true, bottom: true, left: true, right: true }; + + /// Anchor to top edge only (horizontal bar at top). + pub const TOP: Self = Self { top: true, bottom: false, left: true, right: true }; + + /// Anchor to bottom edge only (horizontal bar at bottom). + pub const BOTTOM: Self = Self { top: false, bottom: true, left: true, right: true }; + + /// Anchor to left edge only (vertical bar at left). + pub const LEFT: Self = Self { top: true, bottom: true, left: true, right: false }; + + /// Anchor to right edge only (vertical bar at right). + pub const RIGHT: Self = Self { top: true, bottom: true, left: false, right: true }; + + /// Convert to smithay's Anchor bitflags. + pub( crate ) fn to_wlr_anchor( self ) -> smithay_client_toolkit::shell::wlr_layer::Anchor + { + use smithay_client_toolkit::shell::wlr_layer::Anchor as WlrAnchor; + let mut anchor = WlrAnchor::empty(); + if self.top { anchor |= WlrAnchor::TOP; } + if self.bottom { anchor |= WlrAnchor::BOTTOM; } + if self.left { anchor |= WlrAnchor::LEFT; } + if self.right { anchor |= WlrAnchor::RIGHT; } + anchor + } +} + +/// Stable identifier for an overlay surface. The runtime uses it to diff the +/// overlay list between frames: if the same `OverlayId` is returned from +/// [`App::overlays`] on consecutive frames the underlying Wayland surface +/// and its internal state are kept; if it disappears the surface is destroyed. +#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )] +pub struct OverlayId( pub u32 ); + +/// One of the surfaces an [`App`] can target with an invalidation. Used inside +/// [`InvalidationScope::Only`] to name the affected surfaces. +#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )] +pub enum SurfaceTarget +{ + /// The application's main surface (the one returned by [`App::view`]). + Main, + /// The overlay with the given stable [`OverlayId`]. + Overlay( OverlayId ), +} + +/// Which surfaces a given [`App::update`] mutation can affect. +/// +/// Returned by [`App::invalidate_after`] (default [`InvalidationScope::All`]) +/// to let the runtime skip redraws on surfaces whose contents could not +/// possibly have changed by the message in question. On a shell with many +/// overlays most messages only touch one of them, so the savings are large. +#[derive( Debug, Clone )] +pub enum InvalidationScope +{ + /// Treat every surface as potentially affected (safe default). + All, + /// Only the listed surfaces may have changed; others can be skipped this + /// iteration. An empty list means "nothing visible to redraw" — useful + /// for messages that only touch internal bookkeeping. + Only( Vec ), +} + +impl InvalidationScope +{ + /// Combine two scopes. `All` absorbs any other scope; two `Only` scopes + /// merge into a deduped list of targets. Used by the run loop to fold the + /// per-message scopes from a batch of pending messages into a single + /// decision before applying it. + pub fn union( self, other: Self ) -> Self + { + match ( self, other ) + { + ( Self::All, _ ) | ( _, Self::All ) => Self::All, + ( Self::Only( mut a ), Self::Only( b ) ) => + { + for t in b + { + if !a.contains( &t ) { a.push( t ); } + } + Self::Only( a ) + } + } + } +} + +/// Description of an additional layer-shell surface rendered on top of the +/// main application surface. +/// +/// Apps return a list of these from [`App::overlays`] each frame. The runtime +/// creates one Wayland layer-shell surface per active overlay, renders its +/// [`view`](Self::view) to its own canvas, and dispatches input to it +/// independently of the main surface. Overlays share the same message type as +/// the main app — messages emitted by widgets inside an overlay are delivered +/// to [`App::update`] exactly like messages from the main view. +/// +/// Overlays are useful for building shells whose main surface sits behind +/// regular app windows (e.g. a homescreen on [`Layer::Background`]) while +/// still exposing on-demand panels above everything (launcher, quick +/// settings, power menu…). +pub struct OverlaySpec +{ + /// Stable identifier, used to diff overlays between frames. + pub id: OverlayId, + + /// Wayland layer for this overlay. Typically [`Layer::Overlay`] or + /// [`Layer::Top`]. + pub layer: Layer, + + /// Screen edges to anchor to. + pub anchor: Anchor, + + /// Desired size `( width, height )` in logical pixels. `0` in either + /// component means "fill available space in that dimension". + pub size: ( u32, u32 ), + + /// Exclusive zone in pixels reserved from the anchored edge. + /// `-1` requests focus without reserving space, `0` is the default for + /// transient overlays that should not push other surfaces around. + pub exclusive_zone: i32, + + /// When `true`, the compositor sends keyboard events to this overlay + /// without requiring a click first. + pub keyboard_exclusive: bool, + + /// Interactive input region as a list of rects (logical pixels). Only + /// these areas receive pointer/touch input; the rest passes through. + /// `None` means the full surface receives input. + pub input_region: Option>, + + /// Widget tree for this overlay. + pub view: Element, + + /// Message sent when the overlay should be dismissed. The runtime + /// fires it in three situations: + /// + /// 1. The compositor sends `xdg_popup.popup_done` (xdg-popup mode) + /// — typically when the user clicks outside the popup with the + /// grab fully active. + /// 2. The user presses pointer / touch on the main surface while + /// the overlay is still mapped, and the press does not fall on + /// the trigger rect identified by [`Self::anchor_widget_id`]. + /// This covers compositors (notably Mutter) that route the + /// button to the parent surface instead of breaking the popup + /// grab when the cursor was already over the parent. + /// 3. The user presses Escape while at least one xdg-popup overlay + /// is open. + /// + /// The application is expected to flip its `is_open` flag (or + /// equivalent) to `false` in `update()` so the next frame stops + /// returning the spec from [`App::overlays`]. The runtime is + /// idempotent if the message arrives more than once for the same + /// open / close cycle. + pub on_dismiss: Option, + + /// When set, the overlay is rendered as a Wayland **xdg-popup** + /// anchored to the rect of the widget tagged with this + /// [`crate::types::WidgetId`] in the previous frame's layout — the + /// standard mechanism for combo / context-menu / tooltip popups in + /// `xdg-shell` applications. The compositor positions the popup + /// adjacent to the anchor and is allowed to flip it (drop-up + /// instead of drop-down) when there is not enough room. + /// + /// When this field is `None`, the overlay is rendered as a + /// [`wlr-layer-shell`](Layer) surface — the path used by shells, + /// panels, lock screens and overlays that need full-surface + /// coverage. In that mode `layer` / `anchor` / `exclusive_zone` / + /// `keyboard_exclusive` carry the placement; for the popup mode + /// they are ignored. + pub anchor_widget_id: Option, +} + +/// Trait that application types must implement to integrate with ltk. +pub trait App: 'static +{ + /// The message type produced by this application. + type Message: Clone + 'static; + + /// Build the widget tree for the current frame. + fn view( &self ) -> Element; + + /// Apply a message to the application state. + fn update( &mut self, msg: Self::Message ); + + /// Tell the runtime which surfaces *could* have changed visibly as a + /// result of [`update`](Self::update) being called with this message. + /// + /// Default is [`InvalidationScope::All`] — every surface is redrawn. + /// A shell that knows, for example, that a `SetVolume` message only + /// affects its quick-settings overlay can return + /// `InvalidationScope::Only( vec![ SurfaceTarget::Overlay( quick_id ) ] )` + /// to skip pointless work on other surfaces. + /// + /// Called *before* [`update`](Self::update) (so it sees the message but + /// not the post-update state). Side-effect free: must not mutate `self`. + fn invalidate_after( &self, _msg: &Self::Message ) -> InvalidationScope + { + InvalidationScope::All + } + + /// Describe the auxiliary overlay surfaces that should be active this + /// frame. Each entry becomes its own Wayland layer-shell surface + /// independent from the main surface returned by [`view`](Self::view). + /// + /// Return an empty `Vec` (the default) to disable multi-surface support — + /// the app then renders only onto its main surface. + /// + /// The runtime diffs the list across frames using [`OverlaySpec::id`]: + /// stable IDs are kept alive, new IDs cause a surface to be created, and + /// IDs that disappear cause the surface to be destroyed. + fn overlays( &self ) -> Vec> { Vec::new() } + + /// Return any pending messages from external sources (timers, async, etc.). + fn poll_external( &mut self ) -> Vec { vec![] } + + /// Called when the surface is asked to close (compositor request, titlebar button, + /// layer-shell closed event). Return `true` to allow the application to exit, + /// or `false` to cancel. + /// + /// Default: `true` (always close). + fn on_close_requested( &mut self ) -> bool { true } + + /// Called when tapping/clicking outside any widget. + fn on_tap( &mut self ) -> Option { None } + + /// Called on key press when no text input is focused. + fn on_key( &mut self, _keysym: Keysym ) -> Option { None } + + /// Called on key press with modifier state. Override this instead of + /// `on_key` when you need Ctrl/Shift/Alt awareness. + fn on_key_with_modifiers( &mut self, keysym: Keysym, _ctrl: bool, _shift: bool ) -> Option + { + self.on_key( keysym ) + } + + /// Called when a sufficient upward swipe gesture is detected. + /// + /// Return a message to handle the swipe (e.g., opening an overview). + /// The gesture is recognized when the user drags upward by at least + /// `swipe_threshold()` × screen height and then releases. + fn on_swipe_up( &mut self ) -> Option { None } + + /// Called during an upward swipe gesture with progress 0.0..=1.0. + /// + /// Use this to create follow-the-finger animations. `progress` starts at 0.0 + /// when the drag begins and increases as the finger moves upward, reaching 1.0 + /// when the drag distance equals `swipe_threshold()` × screen height. + fn on_swipe_progress( &mut self, _progress: f32 ) {} + + /// Called when a sufficient downward swipe gesture is detected. + /// + /// Return a message to handle the swipe (e.g., toggling quick settings). + /// The gesture is recognized when the user drags downward by at least + /// `swipe_down_threshold()` × screen height and then releases. + fn on_swipe_down( &mut self ) -> Option { None } + + /// Called during a downward swipe gesture with progress ≥ 0.0. + /// + /// Use this to create follow-the-finger animations. `progress` starts at 0.0 + /// when the drag begins and increases as the finger moves downward, reaching + /// 1.0 when the drag distance equals `swipe_down_threshold()` × screen height. + /// It is **not** clamped at 1.0 — the value keeps growing if the finger drags + /// further so that follow-the-finger panels can continue tracking the finger + /// all the way down the screen. Clamp inside the handler if you need a + /// bounded `[0, 1]` range for a visual indicator. + /// + /// When the user releases without completing the gesture, this method is called + /// once more with `progress = 0.0` to signal cancellation. + fn on_swipe_down_progress( &mut self, _progress: f32 ) {} + + /// Fraction of screen height required to trigger [`on_swipe_up`](Self::on_swipe_up). + /// + /// Default: `0.6` (60% of screen height). Lower values make the gesture easier + /// to trigger; higher values require more vertical travel. + fn swipe_threshold( &self ) -> f32 { 0.6 } + + /// Fraction of screen height required to trigger [`on_swipe_down`](Self::on_swipe_down). + /// + /// Default: `0.15` (15% of screen height). Typically lower than `swipe_threshold()` + /// because downward swipes are often initiated from a small UI element like a top bar. + fn swipe_down_threshold( &self ) -> f32 { 0.15 } + + /// Top-edge band where a downward swipe may originate, as a fraction of + /// screen height. Presses below this band never fire + /// [`on_swipe_down`](Self::on_swipe_down) nor + /// [`on_swipe_down_progress`](Self::on_swipe_down_progress). + /// + /// Default: `1.0` (entire screen — any starting point is accepted). + /// Set to a small value like `0.05` to restrict the gesture to the top + /// edge, matching the usual system-panel pull-down UX. + fn swipe_down_edge( &self ) -> f32 { 1.0 } + + /// Called when a sufficient leftward swipe gesture is detected. + /// + /// Return a message to handle the swipe (e.g., paging to the next + /// homescreen). Fires on release when the user dragged left by at least + /// `swipe_horizontal_threshold()` × screen width. Unlike vertical swipes, + /// horizontal swipes survive starting the press on an interactive widget: + /// once the drag distance crosses an 8 px threshold the press is promoted + /// to a horizontal drag and no tap will fire at release. + fn on_swipe_left( &mut self ) -> Option { None } + + /// Called when a sufficient rightward swipe gesture is detected. + /// Mirror of [`on_swipe_left`](Self::on_swipe_left) for the other direction. + fn on_swipe_right( &mut self ) -> Option { None } + + /// Called during a horizontal swipe with signed progress. Negative values + /// mean the finger has moved left, positive values mean right. The value + /// is `dx / (threshold * width)`, so ±1.0 marks the commit threshold; + /// it is **not** clamped so follow-the-finger panels can keep tracking + /// past the threshold. When the user releases without completing the + /// gesture this method is called once more with `0.0` to signal + /// cancellation. + fn on_swipe_horizontal_progress( &mut self, _progress: f32 ) {} + + /// Fraction of screen width required to commit [`App::on_swipe_left`] / + /// [`App::on_swipe_right`]. Default: `0.5` (half the screen). A shell that + /// wants easier paging can lower this; one that wants to avoid + /// accidental pages can raise it. + fn swipe_horizontal_threshold( &self ) -> f32 { 0.5 } + + /// Duration a press must remain stationary (within tolerance) before the + /// widget's long-press message fires and the gesture transitions into + /// drag mode. Default: `500 ms`. + fn long_press_duration( &self ) -> std::time::Duration + { + std::time::Duration::from_millis( 500 ) + } + + /// Called on every pointer motion once a long-press has fired and the + /// gesture has entered drag mode. `x`, `y` are in physical pixels of the + /// surface that owned the original press. + fn on_drag_move( &mut self, _x: f32, _y: f32 ) {} + + /// Called on release once a long-press has fired. Return a message to + /// handle the drop (e.g., commit the drop target). `x`, `y` are in + /// physical pixels of the surface that owned the original press. The + /// regular tap / swipe handling is suppressed for this gesture. + fn on_drop( &mut self, _x: f32, _y: f32 ) -> Option { None } + + /// Called when a text-input widget gains or loses focus. + fn on_text_input_focused( &mut self, _active: bool ) {} + + /// Return `Some(id)` once to focus the widget with that [`WidgetId`](crate::types::WidgetId). + /// The app must return `None` after the first call. + fn take_focus_request( &mut self ) -> Option { None } + + /// Called on every pointer motion (mouse move, button press, button + /// release). `x`, `y` are in physical pixels of the main surface, + /// the same coordinate space as widget rects. Default: no-op. + /// + /// Useful for embedding components that need to know the live + /// pointer position without going through a full + /// [`crate::widget::external::ExternalSource`] / `WidgetHandlers` + /// dispatch path — for example forwarding clicks into an embedded + /// WPE WebView. + fn on_pointer_move( &mut self, _x: f32, _y: f32 ) {} + + /// Called on a wheel / touchpad scroll event whose position does + /// not fall inside any LTK [`scroll`](crate::scroll) viewport. `x`, + /// `y` are in physical pixels (same coordinate space as + /// [`Self::on_pointer_move`]); `dx`, `dy` are scroll deltas in the + /// raw axis units the compositor delivered (`AxisSource::Wheel` + /// gives ~10 px ticks, touchpads give continuous values). Default: + /// no-op. + /// + /// Useful for embeddings that take over scrolling for their own + /// content — for example forwarding the event to a WPE view that + /// owns a scrollable web page. + fn on_pointer_axis( &mut self, _x: f32, _y: f32, _dx: f32, _dy: f32 ) {} + + /// Called on every left-button mouse press / release before the + /// regular gesture machine fires. `pressed = true` for press, + /// `false` for release. `(x, y)` are in physical pixels (same + /// coordinate space as [`Self::on_pointer_move`]). Default: no-op. + /// + /// Lets embeddings see the raw button transitions without going + /// through LTK's tap/long-press/drag classification — needed for + /// forwarding clicks AND drags to an embedded view, since the tap + /// classifier collapses press+release into a single event. + fn on_pointer_button( &mut self, _x: f32, _y: f32, _pressed: bool ) {} + + /// Called on every keyboard event (press *and* release) before the + /// regular focus-aware dispatch ([`Self::on_key`] / + /// [`Self::on_key_with_modifiers`]) runs. Default: no-op. + /// + /// `keycode` is the raw hardware scancode straight from the + /// compositor (`wl_keyboard.key`'s `key` argument); `keysym` is + /// the xkb-translated keysym the user effectively pressed. Most + /// consumers only care about `keysym`, but embeddings that + /// forward into an inner window system (a WPE web view, an X11 + /// emulator, …) typically need both — the inner system does its + /// own keycode → keysym translation for layout-aware shortcuts. + /// + /// This bypass exists for those embeddings: it fires *in addition* + /// to the normal callbacks; consume or ignore at will. + fn on_raw_key( &mut self, _keysym: Keysym, _keycode: u32, _pressed: bool, _ctrl: bool, _shift: bool ) {} + + /// Called when the surface is (re-)configured with a new size. + /// + /// `width` and `height` are in **physical pixels** (the Wayland surface + /// size multiplied by the current buffer scale). They therefore match the + /// coordinate space used for layout and the pointer/touch callbacks + /// (`on_drag_move`, `on_drop`, widget hit-testing). Also fired when the + /// buffer scale changes, so apps can refresh any state keyed off the old + /// physical dimensions. + fn on_resize( &mut self, _width: u32, _height: u32 ) {} + + /// Called once at startup with a channel sender that can be used from any + /// thread to deliver messages into the event loop. Sending a message + /// immediately wakes the event loop — no polling delay. + fn set_channel_sender( &mut self, _sender: ChannelSender ) {} + + /// How often the event loop should call [`poll_external`](Self::poll_external) for + /// periodic work such as clock ticks. Return `None` (the default) to disable the + /// timer — `poll_external` will still be called after every Wayland event. + fn poll_interval( &self ) -> Option { None } + + /// Delay before the runtime starts repeating a held-down key. + /// + /// `Some(d)` overrides whatever the compositor advertises through + /// `wl_keyboard.repeat_info`; `None` (the default) means "use the + /// compositor's setting, or fall back to 500 ms when the compositor + /// did not provide one". + /// + /// Returning `Some(Duration::ZERO)` effectively disables the wait + /// before the first repeat — useful only for diagnostic builds. + fn key_repeat_delay( &self ) -> Option { None } + + /// Interval between successive synthetic key events while a key is + /// held down past the initial [`Self::key_repeat_delay`]. + /// + /// `Some(d)` overrides the compositor's `wl_keyboard.repeat_info` + /// rate; `None` (the default) means "use the compositor's setting, + /// or fall back to 33 ms (~30 Hz) when the compositor did not + /// provide one". Pass `Some(Duration::ZERO)` to disable repeats + /// entirely (or override the per-key gate via + /// [`Self::key_repeats`]). + fn key_repeat_interval( &self ) -> Option { None } + + /// Override the pointer cursor shape globally. When `Some(shape)`, + /// the runtime sends that shape to the compositor regardless of + /// which widget the pointer is over — useful for "the app is busy" + /// states (return [`crate::CursorShape::Wait`]) or while the app + /// is doing a long synchronous operation. Returning `None` (the + /// default) lets per-widget defaults take effect. + /// + /// The runtime calls this every frame, so flipping a `loading` + /// boolean in [`Self::update`] propagates to the cursor on the + /// next iteration without any extra wiring. + fn cursor_override( &self ) -> Option { None } + + /// Decide whether a given key participates in held-key repeat. + /// + /// Default: every non-modifier key repeats *except* `Escape`, `Tab` + /// and `ISO_Left_Tab` — those drive one-shot UI semantics + /// (dismiss / focus cycle) where a held key would multiply the + /// effect in surprising ways. Override to widen or narrow the gate + /// (a chess clock app, for example, might want even Tab to + /// repeat). + fn key_repeats( &self, keysym: Keysym ) -> bool + { + !matches!( keysym, + Keysym::Escape | Keysym::Tab | Keysym::ISO_Left_Tab, + ) + } + + /// Return `true` while a frame-by-frame animation is running. + /// The event loop will keep requesting redraws at ~60 fps until this returns `false`. + fn is_animating( &self ) -> bool { false } + + /// Return `true` while the next frame should swap the expensive + /// Glass passes for cheap fallbacks — currently the + /// `backdrop-filter` blur drops from a 41-tap kernel to a 9-tap + /// kernel, with the snapshot region shrunk to match. The event + /// loop sets this on the renderer right before drawing through an + /// internal low-quality-paint flag. + /// + /// Default: matches [`Self::is_animating`], so a settle animation + /// automatically downgrades. Override to include other "in motion" + /// states the runtime cannot observe — e.g. a finger-tracked + + /// Background color for the canvas. Override to make the surface + /// transparent or to deviate from the theme. Default: the active + /// theme's `bg` token, so the window matches the rest of the + /// shell out of the box. + fn background_color( &self ) -> crate::types::Color + { + crate::theme::palette().bg + } + + /// Return the interactive input region as a list of rects (logical pixels). + /// Only these areas receive pointer/touch input; the rest passes through. + /// Return `None` (default) to receive input everywhere. + fn input_region( &self ) -> Option> { None } + + /// Return `Some(( title, app_id ))` to force an XDG toplevel window instead of + /// layer-shell overlay. The compositor will display the title in the title bar + /// and use the app_id for taskbar/icon matching. Return `None` (default) to + /// use layer-shell when available. + /// + /// **Deprecated**: Use [`shell_mode`](Self::shell_mode) instead. + fn window_config( &self ) -> Option<( &str, &str )> { None } + + /// Specify the Wayland shell mode for this application. + /// + /// - [`ShellMode::Window`]: Normal application window (xdg-shell). **Default.** + /// - [`ShellMode::Layer`]: System component at a specific layer (layer-shell). + /// + /// For regular applications, use the default `Window` mode. + /// For shell components (panels, backgrounds, overlays), use `Layer`. + fn shell_mode( &self ) -> ShellMode { ShellMode::Window } + + /// Suggest an initial size for an xdg-shell window in logical pixels. + /// + /// Returning `Some(( w, h ))` makes ltk call both + /// `xdg_toplevel.set_min_size( w, h )` and + /// `xdg_toplevel.set_max_size( w, h )` before the first commit, which + /// most compositors honour as an exact size for the configure they + /// send back. The window is still resizable from the application's + /// point of view — if the compositor sends a different configure, the + /// runtime adopts that size on the next frame. + /// + /// Only applies to [`ShellMode::Window`] surfaces and to the + /// xdg-shell fallback path used when `wlr-layer-shell` is missing. + /// Ignored for layer-shell surfaces, which use [`Self::layer_size`] + /// instead. + /// + /// **Default**: `None` (let the compositor pick). + fn window_size_hint( &self ) -> Option<( u32, u32 )> { None } + + /// Request fullscreen on the toplevel before its first commit. + /// + /// When `true`, ltk calls `xdg_toplevel.set_fullscreen( None )` so + /// the compositor picks an output and maps the surface fullscreen + /// from the start (no flicker through a windowed configure first). + /// The compositor's own decorations are suppressed for fullscreen + /// surfaces; the built-in titlebar from [`Self::window_config`] is + /// still painted by ltk on top of the surface unless the app opts + /// out. + /// + /// Only applies to [`ShellMode::Window`] surfaces and to the + /// xdg-shell fallback path used when `wlr-layer-shell` is missing. + /// + /// **Default**: `false`. + fn start_fullscreen( &self ) -> bool { false } + + /// Specify the exclusive zone for layer-shell surfaces. + /// + /// The exclusive zone reserves screen space for this surface. + /// For example, a top panel with height 50 should return `50` to prevent + /// other windows from overlapping it. + /// + /// - `> 0`: Reserve this many pixels from the anchored edge + /// - `0`: No exclusive zone (default for overlays) + /// - `-1`: Don't reserve space but request focus + /// + /// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows. + fn exclusive_zone( &self ) -> i32 { -1 } + + /// Specify which screen edges the layer-shell surface is anchored to. + /// + /// Anchoring determines the position and size of the surface: + /// - [`Anchor::TOP`]: Top bar (anchored to top, left, right) + /// - [`Anchor::BOTTOM`]: Bottom bar (anchored to bottom, left, right) + /// - [`Anchor::LEFT`]: Left sidebar (anchored to left, top, bottom) + /// - [`Anchor::RIGHT`]: Right sidebar (anchored to right, top, bottom) + /// - [`Anchor::ALL`]: Full screen (anchored to all edges) + /// + /// **Default**: `Anchor::ALL` (full screen) + /// + /// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows. + fn layer_anchor( &self ) -> Anchor { Anchor::ALL } + + /// Specify the desired size for layer-shell surfaces. + /// + /// Returns `(width, height)` where: + /// - `0` means "fill available space in that dimension" + /// - `> 0` means "use this exact size in pixels" + /// + /// **Examples:** + /// - Top bar: `(0, 50)` - full width, 50px height + /// - Bottom bar: `(0, 40)` - full width, 40px height + /// - Side panel: `(300, 0)` - 300px width, full height + /// - Full screen: `(0, 0)` - fill everything (default) + /// + /// **Default**: `(0, 0)` (fill all available space) + /// + /// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows. + fn layer_size( &self ) -> ( u32, u32 ) { ( 0, 0 ) } + + /// Request exclusive keyboard focus for this layer-shell surface. + /// + /// When `true`, the compositor sends keyboard events to this surface + /// without requiring a pointer click first. Useful for greeters, + /// lock screens, and other surfaces that must capture input immediately. + /// + /// **Default**: `false` (on-demand keyboard interactivity) + /// + /// Only applies to [`ShellMode::Layer`] surfaces. Ignored for windows. + fn keyboard_exclusive( &self ) -> bool { false } +} + +/// Run the application. Blocks until the window is closed. +/// +/// Panics on init failure (no Wayland compositor, missing protocol, etc.). +/// Embedders that need to recover gracefully — e.g. fall back to a +/// software TTY UI when no compositor is reachable — should call +/// [`try_run`] instead and match on the returned [`RunError`]. +pub fn run( app: A ) +{ + crate::event_loop::run( app ); +} + +/// Run the application, returning a typed error on init failure. +/// +/// Same as [`run`] but recoverable: every fatal init step (Wayland +/// connection, registry, calloop event loop, `wl_compositor` / `wl_shm` +/// / `xdg_wm_base` bindings) is converted into a [`RunError`] variant +/// the caller can match on. Once init succeeds the function blocks +/// until the surface is closed and returns `Ok(())`. Errors from the +/// dispatch loop itself (already on screen) still panic — they are +/// non-recoverable since the surface state machine cannot be unwound +/// cleanly from this entry point. +/// +/// Use this when: +/// +/// * the application needs a graceful fallback path on systems without +/// a Wayland session (CI runners, minimal containers, X11-only +/// environments where ltk has no backend), +/// * an embedder wants to log the specific protocol that's missing +/// instead of panicking with a generic stack trace, +/// * the caller wants to retry / wait for a compositor to come up +/// instead of aborting. +/// +/// The standard `run( app )` remains the simpler entry point for +/// applications that always run on a known-good Wayland session. +/// +/// ```rust,no_run +/// # use ltk::{ button, App, Element, RunError }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # struct MyApp; +/// # impl App for MyApp { +/// # type Message = Msg; +/// # fn view( &self ) -> Element { button( "x" ).into() } +/// # fn update( &mut self, _: Msg ) {} +/// # } +/// match ltk::try_run( MyApp ) +/// { +/// Ok( () ) => {} +/// Err( RunError::NoWaylandConnection( _ ) ) => +/// { +/// eprintln!( "no compositor — falling back to stdio" ); +/// // run a CLI fallback… +/// } +/// Err( RunError::MissingProtocol { name, .. } ) => +/// { +/// eprintln!( "compositor lacks `{name}` — refusing to start" ); +/// std::process::exit( 1 ); +/// } +/// Err( e ) => +/// { +/// eprintln!( "ltk init failed: {e}" ); +/// std::process::exit( 1 ); +/// } +/// } +/// ``` +pub fn try_run( app: A ) -> Result<(), RunError> +{ + crate::event_loop::try_run( app ) +} + +pub use crate::event_loop::RunError; diff --git a/src/core.rs b/src/core.rs new file mode 100644 index 0000000..72e0f66 --- /dev/null +++ b/src/core.rs @@ -0,0 +1,485 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Runtime-free UI surface primitives. +//! +//! This module exposes the part of ltk that is useful outside `ltk::run`: +//! widget tree layout, drawing into a `Canvas`, hit-test rect snapshots, and +//! damage tracking. It deliberately contains no Wayland client event loop, +//! layer-shell, xdg-shell, SHM pool, or frame-callback logic. + +use std::collections::HashMap; +use std::ffi::c_void; +use std::sync::Arc; + +pub use crate::gles_render::{ BorrowedGlesTexture, GlesVersion }; +pub use crate::render::Canvas; +pub use crate::widget::{ LaidOutWidget, WidgetHandlers }; + +use crate::draw::{ self, DrawCtx }; +use crate::egl_context::EglOffscreenContext; +use crate::types::{ Color, Point, Rect }; +use crate::widget::Element; + +/// Options for one runtime-free render pass. +#[ derive( Debug, Clone, Copy ) ] +pub struct RenderOptions +{ + /// Physical-pixel bounds to lay out the tree into. + pub bounds: Rect, + /// Background fill. Use [`Color::TRANSPARENT`] for a transparent target. + pub background: Color, + /// Draw red debug rectangles around laid-out interactive widgets. + pub debug_layout: bool, +} + +impl RenderOptions +{ + /// Render into the full `width × height` canvas with a transparent + /// background and debug layout disabled. + pub fn full_canvas( width: u32, height: u32 ) -> Self + { + Self + { + bounds: Rect { x: 0.0, y: 0.0, width: width as f32, height: height as f32 }, + background: Color::TRANSPARENT, + debug_layout: false, + } + } + + /// Set the background fill for the render pass. + pub fn background( mut self, color: Color ) -> Self + { + self.background = color; + self + } + + /// Enable or disable debug layout rectangles. + pub fn debug_layout( mut self, yes: bool ) -> Self + { + self.debug_layout = yes; + self + } +} + +/// Result of rendering a tree into a [`UiSurface`]. +#[ derive( Debug, Clone ) ] +pub struct RenderOutput +{ + /// Damage rects in physical pixels. Empty means "damage the full bounds". + pub damage_rects: Vec, + /// True when the caller should treat the whole render bounds as dirty. + pub full_redraw: bool, +} + +/// A retained rendering target for code that wants ltk widgets without +/// `ltk::run`. +/// +/// A compositor can keep one `UiSurface` per decoration or panel, mutate +/// focus/hover/pressed state from its own input routing, then call +/// [`Self::render`] whenever it decides a repaint is needed. +// Field declaration order is load-bearing: Rust drops fields top-to-bottom, +// so `canvas` is dropped before `egl_context`. That matters because +// `Canvas::Gles::Drop` releases textures / FBOs / shader programs through +// glow, which requires the matching GL context to be current — which is what +// `Drop for UiSurface` ensures by calling `make_owned_gles_current()` first. +// If `egl_context` were declared above `canvas`, the EGL context would be +// torn down first and `canvas`'s GL releases would silently leak / corrupt +// state. Do not reorder these two fields without preserving that property. +pub struct UiSurface +{ + canvas: Canvas, + egl_context: Option, + focused_idx: Option, + hovered_idx: Option, + pressed_idx: Option, + prev_focused: Option, + prev_hovered: Option, + prev_pressed: Option, + widget_rects: Vec>, + cursor_state: HashMap, + selection_anchor: HashMap, + scroll_offsets: HashMap, + scroll_canvases: HashMap, + scroll_navigable_items: HashMap>, + content_dirty: bool, +} + +impl UiSurface +{ + /// Create a software-backed surface of the given physical size. + /// + /// This is intentionally conservative for compositor integrations: an + /// embedder typically already owns an EGL/GLES context, so `new` must + /// not allocate a hidden offscreen context per decoration. Use + /// [`Self::from_gles_context`] when the caller can provide an + /// already-current GL context. + pub fn new( width: u32, height: u32 ) -> Self + { + Self::new_software( width, height ) + } + + /// Create a software-backed surface explicitly. + pub fn new_software( width: u32, height: u32 ) -> Self + { + Self::from_canvas( Canvas::new( width, height ) ) + } + + /// Create a GLES-backed surface with an ltk-owned offscreen EGL context. + /// + /// The context is made current before render/resize operations. This is + /// useful for runtime-free rendering; compositors that already own a GL + /// context can instead build a `Canvas::new_gles(...)` and pass it to + /// [`Self::from_canvas`]. + pub fn try_new_gles( width: u32, height: u32 ) -> Result + { + let egl_context = EglOffscreenContext::new()?; + egl_context.make_current()?; + let canvas = Canvas::new_gles( + Arc::clone( egl_context.gl() ), + egl_context.version(), + width, + height, + ); + Ok( Self::from_canvas_with_egl_context( canvas, Some( egl_context ) ) ) + } + + /// Create a GLES-backed surface from a caller-owned, already-current GL + /// context. + /// + /// This is the path intended for compositor embedders: no EGL display, + /// EGL context, or pbuffer is allocated by ltk. The caller must keep + /// the underlying GL context alive and current whenever this surface + /// is created, rendered, resized, or dropped. + pub fn from_gles_context( + gl: Arc, + version: GlesVersion, + width: u32, + height: u32, + ) -> Self + { + Self::from_canvas( Canvas::new_gles( gl, version, width, height ) ) + } + + /// Create a GLES-backed surface from the GL function loader of the current + /// context. + /// + /// This is useful for renderers such as Smithay's `GlesRenderer`, which + /// keep their own EGL context and expose custom GL access through a + /// callback. This constructor does not allocate an EGL context; it only + /// builds ltk's `glow` dispatch table and GPU canvas resources in the + /// context that is current while this function runs. + /// + /// # Safety + /// + /// `loader` must resolve symbols for the GL context that is current on this + /// thread. That same context must remain alive, and must be made current + /// before any call that touches the returned surface — **including the + /// implicit destructor**: dropping the `UiSurface` releases GPU resources + /// (textures, FBOs, programs) through the caller-owned context and will + /// leak / corrupt state if that context is not current at drop time. + pub unsafe fn from_current_gles_loader( + mut loader: F, + version: GlesVersion, + width: u32, + height: u32, + ) -> Self + where + F: FnMut( &str ) -> *const c_void, + { + // SAFETY: forwarded from `from_current_gles_loader`'s own `# Safety` + // contract — `loader` resolves symbols for a context current on this + // thread, so `glGetString( GL_VERSION )` (called inside + // `from_loader_function`) is well-defined. + let gl = Arc::new( unsafe { glow::Context::from_loader_function( move |name| loader( name ) ) } ); + Self::from_gles_context( gl, version, width, height ) + } + + /// Wrap an existing canvas. This is the hook for compositor-owned GPU + /// targets once the caller provides an already-current GL context. If + /// `canvas` is `Canvas::Gles`, the caller remains responsible for making the + /// matching GL context current before calling methods that touch the + /// canvas — **including the implicit destructor**: dropping the + /// `UiSurface` releases GPU resources (textures, FBOs, programs) through + /// the caller-owned context and will leak / corrupt state if that context + /// is not current at drop time. + pub fn from_canvas( canvas: Canvas ) -> Self + { + Self::from_canvas_with_egl_context( canvas, None ) + } + + fn from_canvas_with_egl_context( + canvas: Canvas, + egl_context: Option, + ) -> Self + { + Self + { + canvas, + egl_context, + focused_idx: None, + hovered_idx: None, + pressed_idx: None, + prev_focused: None, + prev_hovered: None, + prev_pressed: None, + widget_rects: Vec::new(), + cursor_state: HashMap::new(), + selection_anchor: HashMap::new(), + scroll_offsets: HashMap::new(), + scroll_canvases: HashMap::new(), + scroll_navigable_items: HashMap::new(), + content_dirty: true, + } + } + + /// Access the backing canvas after a render pass. + pub fn canvas( &self ) -> &Canvas + { + &self.canvas + } + + /// Mutable access to the backing canvas for compositor-specific upload or + /// presentation code. Mark content dirty afterwards if external drawing + /// changes what ltk should preserve across partial redraws. + pub fn canvas_mut( &mut self ) -> &mut Canvas + { + self.make_owned_gles_current(); + &mut self.canvas + } + + /// Current canvas size in physical pixels. + pub fn size( &self ) -> ( u32, u32 ) + { + self.canvas.size() + } + + /// Resize the backing canvas and force the next render to redraw fully. + pub fn resize( &mut self, width: u32, height: u32 ) + { + self.make_owned_gles_current(); + self.canvas.resize( width, height ); + self.mark_content_dirty(); + } + + /// Set the DPI scale used for text and font metrics. + pub fn set_dpi_scale( &mut self, scale: f32 ) + { + self.make_owned_gles_current(); + self.canvas.set_dpi_scale( scale ); + self.mark_content_dirty(); + } + + /// Mark the next render as content-changing, forcing a full repaint. + pub fn mark_content_dirty( &mut self ) + { + self.content_dirty = true; + } + + /// Current laid-out interactive widgets from the last render. + pub fn widget_rects( &self ) -> &[ LaidOutWidget ] + { + &self.widget_rects + } + + /// Hit-test a physical point against the last rendered widget rects. + pub fn hit_test( &self, pos: Point ) -> Option + { + crate::tree::find_widget_at( &self.widget_rects, pos ) + } + + /// Lookup a laid-out widget by flat index. + pub fn widget( &self, flat_idx: usize ) -> Option<&LaidOutWidget> + { + crate::tree::find_widget( &self.widget_rects, flat_idx ) + } + + /// Lookup the handler snapshot for a laid-out widget. + pub fn handlers( &self, flat_idx: usize ) -> Option<&WidgetHandlers> + { + crate::tree::find_handlers( &self.widget_rects, flat_idx ) + } + + /// Update keyboard focus state. This is interaction-only, so the next + /// render can use partial damage when layout/content did not change. + pub fn set_focused( &mut self, idx: Option ) + { + self.focused_idx = idx; + } + + /// Update hover state. This is interaction-only. + pub fn set_hovered( &mut self, idx: Option ) + { + self.hovered_idx = idx; + } + + /// Update pressed state. This is interaction-only. + pub fn set_pressed( &mut self, idx: Option ) + { + self.pressed_idx = idx; + } + + pub fn focused( &self ) -> Option { self.focused_idx } + pub fn hovered( &self ) -> Option { self.hovered_idx } + pub fn pressed( &self ) -> Option { self.pressed_idx } + + /// Render `element` into the backing canvas. + /// + /// This does not commit, swap buffers, request frame callbacks, or talk to + /// Wayland. The caller owns presentation and frame pacing. + pub fn render( &mut self, element: &Element, options: RenderOptions ) -> RenderOutput + { + self.make_owned_gles_current(); + let ( width, height ) = self.canvas.size(); + + let damage_rects = if self.content_dirty || self.widget_rects.is_empty() + { + Vec::new() + } + else + { + draw::compute_interaction_dirty_rects( + &self.widget_rects, + self.prev_focused, self.prev_hovered, self.prev_pressed, + self.focused_idx, self.hovered_idx, self.pressed_idx, + width, + height, + ) + }; + + let use_partial = !self.content_dirty && !damage_rects.is_empty(); + // Only `compute_damage` (called below in the !use_partial branch) + // needs the previous frame's rects, and it consumes them by reference. + // The clone is therefore deferred to that branch — the partial-damage + // path (typical for hover / focus / press transitions) skips it + // entirely, saving one Vec clone per frame. + let old_rects: Vec> = if use_partial + { + Vec::new() + } + else + { + self.widget_rects.clone() + }; + if use_partial + { + self.canvas.set_clip_rects( &damage_rects ); + if options.background.a > 0.0 + { + self.canvas.fill( options.background ); + } + else + { + self.canvas.clear_rects_transparent( &damage_rects ); + } + } + else + { + self.canvas.clear_clip(); + if options.background.a > 0.0 + { + self.canvas.fill( options.background ); + } + else + { + self.canvas.clear(); + } + } + + let mut ctx: DrawCtx = DrawCtx + { + focused_idx: self.focused_idx, + hovered_idx: self.hovered_idx, + pressed_idx: self.pressed_idx, + cursor_state: std::mem::take( &mut self.cursor_state ), + selection_anchor: std::mem::take( &mut self.selection_anchor ), + widget_rects: Vec::new(), + debug_layout: options.debug_layout && !use_partial, + scroll_offsets: std::mem::take( &mut self.scroll_offsets ), + scroll_rects: Vec::new(), + scroll_canvases: std::mem::take( &mut self.scroll_canvases ), + scroll_navigable_items: std::mem::take( &mut self.scroll_navigable_items ), + previous_widget_rects: self.widget_rects.clone(), + }; + + draw::layout_and_draw( element, &mut self.canvas, options.bounds, &mut ctx, 0 ); + + if ctx.debug_layout && !use_partial + { + for w in &ctx.widget_rects + { + self.canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 ); + } + } + self.canvas.clear_clip(); + + let output_damage = if use_partial + { + damage_rects + } + else + { + draw::compute_damage( + &old_rects, + &ctx.widget_rects, + self.prev_focused, self.prev_hovered, self.prev_pressed, + self.focused_idx, self.hovered_idx, self.pressed_idx, + width, + height, + ) + }; + + self.prev_focused = self.focused_idx; + self.prev_hovered = self.hovered_idx; + self.prev_pressed = self.pressed_idx; + self.widget_rects = ctx.widget_rects; + self.cursor_state = ctx.cursor_state; + self.selection_anchor = ctx.selection_anchor; + self.scroll_offsets = ctx.scroll_offsets; + // `ctx.scroll_rects` is intentionally dropped here. The `Scroll` widget + // pushes its hit-test rects into the DrawCtx during the draw pass, but + // `UiSurface` exposes no wheel-event routing API, so persisting them + // across frames serves no consumer. If a future embedder needs to + // dispatch wheel events through `UiSurface`, add a `scroll_rects()` + // getter together with a `scroll_by( flat_idx, dy )` mutator and start + // keeping the field again. + self.scroll_canvases = ctx.scroll_canvases; + self.scroll_navigable_items = ctx.scroll_navigable_items; + self.content_dirty = false; + + RenderOutput + { + full_redraw: output_damage.is_empty(), + damage_rects: output_damage, + } + } + + fn make_owned_gles_current( &self ) + { + if let Some( egl_context ) = &self.egl_context + { + if let Err( e ) = egl_context.make_current() + { + log_make_current_failure_once( &e ); + } + } + } +} + +fn log_make_current_failure_once( reason: &str ) +{ + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once( || + { + eprintln!( "[ltk] core: eglMakeCurrent failed: {reason} (subsequent GL ops will silently no-op or render garbage)" ); + } ); +} + +impl Drop for UiSurface +{ + fn drop( &mut self ) + { + self.make_owned_gles_current(); + } +} diff --git a/src/draw/chrome.rs b/src/draw/chrome.rs new file mode 100644 index 0000000..d6f70be --- /dev/null +++ b/src/draw/chrome.rs @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Surface chrome: the client-side title bar and the Wayland input +//! region. Both are backend-agnostic — the software and GPU draw +//! paths call them with a `&mut Canvas` / a `&WlSurface` and get back +//! the geometry they need for hit testing + commit. + +use smithay_client_toolkit::compositor::{ CompositorState, Region }; +use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface; + +use crate::render::Canvas; +use crate::types::{ Color, Rect }; + +/// Paint the client-side title bar (close button, divider, title text). +/// +/// Returns the close button rect in physical pixels so the caller can +/// store it for hit testing. Returns `Rect::default()` when `tb_h <= +/// 0` — overlays / layer-shell surfaces have no title bar. +pub( crate ) fn draw_titlebar( canvas: &mut Canvas, title: &str, pw: u32, tb_h: f32, sf: f32 ) -> Rect +{ + if tb_h <= 0.0 { return Rect::default(); } + let tb_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: tb_h }; + canvas.fill_rect( tb_rect, Color::rgb( 0.15, 0.15, 0.18 ), 0.0 ); + + let title_size = 15.0; + // `Canvas::draw_text` interprets `y` as the baseline. Match the + // vertical-centring formula used by `Button::draw_text_button` so + // chrome and content text lines line up at the same optical height. + let title_y = ( tb_h + title_size * sf ) / 2.0 - 2.0; + let title_w = canvas.measure_text( title, title_size ); + let title_x = ( pw as f32 - title_w ) / 2.0; + canvas.draw_text( title, title_x, title_y, title_size, Color::WHITE ); + + let btn_size = tb_h - 8.0 * sf; + let btn_x = pw as f32 - btn_size - 8.0 * sf; + let btn_y = 4.0 * sf; + let close_rect_phys = Rect { x: btn_x, y: btn_y, width: btn_size, height: btn_size }; + canvas.fill_rect( close_rect_phys, Color::rgba( 1.0, 1.0, 1.0, 0.1 ), 4.0 * sf ); + + let cx = btn_x + btn_size / 2.0; + let cy = btn_y + btn_size / 2.0; + let arm = btn_size * 0.25; + canvas.draw_line( cx - arm, cy - arm, cx + arm, cy + arm, Color::WHITE, 2.0 * sf ); + canvas.draw_line( cx + arm, cy - arm, cx - arm, cy + arm, Color::WHITE, 2.0 * sf ); + + canvas.draw_line( 0.0, tb_h - 0.5, pw as f32, tb_h - 0.5, Color::rgba( 1.0, 1.0, 1.0, 0.15 ), 1.0 ); + + close_rect_phys +} + +/// Stamp a red warning banner at the top of the surface when the +/// active theme came from the in-memory B/W fallback (i.e. +/// [`crate::theme::is_fallback_active`] returned `true`). Gives the +/// user a clear visual cue that `ltk-theme-default` is missing. +/// +/// Paints a thin red strip across the top of the surface (24 px +/// logical) with the install-instruction text. When the fallback is +/// not active this is a no-op. +pub( crate ) fn draw_fallback_banner( + canvas: &mut Canvas, + pw: u32, + sf: f32, +) +{ + if !crate::theme::is_fallback_active() { return; } + + let banner_h = 24.0 * sf; + let rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: banner_h }; + canvas.fill_rect( rect, Color::rgb( 0.75, 0.10, 0.10 ), 0.0 ); + + let msg = "ltk: fallback theme — install `ltk-theme-default`"; + let size = 12.0; + let msg_w = canvas.measure_text( msg, size ); + let x = ( ( pw as f32 - msg_w ) / 2.0 ).max( 6.0 * sf ); + let y = ( banner_h - size * sf ) / 2.0; + canvas.draw_text( msg, x, y, size, Color::WHITE ); +} + +/// Apply or clear the surface's input region (set by +/// `App::input_region`). `None` restores the default of "receive +/// input everywhere"; `Some(&[])` makes the surface pointer-through. +pub( crate ) fn apply_input_region( + wl_surface: &WlSurface, + compositor: &CompositorState, + input_region: Option<&[Rect]>, +) +{ + if let Some( regions ) = input_region + { + if let Ok( region ) = Region::new( compositor ) + { + for r in regions + { + region.add( r.x as i32, r.y as i32, r.width as i32, r.height as i32 ); + } + wl_surface.set_input_region( Some( region.wl_region() ) ); + } + } else { + wl_surface.set_input_region( None ); + } +} diff --git a/src/draw/damage.rs b/src/draw/damage.rs new file mode 100644 index 0000000..32e8a9b --- /dev/null +++ b/src/draw/damage.rs @@ -0,0 +1,511 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Damage tracking: what rects need to be repainted this frame, and +//! what rects need to be declared to Wayland. +//! +//! Two entry points with different jobs: +//! +//! * [`compute_interaction_dirty_rects`] — called on the partial-redraw +//! path BEFORE painting. Takes the previous and current interaction +//! snapshots (focus / hover / pressed) and emits the union of the +//! `paint_rect`s of the widgets whose state transitioned. The caller +//! uses these to install a clip mask before the partial repaint and +//! to stamp `wl_surface.damage_buffer` afterwards. +//! * [`compute_damage`] — called on the full-redraw path AFTER painting. +//! Compares the previous-frame widget rects with the just-produced +//! ones; if layout moved or changed, returns an empty vec (meaning +//! "damage the whole surface"), otherwise returns a tight list of +//! rects for the widgets whose interaction state changed. +//! +//! The 50%-of-screen heuristic is shared by both: if the accumulated +//! damage covers more than half the surface, the single full-surface +//! damage rect is cheaper than the per-region list. + +use crate::types::Rect; +use crate::widget::LaidOutWidget; + +/// Intersect `r` with `bounds`, returning a rect that is entirely +/// inside `bounds`. Returns a zero-size rect if they do not overlap. +pub( super ) fn clamp_rect_to( r: Rect, bounds: Rect ) -> Rect +{ + let x0 = r.x.max( bounds.x ); + let y0 = r.y.max( bounds.y ); + let x1 = ( r.x + r.width ).min( bounds.x + bounds.width ); + let y1 = ( r.y + r.height ).min( bounds.y + bounds.height ); + if x1 <= x0 || y1 <= y0 + { + Rect { x: x0, y: y0, width: 0.0, height: 0.0 } + } else { + Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } + } +} + +/// Build the dirty-rect list for an interaction-only frame: union of +/// the `paint_rect`s of the widgets whose focus / hover / pressed +/// transitioned. Each widget's `paint_rect` already encloses its hover +/// halo, focus ring, and any other overdraw, so no extra padding is +/// needed here. +pub( crate ) fn compute_interaction_dirty_rects( + widget_rects: &[ LaidOutWidget ], + prev_focused: Option, prev_hovered: Option, prev_pressed: Option, + new_focused: Option, new_hovered: Option, new_pressed: Option, + pw: u32, + ph: u32, +) -> Vec +{ + let mut rects: Vec = Vec::new(); + let visit = |idx_opt: Option, sink: &mut Vec| + { + if let Some( idx ) = idx_opt + { + if let Some( w ) = widget_rects.iter().find( |w| w.flat_idx == idx ) + { + if !w.handlers.is_slider() + { + sink.push( w.paint_rect ); + } + } + } + }; + if prev_focused != new_focused + { + visit( prev_focused, &mut rects ); + visit( new_focused, &mut rects ); + } + if prev_hovered != new_hovered + { + visit( prev_hovered, &mut rects ); + visit( new_hovered, &mut rects ); + } + if prev_pressed != new_pressed + { + visit( prev_pressed, &mut rects ); + visit( new_pressed, &mut rects ); + } + // Snap to integer pixel boundaries before clamping. The clip mask is + // rasterized with anti_alias=false (binary, sampled at pixel centers), and + // `Canvas::clear_rects_transparent` uses `as i32` floor on the min and + // `.ceil() as i32` on the max. If `paint_rect` carries fractional coords + // (e.g. from `expand( 14.5 )` on icon-button hover halos), those two paths + // can disagree by 1 px at the edge — leaving a pixel cleared to transparent + // black but not repainted on the next frame. Floor min / ceil max here so + // both paths see the same whole-pixel rect. + let sw = pw as f32; + let sh = ph as f32; + for r in &mut rects + { + let x0 = r.x.floor().max( 0.0 ); + let y0 = r.y.floor().max( 0.0 ); + let x1 = ( r.x + r.width ).ceil().min( sw ); + let y1 = ( r.y + r.height ).ceil().min( sh ); + r.x = x0; + r.y = y0; + r.width = ( x1 - x0 ).max( 0.0 ); + r.height = ( y1 - y0 ).max( 0.0 ); + } + rects.retain( |r| r.width > 0.0 && r.height > 0.0 ); + rects +} + +/// Compare previous and current widget rects + interaction state to +/// find damaged regions. Returns a list of damage rects, or empty vec +/// if everything changed (full redraw). +pub( crate ) fn compute_damage( + old_rects: &[ LaidOutWidget ], + new_rects: &[ LaidOutWidget ], + old_focused: Option, + old_hovered: Option, + old_pressed: Option, + new_focused: Option, + new_hovered: Option, + new_pressed: Option, + screen_w: u32, + screen_h: u32, +) -> Vec +{ + // Widget tree structure changed: full redraw. + if old_rects.len() != new_rects.len() + { + return Vec::new(); + } + + let mut damage = Vec::new(); + + let changed_indices: Vec = [ + old_focused, new_focused, + old_hovered, new_hovered, + old_pressed, new_pressed, + ].iter().filter_map( |&idx| idx ).collect(); + + for &idx in &changed_indices + { + // Each widget's declared `paint_rect` already encloses its overdraw. + if let Some( w ) = old_rects.iter().find( |w| w.flat_idx == idx ) + { + damage.push( w.paint_rect ); + } + if let Some( w ) = new_rects.iter().find( |w| w.flat_idx == idx ) + { + damage.push( w.paint_rect ); + } + } + + // Layout shift: any rect moved or resized => full redraw. + for ( i, old_w ) in old_rects.iter().enumerate() + { + if let Some( new_w ) = new_rects.get( i ) + { + let old_r = old_w.rect; + let new_r = new_w.rect; + if ( old_r.x - new_r.x ).abs() > 0.5 + || ( old_r.y - new_r.y ).abs() > 0.5 + || ( old_r.width - new_r.width ).abs() > 0.5 + || ( old_r.height - new_r.height ).abs() > 0.5 + { + return Vec::new(); + } + } + } + + // No interaction changes but a redraw was requested => content changed + // (e.g. clock tick) and a full redraw is the right answer. + if damage.is_empty() + { + return Vec::new(); + } + + // Dilate every damage rect by 1 px on each side. The GPU rect/gradient + // shaders draw their quads expanded by 1 px so the outer half of the + // SDF antialiasing band has fragments to cover; under a partial + // redraw the scissor would otherwise clip that band at the widget's + // `paint_rect` boundary, re-introducing the aliased step on the + // straight edges of pills / rounded rects. The cost is negligible + // (one extra pixel of clear + repaint per damage rect). + for r in &mut damage + { + r.x -= 1.0; + r.y -= 1.0; + r.width += 2.0; + r.height += 2.0; + } + + let sw = screen_w as f32; + let sh = screen_h as f32; + for r in &mut damage + { + r.x = r.x.max( 0.0 ); + r.y = r.y.max( 0.0 ); + r.width = r.width.min( sw - r.x ); + r.height = r.height.min( sh - r.y ); + } + + // Total damage > 50% of screen: full redraw is no slower and emits a + // single damage rect instead of many. + let total_damage: f32 = damage.iter().map( |r| r.width * r.height ).sum(); + if total_damage > sw * sh * 0.5 + { + return Vec::new(); + } + + damage +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::widget::WidgetHandlers; + + fn r( x: f32, y: f32, w: f32, h: f32 ) -> Rect + { + Rect { x, y, width: w, height: h } + } + + fn lw( idx: usize, rect: Rect, paint: Rect ) -> LaidOutWidget<()> + { + LaidOutWidget + { + rect, + flat_idx: idx, + id: None, + paint_rect: paint, + handlers: WidgetHandlers::None, + keyboard_focusable: true, + cursor: crate::types::CursorShape::Default, + } + } + + // ── clamp_rect_to ───────────────────────────────────────────────────────── + + #[ test ] + fn clamp_rect_to_returns_intersection() + { + let bounds = r( 0.0, 0.0, 100.0, 100.0 ); + let inside = r( 10.0, 10.0, 20.0, 20.0 ); + assert_eq!( clamp_rect_to( inside, bounds ), inside ); + } + + #[ test ] + fn clamp_rect_to_clips_to_bounds() + { + let bounds = r( 0.0, 0.0, 100.0, 100.0 ); + let bleed = r( 80.0, 80.0, 50.0, 50.0 ); + assert_eq!( clamp_rect_to( bleed, bounds ), r( 80.0, 80.0, 20.0, 20.0 ) ); + } + + #[ test ] + fn clamp_rect_to_disjoint_returns_zero_size() + { + let bounds = r( 0.0, 0.0, 100.0, 100.0 ); + let off = r( 200.0, 200.0, 50.0, 50.0 ); + let out = clamp_rect_to( off, bounds ); + assert_eq!( ( out.width, out.height ), ( 0.0, 0.0 ) ); + } + + // ── compute_interaction_dirty_rects ─────────────────────────────────────── + + #[ test ] + fn no_state_change_yields_empty_dirty_rects() + { + let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ]; + let rects = compute_interaction_dirty_rects( + &widgets, + Some( 1 ), None, None, + Some( 1 ), None, None, + 800, 600, + ); + assert!( rects.is_empty() ); + } + + #[ test ] + fn focus_change_emits_both_old_and_new_paint_rects() + { + let widgets = vec![ + lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ), + lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ), + ]; + let rects = compute_interaction_dirty_rects( + &widgets, + Some( 1 ), None, None, + Some( 2 ), None, None, + 800, 600, + ); + assert_eq!( rects.len(), 2 ); + } + + #[ test ] + fn hover_change_emits_paint_rects_independent_of_focus() + { + let widgets = vec![ + lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ), + lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ), + ]; + let rects = compute_interaction_dirty_rects( + &widgets, + Some( 1 ), Some( 1 ), None, + Some( 1 ), Some( 2 ), None, + 800, 600, + ); + assert_eq!( rects.len(), 2 ); + } + + #[ test ] + fn dirty_rects_are_snapped_and_clamped_to_screen() + { + let widgets = vec![ + lw( 1, r( -10.0, -10.0, 30.5, 40.5 ), r( -10.0, -10.0, 30.5, 40.5 ) ), + ]; + let rects = compute_interaction_dirty_rects( + &widgets, + None, None, None, + Some( 1 ), None, None, + 100, 100, + ); + assert_eq!( rects.len(), 1 ); + // Floor of -10 is -10 → max(0) = 0; ceil of -10+30.5=20.5 → 21. + assert_eq!( rects[ 0 ].x, 0.0 ); + assert_eq!( rects[ 0 ].y, 0.0 ); + assert_eq!( rects[ 0 ].width, 21.0 ); + assert_eq!( rects[ 0 ].height, 31.0 ); + } + + #[ test ] + fn dirty_rects_outside_screen_are_dropped() + { + let widgets = vec![ + lw( 1, r( 1000.0, 1000.0, 50.0, 50.0 ), r( 1000.0, 1000.0, 50.0, 50.0 ) ), + ]; + let rects = compute_interaction_dirty_rects( + &widgets, + None, None, None, + Some( 1 ), None, None, + 100, 100, + ); + assert!( rects.is_empty() ); + } + + #[ test ] + fn missing_widget_idx_silently_skipped() + { + let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ]; + // Old focus references an idx that no longer exists in widget_rects — + // this happens during a layout where a widget disappeared between + // frames. The function must not panic; it just emits whatever new + // state's rect it can find. + let rects = compute_interaction_dirty_rects( + &widgets, + Some( 99 ), None, None, + Some( 1 ), None, None, + 800, 600, + ); + assert_eq!( rects.len(), 1 ); + } + + // ── compute_damage ──────────────────────────────────────────────────────── + + #[ test ] + fn tree_size_change_returns_full_redraw() + { + let old = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ]; + let new = vec![ + lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ), + lw( 2, r( 0.0, 60.0, 50.0, 50.0 ), r( 0.0, 60.0, 50.0, 50.0 ) ), + ]; + let damage = compute_damage( + &old, &new, + None, None, None, + None, None, None, + 800, 600, + ); + assert!( damage.is_empty(), "tree size change must signal full redraw via empty vec" ); + } + + #[ test ] + fn layout_shift_returns_full_redraw() + { + let old = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ]; + let new = vec![ lw( 1, r( 0.0, 60.0, 50.0, 50.0 ), r( 0.0, 60.0, 50.0, 50.0 ) ) ]; + let damage = compute_damage( + &old, &new, + Some( 1 ), None, None, + Some( 1 ), None, None, + 800, 600, + ); + assert!( damage.is_empty() ); + } + + #[ test ] + fn focus_change_emits_partial_damage() + { + let widgets = vec![ + lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ), + lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ), + ]; + let damage = compute_damage( + &widgets, &widgets, + Some( 1 ), None, None, + Some( 2 ), None, None, + 800, 600, + ); + assert!( !damage.is_empty(), "focus change must produce non-empty damage list" ); + assert!( + damage.len() >= 2, + "both old and new focused widgets contribute their paint_rects" + ); + } + + #[ test ] + fn no_change_with_redraw_request_returns_full_redraw() + { + // `damage.is_empty()` after the changed_indices loop means nothing + // interaction-level changed but the caller still asked to redraw — + // content tick (e.g. clock). The function returns the empty vec to + // signal "redraw everything" rather than nothing. + let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ]; + let damage = compute_damage( + &widgets, &widgets, + None, None, None, + None, None, None, + 800, 600, + ); + assert!( damage.is_empty() ); + } + + #[ test ] + fn damage_rects_are_dilated_by_one_pixel_each_side() + { + // Every damage rect grows by 1 px on each side to cover the SDF + // antialiasing band; the rect is then clamped to the surface. + let widgets = vec![ lw( 1, r( 100.0, 100.0, 50.0, 50.0 ), r( 100.0, 100.0, 50.0, 50.0 ) ) ]; + let damage = compute_damage( + &widgets, &widgets, + None, None, None, + Some( 1 ), None, None, + 800, 600, + ); + assert_eq!( damage.len(), 2 ); + for d in &damage + { + assert_eq!( d.x, 99.0 ); + assert_eq!( d.y, 99.0 ); + assert_eq!( d.width, 52.0 ); + assert_eq!( d.height, 52.0 ); + } + } + + #[ test ] + fn damage_above_fifty_percent_collapses_to_full_redraw() + { + // Widget covers 60 % of a 100×100 surface. The dilated rect easily + // exceeds the 50 % threshold and the function falls back to full + // redraw. + let widgets = vec![ + lw( 1, r( 0.0, 0.0, 80.0, 80.0 ), r( 0.0, 0.0, 80.0, 80.0 ) ), + ]; + let damage = compute_damage( + &widgets, &widgets, + None, None, None, + Some( 1 ), None, None, + 100, 100, + ); + assert!( damage.is_empty(), "damage > 50 % of surface must signal full redraw" ); + } + + #[ test ] + fn damage_below_fifty_percent_returns_partial() + { + // 10×10 widget on a 100×100 surface → ~1 % per rect, well under 50 %. + let widgets = vec![ + lw( 1, r( 5.0, 5.0, 10.0, 10.0 ), r( 5.0, 5.0, 10.0, 10.0 ) ), + ]; + let damage = compute_damage( + &widgets, &widgets, + None, None, None, + Some( 1 ), None, None, + 100, 100, + ); + assert!( !damage.is_empty() ); + } + + #[ test ] + fn damage_clamped_to_surface_extents() + { + // Widget paint_rect at the right edge — dilation can push it past the + // surface; the clamp must keep width/height within the surface. + let widgets = vec![ + lw( 1, r( 90.0, 90.0, 5.0, 5.0 ), r( 90.0, 90.0, 5.0, 5.0 ) ), + ]; + let damage = compute_damage( + &widgets, &widgets, + None, None, None, + Some( 1 ), None, None, + 100, 100, + ); + for d in &damage + { + assert!( d.x + d.width <= 100.0 + f32::EPSILON ); + assert!( d.y + d.height <= 100.0 + f32::EPSILON ); + } + } +} diff --git a/src/draw/gles.rs b/src/draw/gles.rs new file mode 100644 index 0000000..d3af437 --- /dev/null +++ b/src/draw/gles.rs @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! GLES (EGL + FBO) draw paths. +//! +//! The GPU counterparts to [`super::software::draw_surface_full`] and +//! [`super::software::draw_surface_partial`]. Same DrawCtx flow; the +//! differences are: +//! +//! * Canvas is `Canvas::new_gles` backed by a persistent FBO rather +//! than a CPU pixmap → FBO pixels survive across frames naturally, +//! so the partial path only needs a scissor. +//! * Presentation goes through `egl_ctx.swap_buffers_with_damage` +//! which attaches + damages + commits atomically. The partial path +//! translates the top-left dirty rects into EGL's bottom-left +//! convention before passing them in. +//! * No `wl_shm` pool; no `pick_shm_format`; no byte-order swap. +//! +//! The Y-flip for the swap-with-damage call lives here (only the GPU +//! path needs it — SHM buffers are already top-down). + +use std::sync::Arc; + +use smithay_client_toolkit::compositor::CompositorState; +use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface; + +use crate::egl_context::EglContext; +use crate::event_loop::SurfaceState; +use crate::render::Canvas; +use crate::types::{ Color, Rect }; +use crate::widget::Element; + +use super::{ DrawCtx, layout_and_draw }; +use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar }; + +/// GPU full-redraw path. Mirrors [`super::software::draw_surface_full`] +/// but writes into the EGL surface's persistent FBO instead of an SHM +/// buffer: +/// +/// 1. `eglMakeCurrent` so subsequent GL calls hit this surface. +/// 2. (Lazily) build the [`Canvas::Gles`], or resize its FBO if the +/// surface dimensions changed. +/// 3. Clear, run layout+draw, blit the FBO to the default framebuffer. +/// 4. Set buffer_scale + input_region (state attached to the implicit +/// commit done by `eglSwapBuffers`). +/// 5. `eglSwapBuffers` — performs the wl attach/damage/commit atomically. +pub( crate ) fn draw_surface_full_gpu( + ss: &mut SurfaceState, + compositor: &CompositorState, + egl_ctx: &Arc, + view: &Element, + bg: Color, + input_region: Option<&[Rect]>, + debug_layout: bool, + pw: u32, + ph: u32, + scale: u32, + request_frame: &dyn Fn( &WlSurface ), +) +{ + let Some( es ) = ss.egl_surface.as_ref() else { return }; + if egl_ctx.make_current( es ).is_err() { return; } + + let canvas = ss.canvas.get_or_insert_with( || + { + let mut c = Canvas::new_gles( + Arc::clone( egl_ctx.gl() ), egl_ctx.version, pw, ph, + ); + c.set_dpi_scale( scale as f32 ); + if let Some( reg ) = crate::theme::build_font_registry() + { + c.set_font_registry( Arc::new( reg ) ); + } + c + } ); + if canvas.size() != ( pw, ph ) + { + canvas.resize( pw, ph ); + canvas.set_dpi_scale( scale as f32 ); + } + canvas.clear_clip(); + + let sf = scale as f32; + let tb_h = ss.titlebar_height * sf; + let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h ).max( 0.0 ) }; + + if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear(); } + + ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); + + let mut ctx: DrawCtx = DrawCtx + { + focused_idx: ss.focused_idx, + hovered_idx: ss.hovered_idx, + pressed_idx: ss.gesture.pressed_idx, + cursor_state: std::mem::take( &mut ss.cursor_state ), + selection_anchor: std::mem::take( &mut ss.selection_anchor ), + widget_rects: Vec::new(), + debug_layout, + scroll_offsets: std::mem::take( &mut ss.scroll_offsets ), + scroll_rects: Vec::new(), + scroll_canvases: std::mem::take( &mut ss.scroll_canvases ), + scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ), + previous_widget_rects: ss.widget_rects.clone(), + }; + layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); + + if ctx.debug_layout + { + for w in &ctx.widget_rects + { + canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 ); + } + } + + if let Some( ref menu ) = ss.context_menu + { + super::draw_context_menu( canvas, menu ); + } + + // Fallback-theme warning. Painted last so it sits on top of every + // widget; no-op when the real theme is loaded. + draw_fallback_banner( canvas, pw, sf ); + + canvas.present(); + + ss.prev_focused = ss.focused_idx; + ss.prev_hovered = ss.hovered_idx; + ss.prev_pressed = ss.gesture.pressed_idx; + + ss.widget_rects = ctx.widget_rects; + ss.scroll_rects = ctx.scroll_rects; + ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases ); + ss.cursor_state = ctx.cursor_state; + ss.selection_anchor = ctx.selection_anchor; + ss.scroll_offsets = ctx.scroll_offsets; + ss.scroll_navigable_items = ctx.scroll_navigable_items; + ss.content_dirty = false; + + let wl_surface = ss.surface.wl_surface(); + apply_input_region( wl_surface, compositor, input_region ); + // Frame callback request must precede the implicit commit done by + // `swap_buffers_with_damage`, so the compositor can attach it to this + // frame and not the previous one. + request_frame( wl_surface ); + // Full-surface damage. (0,0,w,h) is identical in EGL bottom-left and + // top-left coords, so no Y-flip needed. + let _ = egl_ctx.swap_buffers_with_damage( es, &[ ( 0, 0, pw as i32, ph as i32 ) ] ); + ss.frame_pending = true; +} + +/// GPU partial-redraw path. Same flow as +/// [`super::software::draw_surface_partial`] (clip mask → repaint +/// background + widgets → present), but the clip is implemented with +/// `glScissor` (bbox union) inside the canvas, and presentation goes +/// through `eglSwapBuffers`. The FBO is preserved between frames so +/// the unclipped pixels naturally carry over. +pub( crate ) fn draw_surface_partial_gpu( + ss: &mut SurfaceState, + compositor: &CompositorState, + egl_ctx: &Arc, + view: &Element, + bg: Color, + input_region: Option<&[Rect]>, + dirty_rects: Vec, + pw: u32, + ph: u32, + scale: u32, + request_frame: &dyn Fn( &WlSurface ), +) +{ + let Some( es ) = ss.egl_surface.as_ref() else { return }; + if egl_ctx.make_current( es ).is_err() { return; } + + let canvas = ss.canvas.as_mut().expect( "partial path requires existing canvas" ); + if canvas.size() != ( pw, ph ) + { + canvas.resize( pw, ph ); + canvas.set_dpi_scale( scale as f32 ); + } + canvas.set_clip_rects( &dirty_rects ); + + let sf = scale as f32; + let tb_h = ss.titlebar_height * sf; + let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: ( ph as f32 - tb_h ).max( 0.0 ) }; + + if bg.a > 0.0 { canvas.fill( bg ); } + else + { + canvas.clear_rects_transparent( &dirty_rects ); + } + + ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); + + let mut ctx: DrawCtx = DrawCtx + { + focused_idx: ss.focused_idx, + hovered_idx: ss.hovered_idx, + pressed_idx: ss.gesture.pressed_idx, + cursor_state: std::mem::take( &mut ss.cursor_state ), + selection_anchor: std::mem::take( &mut ss.selection_anchor ), + widget_rects: Vec::new(), + debug_layout: false, + scroll_offsets: std::mem::take( &mut ss.scroll_offsets ), + scroll_rects: Vec::new(), + scroll_canvases: std::mem::take( &mut ss.scroll_canvases ), + scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ), + previous_widget_rects: ss.widget_rects.clone(), + }; + layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); + + if let Some( ref menu ) = ss.context_menu + { + super::draw_context_menu( canvas, menu ); + } + + // Fallback-theme warning. Painted last so it sits on top of every + // widget; no-op when the real theme is loaded. + draw_fallback_banner( canvas, pw, sf ); + + canvas.clear_clip(); + canvas.present(); + + ss.prev_focused = ss.focused_idx; + ss.prev_hovered = ss.hovered_idx; + ss.prev_pressed = ss.gesture.pressed_idx; + + ss.widget_rects = ctx.widget_rects; + ss.scroll_rects = ctx.scroll_rects; + ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases ); + ss.cursor_state = ctx.cursor_state; + ss.selection_anchor = ctx.selection_anchor; + ss.scroll_offsets = ctx.scroll_offsets; + ss.scroll_navigable_items = ctx.scroll_navigable_items; + ss.content_dirty = false; + + let wl_surface = ss.surface.wl_surface(); + apply_input_region( wl_surface, compositor, input_region ); + request_frame( wl_surface ); + // Convert top-left dirty rects to EGL bottom-left coords for the + // swap-with-damage call. + let damage: Vec<( i32, i32, i32, i32 )> = dirty_rects.iter().map( |r| + { + let x = r.x.floor() as i32; + let w = r.width.ceil() as i32; + let h = r.height.ceil() as i32; + let y_top = r.y.floor() as i32; + let y_bottom = ph as i32 - y_top - h; + ( x, y_bottom.max( 0 ), w.max( 0 ), h.max( 0 ) ) + } ).collect(); + let _ = egl_ctx.swap_buffers_with_damage( es, &damage ); + ss.frame_pending = true; +} diff --git a/src/draw/layout.rs b/src/draw/layout.rs new file mode 100644 index 0000000..bb1cf9a --- /dev/null +++ b/src/draw/layout.rs @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Element-tree recursive walker. +//! +//! [`layout_and_draw`] is the single bottom of the draw pipeline: +//! given an [`Element`] + its allocated rect, it lays out children, +//! paints leaves, threads scroll sub-canvases, and records the +//! [`LaidOutWidget`] entries the input layer will hit-test against. +//! Both software and GPU paths funnel every widget through this one +//! function. + +use crate::render::Canvas; +use crate::types::Rect; +use crate::widget::{ Element, LaidOutWidget, WidgetHandlers }; + +use super::DrawCtx; +use super::damage::clamp_rect_to; + +pub( crate ) fn layout_and_draw( + element: &Element, + canvas: &mut Canvas, + rect: Rect, + ctx: &mut DrawCtx, + flat_idx: usize, +) -> usize +{ + match element + { + Element::Column( col ) => + { + let child_rects = col.layout( rect, canvas ); + let mut idx = flat_idx; + for ( child_rect, child_i ) in child_rects + { + idx = layout_and_draw::( &col.children[child_i], canvas, child_rect, ctx, idx ); + } + idx + } + Element::Row( r ) => + { + let child_rects = r.layout( rect, canvas ); + let mut idx = flat_idx; + for ( child_rect, child_i ) in child_rects + { + idx = layout_and_draw::( &r.children[child_i], canvas, child_rect, ctx, idx ); + } + idx + } + Element::Stack( s ) => + { + let child_rects = s.layout( rect, canvas ); + let mut idx = flat_idx; + for ( child_rect, child_i ) in child_rects + { + idx = layout_and_draw::( &s.children[ child_i ].0, canvas, child_rect, ctx, idx ); + } + idx + } + Element::WrapGrid( g ) => + { + let child_rects = g.layout( rect, canvas ); + let mut idx = flat_idx; + for ( child_rect, child_i ) in child_rects + { + idx = layout_and_draw::( &g.children[child_i], canvas, child_rect, ctx, idx ); + } + idx + } + Element::Flex( f ) => + { + // `Flex` is invisible chrome: it claimed leftover width up at + // `Row::layout`, here we just unwrap it and draw the child + // inside the allocated rect. No flat-index of its own. + layout_and_draw::( f.child.as_ref(), canvas, rect, ctx, flat_idx ) + } + Element::AnchoredOverlay( a ) => + { + // Look up the anchor's rect from the previous frame's + // `widget_rects` snapshot. If found, place the child flush + // below the anchor at the child's intrinsic size; if not, + // fall back to the parent-supplied rect so the child still + // renders (modal-style, typically a one-frame artefact on + // the first frame after open). + let anchor_rect = ctx.previous_widget_rects.iter() + .find( |w| w.id == Some( a.anchor_id ) ) + .map( |w| w.rect ); + let target = match anchor_rect + { + Some( anchor ) => + { + // Use the child's intrinsic preferred size so the + // popup keeps its design width / height regardless + // of how wide the trigger pill happens to be. + let ( w, h ) = a.child.preferred_size( rect.width, canvas ); + Rect + { + x: anchor.x, + y: anchor.y + anchor.height + a.gap, + width: w, + height: h, + } + } + None => rect, + }; + layout_and_draw::( a.child.as_ref(), canvas, target, ctx, flat_idx ) + } + Element::Pressable( p ) => + { + // Push the wrapper's hit rect *before* recursing so that any + // interactive child pushed during recursion sits later in + // `widget_rects` and wins under `iter().rev()` hit testing. + let my_idx = flat_idx; + if p.has_handler() + { + ctx.widget_rects.push( LaidOutWidget + { + rect, + flat_idx: my_idx, + id: p.id, + paint_rect: rect, + handlers: WidgetHandlers::Button + { + on_press: p.on_press.clone(), + on_long_press: p.on_long_press.clone(), + on_drag_start: p.on_drag_start.clone(), + on_escape: p.on_escape.clone(), + repeating: false, + }, + keyboard_focusable: false, + cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ), + } ); + } + layout_and_draw::( p.child.as_ref(), canvas, rect, ctx, my_idx + 1 ) + } + Element::Container( c ) => + { + let saved_alpha = canvas.global_alpha(); + canvas.set_global_alpha( saved_alpha * c.opacity ); + + // Surface slot takes precedence over flat background; falls + // through to `c.background` when the slot is absent (third- + // party theme without the named surface — content still + // renders, just without the themed chrome). + let painted = match c.surface.as_deref() + { + Some( slot ) => match crate::theme::resolve_surface( slot ) + { + Some( ( surf, outer ) ) => + { + canvas.fill_surface + ( + rect, + &surf.fill, + &outer, + &surf.inset_shadows, + c.corners, + ); + true + } + None => false, + }, + None => false, + }; + if !painted + { + if let Some( bg ) = c.background + { + canvas.fill_rect( rect, bg, c.corners ); + } + } + if let Some( ( color, width ) ) = c.border + { + canvas.stroke_rect( rect, color, width, c.corners ); + } + let inner = crate::types::Rect + { + x: rect.x + c.pad_left, + y: rect.y + c.pad_top, + width: ( rect.width - c.pad_left - c.pad_right ).max( 0.0 ), + height: ( rect.height - c.pad_top - c.pad_bottom ).max( 0.0 ), + }; + let result = layout_and_draw::( c.child.as_ref(), canvas, inner, ctx, flat_idx ); + + canvas.set_global_alpha( saved_alpha ); + result + } + Element::Scroll( s ) => + { + let my_idx = flat_idx; + let offset_raw = ctx.scroll_offsets.get( &my_idx ).copied().unwrap_or( 0.0 ); + let child_h = s.child.preferred_size( rect.width, canvas ).1; + let offset = crate::widget::scroll::clamp_offset( offset_raw, child_h, rect.height ); + // Write the clamped offset back so input handlers (wheel / + // drag) cannot accumulate past the content extents. Without + // this, repeated wheel ticks past the bottom keep growing + // `offset_raw` and the user has to "undo" that excess before + // the content scrolls back up. The clamp is idempotent for + // in-range values, so the only effect is collapsing + // out-of-range entries to the legitimate maximum. + if offset != offset_raw + { + ctx.scroll_offsets.insert( my_idx, offset ); + } + + // Reuse the sub-canvas from the previous frame if its size matches; + // only reallocate when the viewport is resized. + let sw = (rect.width.ceil() as u32).max( 1 ); + let sh = (rect.height.ceil() as u32).max( 1 ); + let mut sub = ctx.scroll_canvases.remove( &my_idx ) + .filter( |c| c.size() == ( sw, sh ) ) + .unwrap_or_else( || canvas.sub_canvas( sw, sh ) ); + sub.clear(); + + // Child content fills at least the full viewport height so that + // children with VAlign::Bottom are positioned correctly when the + // content is shorter than the viewport. + let effective_h = child_h.max( rect.height ); + // Shift child up by offset so scrolled content appears at y=0 in + // the sub-canvas. + let child_rect = Rect { x: 0.0, y: -offset, width: rect.width, height: effective_h }; + + let rects_before = ctx.widget_rects.len(); + let scroll_rects_before = ctx.scroll_rects.len(); + let next_idx = layout_and_draw::( s.child.as_ref(), &mut sub, child_rect, ctx, my_idx + 1 ); + + // Translate widget_rects from sub-canvas space to global space; drop + // clipped ones. Also clamp `paint_rect` to the scroll viewport so a + // widget painting outside the sub-canvas does not invalidate pixels + // it cannot actually reach (the sub-canvas is blitted as a whole, + // so nothing outside the viewport appears on screen anyway). + let new_rects: Vec> = ctx.widget_rects.drain( rects_before.. ).collect(); + + // Capture every interactive item the child laid out — including + // items that the visibility filter below will discard — so the + // keyboard handler can step `hovered_idx` item-by-item without + // caring about which items are currently scrolled into view. + // The recorded Y is in pre-translation, pre-offset coordinates + // (i.e. `child_y` relative to the start of the scroll content), + // recovered by undoing the `-offset` shift the child layout pass + // applied: `content_y = w.rect.y - child_rect.y = w.rect.y + offset`. + let navigable: Vec<( usize, f32, f32 )> = new_rects.iter() + .filter( |w| w.handlers.is_navigable_list_item() ) + .map( |w| ( w.flat_idx, w.rect.y + offset, w.rect.height ) ) + .collect(); + if !navigable.is_empty() + { + ctx.scroll_navigable_items.insert( my_idx, navigable ); + } + + for mut w in new_rects + { + // Sub-canvas origin (0,0) maps to global (rect.x, rect.y). + w.rect.x += rect.x; + w.rect.y += rect.y; + w.paint_rect.x += rect.x; + w.paint_rect.y += rect.y; + w.paint_rect = clamp_rect_to( w.paint_rect, rect ); + // Only keep widgets at least partially visible inside the viewport. + if w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height + { + ctx.widget_rects.push( w ); + } + } + + // Same translation for scroll_rects pushed by any nested + // scroll widgets — without this, the wheel handler hit-tests + // in surface coords against rects in sub-canvas coords and + // only matches when the sub-canvas happens to start at + // (0, 0) of the surface. Clamp to the outer scroll rect so + // the inner scroll only captures wheel events inside its + // visible region. + let new_scroll_rects: Vec<( Rect, usize )> = + ctx.scroll_rects.drain( scroll_rects_before.. ).collect(); + for ( mut r, idx ) in new_scroll_rects + { + r.x += rect.x; + r.y += rect.y; + let clamped = clamp_rect_to( r, rect ); + if clamped.width > 0.0 && clamped.height > 0.0 + { + ctx.scroll_rects.push( ( clamped, idx ) ); + } + } + + ctx.scroll_rects.push( ( rect, my_idx ) ); + + canvas.blit( &sub, rect.x as i32, rect.y as i32 ); + + ctx.scroll_canvases.insert( my_idx, sub ); + + next_idx + } + Element::Viewport( v ) => + { + let child_h = v.child.preferred_size( rect.width, canvas ).1; + let effective_h = child_h.max( rect.height ); + let vw = ( rect.width.ceil() as u32 ).max( 1 ); + let vh = ( rect.height.ceil() as u32 ).max( 1 ); + let mut sub = canvas.sub_canvas( vw, vh ); + sub.clear(); + + let child_rect = Rect { x: 0.0, y: 0.0, width: rect.width, height: effective_h }; + let rects_before = ctx.widget_rects.len(); + let scroll_rects_before = ctx.scroll_rects.len(); + let next_idx = layout_and_draw::( v.child.as_ref(), &mut sub, child_rect, ctx, flat_idx ); + + let new_rects: Vec> = ctx.widget_rects.drain( rects_before.. ).collect(); + for mut w in new_rects + { + w.rect.x += rect.x; + w.rect.y += rect.y; + w.paint_rect.x += rect.x; + w.paint_rect.y += rect.y; + w.paint_rect = clamp_rect_to( w.paint_rect, rect ); + if w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height + { + ctx.widget_rects.push( w ); + } + } + + // Translate scroll_rects pushed by nested scroll widgets + // from sub-canvas to surface space. Same reasoning as the + // scroll-inside-scroll case above. + let new_scroll_rects: Vec<( Rect, usize )> = + ctx.scroll_rects.drain( scroll_rects_before.. ).collect(); + for ( mut r, idx ) in new_scroll_rects + { + r.x += rect.x; + r.y += rect.y; + let clamped = clamp_rect_to( r, rect ); + if clamped.width > 0.0 && clamped.height > 0.0 + { + ctx.scroll_rects.push( ( clamped, idx ) ); + } + } + + canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom ); + next_idx + } + other => + { + // Gate the focus ring by `is_focusable`: a widget that opts out + // of keyboard focus (e.g. `Button::focusable(false)`) should not + // paint a ring even if it ends up in `focused_idx` after a tap. + let is_focused = ctx.focused_idx == Some( flat_idx ) && other.is_focusable(); + let is_hovered = ctx.hovered_idx == Some( flat_idx ); + let is_pressed = ctx.pressed_idx == Some( flat_idx ); + let cursor_pos = ctx.cursor_state.get( &flat_idx ).copied().unwrap_or( 0 ); + let sel_anchor = ctx.selection_anchor.get( &flat_idx ).copied().unwrap_or( cursor_pos ); + let widget_id = match other + { + Element::Button( b ) => b.id, + Element::TextEdit( t ) => t.id, + Element::Toggle( t ) => t.id, + Element::Checkbox( c ) => c.id, + Element::Radio( r ) => r.id, + Element::ListItem( l ) => l.id, + Element::WindowButton( b ) => b.id, + _ => None, + }; + other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor ); + if other.is_interactive() + { + ctx.widget_rects.push( LaidOutWidget + { + rect, + flat_idx, + id: widget_id, + paint_rect: other.paint_bounds( rect ), + handlers: other.handlers(), + keyboard_focusable: other.is_focusable(), + cursor: other.cursor_shape(), + } ); + } + flat_idx + 1 + } + } +} diff --git a/src/draw/mod.rs b/src/draw/mod.rs new file mode 100644 index 0000000..6e99e8d --- /dev/null +++ b/src/draw/mod.rs @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Per-frame drawing pipeline. +//! +//! The run loop hands each configured surface to [`draw_frame`] once per +//! vblank; this module walks it through a decision tree: +//! +//! * **Skip** — no content changed and no interaction state moved, so the +//! previously committed buffer is still correct. +//! * **Partial** — only focus / hover / pressed changed. Install a clip +//! mask covering the paint rects of the affected widgets, repaint only +//! under the clip, damage Wayland with exactly those rects. +//! * **Full** — something substantive changed (app message, animation +//! tick, configure, text edit, scroll, slider drag). Clear + redraw +//! the entire view, let damage tracking tighten the commit. +//! +//! Each of those paths has a software variant (CPU + SHM pool) and a +//! GLES variant (FBO + EGL swap). The four resulting functions live in +//! [`software`] and [`gles`]; this file is just the router plus the +//! small shared setup ([`DrawCtx`], [`pick_shm_format`]). +//! +//! # Submodule layout +//! +//! * [`software`] — `draw_surface_full` / `draw_surface_partial` +//! * [`gles`] — `draw_surface_full_gpu` / `draw_surface_partial_gpu` +//! * [`damage`] — `compute_interaction_dirty_rects`, `compute_damage`, +//! `clamp_rect_to` +//! * [`chrome`] — `draw_titlebar`, `apply_input_region` +//! * [`layout`] — `layout_and_draw` (the recursive element walker) + +use std::collections::HashMap; +use std::sync::Arc; +use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface }; +use smithay_client_toolkit::shm::Shm; + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState }; +use crate::render::Canvas; +use crate::types::{ Color, Rect }; +use crate::widget::{ Element, LaidOutWidget }; + +pub( crate ) mod software; +pub( crate ) mod gles; +pub( crate ) mod damage; +pub( crate ) mod chrome; +pub( crate ) mod layout; + +pub( crate ) use damage::{ compute_damage, compute_interaction_dirty_rects }; +pub( crate ) use layout::layout_and_draw; + +/// Pick the best wl_shm format for our RGBA-premultiplied pixmap. +/// +/// Abgr8888 matches tiny-skia's byteorder on little-endian systems, so we can +/// copy with a plain memcpy. If the compositor doesn't advertise it, fall back +/// to Argb8888 (mandatory per wl_shm) which requires a per-channel swap. +/// +/// Returns `(format, swap_rb)`. +pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool ) +{ + if shm.formats().contains( &wl_shm::Format::Abgr8888 ) + { + ( wl_shm::Format::Abgr8888, false ) + } else { + ( wl_shm::Format::Argb8888, true ) + } +} + +/// Per-frame draw state threaded through [`layout_and_draw`]. Captures +/// the interaction snapshot (focus / hover / pressed), scratch space +/// for the widget-rect list the frame will produce, and the scroll +/// offsets / sub-canvases carried across frames. +/// +/// `scroll_canvases` arrives populated (the previous frame's +/// sub-canvases) so `layout_and_draw` can re-use them for Scroll +/// viewports whose size did not change. `scroll_rects` and +/// `widget_rects` start empty and get filled as the element tree is +/// walked. +pub( crate ) struct DrawCtx +{ + pub focused_idx: Option, + pub hovered_idx: Option, + pub pressed_idx: Option, + pub cursor_state: HashMap, + pub selection_anchor: HashMap, + pub widget_rects: Vec>, + pub debug_layout: bool, + pub scroll_offsets: HashMap, + pub scroll_rects: Vec<(Rect, usize)>, + pub scroll_canvases: HashMap, + /// Per-scroll navigation map: list of `(flat_idx, content_y, height)` + /// for every interactive item the scroll's child laid out, in + /// document order, **including items currently scrolled off-screen**. + /// Keyboard arrow handlers read this to step the runtime's + /// `hovered_idx` item-by-item without needing to know how the popup + /// content was composed. The Y is in pre-translation, pre-offset + /// coordinates (i.e. relative to the start of the scroll's child + /// content) so the keyboard auto-scroll can compute the offset + /// needed to bring an item into view without depending on the + /// current scroll position. + pub scroll_navigable_items: HashMap>, + /// Snapshot of the previous frame's `widget_rects`. Read by + /// [`crate::widget::anchored_overlay::AnchoredOverlay`] at draw time + /// to look up the rect of an anchor widget by [`crate::WidgetId`] and + /// re-position itself relative to that rect. Drivers populate this + /// before invoking the recursive layout / draw walk. + pub previous_widget_rects: Vec>, +} + +/// Paint the built-in Copy / Cut / Paste context menu on top of the +/// finished surface content. Called from the software and GLES draw +/// paths right before `present()` so the menu sits above everything +/// the widget tree painted, matching the convention every other +/// toolkit follows for runtime-internal popups. +pub( crate ) fn draw_context_menu( + canvas: &mut crate::render::Canvas, + menu: &crate::event_loop::app_data::ContextMenu, +) +{ + let palette = crate::theme::palette(); + let bg = palette.surface; + let border = palette.divider; + let text = palette.text_primary; + let muted = palette.text_secondary; + let hi = palette.surface_alt; + + let r = menu.rect; + canvas.fill_rect( r, bg, 8.0 ); + canvas.stroke_rect( r, border, 1.0, 8.0 ); + + let ( ys, row_h ) = menu.row_ys(); + // Row order: Copy / Cut / Paste / Delete. Labels go through + // `rust_i18n::t!()` so the menu picks up the active locale; the + // `enabled` flag mirrors the gating in `handle_context_menu_press`. + let labels: [ ( String, bool ); 4 ] = + [ + ( rust_i18n::t!( "context_menu.copy" ).to_string(), menu.has_selection ), + ( rust_i18n::t!( "context_menu.cut" ).to_string(), menu.has_selection ), + ( rust_i18n::t!( "context_menu.paste" ).to_string(), menu.can_paste ), + ( rust_i18n::t!( "context_menu.delete" ).to_string(), menu.has_selection ), + ]; + + // Subtle accent band on the row matching the *primary* action so + // the menu reads as "Paste is the default" when there is no + // selection (the common case for a paste-into-empty-field click) + // and "Copy is the default" when a selection is active. Just a + // hint, not a binding — every row still works on its own click. + let primary_idx = if menu.has_selection { 0 } else { 2 }; + let primary_band = crate::types::Rect + { + x: r.x + 4.0, y: ys[ primary_idx ] + 2.0, + width: r.width - 8.0, height: row_h - 4.0, + }; + canvas.fill_rect( primary_band, hi, 6.0 ); + + for ( i, ( label, enabled ) ) in labels.iter().enumerate() + { + let color = if *enabled { text } else { muted }; + canvas.draw_text( + label, + r.x + 16.0, + ys[ i ] + row_h * 0.5 + 5.0, + 14.0, + color, + ); + } + + // Thin separator between every pair of rows. + let sep_color = palette.divider; + for y in ys.iter().skip( 1 ) + { + canvas.draw_line( r.x + 8.0, *y, r.x + r.width - 8.0, *y, sep_color, 1.0 ); + } +} + +pub( crate ) fn draw_frame( data: &mut AppData ) +{ + // Caches were refreshed by the run loop just before calling us; pull them + // by reference instead of re-invoking `App::view` / `App::overlays` each + // frame. The two `expect`s document the run loop's contract. + let main_view = data.cached_view.as_ref().expect( "view cache populated" ); + let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" ); + + let main_bg = data.app.background_color(); + let main_region = data.app.input_region(); + let debug_layout = data.debug_layout; + let ( format, swap_rb ) = pick_shm_format( &data.shm ); + let egl_ctx = data.egl_context.as_ref(); + let qh = &data.qh; + + if data.main.configured && data.main.needs_redraw && !data.main.frame_pending + { + let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); }; + draw_surface::( + &mut data.main, + &data.compositor_state, + egl_ctx, + main_view, + main_bg, + main_region.as_deref(), + debug_layout, + format, + swap_rb, + &req_frame, + ); + data.main.needs_redraw = false; + data.main.last_draw = std::time::Instant::now(); + } + + for spec in overlays + { + if let Some( ss ) = data.overlays.get_mut( &spec.id ) + { + if !ss.configured || !ss.needs_redraw || ss.frame_pending { continue; } + let focus = SurfaceFocus::Overlay( spec.id ); + let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, focus ); }; + let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 ); + draw_surface::( + ss, + &data.compositor_state, + egl_ctx, + &spec.view, + bg, + spec.input_region.as_deref(), + debug_layout, + format, + swap_rb, + &req_frame, + ); + ss.needs_redraw = false; + ss.last_draw = std::time::Instant::now(); + } + } +} + +/// Render one surface's current view. Picks between software and GLES, +/// and within each picks between full and partial redraw based on what +/// changed since the last committed frame. +/// +/// The decision: +/// * **Skip** — `content_dirty` is false and no interaction state +/// changed. Previously committed buffer stays on screen. +/// * **Partial** — `content_dirty` is false but focus / hover / pressed +/// transitioned. Canvas is preserved across frames, so clip to the +/// dirty widgets and repaint only under the clip. +/// * **Full** — `content_dirty` is true. Clear + redraw + damage. +/// +/// Partial eligibility also bails out when the total dirty area >50% +/// of the surface: at that ratio per-region clipping is no faster than +/// a plain full redraw, so the code prefers one big damage rect over +/// several small ones. +fn draw_surface( + ss: &mut SurfaceState, + compositor: &smithay_client_toolkit::compositor::CompositorState, + egl_ctx: Option<&Arc>, + view: &Element, + bg: Color, + input_region: Option<&[Rect]>, + debug_layout: bool, + shm_format: wl_shm::Format, + swap_rb: bool, + request_frame: &dyn Fn( &WlSurface ), +) +{ + let scale = ss.scale_factor.max( 1 ) as u32; + let w = ss.width; + let h = ss.height; + if w == 0 || h == 0 { return; } + let pw = w * scale; + let ph = h * scale; + + // Decide partial-redraw eligibility BEFORE allocating a buffer. If we end + // up skipping the frame, we want to avoid touching the SHM pool at all. + let canvas_ready = ss.canvas.as_ref() + .map( |c| c.size() == ( pw, ph ) ) + .unwrap_or( false ); + let partial_eligible = !ss.content_dirty + && canvas_ready + && !ss.widget_rects.is_empty(); + + if partial_eligible + { + let dirty_rects = compute_interaction_dirty_rects( + &ss.widget_rects, + ss.prev_focused, ss.prev_hovered, ss.prev_pressed, + ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, + pw, ph, + ); + if dirty_rects.is_empty() + { + // Nothing visible changed — keep the previously committed buffer. + return; + } + // Total dirty area > 50% of screen: a full redraw is no slower than + // per-region clipping but emits a single damage rect. + let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum(); + if total < pw as f32 * ph as f32 * 0.5 + { + if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() ) + { + gles::draw_surface_partial_gpu( + ss, compositor, ctx, view, bg, input_region, + dirty_rects, pw, ph, scale, request_frame, + ); + } else { + software::draw_surface_partial( + ss, compositor, view, bg, input_region, + shm_format, swap_rb, dirty_rects, pw, ph, scale, request_frame, + ); + } + return; + } + } + + if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() ) + { + gles::draw_surface_full_gpu( + ss, compositor, ctx, view, bg, input_region, debug_layout, + pw, ph, scale, request_frame, + ); + } else { + software::draw_surface_full( + ss, compositor, view, bg, input_region, debug_layout, + shm_format, swap_rb, pw, ph, scale, request_frame, + ); + } +} diff --git a/src/draw/software.rs b/src/draw/software.rs new file mode 100644 index 0000000..89a4df5 --- /dev/null +++ b/src/draw/software.rs @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Software (CPU + `wl_shm` pool) draw paths. +//! +//! Two functions, both symmetric with their GPU counterparts in +//! [`super::gles`]: +//! +//! * [`draw_surface_full`] — full redraw. Allocate a buffer, clear the +//! canvas, run layout+draw, write pixels to the SHM buffer, damage +//! the whole surface (or fine-grained rects from [`compute_damage`]), +//! commit. +//! * [`draw_surface_partial`] — clip-masked repaint. The canvas +//! pixmap from the previous frame is still valid; install a clip +//! covering only the dirty rects, repaint the background + title bar +//! + widget tree under the clip, and damage Wayland with exactly +//! those rects. +//! +//! Both paths share the DrawCtx / `layout_and_draw` / `draw_titlebar` +//! / `apply_input_region` helpers with the GPU path. + +use std::sync::Arc; + +use smithay_client_toolkit::compositor::CompositorState; +use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface }; + +use crate::event_loop::SurfaceState; +use crate::render::Canvas; +use crate::types::{ Color, Rect }; +use crate::widget::Element; + +use super::{ DrawCtx, compute_damage, layout_and_draw }; +use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar }; + +/// Full redraw path: clear the canvas, run layout+draw for every widget, then +/// emit either tight per-widget damage or a single full-surface damage rect +/// depending on what [`compute_damage`] derives. +pub( crate ) fn draw_surface_full( + ss: &mut SurfaceState, + compositor: &CompositorState, + view: &Element, + bg: Color, + input_region: Option<&[Rect]>, + debug_layout: bool, + shm_format: wl_shm::Format, + swap_rb: bool, + pw: u32, + ph: u32, + scale: u32, + request_frame: &dyn Fn( &WlSurface ), +) +{ + let Some( pool ) = ss.pool.as_mut() else { return }; + let stride = pw * 4; + + let ( buffer, canvas_buf ) = match pool.create_buffer( + pw as i32, ph as i32, stride as i32, shm_format, + ) + { + Ok( r ) => r, + Err( _ ) => return, + }; + + let canvas = ss.canvas.get_or_insert_with( || { + let mut c = Canvas::new( pw, ph ); + c.set_dpi_scale( scale as f32 ); + if let Some( reg ) = crate::theme::build_font_registry() + { + c.set_font_registry( Arc::new( reg ) ); + } + c + } ); + if canvas.size() != ( pw, ph ) + { + canvas.resize( pw, ph ); + canvas.set_dpi_scale( scale as f32 ); + } + canvas.clear_clip(); + + let sf = scale as f32; + let tb_h = ss.titlebar_height * sf; + let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: (ph as f32 - tb_h).max( 0.0 ) }; + + if bg.a > 0.0 { canvas.fill( bg ); } else { canvas.clear(); } + + ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); + + let mut ctx: DrawCtx = DrawCtx + { + focused_idx: ss.focused_idx, + hovered_idx: ss.hovered_idx, + pressed_idx: ss.gesture.pressed_idx, + cursor_state: std::mem::take( &mut ss.cursor_state ), + selection_anchor: std::mem::take( &mut ss.selection_anchor ), + widget_rects: Vec::new(), + debug_layout, + scroll_offsets: std::mem::take( &mut ss.scroll_offsets ), + scroll_rects: Vec::new(), + scroll_canvases: std::mem::take( &mut ss.scroll_canvases ), + scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ), + previous_widget_rects: ss.widget_rects.clone(), + }; + layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); + + if ctx.debug_layout + { + for w in &ctx.widget_rects + { + canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 ); + } + } + + if let Some( ref menu ) = ss.context_menu + { + super::draw_context_menu( canvas, menu ); + } + + // Fallback-theme warning. Painted last so it sits on top of every + // widget; no-op when the real theme is loaded. + draw_fallback_banner( canvas, pw, sf ); + + let damage_rects = if ss.content_dirty + { + Vec::new() + } else { + compute_damage( + &ss.widget_rects, + &ctx.widget_rects, + ss.prev_focused, + ss.prev_hovered, + ss.prev_pressed, + ss.focused_idx, + ss.hovered_idx, + ss.gesture.pressed_idx, + pw, ph, + ) + }; + + ss.prev_focused = ss.focused_idx; + ss.prev_hovered = ss.hovered_idx; + ss.prev_pressed = ss.gesture.pressed_idx; + + ss.widget_rects = ctx.widget_rects; + ss.scroll_rects = ctx.scroll_rects; + ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases ); + ss.cursor_state = ctx.cursor_state; + ss.selection_anchor = ctx.selection_anchor; + ss.scroll_offsets = ctx.scroll_offsets; + ss.scroll_navigable_items = ctx.scroll_navigable_items; + ss.content_dirty = false; + + canvas.write_to_wayland_buf( canvas_buf, swap_rb ); + + let wl_surface = ss.surface.wl_surface(); + buffer.attach_to( wl_surface ).expect( "attach" ); + + if damage_rects.is_empty() + { + wl_surface.damage_buffer( 0, 0, pw as i32, ph as i32 ); + } else { + for r in &damage_rects + { + wl_surface.damage_buffer( r.x as i32, r.y as i32, r.width as i32, r.height as i32 ); + } + } + + apply_input_region( wl_surface, compositor, input_region ); + // Request a frame callback BEFORE commit so the compositor schedules it + // against this exact frame; sets `frame_pending` so the run loop won't + // try to draw this surface again until the callback fires. + request_frame( wl_surface ); + wl_surface.commit(); + ss.frame_pending = true; +} + +/// Partial redraw: install a clip mask covering only `dirty_rects`, repaint +/// the background + title bar + widget tree under that mask (so unchanged +/// pixels from the previous frame stay), and damage Wayland with exactly +/// those rects. +pub( crate ) fn draw_surface_partial( + ss: &mut SurfaceState, + compositor: &CompositorState, + view: &Element, + bg: Color, + input_region: Option<&[Rect]>, + shm_format: wl_shm::Format, + swap_rb: bool, + dirty_rects: Vec, + pw: u32, + ph: u32, + scale: u32, + request_frame: &dyn Fn( &WlSurface ), +) +{ + let Some( pool ) = ss.pool.as_mut() else { return }; + let stride = pw * 4; + + let ( buffer, canvas_buf ) = match pool.create_buffer( + pw as i32, ph as i32, stride as i32, shm_format, + ) + { + Ok( r ) => r, + Err( _ ) => return, + }; + + // Canvas pixels from the previous frame are still valid for the unclipped + // region and serve as our background — do not call clear()/fill() here. + let canvas = ss.canvas.as_mut().expect( "partial path requires existing canvas" ); + canvas.set_clip_rects( &dirty_rects ); + + let sf = scale as f32; + let tb_h = ss.titlebar_height * sf; + let screen_rect = Rect { x: 0.0, y: tb_h, width: pw as f32, height: (ph as f32 - tb_h).max( 0.0 ) }; + + // Repaint surface background under the clip so the dirty rects start from + // a known state instead of leaking the previous widget's pixels. + if bg.a > 0.0 { canvas.fill( bg ); } + else + { + canvas.clear_rects_transparent( &dirty_rects ); + } + + ss.titlebar_close_rect = draw_titlebar( canvas, &ss.titlebar_title, pw, tb_h, sf ); + + let mut ctx: DrawCtx = DrawCtx + { + focused_idx: ss.focused_idx, + hovered_idx: ss.hovered_idx, + pressed_idx: ss.gesture.pressed_idx, + cursor_state: std::mem::take( &mut ss.cursor_state ), + selection_anchor: std::mem::take( &mut ss.selection_anchor ), + widget_rects: Vec::new(), + debug_layout: false, + scroll_offsets: std::mem::take( &mut ss.scroll_offsets ), + scroll_rects: Vec::new(), + scroll_canvases: std::mem::take( &mut ss.scroll_canvases ), + scroll_navigable_items: std::mem::take( &mut ss.scroll_navigable_items ), + previous_widget_rects: ss.widget_rects.clone(), + }; + layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); + + if let Some( ref menu ) = ss.context_menu + { + super::draw_context_menu( canvas, menu ); + } + + // Fallback-theme warning. Painted last so it sits on top of every + // widget; no-op when the real theme is loaded. If the banner's row + // happens to fall outside the partial-redraw clip, the previous + // frame's banner is still on the canvas (pixmap preserved), which + // is the correct behaviour. + draw_fallback_banner( canvas, pw, sf ); + + canvas.clear_clip(); + + ss.prev_focused = ss.focused_idx; + ss.prev_hovered = ss.hovered_idx; + ss.prev_pressed = ss.gesture.pressed_idx; + + ss.widget_rects = ctx.widget_rects; + ss.scroll_rects = ctx.scroll_rects; + ss.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases ); + ss.cursor_state = ctx.cursor_state; + ss.selection_anchor = ctx.selection_anchor; + ss.scroll_offsets = ctx.scroll_offsets; + ss.scroll_navigable_items = ctx.scroll_navigable_items; + ss.content_dirty = false; + + canvas.write_to_wayland_buf( canvas_buf, swap_rb ); + + let wl_surface = ss.surface.wl_surface(); + buffer.attach_to( wl_surface ).expect( "attach" ); + + for r in &dirty_rects + { + wl_surface.damage_buffer( r.x as i32, r.y as i32, r.width as i32, r.height as i32 ); + } + + apply_input_region( wl_surface, compositor, input_region ); + request_frame( wl_surface ); + wl_surface.commit(); + ss.frame_pending = true; +} diff --git a/src/egl_context.rs b/src/egl_context.rs new file mode 100644 index 0000000..2495546 --- /dev/null +++ b/src/egl_context.rs @@ -0,0 +1,515 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! EGL bootstrap for the GPU rendering path. +//! +//! Initialises an `EGLDisplay` from the Wayland connection, picks an +//! `EGLConfig`, and creates an `EGLContext`. Tries GLES 3 first and falls +//! back to GLES 2 if the driver does not advertise it. On success returns an +//! [`EglContext`] holding a `glow::Context` already pointed at the resolved +//! GL functions; on failure returns `Err( reason )` so the caller can fall +//! back to the software `wl_shm` path. +//! +//! Per-surface, [`EglSurface`] wraps a `wl_egl_window` plus an `EGLSurface` +//! pinned to the wayland surface. Resizing the wayland surface must call +//! [`EglSurface::resize`] so the underlying buffer follows. +//! +//! The bootstrap honours `LTK_FORCE_SOFTWARE=1` by failing fast with a +//! descriptive error. Backend selection is announced exactly once per +//! process via `eprintln!( "[ltk] render backend: {GLES3|GLES2|SOFTWARE (...)}" )` +//! — the GPU branch logs from [`EglContext::new`], the SOFTWARE branch from +//! [`log_software_fallback`] (called by the integration site in `draw.rs`). + +use khronos_egl as egl; +use std::ffi::c_void; +use std::sync::{ Arc, Once, OnceLock }; + +use crate::gles_render::GlesVersion; +use smithay_client_toolkit::reexports::client:: +{ + Connection, + Proxy, + protocol::wl_surface::WlSurface, +}; + +type EglInstance = egl::DynamicInstance; + +/// `eglSwapBuffersWithDamageKHR` / `EXT` signature. Loaded at runtime via +/// `eglGetProcAddress` when the corresponding extension is advertised. +/// +/// Why we need this: Mesa's plain `eglSwapBuffers` emits +/// `wl_surface.damage(0, 0, INT32_MAX, INT32_MAX)` as a "damage everything" +/// sentinel. Some compositor renderers (sway/wlroots GLES backend at the +/// time of writing) consume that value literally, calling +/// `glTexSubImage2D(0, 0, INT32_MAX, INT32_MAX)` to upload the wl_buffer to +/// their internal cache texture. That fails with `GL_INVALID_VALUE` and the +/// compositor keeps showing the previously-cached frame (the first frame +/// goes through `glTexImage2D` which doesn't have this bug, so the *initial* +/// render is fine — but every subsequent eglSwapBuffers is silently dropped). +/// +/// Using `eglSwapBuffersWithDamage` makes Mesa emit +/// `wl_surface.damage_buffer(x, y, w, h)` with the actual rect we pass, +/// which the compositor then uploads correctly. +type SwapBuffersWithDamageFn = unsafe extern "system" fn( + display: egl::EGLDisplay, + surface: egl::EGLSurface, + rects: *const egl::Int, + n_rects: egl::Int, +) -> egl::Boolean; + +/// Process-wide EGL display + GLES context. Cheap to clone because the heavy +/// state (`Arc`, `Arc`) is reference-counted; the +/// raw EGL handles are POD. +pub struct EglContext +{ + pub egl: Arc, + pub display: egl::Display, + pub config: egl::Config, + pub context: egl::Context, + pub version: GlesVersion, + // Initialised lazily on the first `make_current`. Glow's constructor + // eagerly calls `glGetString( GL_VERSION )`, which requires a current + // context — and at `EglContext::new` time there is no surface yet. + gl: OnceLock>, + /// Resolved `eglSwapBuffersWithDamageKHR` / `EXT` pointer when either + /// extension is advertised, `None` otherwise. The draw path uses it in + /// place of `eglSwapBuffers` to avoid Mesa's `INT32_MAX` damage sentinel + /// (see [`SwapBuffersWithDamageFn`] docs). + swap_with_damage: Option, +} + +/// Per-Wayland-surface EGL window. `egl_window` owns the `wl_egl_window`; it +/// must outlive `surface` because EGL keeps a raw pointer into it. `egl` and +/// `display` are kept so that `Drop` can call `eglDestroySurface` without +/// requiring a `&EglContext` at the call site. +pub struct EglSurface +{ + pub egl_window: wayland_egl::WlEglSurface, + pub surface: egl::Surface, + egl: Arc, + display: egl::Display, +} + +/// Runtime-free EGL target for code that wants a GPU [`crate::render::Canvas`] +/// without going through `ltk::run`. +/// +/// This owns an EGL display, GLES context, and tiny pbuffer surface. The ltk +/// GLES canvas still renders into its own FBO; the pbuffer exists only to make +/// a valid EGL context current for GL calls. Compositors that already own a GL +/// context should normally create `Canvas::new_gles(...)` themselves and pass it +/// to `UiSurface::from_canvas` instead. +pub struct EglOffscreenContext +{ + egl: Arc, + display: egl::Display, + config: egl::Config, + context: egl::Context, + surface: egl::Surface, + version: GlesVersion, + gl: Arc, +} + +impl Drop for EglSurface +{ + fn drop( &mut self ) + { + // Best-effort: errors here can't be acted on, the surface is going away. + let _ = self.egl.destroy_surface( self.display, self.surface ); + } +} + +impl Drop for EglOffscreenContext +{ + fn drop( &mut self ) + { + let _ = self.egl.make_current( self.display, None, None, None ); + let _ = self.egl.destroy_surface( self.display, self.surface ); + let _ = self.egl.destroy_context( self.display, self.context ); + let _ = self.egl.terminate( self.display ); + } +} + +impl EglContext +{ + /// Initialise EGL on `conn`. Returns `Err( reason )` if EGL cannot be + /// used (forced software, library missing, no compatible config, ES2/3 + /// context creation failed). The caller falls back to SHM and is + /// expected to log via [`log_software_fallback`]. + pub fn new( conn: &Connection ) -> Result + { + if std::env::var( "LTK_FORCE_SOFTWARE" ).map( |v| v != "0" ).unwrap_or( false ) + { + return Err( "LTK_FORCE_SOFTWARE=1".to_string() ); + } + + // SAFETY: `EglInstance::load_required` performs a `dlopen` on the + // system `libEGL.so` and is unsafe because the loaded library has + // arbitrary side effects on global process state. We tolerate that: + // libEGL is an established system component and ltk has no + // alternative path to GPU rendering. + let egl: Arc = Arc::new( + unsafe { EglInstance::load_required() } + .map_err( |e| format!( "load libEGL: {e:?}" ) )?, + ); + + let wl_display_ptr = conn.backend().display_ptr() as *mut c_void; + // SAFETY: `wl_display_ptr` comes from `Connection::backend().display_ptr()`, + // which guarantees a valid `wl_display *` for as long as `conn` lives. + // `conn` is a `&Connection` argument so the pointer is valid for the + // duration of this call. EGL retains the display reference internally + // and we keep `egl` alive for the process lifetime. + let display = unsafe { egl.get_display( wl_display_ptr ) } + .ok_or_else( || "eglGetDisplay returned NULL".to_string() )?; + + egl.initialize( display ) + .map_err( |e| format!( "eglInitialize: {e:?}" ) )?; + + // Multiple client APIs can coexist on EGL 1.4; we want OpenGL ES. + egl.bind_api( egl::OPENGL_ES_API ) + .map_err( |e| format!( "eglBindAPI: {e:?}" ) )?; + + let config_attribs = [ + egl::SURFACE_TYPE, egl::WINDOW_BIT, + egl::RED_SIZE, 8, + egl::GREEN_SIZE, 8, + egl::BLUE_SIZE, 8, + egl::ALPHA_SIZE, 8, + egl::RENDERABLE_TYPE, egl::OPENGL_ES2_BIT, + egl::NONE, + ]; + let config = egl.choose_first_config( display, &config_attribs ) + .map_err( |e| format!( "eglChooseConfig: {e:?}" ) )? + .ok_or_else( || "no compatible EGL config".to_string() )?; + + // Try ES3 first, fall back to ES2. CONTEXT_CLIENT_VERSION applies to + // both ES2 and ES3 contexts when the value is the major version. + let ( context, version ) = match try_create_context( &egl, display, config, 3 ) + { + Ok( ctx ) => ( ctx, GlesVersion::V3 ), + Err( _ ) => match try_create_context( &egl, display, config, 2 ) + { + Ok( ctx ) => ( ctx, GlesVersion::V2 ), + Err( e ) => return Err( format!( "eglCreateContext (ES2/ES3): {e:?}" ) ), + }, + }; + + log_backend_once( match version + { + GlesVersion::V3 => "GLES3", + GlesVersion::V2 => "GLES2", + } ); + + // Try to resolve eglSwapBuffersWithDamage{KHR,EXT}. The KHR variant is + // preferred (newer, identical signature). Falling back to plain + // eglSwapBuffers when neither is available means we keep the latent + // INT32_MAX-damage bug, but at least we don't silently fail to start. + let extensions = egl.query_string( Some( display ), egl::EXTENSIONS ) + .ok() + .and_then( |s| s.to_str().ok() ) + .unwrap_or( "" ); + let proc_name = if extensions.contains( "EGL_KHR_swap_buffers_with_damage" ) + { + Some( "eglSwapBuffersWithDamageKHR" ) + } else if extensions.contains( "EGL_EXT_swap_buffers_with_damage" ) { + Some( "eglSwapBuffersWithDamageEXT" ) + } else { + None + }; + // SAFETY: `proc_name` is one of the literals + // `"eglSwapBuffersWithDamageKHR"` / `"eglSwapBuffersWithDamageEXT"`, + // gated on the matching extension being advertised by `eglQueryString`. + // When EGL returns a non-null pointer for that name, the symbol is + // guaranteed by the EGL extension spec to have the + // `SwapBuffersWithDamageFn` signature. The pointer is stored as + // `Option` and only invoked through that typed slot. + let swap_with_damage: Option = proc_name + .and_then( |n| egl.get_proc_address( n ) ) + .map( |p| unsafe { std::mem::transmute::<_, SwapBuffersWithDamageFn>( p ) } ); + + Ok( Self { egl, display, config, context, version, gl: OnceLock::new(), swap_with_damage } ) + } + + /// Access the lazily-constructed `glow::Context`. Must be called only after + /// the first successful `make_current`; panics otherwise. + pub fn gl( &self ) -> &Arc + { + self.gl.get().expect( "EglContext::gl() called before make_current" ) + } + + /// Create an `EGLSurface` pinned to `wl_surface` at the given pixel size. + pub fn create_surface( + &self, wl_surface: &WlSurface, width: i32, height: i32, + ) -> Result + { + let id = wl_surface.id(); + let egl_window = wayland_egl::WlEglSurface::new( id, width.max( 1 ), height.max( 1 ) ) + .map_err( |e| format!( "wl_egl_window::new: {e:?}" ) )?; + // SAFETY: `egl_window` is a freshly-built `WlEglSurface` whose `ptr()` + // returns the live `wl_egl_window *`. The returned `EglSurface` + // embeds `egl_window` so the pointer outlives the EGL surface (EGL + // retains its own internal reference). `display` and `config` are + // the values we used at context creation, valid for the lifetime + // of `self`. + let surface = unsafe + { + self.egl.create_window_surface( + self.display, + self.config, + egl_window.ptr() as egl::NativeWindowType, + None, + ) + }.map_err( |e| format!( "eglCreateWindowSurface: {e:?}" ) )?; + Ok( EglSurface + { + egl_window, + surface, + egl: Arc::clone( &self.egl ), + display: self.display, + } ) + } + + /// Make `surface` current for subsequent GL calls. The first successful + /// call lazily constructs the shared `glow::Context` (glow needs a live + /// current context to read `GL_VERSION` during initialisation). + pub fn make_current( &self, surface: &EglSurface ) -> Result<(), String> + { + self.egl.make_current( + self.display, + Some( surface.surface ), + Some( surface.surface ), + Some( self.context ), + ).map_err( |e| format!( "eglMakeCurrent: {e:?}" ) )?; + + if self.gl.get().is_none() + { + let egl_for_loader = Arc::clone( &self.egl ); + // SAFETY: `from_loader_function` is unsafe because it calls + // `glGetString( GL_VERSION )` during construction, which requires + // a current context — established by the `make_current` call + // immediately above. The closure resolves symbols through the + // retained `egl_for_loader` (refcounted clone) so the loader + // stays valid for the lifetime of the returned `glow::Context`. + let gl = Arc::new( unsafe + { + glow::Context::from_loader_function( move |name| + { + egl_for_loader.get_proc_address( name ) + .map( |p| p as *const _ ) + .unwrap_or( std::ptr::null() ) + } ) + } ); + let _ = self.gl.set( gl ); + } + Ok( () ) + } + + pub fn swap_buffers( &self, surface: &EglSurface ) -> Result<(), String> + { + self.egl.swap_buffers( self.display, surface.surface ) + .map_err( |e| format!( "eglSwapBuffers: {e:?}" ) ) + } + + /// Like [`Self::swap_buffers`] but submits explicit damage rects so Mesa + /// emits proper `wl_surface.damage_buffer` requests instead of its + /// `INT32_MAX`-everywhere sentinel. Falls back to plain `swap_buffers` + /// when the extension is unavailable — that path still works on + /// cooperative compositors but trips over the sentinel on others (see the + /// crate-private `SwapBuffersWithDamageFn` type alias above for the + /// underlying rationale). + /// + /// Each rect is `(x, y, width, height)` in **EGL window coordinates** + /// (origin at the bottom-left, in physical pixels). Callers that work in + /// top-left screen coords must flip Y before passing them in. For + /// full-surface damage, `(0, 0, w, h)` is correct in either convention. + pub fn swap_buffers_with_damage( + &self, surface: &EglSurface, rects: &[ ( i32, i32, i32, i32 ) ], + ) -> Result<(), String> + { + let Some( func ) = self.swap_with_damage else + { + return self.swap_buffers( surface ); + }; + // Pack rects into a contiguous i32 array as required by the extension. + let mut packed: Vec = Vec::with_capacity( rects.len() * 4 ); + for &( x, y, w, h ) in rects + { + packed.extend_from_slice( &[ x, y, w, h ] ); + } + // SAFETY: `func` was resolved in `EglContext::new` via the typed + // `Option` slot, so its signature is + // known. `display` / `surface.surface` belong to the live `&self` + // / `&surface` borrows. `packed.as_ptr()` is valid for `rects.len() + // * 4` `egl::Int` reads — we just built it with that exact length. + let ok = unsafe + { + func( + self.display.as_ptr(), + surface.surface.as_ptr(), + packed.as_ptr(), + rects.len() as egl::Int, + ) + }; + if ok == egl::TRUE + { + Ok( () ) + } else { + Err( "eglSwapBuffersWithDamage failed".to_string() ) + } + } +} + +impl EglSurface +{ + pub fn resize( &self, width: i32, height: i32 ) + { + self.egl_window.resize( width.max( 1 ), height.max( 1 ), 0, 0 ); + } +} + +impl EglOffscreenContext +{ + /// Create a runtime-free EGL context suitable for `Canvas::new_gles`. + pub fn new() -> Result + { + if std::env::var( "LTK_FORCE_SOFTWARE" ).map( |v| v != "0" ).unwrap_or( false ) + { + return Err( "LTK_FORCE_SOFTWARE=1".to_string() ); + } + + // SAFETY: same as `EglContext::new` — `dlopen` of the system + // `libEGL.so`. Same tradeoff and rationale apply. + let egl: Arc = Arc::new( + unsafe { EglInstance::load_required() } + .map_err( |e| format!( "load libEGL: {e:?}" ) )?, + ); + + let display = offscreen_display( &egl )?; + egl.initialize( display ) + .map_err( |e| format!( "eglInitialize: {e:?}" ) )?; + egl.bind_api( egl::OPENGL_ES_API ) + .map_err( |e| format!( "eglBindAPI: {e:?}" ) )?; + + let config_attribs = [ + egl::SURFACE_TYPE, egl::PBUFFER_BIT, + egl::RED_SIZE, 8, + egl::GREEN_SIZE, 8, + egl::BLUE_SIZE, 8, + egl::ALPHA_SIZE, 8, + egl::RENDERABLE_TYPE, egl::OPENGL_ES2_BIT, + egl::NONE, + ]; + let config = egl.choose_first_config( display, &config_attribs ) + .map_err( |e| format!( "eglChooseConfig: {e:?}" ) )? + .ok_or_else( || "no compatible offscreen EGL config".to_string() )?; + + let ( context, version ) = match try_create_context( &egl, display, config, 3 ) + { + Ok( ctx ) => ( ctx, GlesVersion::V3 ), + Err( _ ) => match try_create_context( &egl, display, config, 2 ) + { + Ok( ctx ) => ( ctx, GlesVersion::V2 ), + Err( e ) => return Err( format!( "eglCreateContext (ES2/ES3): {e:?}" ) ), + }, + }; + + let pbuffer_attribs = [ + egl::WIDTH, 1, + egl::HEIGHT, 1, + egl::NONE, + ]; + let surface = match egl.create_pbuffer_surface( display, config, &pbuffer_attribs ) + { + Ok( surface ) => surface, + Err( e ) => + { + let _ = egl.destroy_context( display, context ); + let _ = egl.terminate( display ); + return Err( format!( "eglCreatePbufferSurface: {e:?}" ) ); + }, + }; + + if let Err( e ) = egl.make_current( display, Some( surface ), Some( surface ), Some( context ) ) + { + let _ = egl.destroy_surface( display, surface ); + let _ = egl.destroy_context( display, context ); + let _ = egl.terminate( display ); + return Err( format!( "eglMakeCurrent: {e:?}" ) ); + } + + let egl_for_loader = Arc::clone( &egl ); + // SAFETY: as in `EglContext::make_current`. The `make_current` + // call above has already established the EGL context as current + // on this thread, so `glGetString( GL_VERSION )` (called inside + // `from_loader_function`) is well-defined. + let gl = Arc::new( unsafe + { + glow::Context::from_loader_function( move |name| + { + egl_for_loader.get_proc_address( name ) + .map( |p| p as *const _ ) + .unwrap_or( std::ptr::null() ) + } ) + } ); + + log_backend_once( match version + { + GlesVersion::V3 => "GLES3", + GlesVersion::V2 => "GLES2", + } ); + + Ok( Self { egl, display, config, context, surface, version, gl } ) + } + + /// Make this context current on the calling thread. + pub fn make_current( &self ) -> Result<(), String> + { + self.egl.make_current( + self.display, + Some( self.surface ), + Some( self.surface ), + Some( self.context ), + ).map_err( |e| format!( "eglMakeCurrent: {e:?}" ) ) + } + + pub fn gl( &self ) -> &Arc { &self.gl } + pub fn version( &self ) -> GlesVersion { self.version } + pub fn config( &self ) -> egl::Config { self.config } +} + +fn try_create_context( + egl: &EglInstance, display: egl::Display, config: egl::Config, major: i32, +) -> Result +{ + let attribs = [ + egl::CONTEXT_CLIENT_VERSION, major, + egl::NONE, + ]; + egl.create_context( display, config, None, &attribs ) +} + +fn offscreen_display( egl: &EglInstance ) -> Result +{ + // SAFETY: `DEFAULT_DISPLAY` is the EGL sentinel for "the default display + // for the current platform" and is always a valid argument to + // `eglGetDisplay` — the spec guarantees it returns either a valid + // display handle or `EGL_NO_DISPLAY`. No raw pointer is dereferenced + // in this crate. + unsafe { egl.get_display( egl::DEFAULT_DISPLAY ) } + .ok_or_else( || "eglGetDisplay(DEFAULT_DISPLAY) returned NULL".to_string() ) +} + +/// Log the SOFTWARE fallback once, with the reason. The GPU branch logs +/// from [`EglContext::new`]. +pub fn log_software_fallback( reason: &str ) +{ + log_backend_once( &format!( "SOFTWARE ({reason})" ) ); +} + +fn log_backend_once( label: &str ) +{ + static ONCE: Once = Once::new(); + ONCE.call_once( || + { + eprintln!( "[ltk] render backend: {label}" ); + } ); +} diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs new file mode 100644 index 0000000..50d3e55 --- /dev/null +++ b/src/event_loop/app_data.rs @@ -0,0 +1,2037 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use smithay_client_toolkit:: +{ + compositor::CompositorState, + output::OutputState, + registry::RegistryState, + seat::SeatState, + shell:: + { + WaylandSurface, + wlr_layer::{ KeyboardInteractivity, Layer, LayerShell, LayerSurface }, + xdg::{ XdgShell, popup::Popup, window::Window }, + }, + shm::{ Shm, slot::SlotPool }, +}; +use smithay_client_toolkit::reexports::client:: +{ + protocol:: + { + wl_keyboard::WlKeyboard, + wl_pointer::WlPointer, + wl_surface::WlSurface, + wl_touch::WlTouch, + }, + QueueHandle, +}; +use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge; +use wayland_protocols::wp::text_input::zv3::client:: +{ + zwp_text_input_manager_v3::ZwpTextInputManagerV3, + zwp_text_input_v3::{ self, ZwpTextInputV3 }, +}; +use std::collections::HashMap; +use std::sync::Arc; + +use smithay_client_toolkit::seat::keyboard::KeyEvent; +use calloop::RegistrationToken; + +use crate::app::{ App, OverlayId }; +use crate::egl_context::{ EglContext, EglSurface }; +use crate::render::Canvas; +use crate::input::GestureState; +use crate::tree::{ find_handlers, find_widget }; +use crate::widget::WidgetHandlers; + +/// Classify a pointer position against a surface of size `w × h` (in +/// physical pixels). Returns the resize edge if the pointer is inside +/// the `border`-thick grab band along any edge or corner. +pub( crate ) fn resize_edge_for_pos( pos: Point, w: f32, h: f32, border: f32 ) -> Option +{ + let on_left = pos.x < border; + let on_right = pos.x > w - border; + let on_top = pos.y < border; + let on_bottom = pos.y > h - border; + Some( match ( on_top, on_bottom, on_left, on_right ) + { + ( true, _, true, _ ) => ResizeEdge::TopLeft, + ( true, _, _, true ) => ResizeEdge::TopRight, + ( _, true, true, _ ) => ResizeEdge::BottomLeft, + ( _, true, _, true ) => ResizeEdge::BottomRight, + ( true, _, _, _ ) => ResizeEdge::Top, + ( _, true, _, _ ) => ResizeEdge::Bottom, + ( _, _, true, _ ) => ResizeEdge::Left, + ( _, _, _, true ) => ResizeEdge::Right, + _ => return None, + } ) +} + +/// Map an [`ResizeEdge`] to the [`CursorShape`](crate::types::CursorShape) +/// to display while hovering / dragging it. +pub( crate ) fn resize_edge_to_cursor( edge: ResizeEdge ) -> crate::types::CursorShape +{ + use crate::types::CursorShape::*; + match edge + { + ResizeEdge::Top => NResize, + ResizeEdge::Bottom => SResize, + ResizeEdge::Left => WResize, + ResizeEdge::Right => EResize, + ResizeEdge::TopLeft => NwResize, + ResizeEdge::TopRight => NeResize, + ResizeEdge::BottomLeft => SwResize, + ResizeEdge::BottomRight => SeResize, + _ => Default, + } +} +use crate::types::{ Point, Rect, WidgetId }; +use crate::widget::LaidOutWidget; + +/// Built-in context menu for text editing — Copy / Cut / Paste rows +/// drawn on top of the surface content. Shown on a right-click (or +/// long-press) in a [`crate::widget::text_edit::TextEdit`]; dismissed +/// by clicking outside or selecting an action. Lives on +/// [`SurfaceState`] so a press inside an overlay shows its own menu +/// in overlay-local coordinates. +#[ derive( Clone, Debug ) ] +pub( crate ) struct ContextMenu +{ + /// `widget_rects` index of the TextEdit the menu targets. Used by + /// the action dispatch so we operate on the right field even + /// after focus has moved. + pub widget_idx: usize, + /// Menu rect in surface-local physical pixels. + pub rect: Rect, + pub has_selection: bool, + pub can_paste: bool, + /// Byte offset within the target TextEdit's value that + /// corresponds to the original press point that opened the menu. + /// Paste uses this so the clipboard contents land exactly where + /// the user right-clicked / long-pressed, regardless of where + /// the cursor was beforehand. `None` only when the open path + /// could not snapshot the position (e.g. canvas missing). + pub paste_offset: Option, +} + +/// Number of rows in the built-in clipboard context menu — Copy / +/// Cut / Paste / Delete. Bumped from three when Delete was added so +/// the user has a parallel UI affordance to the Backspace / Delete +/// keys for clearing a selection without touching the clipboard. +pub( crate ) const CONTEXT_MENU_ROWS: usize = 4; + +impl ContextMenu +{ + /// Vertical band offsets inside [`Self::rect`] for each row. The + /// returned vector always has [`CONTEXT_MENU_ROWS`] entries; the + /// last element is the per-row height. Used by both hit-testing + /// and the renderer so the two stay in sync. + pub fn row_ys( &self ) -> ( Vec, f32 ) + { + let row_h = self.rect.height / CONTEXT_MENU_ROWS as f32; + let ys = ( 0..CONTEXT_MENU_ROWS ) + .map( |i| self.rect.y + i as f32 * row_h ) + .collect(); + ( ys, row_h ) + } + + /// Which row, if any, contains `pos`. Returns `0..CONTEXT_MENU_ROWS` + /// (Copy / Cut / Paste / Delete) or `None` if `pos` is outside the + /// menu rect. + pub fn row_at( &self, pos: Point ) -> Option + { + if !self.rect.contains( pos ) { return None; } + let row_h = self.rect.height / CONTEXT_MENU_ROWS as f32; + let i = ( ( pos.y - self.rect.y ) / row_h ).floor() as i32; + if ( 0..CONTEXT_MENU_ROWS as i32 ).contains( &i ) { Some( i as usize ) } else { None } + } +} + +/// Compute the byte range of the "word" surrounding `byte_offset` in +/// `value`. A word is a maximal run of alphanumeric / underscore +/// chars; whitespace and punctuation count as separators. When +/// `byte_offset` falls *between* two non-word chars, both bounds +/// collapse to that offset (zero-width selection — the caller is +/// expected to either accept that or fall back to a click-position +/// cursor). +fn word_bounds_at( value: &str, byte_offset: usize ) -> ( usize, usize ) +{ + if value.is_empty() { return ( 0, 0 ); } + let bo = byte_offset.min( value.len() ); + let is_word = | c: char | c.is_alphanumeric() || c == '_'; + + // Walk left from `bo` until we cross a non-word boundary. + let mut start = bo; + { + let mut iter = value[..bo].char_indices().rev(); + while let Some( ( i, c ) ) = iter.next() + { + if is_word( c ) { start = i; } else { break; } + } + } + + // Walk right from `bo` until the same. + let mut end = bo; + for ( i, c ) in value[bo..].char_indices() + { + if is_word( c ) { end = bo + i + c.len_utf8(); } else { break; } + } + + ( start, end ) +} + +/// Snapshot of the key whose repeat timer is currently armed. +/// +/// Stored on [`AppData::key_repeat`] between press and release of a +/// held-down key. The timer's calloop callback reads this back to +/// re-dispatch the same `KeyEvent` against the same focus, so a held +/// arrow key keeps moving the cursor in the originally-focused text +/// input even if the focus migrates mid-repeat (which it should not +/// — release cancels the timer, and focus changes only happen via +/// Tab / pointer events that arrive after `release_key` is processed). +pub( crate ) struct KeyRepeatState +{ + pub event: KeyEvent, + pub token: RegistrationToken, +} + +/// Snapshot of the button whose press-and-hold repeat timer is +/// currently armed. Mirrors [`KeyRepeatState`] for pointer / touch +/// presses on a [`crate::widget::button::Button`] built with +/// `.repeating( true )`. The timer's callback re-runs the press +/// against the *current* widget tree: it looks the pressed widget +/// up by `flat_idx` on the focus surface, reads its `on_press` +/// fresh, and pushes that into the pending queue. Re-resolving each +/// tick is what makes a stepper button work — the message a stepper +/// builds is `"go to value + step"` computed at view-build time, so +/// replaying the captured message would re-issue the same target +/// after the first fire and the user would see the value freeze +/// after one step. Reading the live `on_press` instead picks up the +/// new target the time picker just rebuilt with the updated value. +/// +/// Cancelled on release, pointer leave, gesture cancel, focus loss +/// and long-press promotion. Also self-cancels on the first tick +/// where the widget at `idx` no longer exists or no longer carries +/// an `on_press` message — view restructuring (e.g. tab switch) +/// invalidates the snapshot and we stop rather than firing the +/// wrong widget's message. +pub( crate ) struct ButtonRepeatState +{ + /// calloop registration handle so [`stop_button_repeat`] can + /// remove the timer from the event loop without leaking the + /// source. The surface focus and pressed `flat_idx` live in + /// the timer's closure capture instead of here — the only + /// caller that needs to act on them is the closure itself. + pub token: RegistrationToken, +} + +/// Identifies which surface an event, focus, or draw call belongs to. +/// +/// `Main` refers to the application's main surface (xdg window or layer +/// shell). `Overlay( id )` refers to an auxiliary layer-shell surface created +/// from an entry in [`crate::app::App::overlays`]. +#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )] +pub( crate ) enum SurfaceFocus +{ + Main, + #[allow( dead_code )] + Overlay( OverlayId ), +} + +/// Configuration for a layer-shell surface, used both for the main surface +/// (when the app uses [`crate::app::ShellMode::Layer`]) and for each overlay +/// returned by [`crate::app::App::overlays`]. +#[derive( Clone )] +pub( crate ) struct LayerConfig +{ + pub layer: Layer, + pub exclusive_zone: i32, + pub anchor: crate::app::Anchor, + pub size: ( u32, u32 ), + pub keyboard_exclusive: bool, + /// Wayland surface role namespace, sent to the compositor for debugging. + pub namespace: &'static str, +} + +pub( crate ) enum SurfaceKind +{ + /// Layer shell surface, fully created and committed. + Layer( LayerSurface ), + /// XDG window fallback (no layer shell compositor support). + Window( Window ), + /// Layer shell is available but we are waiting for an output to be + /// advertised before creating the Wayland surface. + Pending( LayerConfig ), + /// XDG popup, child of the main window. Used for combo dropdowns, + /// context menus, tooltips. The compositor positions it relative + /// to an anchor rect specified at creation time and may flip it + /// (drop-up vs drop-down) when constrained. + Popup( Popup ), +} + +impl SurfaceKind +{ + pub( crate ) fn wl_surface( &self ) -> &WlSurface + { + match self + { + SurfaceKind::Layer( l ) => l.wl_surface(), + SurfaceKind::Window( w ) => w.wl_surface(), + SurfaceKind::Popup( p ) => p.wl_surface(), + // Unreachable: draw_frame is only called when configured == true, + // which only becomes true after on_configure, which requires a real surface. + SurfaceKind::Pending( .. ) => unreachable!( "surface not yet created" ), + } + } + + /// Same as [`wl_surface`] but returns `None` for a [`SurfaceKind::Pending`] + /// surface instead of panicking. Used by event dispatch to look up which + /// [`SurfaceState`] an incoming Wayland event belongs to without risk of + /// crashing during startup. + pub( crate ) fn try_wl_surface( &self ) -> Option<&WlSurface> + { + match self + { + SurfaceKind::Layer( l ) => Some( l.wl_surface() ), + SurfaceKind::Window( w ) => Some( w.wl_surface() ), + SurfaceKind::Popup( p ) => Some( p.wl_surface() ), + SurfaceKind::Pending( .. ) => None, + } + } + + /// Create the actual layer surface on `output` using `layer_shell`. Returns + /// true if the surface was created (i.e., was `Pending` before). + pub( crate ) fn materialize( + &mut self, + compositor: &CompositorState, + layer_shell: &LayerShell, + qh: &QueueHandle>, + output: &smithay_client_toolkit::reexports::client::protocol::wl_output::WlOutput, + ) -> bool + { + if let SurfaceKind::Pending( ref cfg ) = *self + { + let surface = compositor.create_surface( qh ); + let layer_surface = layer_shell.create_layer_surface( + qh, + surface, + cfg.layer, + Some( cfg.namespace ), + Some( output ), + ); + layer_surface.set_exclusive_zone( cfg.exclusive_zone ); + layer_surface.set_anchor( cfg.anchor.to_wlr_anchor() ); + let interactivity = if cfg.keyboard_exclusive + { + KeyboardInteractivity::Exclusive + } else { + KeyboardInteractivity::OnDemand + }; + layer_surface.set_keyboard_interactivity( interactivity ); + layer_surface.set_size( cfg.size.0, cfg.size.1 ); + layer_surface.commit(); + *self = SurfaceKind::Layer( layer_surface ); + true + } else { + false + } + } +} + +/// Per-surface render + interaction state. +/// +/// Each Wayland surface managed by the event loop (main surface and any +/// active overlays) owns an instance of this struct, so multiple surfaces +/// can coexist without interfering. +pub( crate ) struct SurfaceState +{ + pub pool: Option, + /// EGL window surface for the GPU path. Populated on the first configure + /// when an [`EglContext`] is available; mutually exclusive with `pool` + /// (the path is locked after the first configure). + pub egl_surface: Option, + pub surface: SurfaceKind, + pub canvas: Option, + pub width: u32, + pub height: u32, + pub configured: bool, + pub needs_redraw: bool, + /// Set to `true` when the app's view may have changed in a way that rects + /// don't capture (text content, progress value, slider thumb position, etc). + /// Interaction-only transitions (hover in/out, pressed in/out) leave this + /// `false` so the draw path can take the partial-redraw fast path. + /// Always reset to `false` at the end of a successful draw. + pub content_dirty: bool, + pub last_draw: std::time::Instant, + pub focused_idx: Option, + pub focused_id: Option, + pub hovered_idx: Option, + pub widget_rects: Vec>, + pub cursor_state: HashMap, + /// Selection anchor (byte offset). When equal to the cursor, there + /// is no active selection. Mutated jointly with `cursor_state`: + /// plain arrow keys collapse the selection by setting anchor = + /// cursor; Shift+arrow extends by leaving anchor put while moving + /// cursor; pointer drags set both ends. + pub selection_anchor: HashMap, + pub pending_text_values: HashMap, + pub scroll_offsets: HashMap, + pub scroll_rects: Vec<( Rect, usize )>, + pub scroll_canvases: HashMap, + /// Per-scroll list of `(flat_idx, content_y, height)` entries for + /// every interactive item the scroll's child laid out, in document + /// order. Includes items currently scrolled off-screen so keyboard + /// arrow handlers can step `hovered_idx` item-by-item without + /// regenerating the layout pass. Y is in pre-translation, + /// pre-offset content coordinates. + pub scroll_navigable_items: HashMap>, + /// Built-in Copy / Cut / Paste context menu when shown on this + /// surface. `None` means no menu is up. + pub context_menu: Option, + /// Per-surface gesture state machine. Owns the press → + /// (long-press? / drag? / scroll? / swipe?) → release lifecycle, + /// driven by both pointer and touch handlers. The input layer + /// (`src/input/`) calls the lifecycle methods; the long-press + /// deadline poller below ([`AppData::check_long_press_deadlines`]) + /// reaches into `gesture.long_press_*` directly. + pub gesture: GestureState, + /// Previous frame interaction state for damage tracking + pub prev_focused: Option, + pub prev_hovered: Option, + pub prev_pressed: Option, + /// Height of the client-side title bar (0 for layer-shell surfaces). + pub titlebar_height: f32, + /// Title text shown in the client-side title bar. + pub titlebar_title: String, + /// Rect of the close button in the title bar (for hit testing). + pub titlebar_close_rect: Rect, + /// Compositor scale factor (integer, e.g. 1 or 2). Used to create + /// high-DPI buffers and set `dpi_scale` on the canvas. + pub scale_factor: i32, + /// Last size requested from the compositor via `layer_surface.set_size`. + /// Compared against the app's current [`crate::app::OverlaySpec::size`] + /// each reconcile pass so that overlays can resize after creation (used + /// for slide-down / grow animations). `(0, 0)` for non-layer surfaces + /// and for layer surfaces before their first configure. + pub last_requested_size: ( u32, u32 ), + /// Anchor rect the xdg-popup positioner was last configured with. + /// `None` for non-popup surfaces. + pub last_popup_anchor: Option, + /// Monotonic counter for `xdg_popup.reposition` tokens. + pub popup_reposition_token: u32, + /// `true` after the surface has been committed and we are waiting for the + /// matching `wl_surface.frame` callback to fire. Drawing is gated on this + /// being `false`: even if `needs_redraw` flips back to `true` we sit on + /// the next frame until the compositor signals it's time to commit again, + /// so the per-surface pacing follows the compositor (and the display + /// refresh / VRR / "screen is off") instead of a fixed-period timer. + pub frame_pending: bool, +} + +impl SurfaceState +{ + pub( crate ) fn new( surface: SurfaceKind, titlebar_height: f32, titlebar_title: String ) -> Self + { + Self { + pool: None, + egl_surface: None, + surface, + canvas: None, + width: 0, + height: 0, + configured: false, + needs_redraw: false, + content_dirty: true, + last_draw: std::time::Instant::now() - std::time::Duration::from_millis( 100 ), + focused_idx: None, + focused_id: None, + hovered_idx: None, + widget_rects: Vec::new(), + cursor_state: HashMap::new(), + selection_anchor: HashMap::new(), + pending_text_values: HashMap::new(), + scroll_offsets: HashMap::new(), + scroll_rects: Vec::new(), + scroll_navigable_items: HashMap::new(), + context_menu: None, + scroll_canvases: HashMap::new(), + gesture: GestureState::new(), + prev_focused: None, + prev_hovered: None, + prev_pressed: None, + titlebar_height, + titlebar_title, + titlebar_close_rect: Rect::default(), + scale_factor: 1, + last_requested_size: ( 0, 0 ), + last_popup_anchor: None, + popup_reposition_token: 0, + frame_pending: false, + } + } + + /// Convert a logical (surface-local) position to physical pixel coordinates. + pub( crate ) fn to_physical( &self, x: f64, y: f64 ) -> Point + { + let sf = self.scale_factor.max( 1 ) as f32; + Point { x: x as f32 * sf, y: y as f32 * sf } + } + + /// Surface width in physical pixels (logical × buffer scale). Matches the + /// coordinate space used by pointer/touch callbacks and layout, so swipe + /// thresholds can be compared against physical `dx` / `start.x` without + /// unit mixing. + pub( crate ) fn physical_width( &self ) -> u32 + { + self.width * self.scale_factor.max( 1 ) as u32 + } + + /// Surface height in physical pixels. See [`Self::physical_width`]. + pub( crate ) fn physical_height( &self ) -> u32 + { + self.height * self.scale_factor.max( 1 ) as u32 + } + + /// Configure the surface with a new logical size. Picks the rendering path + /// on the very first configure (GPU when `egl_context` is `Some` and the + /// EGL window can be created, SHM otherwise) and just resizes the existing + /// path on subsequent configures. The two paths are mutually exclusive + /// (`pool.is_some()` ⇔ SHM, `egl_surface.is_some()` ⇔ GPU) and locked + /// after the first configure — switching backends mid-life would require + /// tearing down the canvas as well. + pub( crate ) fn on_configure( + &mut self, + shm: &Shm, + egl_context: Option<&Arc>, + w: u32, + h: u32, + ) + { + self.width = w; + self.height = h; + let scale = self.scale_factor.max( 1 ) as u32; + let pw = w * scale; + let ph = h * scale; + + if self.egl_surface.is_some() + { + // GPU path: resize the wl_egl_window so the next eglSwapBuffers + // publishes a buffer of the new size. The canvas FBO is reallocated + // by `Canvas::resize`, but only after we eglMakeCurrent — the draw + // path will do that, so we just record the pending size here. + if let Some( ref es ) = self.egl_surface + { + es.resize( pw as i32, ph as i32 ); + } + } else if self.pool.is_some() { + // SHM path: reallocate the pool to fit the new buffer size. + self.pool = Some( + SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ), + ); + } else { + // First configure — pick the path. Try GPU when the bootstrap + // succeeded; fall back to SHM if surface creation fails. + let mut chose_gpu = false; + if let Some( ctx ) = egl_context + { + match ctx.create_surface( self.surface.wl_surface(), pw as i32, ph as i32 ) + { + Ok( es ) => + { + self.egl_surface = Some( es ); + chose_gpu = true; + } + Err( e ) => + { + eprintln!( "[ltk] eglCreateWindowSurface failed: {e} — using SHM" ); + } + } + } + if !chose_gpu + { + self.pool = Some( + SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ), + ); + } + } + + self.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) ); + self.configured = true; + self.needs_redraw = true; + self.content_dirty = true; + } + + /// Request a redraw and mark the surface's content as dirty. + /// + /// Use this from every site that potentially changes what the app's view + /// returns (text edits, scroll offset, slider value, app messages, animation + /// tick, configure). Pure hover/pressed transitions should leave + /// `content_dirty` alone and only set `needs_redraw` directly so the draw + /// pass can take the cheap partial-redraw path. + pub( crate ) fn request_redraw( &mut self ) + { + self.needs_redraw = true; + self.content_dirty = true; + } +} + +pub struct AppData +{ + pub app: A, + pub registry_state: RegistryState, + pub seat_state: SeatState, + pub output_state: OutputState, + pub compositor_state: CompositorState, + pub shm: Shm, + /// Process-wide EGL context (display + GLES context). `None` when EGL + /// failed to initialise or `LTK_FORCE_SOFTWARE=1` — every surface then + /// falls back to the SHM path. + pub egl_context: Option>, + #[allow(dead_code)] + pub xdg_shell: Option, + /// Shared layer-shell binding used for the main surface and every + /// overlay. `None` when the compositor does not advertise the protocol. + pub layer_shell: Option, + pub keyboard: Option, + pub pointer: Option, + pub touch: Option, + pub pointer_pos: Point, + /// Process-wide cursor-shape manager (`wp_cursor_shape_v1`). + /// `None` when the compositor does not advertise the protocol — + /// the runtime then leaves cursor shape to the compositor's + /// defaults. + pub cursor_shape_manager: Option, + /// Per-pointer cursor-shape device, populated when the compositor + /// supports cursor-shape AND the seat has a pointer capability. + pub cursor_shape_device: Option, + /// Last `Enter` serial seen on a pointer event. Required by + /// `wp_cursor_shape_device_v1::set_shape` per spec — the + /// compositor tags every cursor change with the entry serial. + pub last_pointer_enter_serial: u32, + /// The cursor shape currently active on the pointer. `None` + /// means "we have not pushed any shape since the last + /// `wl_pointer.enter`" — the compositor is showing whatever the + /// previous client requested, so the next `dispatch_cursor_shape` + /// must send unconditionally to claim the cursor for our + /// surface. `Some(s)` lets the dispatch short-circuit when the + /// target equals what we already sent. + pub current_cursor_shape: Option, + pub text_input_manager: Option, + pub text_input: Option, + pub shift_pressed: bool, + pub ctrl_pressed: bool, + /// Calloop handle for inserting timers / channels. Used by the + /// key-repeat machinery and any future feature that needs to + /// schedule work on the run loop without going through the message + /// queue. + pub loop_handle: calloop::LoopHandle<'static, Self>, + /// Repeat rate (events per second) advertised by the compositor in + /// `wl_keyboard.repeat_info`. `0` means "the compositor disabled + /// repeat" — we honour that and never start a repeat timer. + pub compositor_repeat_rate: u32, + /// Initial delay (ms) before the first repeat fires, advertised by + /// the compositor. `0` means the compositor disabled repeat. + pub compositor_repeat_delay: u32, + /// Currently-active key repeat. `Some` between the press of a + /// repeating key and either its release, the keyboard losing focus + /// or another key being pressed. + pub key_repeat: Option, + /// Currently-active button-press repeat. `Some` between the + /// press of a repeating-button and either its release, the + /// pointer leaving the surface, a gesture cancel, focus loss or + /// long-press promotion. + pub button_repeat: Option, + /// Process-local clipboard buffer. Copy / Cut store the selected + /// text here; Paste retrieves from here. Cross-application + /// clipboard via `wl_data_device_manager` is intentionally not + /// wired up — most app workflows need a same-process buffer first + /// (move text between fields, undo a delete by paste-back) and + /// the Wayland integration is a sizeable separate piece. + pub clipboard: String, + /// Timestamp of the previous press (pointer or touch). Combined + /// with [`Self::last_press_pos`] it lets the press handler + /// detect a double-click on a TextEdit and turn it into a + /// word-select. + pub last_press_time: Option, + /// Position of the previous press. See [`Self::last_press_time`]. + pub last_press_pos: Option, + pub debug_layout: bool, + pub pending_msgs: Vec, + /// Initial cursor positions queued when a long-press fires, to be flushed + /// to [`App::on_drag_move`] right after the paired long-press message is + /// processed. This gives the drag ghost a valid starting position even + /// before the user's finger / cursor moves — useful on mouse where the + /// pointer might sit perfectly still between press and drag. + pub pending_drag_inits: Vec, + pub qh: QueueHandle, + /// Last pointer serial (needed for interactive move). + pub last_pointer_serial: u32, + /// Last serial from any input event. Required by `xdg_popup.grab`, + /// which only honours serials from recent input events on the same + /// seat. + pub last_input_serial: u32, + /// Set to true when the app has accepted a close request. + pub exit_requested: bool, + /// First-configure latch for `App::start_fullscreen`. + pub pending_fullscreen: bool, + /// Main application surface. + pub main: SurfaceState, + /// Auxiliary layer-shell surfaces keyed by their stable [`OverlayId`]. + /// Populated and reconciled by the run loop from [`App::overlays`]. Always + /// empty until overlay diffing is wired up. + pub overlays: HashMap>, + /// Surface currently receiving pointer events (updated on each pointer + /// event via its `surface` field). + pub pointer_focus: SurfaceFocus, + /// Surface currently holding keyboard focus (updated on keyboard + /// enter/leave). + pub keyboard_focus: SurfaceFocus, + /// Per-touch-id tracking of which surface each active touch belongs to + /// (populated on `down`, cleared on `up`/`cancel`). + pub touch_focus: HashMap, + /// Cached widget tree returned by [`App::view`]. Populated lazily by the + /// run loop just before drawing when `view_dirty` is set, then re-used by + /// every subsequent partial / interaction-only redraw until invalidated. + /// Dropping the rebuild on idle frames is a big win for shells that sit + /// idle most of the time. + pub cached_view: Option>, + /// Cached overlay-spec list returned by [`App::overlays`]. Same lifecycle + /// as `cached_view` but driven by `overlays_dirty`. + pub cached_overlays: Option>>, + /// `true` when `cached_view` is stale and must be rebuilt before the next + /// draw. Set on startup, by every `InvalidationScope` touching `Main`, and + /// every frame `App::is_animating` returns `true`. + pub view_dirty: bool, + /// Counterpart of `view_dirty` for `cached_overlays`. + pub overlays_dirty: bool, +} + +impl AppData +{ + /// Find which [`SurfaceFocus`] owns the given `WlSurface`, or `None` if it + /// does not correspond to any tracked surface. Safe even when the main + /// surface is still `Pending` (returns `None`). + pub( crate ) fn focus_for_surface( + &self, + wl: &WlSurface, + ) -> Option + { + if self.main.surface.try_wl_surface() == Some( wl ) + { + return Some( SurfaceFocus::Main ); + } + for ( id, ss ) in self.overlays.iter() + { + if ss.surface.try_wl_surface() == Some( wl ) + { + return Some( SurfaceFocus::Overlay( *id ) ); + } + } + None + } + + /// Borrow the [`SurfaceState`] identified by `focus`. Panics if `focus` + /// refers to an overlay that is not currently registered — callers must + /// only pass focus values obtained from [`focus_for_surface`] or from the + /// per-device focus fields, which the run loop keeps in sync. + #[allow( dead_code )] + pub( crate ) fn surface( &self, focus: SurfaceFocus ) -> &SurfaceState + { + match focus + { + SurfaceFocus::Main => &self.main, + SurfaceFocus::Overlay( id ) => self.overlays.get( &id ) + .expect( "surface(): overlay not registered" ), + } + } + + /// Mutable counterpart of [`surface`]. + #[allow( dead_code )] + pub( crate ) fn surface_mut( &mut self, focus: SurfaceFocus ) -> &mut SurfaceState + { + match focus + { + SurfaceFocus::Main => &mut self.main, + SurfaceFocus::Overlay( id ) => self.overlays.get_mut( &id ) + .expect( "surface_mut(): overlay not registered" ), + } + } + + /// Push `on_dismiss` for every active xdg-popup overlay whose + /// anchor widget was not hit by a press at `pos` (logical pixels + /// in main-surface space). Used to compensate for compositors + /// (notably Mutter) that route pointer button events to the main + /// surface while a popup grab is technically active, instead of + /// breaking the grab. The check skips presses on the trigger pill + /// itself so the trigger's own `toggle` message keeps owning that + /// transition. + pub( crate ) fn dismiss_main_outside_popups( &mut self, pos: Point ) + { + if self.overlays.is_empty() { return; } + let specs = self.app.overlays(); + for spec in specs + { + // Only xdg-popups need the main-side fallback: they grab + // the seat and rely on the compositor to break the grab on + // outside input, which Mutter does not do reliably. Layer- + // shell overlays own their own `input_region` and dispatch + // dismissal from the overlay tap path, so firing it again + // from a press on the main surface produces a duplicate + // dismiss that races other intents (e.g. forge's topbar + // taps that route a separate IPC into the same overlay). + let Some( anchor_id ) = spec.anchor_widget_id else { continue; }; + let anchor_rect = self.main.widget_rects.iter() + .find( | w | w.id == Some( anchor_id ) ) + .map( | w | w.rect ); + let on_anchor = anchor_rect + .map( | r | pos.x >= r.x && pos.x < r.x + r.width + && pos.y >= r.y && pos.y < r.y + r.height ) + .unwrap_or( false ); + if !on_anchor + { + if let Some( msg ) = spec.on_dismiss + { + self.pending_msgs.push( msg ); + } + } + } + } + + /// Push `on_dismiss` for every active xdg-popup overlay + /// unconditionally (used by keyboard Escape handling — there is + /// no spatial test to do). + pub( crate ) fn dismiss_all_popups( &mut self ) + { + if self.overlays.is_empty() { return; } + let specs = self.app.overlays(); + for spec in specs + { + if spec.anchor_widget_id.is_some() + { + if let Some( msg ) = spec.on_dismiss + { + self.pending_msgs.push( msg ); + } + } + } + } + + /// Look up the dismiss message for an overlay (tap on empty area). + /// Returns `None` if the overlay has no `on_dismiss` set or was removed. + pub( crate ) fn overlay_dismiss_msg( &self, id: OverlayId ) -> Option + { + self.app.overlays().into_iter() + .find( |s| s.id == id ) + .and_then( |s| s.on_dismiss ) + } + + pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option, qh: &QueueHandle ) + { + let was_text_input; + let is_text_input; + { + let ss = self.surface_mut( focus ); + + is_text_input = idx + .and_then( |i| find_handlers( &ss.widget_rects, i ) ) + .map( |h| h.is_text_input() ) + .unwrap_or( false ); + was_text_input = ss.focused_idx + .and_then( |i| find_handlers( &ss.widget_rects, i ) ) + .map( |h| h.is_text_input() ) + .unwrap_or( false ); + + // Clear pending text value when losing focus + if let Some( prev_idx ) = ss.focused_idx + { + if idx != Some( prev_idx ) + { + ss.pending_text_values.remove( &prev_idx ); + } + } + + ss.focused_idx = idx; + ss.focused_id = idx.and_then( |i| + { + ss.widget_rects.iter() + .find( |w| w.flat_idx == i ) + .and_then( |w| w.id ) + } ); + ss.request_redraw(); + + // Sync cursor to end of current value when focusing a text + // input. Default behaviour collapses the selection to the + // cursor — focus changes always discard any prior + // selection state. Fields built with `.select_on_focus( + // true )` instead anchor the selection at `0` so the + // whole value is highlighted, ready to be replaced by the + // next keystroke (typical for numeric pickers). + if is_text_input + { + if let Some( i ) = idx + { + let handler = find_handlers( &ss.widget_rects, i ); + let cursor = handler + .and_then( |h| h.current_value() ) + .map( |v| v.len() ) + .unwrap_or( 0 ); + let anchor = match handler + { + Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ) => 0, + _ => cursor, + }; + ss.cursor_state.insert( i, cursor ); + ss.selection_anchor.insert( i, anchor ); + } + } + } + + if was_text_input && !is_text_input + { + self.deactivate_text_input(); + self.app.on_text_input_focused( false ); + self.dirty_caches(); + } + if is_text_input && !was_text_input + { + self.activate_text_input( qh ); + self.app.on_text_input_focused( true ); + self.dirty_caches(); + } + } + + pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle ) + { + if let ( Some( manager ), None ) = ( &self.text_input_manager, &self.text_input ) + { + let seats: Vec<_> = self.seat_state.seats().collect(); + if let Some( seat ) = seats.into_iter().next() + { + let ti = manager.get_text_input( &seat, qh, () ); + ti.enable(); + ti.set_content_type( + zwp_text_input_v3::ContentHint::None, + zwp_text_input_v3::ContentPurpose::Normal, + ); + ti.commit(); + self.text_input = Some( ti ); + } + } + } + + pub( crate ) fn deactivate_text_input( &mut self ) + { + if let Some( ti ) = self.text_input.take() + { + ti.disable(); + ti.commit(); + ti.destroy(); + } + } + + pub( crate ) fn handle_text_insert( &mut self, focus: SurfaceFocus, text: &str ) + { + let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return }; + + // If a selection is active, replace it. The deletion path + // folds anchor=cursor and updates `pending_text_values`, so + // the rest of this fn keeps the same shape after. + let _ = self.delete_selection( focus ); + + let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx ) + { + pending.clone() + } else { + find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.current_value() ) + .map( |s| s.to_string() ) + .unwrap_or_default() + }; + + // `select_on_focus` fields keep the cursor pinned to the end + // of the *displayed* value. The pending value the user just + // typed (e.g. "2") may render shorter than the post-update + // display value (e.g. "02" after a `format!("{:02}")` + // normalisation in the app's on_change handler), so anchoring + // the cursor at "end of pending" would land it mid-string in + // the next render. `usize::MAX` is a sentinel: every consumer + // (`draw`, arrow-key handlers, `byte_offset_at`) clamps via + // `cursor.min( value.len() )`, which resolves to "end of + // whatever value we eventually render". Without this, typing + // a second digit would insert into the middle of the + // normalised string ("0|2" + "3" → "032" → 32 instead of 23). + let select_on_focus = matches!( + find_handlers( &self.surface( focus ).widget_rects, idx ), + Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), + ); + + let new_value; + { + let ss = self.surface_mut( focus ); + let cursor = ss.cursor_state.entry( idx ).or_insert( current_value.len() ); + let safe_cursor = (*cursor).min( current_value.len() ); + let mut v = current_value.clone(); + v.insert_str( safe_cursor, text ); + let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor + text.len() }; + *ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor; + *ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor; + ss.pending_text_values.insert( idx, v.clone() ); + ss.request_redraw(); + new_value = v; + } + + let msg = find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.text_change_msg( &new_value ) ); + if let Some( m ) = msg + { + self.pending_msgs.push( m ); + } + } + + /// Forward delete — the `Delete` (Supr) key. Mirrors + /// [`Self::handle_backspace`] but removes the character *after* + /// the cursor instead of before. Selection-aware: if a range is + /// active it is removed in one step, exactly like backspace. + pub( crate ) fn handle_delete_forward( &mut self, focus: SurfaceFocus ) + { + let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return }; + + if let Some( new_value ) = self.delete_selection( focus ) + { + let msg = find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.text_change_msg( &new_value ) ); + if let Some( m ) = msg { self.pending_msgs.push( m ); } + return; + } + + let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx ) + { + pending.clone() + } else { + find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.current_value() ) + .map( |s| s.to_string() ) + .unwrap_or_default() + }; + + let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied() + .unwrap_or( current_value.len() ); + let safe_cursor_pre = cursor_val.min( current_value.len() ); + if safe_cursor_pre >= current_value.len() { return; } + // Width of the char *starting* at the cursor — UTF-8 aware so + // `é` / `🦀` come out as one keypress. + let next_char = current_value[safe_cursor_pre..].chars().next(); + let step = match next_char { Some( c ) => c.len_utf8(), None => return }; + let mut new_value = current_value.clone(); + new_value.replace_range( safe_cursor_pre..safe_cursor_pre + step, "" ); + // `select_on_focus` fields keep the cursor at the end of the + // post-update displayed value via the `usize::MAX` sentinel — + // see `handle_text_insert` for the reasoning. + let select_on_focus = matches!( + find_handlers( &self.surface( focus ).widget_rects, idx ), + Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), + ); + let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor_pre }; + { + let ss = self.surface_mut( focus ); + // Cursor stays put — the char to its right is gone, so the + // remaining tail shifts left under it. + *ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor; + *ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor; + ss.pending_text_values.insert( idx, new_value.clone() ); + ss.request_redraw(); + } + + let msg = find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.text_change_msg( &new_value ) ); + if let Some( m ) = msg + { + self.pending_msgs.push( m ); + } + } + + pub( crate ) fn handle_backspace( &mut self, focus: SurfaceFocus ) + { + let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return }; + + // Selection-aware: if a selection is active, backspace just + // deletes the range. The on_change message is emitted from + // here so the app sees a single text update. + if let Some( new_value ) = self.delete_selection( focus ) + { + let msg = find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.text_change_msg( &new_value ) ); + if let Some( m ) = msg { self.pending_msgs.push( m ); } + return; + } + + let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx ) + { + pending.clone() + } else { + find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.current_value() ) + .map( |s| s.to_string() ) + .unwrap_or_default() + }; + + // Scoped block to release the immutable borrow of `self` (via + // `self.surface( focus )`) before taking a mutable one below. + let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied() + .unwrap_or( current_value.len() ); + if cursor_val == 0 { return; } + let safe_cursor = cursor_val.min( current_value.len() ); + let chars: Vec = current_value[..safe_cursor].chars().collect(); + if chars.is_empty() { return; } + + let removed_char = *chars.last().unwrap(); + let new_cursor = safe_cursor - removed_char.len_utf8(); + let mut new_value = current_value.clone(); + new_value.remove( new_cursor ); + // `select_on_focus` fields keep the cursor at the end of the + // post-update displayed value via the `usize::MAX` sentinel — + // see `handle_text_insert` for the reasoning. + let select_on_focus = matches!( + find_handlers( &self.surface( focus ).widget_rects, idx ), + Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), + ); + let next_cursor = if select_on_focus { usize::MAX } else { new_cursor }; + { + let ss = self.surface_mut( focus ); + *ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor; + *ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor; + ss.pending_text_values.insert( idx, new_value.clone() ); + ss.request_redraw(); + } + + let msg = find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.text_change_msg( &new_value ) ); + if let Some( m ) = msg + { + self.pending_msgs.push( m ); + } + } + + /// Read the current text value of the focused widget — preferring + /// the pending typed-but-not-yet-applied value when present, falling + /// back to the value snapshot from the per-frame handler list. + fn focused_text_value( &self, focus: SurfaceFocus ) -> Option<( usize, String )> + { + let idx = self.surface( focus ).focused_idx?; + let value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx ) + { + pending.clone() + } else { + find_handlers( &self.surface( focus ).widget_rects, idx )? + .current_value() + .map( |s| s.to_string() ) + .unwrap_or_default() + }; + Some( ( idx, value ) ) + } + + /// Move the text cursor one codepoint to the left. With `extend`, + /// the selection anchor stays put (Shift+Left widens the + /// selection); without it, the selection is collapsed to the new + /// cursor position. No-op when no text input is focused. + pub( crate ) fn handle_cursor_left( &mut self, focus: SurfaceFocus, extend: bool ) + { + let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return }; + let cursor = self.surface( focus ).cursor_state.get( &idx ).copied() + .unwrap_or( value.len() ).min( value.len() ); + let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied() + .unwrap_or( cursor ).min( value.len() ); + // Without shift: if a selection exists, collapse to its start. + if !extend && anchor != cursor + { + let s = cursor.min( anchor ); + let ss = self.surface_mut( focus ); + *ss.cursor_state.entry( idx ).or_insert( 0 ) = s; + *ss.selection_anchor.entry( idx ).or_insert( 0 ) = s; + ss.request_redraw(); + return; + } + if cursor == 0 { return; } + let prev_char = value[..cursor].chars().last(); + let step = prev_char.map( |c| c.len_utf8() ).unwrap_or( 1 ); + let new_cursor = cursor.saturating_sub( step ); + let ss = self.surface_mut( focus ); + *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + ss.request_redraw(); + } + + /// Move the text cursor one codepoint to the right. + pub( crate ) fn handle_cursor_right( &mut self, focus: SurfaceFocus, extend: bool ) + { + let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return }; + let cursor = self.surface( focus ).cursor_state.get( &idx ).copied() + .unwrap_or( value.len() ).min( value.len() ); + let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied() + .unwrap_or( cursor ).min( value.len() ); + if !extend && anchor != cursor + { + let e = cursor.max( anchor ); + let ss = self.surface_mut( focus ); + *ss.cursor_state.entry( idx ).or_insert( 0 ) = e; + *ss.selection_anchor.entry( idx ).or_insert( 0 ) = e; + ss.request_redraw(); + return; + } + if cursor >= value.len() { return; } + let next_char = value[cursor..].chars().next(); + let step = next_char.map( |c| c.len_utf8() ).unwrap_or( 1 ); + let new_cursor = ( cursor + step ).min( value.len() ); + let ss = self.surface_mut( focus ); + *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + ss.request_redraw(); + } + + /// Move the text cursor up one *visual* row. With `extend`, the + /// selection anchor stays put. + /// + /// In multiline mode the move follows the soft-wrapped layout — a + /// long logical line that wraps onto N visual rows lets Up step + /// through each of those rows in turn, instead of jumping straight + /// over them to the previous hard `\n`. Single-line inputs and + /// secure inputs both render as one visual row, so Up always + /// returns `false` and the runtime falls through to sibling-widget + /// keyboard navigation. + pub( crate ) fn handle_cursor_up( &mut self, focus: SurfaceFocus, extend: bool ) -> bool + { + let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false }; + let cursor = self.surface( focus ).cursor_state.get( &idx ).copied() + .unwrap_or( value.len() ).min( value.len() ); + let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx ) + { + Some( g ) => g, + None => return false, + }; + if !multiline || secure { return false; } + let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => return false }; + let new_cursor = match crate::widget::text_edit::cursor_visual_up( canvas, rect, &value, cursor ) + { + Some( n ) => n, + None => return false, + }; + let ss = self.surface_mut( focus ); + *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + ss.request_redraw(); + true + } + + /// Move the text cursor down one *visual* row. Mirror of + /// [`Self::handle_cursor_up`]. + pub( crate ) fn handle_cursor_down( &mut self, focus: SurfaceFocus, extend: bool ) -> bool + { + let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false }; + let cursor = self.surface( focus ).cursor_state.get( &idx ).copied() + .unwrap_or( value.len() ).min( value.len() ); + let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx ) + { + Some( g ) => g, + None => return false, + }; + if !multiline || secure { return false; } + let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => return false }; + let new_cursor = match crate::widget::text_edit::cursor_visual_down( canvas, rect, &value, cursor ) + { + Some( n ) => n, + None => return false, + }; + let ss = self.surface_mut( focus ); + *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + ss.request_redraw(); + true + } + + /// Move the cursor to the start of the current *visual* row (Home + /// key). On a soft-wrapped logical line that lands at the wrap + /// point of the row the caret is on — a second Home press from + /// there does not move further, since the visual row already + /// starts at that boundary. + pub( crate ) fn handle_cursor_home( &mut self, focus: SurfaceFocus, extend: bool ) + { + let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return }; + let cursor = self.surface( focus ).cursor_state.get( &idx ).copied() + .unwrap_or( value.len() ).min( value.len() ); + let new_cursor = match self.text_input_geometry( focus, idx ) + { + Some( ( rect, _, true, false, _, _ ) ) => match self.surface( focus ).canvas.as_ref() + { + Some( canvas ) => crate::widget::text_edit::cursor_visual_home( canvas, rect, &value, cursor ), + None => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ), + }, + _ => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ), + }; + let ss = self.surface_mut( focus ); + *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + ss.request_redraw(); + } + + /// Move the cursor to the end of the current *visual* row (End + /// key). For a soft-wrapped row that's the wrap point; for a row + /// terminated by `\n` it's the byte just before the newline. + pub( crate ) fn handle_cursor_end( &mut self, focus: SurfaceFocus, extend: bool ) + { + let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return }; + let cursor = self.surface( focus ).cursor_state.get( &idx ).copied() + .unwrap_or( value.len() ).min( value.len() ); + let new_cursor = match self.text_input_geometry( focus, idx ) + { + Some( ( rect, _, true, false, _, _ ) ) => match self.surface( focus ).canvas.as_ref() + { + Some( canvas ) => crate::widget::text_edit::cursor_visual_end( canvas, rect, &value, cursor ), + None => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ), + }, + _ => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ), + }; + let ss = self.surface_mut( focus ); + *ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor; + if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; } + ss.request_redraw(); + } + + /// Convert a public [`CursorShape`](crate::types::CursorShape) into + /// the wire-protocol [`Shape`] enum the compositor expects. + fn cursor_shape_to_wp( shape: crate::types::CursorShape ) + -> smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape + { + use crate::types::CursorShape as C; + use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape as S; + match shape + { + C::Default => S::Default, + C::ContextMenu => S::ContextMenu, + C::Help => S::Help, + C::Pointer => S::Pointer, + C::Progress => S::Progress, + C::Wait => S::Wait, + C::Cell => S::Cell, + C::Crosshair => S::Crosshair, + C::Text => S::Text, + C::VerticalText => S::VerticalText, + C::Alias => S::Alias, + C::Copy => S::Copy, + C::Move => S::Move, + C::NoDrop => S::NoDrop, + C::NotAllowed => S::NotAllowed, + C::Grab => S::Grab, + C::Grabbing => S::Grabbing, + C::EResize => S::EResize, + C::NResize => S::NResize, + C::NeResize => S::NeResize, + C::NwResize => S::NwResize, + C::SResize => S::SResize, + C::SeResize => S::SeResize, + C::SwResize => S::SwResize, + C::WResize => S::WResize, + C::EwResize => S::EwResize, + C::NsResize => S::NsResize, + C::NeswResize => S::NeswResize, + C::NwseResize => S::NwseResize, + C::ColResize => S::ColResize, + C::RowResize => S::RowResize, + C::AllScroll => S::AllScroll, + C::ZoomIn => S::ZoomIn, + C::ZoomOut => S::ZoomOut, + } + } + + /// Compute the cursor shape that should be active right now and + /// push it to the compositor if it changed since the last + /// dispatch. Precedence: + /// + /// 1. [`crate::App::cursor_override`] — application-driven busy + /// state, wins over everything. + /// 2. Active slider drag — `Grabbing` while the gesture machine + /// holds a `dragging_slider`. + /// 3. Cursor declared by the widget under the pointer + /// ([`crate::widget::LaidOutWidget::cursor`]). + /// 4. `Default` — pointer is over surface chrome / empty space. + pub( crate ) fn dispatch_cursor_shape( &mut self, focus: SurfaceFocus ) + { + let target = { + let app_override = self.app.cursor_override(); + let dragging = self.surface( focus ).gesture.dragging_slider.is_some(); + let hover_cursor = self.surface( focus ).hovered_idx + .and_then( |idx| crate::tree::find_widget( &self.surface( focus ).widget_rects, idx ) ) + .map( |w| w.cursor ) + .unwrap_or( crate::types::CursorShape::Default ); + let resize_cursor = self.resize_edge_under_pointer( focus ).map( resize_edge_to_cursor ); + + // Resize-edge hover wins over the widget cursor and over a + // `Default` app override, but loses to a non-default app + // override (so a "busy" / "wait" state still trumps chrome). + let allow_chrome = app_override.is_none() + || app_override == Some( crate::types::CursorShape::Default ); + if let Some( c ) = resize_cursor.filter( |_| allow_chrome ) + { + c + } else if let Some( o ) = app_override { + o + } else if dragging { + crate::types::CursorShape::Grabbing + } else { + hover_cursor + } + }; + // Skip when the compositor already shows the right shape. + // `current_cursor_shape == None` means "we have not pushed + // anything since the last `Enter`" — e.g. the pointer just + // arrived from another client's surface, where the prior + // client may have asked for a text I-beam. We MUST push + // unconditionally in that case so the user does not see the + // stranger cursor lingering until they hit a widget. + if self.current_cursor_shape == Some( target ) { return; } + + if let Some( device ) = self.cursor_shape_device.as_ref() + { + device.set_shape( self.last_pointer_enter_serial, Self::cursor_shape_to_wp( target ) ); + self.current_cursor_shape = Some( target ); + } + } + + /// Width (logical pixels) of the resize-grab band along each + /// edge of an xdg-toplevel surface. 6 px is the GTK / KWin + /// convention. + pub( crate ) const RESIZE_BORDER_LOGICAL: f32 = 6.0; + + /// Return the [`ResizeEdge`] the pointer currently sits on, or + /// `None` if it is not in a resize-grab band. Only fires for the + /// main surface when it is an xdg-toplevel — layer-shell surfaces + /// and overlays cannot be resized by the user. + pub( crate ) fn resize_edge_under_pointer( &self, focus: SurfaceFocus ) -> Option + { + if !matches!( focus, SurfaceFocus::Main ) { return None; } + if !matches!( self.main.surface, crate::event_loop::SurfaceKind::Window( _ ) ) { return None; } + let sf = self.surface( focus ).scale_factor.max( 1 ) as f32; + let border = Self::RESIZE_BORDER_LOGICAL * sf; + let w = self.surface( focus ).physical_width() as f32; + let h = self.surface( focus ).physical_height() as f32; + resize_edge_for_pos( self.pointer_pos, w, h, border ) + } + + /// Open the built-in Copy / Cut / Paste context menu near `pos`, + /// targeting the TextEdit at `widget_idx`. Clamps the menu rect + /// to the surface so it never goes off-screen on a near-edge + /// click. Idempotent — replaces any existing menu. + pub( crate ) fn show_context_menu( &mut self, focus: SurfaceFocus, widget_idx: usize, pos: Point ) + { + const W: f32 = 180.0; + const H: f32 = 44.0 * CONTEXT_MENU_ROWS as f32; + + let has_selection = self.focused_selection_text( focus ).is_some(); + let can_paste = !self.clipboard.is_empty(); + + // Snapshot the byte offset the press fell on so a later + // "Paste" lands exactly at the right-click point, even though + // the menu deliberately preserves the existing cursor / + // selection (so Copy / Cut still operate on the prior + // selection). + let paste_offset = self.text_input_geometry( focus, widget_idx ) + .and_then( |( rect, value, multiline, secure, align, font_size )| + { + let cursor_pos = self.surface( focus ).cursor_state.get( &widget_idx ).copied().unwrap_or( 0 ); + let canvas = self.surface( focus ).canvas.as_ref()?; + Some( crate::widget::text_edit::byte_offset_at( + canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size, + ) ) + } ); + + let ss = self.surface( focus ); + let sw = ss.width as f32 * ss.scale_factor.max( 1 ) as f32; + let sh = ss.height as f32 * ss.scale_factor.max( 1 ) as f32; + let mut x = pos.x; + let mut y = pos.y; + // Clamp inside the surface with a small breathing margin. + x = x.min( sw - W - 4.0 ).max( 4.0 ); + y = y.min( sh - H - 4.0 ).max( 4.0 ); + + let rect = Rect { x, y, width: W, height: H }; + let menu = ContextMenu { widget_idx, rect, has_selection, can_paste, paste_offset }; + let ss = self.surface_mut( focus ); + ss.context_menu = Some( menu ); + ss.request_redraw(); + } + + /// Dismiss the context menu on the given surface (no-op when + /// none is shown). + pub( crate ) fn hide_context_menu( &mut self, focus: SurfaceFocus ) + { + let ss = self.surface_mut( focus ); + if ss.context_menu.is_some() + { + ss.context_menu = None; + ss.request_redraw(); + } + } + + /// Handle a primary-button press while the context menu is open. + /// Returns `true` when the click was consumed by the menu (either + /// a row activation or an outside-click dismissal); the pointer + /// dispatch then skips the regular gesture path for this press. + pub( crate ) fn handle_context_menu_press( &mut self, focus: SurfaceFocus, pos: Point ) -> bool + { + let menu = match self.surface( focus ).context_menu.clone() + { + Some( m ) => m, + None => return false, + }; + // Click outside the menu rect → dismiss only. + let row = match menu.row_at( pos ) + { + Some( r ) => r, + None => + { + self.hide_context_menu( focus ); + return true; + } + }; + // Action gating: rows are clickable only when their + // underlying operation makes sense. + match row + { + 0 => if menu.has_selection { self.handle_copy( focus ); } else { return true; } + 1 => if menu.has_selection { self.handle_cut( focus ); } else { return true; } + 2 => + { + if !menu.can_paste { return true; } + // Paste lands at the click position the menu was + // opened from, not at whatever the cursor happens to + // be (which may sit at the end of the value because + // the user just focused the field). Move cursor + + // anchor to the snapshotted offset (collapsing any + // prior selection there) and then run the regular + // paste path. + if let Some( ofs ) = menu.paste_offset + { + let ss = self.surface_mut( focus ); + ss.cursor_state.insert( menu.widget_idx, ofs ); + ss.selection_anchor.insert( menu.widget_idx, ofs ); + } + self.handle_paste( focus ); + } + 3 => + { + // Delete row — clears the current selection without + // touching the clipboard (the difference vs. Cut). + // Disabled with no selection: nothing to delete. + if !menu.has_selection { return true; } + if let Some( new_value ) = self.delete_selection( focus ) + { + let msg = find_handlers( &self.surface( focus ).widget_rects, menu.widget_idx ) + .and_then( |h| h.text_change_msg( &new_value ) ); + if let Some( m ) = msg { self.pending_msgs.push( m ); } + } + } + _ => return true, + } + self.hide_context_menu( focus ); + true + } + + /// `true` when this press should be treated as the second click + /// of a double-click pair. Looks at the timestamp + position of + /// the previous press and compares against a 400 ms / 6 px + /// threshold (matching the GTK / Cocoa defaults). Always updates + /// the snapshot so the next press can pair with this one. + pub( crate ) fn note_press_for_double_click( &mut self, pos: Point ) -> bool + { + const WINDOW_MS: u128 = 400; + const RADIUS: f32 = 6.0; + let now = std::time::Instant::now(); + let is_double = match ( self.last_press_time, self.last_press_pos ) + { + ( Some( t ), Some( p ) ) => + { + let elapsed = now.duration_since( t ).as_millis(); + let dx = pos.x - p.x; + let dy = pos.y - p.y; + elapsed <= WINDOW_MS && dx.hypot( dy ) <= RADIUS + } + _ => false, + }; + if is_double + { + // Consume the snapshot so a third quick click is not + // re-paired with the same first click. + self.last_press_time = None; + self.last_press_pos = None; + } else { + self.last_press_time = Some( now ); + self.last_press_pos = Some( pos ); + } + is_double + } + + /// Select the word under the press. The "word" is the maximal + /// run of alphanumeric / underscore chars surrounding the byte + /// offset that `pos` falls on; everything else (whitespace, + /// punctuation) counts as a separator. Empty value → no-op. + pub( crate ) fn handle_text_select_word( &mut self, focus: SurfaceFocus, idx: usize, pos: Point ) + { + // `select_on_focus` fields are short-form: the whole value is + // already the natural selection unit, so a double-click does + // not need to add a word-bound selection on top. + let select_on_focus = matches!( + find_handlers( &self.surface( focus ).widget_rects, idx ), + Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), + ); + if select_on_focus { return; } + let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx ) + { + Some( v ) => v, + None => return, + }; + if value.is_empty() { return; } + let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 ); + let click_byte = { + let ss = self.surface( focus ); + let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; + crate::widget::text_edit::byte_offset_at( + canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size, + ) + }; + let ( start, end ) = word_bounds_at( &value, click_byte ); + let ss = self.surface_mut( focus ); + ss.selection_anchor.insert( idx, start ); + ss.cursor_state.insert( idx, end ); + ss.request_redraw(); + } + + /// Position the text cursor at the click and start a fresh + /// selection (anchor = cursor). Called from the pointer / touch + /// press handler after focus has been moved to the TextEdit. + pub( crate ) fn handle_text_pointer_down( &mut self, focus: SurfaceFocus, idx: usize, pos: Point ) + { + // Fields with `select_on_focus` (numeric pickers, short-form + // inputs) keep the whole-value selection that `set_focus` + // just installed — clicking is the *focus* gesture and the + // next keystroke is meant to replace, not insert. Without + // this guard the click-to-position below would collapse the + // selection right after `set_focus` produced it. + let select_on_focus = matches!( + find_handlers( &self.surface( focus ).widget_rects, idx ), + Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), + ); + if select_on_focus { return; } + let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx ) + { + Some( v ) => v, + None => return, + }; + let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 ); + let byte_offset = + { + let ss = self.surface( focus ); + let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; + crate::widget::text_edit::byte_offset_at( + canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size, + ) + }; + let ss = self.surface_mut( focus ); + ss.cursor_state.insert( idx, byte_offset ); + ss.selection_anchor.insert( idx, byte_offset ); + ss.request_redraw(); + } + + /// Extend the selection by moving the cursor to the new pointer + /// position. Called from the pointer / touch motion handler while + /// a press is active and the original press landed on a TextEdit. + /// The selection anchor (set on press) stays put. + pub( crate ) fn handle_text_pointer_drag( &mut self, focus: SurfaceFocus, idx: usize, pos: Point ) + { + // Same `select_on_focus` guard as the press-down path: the + // short-form field stays whole-selected even if the user + // drags the pointer over the digits. + let select_on_focus = matches!( + find_handlers( &self.surface( focus ).widget_rects, idx ), + Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), + ); + if select_on_focus { return; } + let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx ) + { + Some( v ) => v, + None => return, + }; + let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 ); + let byte_offset = + { + let ss = self.surface( focus ); + let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; + crate::widget::text_edit::byte_offset_at( + canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size, + ) + }; + let ss = self.surface_mut( focus ); + ss.cursor_state.insert( idx, byte_offset ); + ss.request_redraw(); + } + + /// Snapshot a focused-widget's geometry needed by the pointer + /// hit-testers for text editing. Returns `None` when the widget + /// isn't a TextEdit or its rect is missing — the helper above + /// short-circuits in that case. + fn text_input_geometry( + &self, + focus: SurfaceFocus, + idx: usize, + ) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, f32 )> + { + let ss = self.surface( focus ); + let widget = find_widget( &ss.widget_rects, idx )?; + let ( value_handler, multiline, secure, align, font_size ) = match &widget.handlers + { + WidgetHandlers::TextEdit { value, multiline, secure, align, font_size, .. } => + ( value.clone(), *multiline, *secure, *align, *font_size ), + _ => return None, + }; + // Prefer the *pending* value (typed-but-not-yet-applied) when + // it exists — that's what the user sees right now and what + // the cursor measurements should be relative to. + let value = ss.pending_text_values.get( &idx ) + .cloned() + .unwrap_or( value_handler ); + Some( ( widget.rect, value, multiline, secure, align, font_size ) ) + } + + /// Copy the currently-selected text into the process-local + /// clipboard. No-op when there is no selection. + pub( crate ) fn handle_copy( &mut self, focus: SurfaceFocus ) + { + if let Some( text ) = self.focused_selection_text( focus ) + { + self.clipboard = text; + } + } + + /// Copy + delete: the selected text lands in the clipboard, then + /// the range is removed from the value. + pub( crate ) fn handle_cut( &mut self, focus: SurfaceFocus ) + { + let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return }; + if let Some( text ) = self.focused_selection_text( focus ) + { + self.clipboard = text; + } + if let Some( new_value ) = self.delete_selection( focus ) + { + let msg = find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.text_change_msg( &new_value ) ); + if let Some( m ) = msg { self.pending_msgs.push( m ); } + } + } + + /// Insert the clipboard contents at the cursor (replacing the + /// selection if there is one). No-op when the clipboard is empty. + pub( crate ) fn handle_paste( &mut self, focus: SurfaceFocus ) + { + if self.clipboard.is_empty() { return; } + // Carbon-copy of the clipboard to break the borrow on `self` + // before `handle_text_insert` runs. + let text = self.clipboard.clone(); + self.handle_text_insert( focus, &text ); + } + + /// Collapse any active selection on the focused text input to its + /// cursor position. Returns `true` when a collapse happened so + /// the caller (Esc handler) can short-circuit other behaviour. + pub( crate ) fn collapse_selection_if_any( &mut self, focus: SurfaceFocus ) -> bool + { + let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return false }; + let cursor = self.surface( focus ).cursor_state.get( &idx ).copied(); + let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied(); + let ( c, a ) = match ( cursor, anchor ) + { + ( Some( c ), Some( a ) ) if c != a => ( c, a ), + _ => return false, + }; + let _ = a; + let ss = self.surface_mut( focus ); + ss.selection_anchor.insert( idx, c ); + ss.request_redraw(); + true + } + + /// Select the entire value of the focused text input. Used by + /// Ctrl+A. + pub( crate ) fn handle_select_all( &mut self, focus: SurfaceFocus ) + { + let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return }; + let ss = self.surface_mut( focus ); + *ss.cursor_state.entry( idx ).or_insert( 0 ) = value.len(); + *ss.selection_anchor.entry( idx ).or_insert( 0 ) = 0; + ss.request_redraw(); + } + + /// Snapshot the currently-selected text for the focused widget. + /// Returns `None` when no text input is focused or the selection + /// is empty. + pub( crate ) fn focused_selection_text( &self, focus: SurfaceFocus ) -> Option + { + let ( idx, value ) = self.focused_text_value( focus )?; + let cursor = self.surface( focus ).cursor_state.get( &idx ).copied() + .unwrap_or( value.len() ).min( value.len() ); + let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied() + .unwrap_or( cursor ).min( value.len() ); + if anchor == cursor { return None; } + let s = cursor.min( anchor ); + let e = cursor.max( anchor ); + Some( value[s..e].to_string() ) + } + + /// Delete the current selection (if non-empty), updating the + /// pending text value and cursor / anchor. Returns the resulting + /// string when a deletion happened so the caller can dispatch the + /// `on_change` message; returns `None` when there was no selection + /// to delete. + pub( crate ) fn delete_selection( &mut self, focus: SurfaceFocus ) -> Option + { + let ( idx, value ) = self.focused_text_value( focus )?; + let cursor = self.surface( focus ).cursor_state.get( &idx ).copied() + .unwrap_or( value.len() ).min( value.len() ); + let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied() + .unwrap_or( cursor ).min( value.len() ); + if anchor == cursor { return None; } + let s = cursor.min( anchor ); + let e = cursor.max( anchor ); + let mut new_value = value; + new_value.replace_range( s..e, "" ); + // `select_on_focus` fields keep the cursor at the end of the + // post-update displayed value via the `usize::MAX` sentinel — + // see `handle_text_insert` for the reasoning. + let select_on_focus = matches!( + find_handlers( &self.surface( focus ).widget_rects, idx ), + Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), + ); + let next_cursor = if select_on_focus { usize::MAX } else { s }; + let ss = self.surface_mut( focus ); + *ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor; + *ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor; + ss.pending_text_values.insert( idx, new_value.clone() ); + ss.request_redraw(); + Some( new_value ) + } + + // Configure the main surface size, (re)allocate its rendering target, and + // request a redraw. Routes to the GPU or SHM path inside `SurfaceState` + // according to whether `self.egl_context` is available. + pub( crate ) fn on_configure( &mut self, w: u32, h: u32 ) + { + self.main.on_configure( &self.shm, self.egl_context.as_ref(), w, h ); + // `on_resize` is documented to deliver **physical** pixels, matching the + // coordinate space that the layout passes and the pointer/touch + // callbacks (`on_drag_move`, `on_drop`) work in. Wayland's + // configure.new_size is surface-local (logical), so multiply by the + // current buffer scale before handing the dimensions to the app. + let sf = self.main.scale_factor.max( 1 ) as u32; + self.app.on_resize( w * sf, h * sf ); + // `on_resize` may flip app-state that the view depends on (apps that + // branch on the new dimensions, layout caches keyed by size, …), so + // drop the cached tree to force a rebuild on the next draw. + self.dirty_caches(); + } + + /// Mark both view caches stale after a direct app-state mutation that + /// doesn't go through [`App::update`] — swipe progress callbacks, text- + /// input focus changes, configure-driven resize. Per-message updates use + /// the run loop's `apply_invalidation` path instead so that + /// [`App::invalidate_after`] can scope the rebuild. + pub( crate ) fn dirty_caches( &mut self ) + { + self.view_dirty = true; + self.overlays_dirty = true; + } + + /// Smallest remaining time until a pending long-press deadline fires. + /// + /// Used by the run loop to bound `event_loop.dispatch()` so that a + /// stationary press wakes us up at the right moment even when no + /// Wayland events arrive. `Some(Duration::ZERO)` means the deadline + /// has already elapsed; `None` means nothing is pending. + pub( crate ) fn next_long_press_wakeup( &self ) -> Option + { + let dur = self.app.long_press_duration(); + let now = std::time::Instant::now(); + let mut soonest: Option = None; + let mut consider = |start: Option| + { + if let Some( s ) = start + { + let deadline = s + dur; + let remaining = deadline.saturating_duration_since( now ); + soonest = Some( match soonest + { + Some( cur ) => cur.min( remaining ), + None => remaining, + } ); + } + }; + consider( self.main.gesture.long_press_start ); + for ss in self.overlays.values() + { + consider( ss.gesture.long_press_start ); + } + soonest + } + + /// Fire long-press messages for any surface whose deadline has + /// elapsed. Idempotent — if a press is in-flight but not yet due this + /// does nothing. On fire, the message is pushed to `pending_msgs`, + /// `long_press_fired` is set, and the per-surface long-press slot is + /// cleared so the same press can't fire twice. + pub( crate ) fn check_long_press_deadlines( &mut self ) + { + let dur = self.app.long_press_duration(); + let now = std::time::Instant::now(); + + // Returns `(lp_msg_opt, ds_msg_opt, origin, text_idx_opt)`: + // - `lp_msg_opt = Some` → user-set on_long_press fires; push msg + // (opens the context menu in the app). + // - `ds_msg_opt = Some` → user-set on_drag_start fires; push + // msg + seed `on_drag_move(origin)` so the drag arms with + // the right anchor point. Also flips `long_press_fired` so + // subsequent motion / release route through drag / drop. + // - `text_idx_opt = Some` → press was on a TextEdit with + // neither user msg; the runtime opens the built-in Copy / + // Cut / Paste menu instead. + // Any combination can be present: an icon with both menu and + // drag fires both messages in order (menu first, then drag). + let fire = |ss: &mut SurfaceState| + -> Option<( Option, Option, Point, Option )> + { + let start = ss.gesture.long_press_start?; + if now.duration_since( start ) < dur { return None; } + ss.gesture.long_press_start = None; + let origin = ss.gesture.long_press_origin.unwrap_or_default(); + let lp_msg = ss.gesture.long_press_msg.take(); + let ds_msg = ss.gesture.drag_start_msg.take(); + let text_idx = ss.gesture.long_press_text_idx.take(); + if lp_msg.is_none() && ds_msg.is_none() && text_idx.is_none() { return None; } + // Only flip "fired" when the gesture actually transitions + // into drag mode. A menu-only widget (no on_drag_start) + // stays in tap mode, so a release after the menu fires + // still goes through `on_release` cleanly. The built-in + // text menu likewise does not switch to drag, so a + // subsequent motion can still extend a selection. + if ds_msg.is_some() { ss.gesture.long_press_fired = true; } + ss.request_redraw(); + Some( ( lp_msg, ds_msg, origin, text_idx ) ) + }; + + // Capture per-surface fires, then post-process so the borrow + // of `self.main` / `self.overlays` is released before we + // call menu helpers (which take &mut self again). + let main_fire = fire( &mut self.main ).map( |x| ( SurfaceFocus::Main, x ) ); + let overlay_fires: Vec<_> = self.overlays.iter_mut() + .filter_map( |( id, ss )| fire( ss ).map( |x| ( SurfaceFocus::Overlay( *id ), x ) ) ) + .collect(); + + let mut consumed_anything = false; + for ( focus, ( lp_msg, ds_msg, origin, text_idx ) ) in main_fire.into_iter().chain( overlay_fires ) + { + // Push menu first so the app sets up `edit_menu` before + // the drag-arm message lands and starts consuming + // `on_drag_move` callbacks. + if let Some( m ) = lp_msg + { + self.pending_msgs.push( m ); + consumed_anything = true; + } + if let Some( m ) = ds_msg + { + self.pending_msgs.push( m ); + self.pending_drag_inits.push( origin ); + // Drag promotion cancels any held-button repeat — the + // gesture has switched semantics and the timer has + // nothing to fire against any more. + self.stop_button_repeat(); + consumed_anything = true; + } + if let Some( idx ) = text_idx + { + // Built-in Copy / Cut / Paste menu near the press. + self.show_context_menu( focus, idx, origin ); + } + } + if consumed_anything { self.dirty_caches(); } + } + + pub( crate ) fn has_active_long_press_drag( &self ) -> bool + { + self.main.gesture.long_press_fired + || self.overlays.values().any( |ss| ss.gesture.long_press_fired ) + } + + pub( crate ) fn clear_long_press_drag( &mut self ) + { + let clear = |ss: &mut SurfaceState| + { + ss.gesture.long_press_start = None; + ss.gesture.long_press_origin = None; + ss.gesture.long_press_msg = None; + ss.gesture.drag_start_msg = None; + ss.gesture.long_press_text_idx = None; + ss.gesture.long_press_fired = false; + ss.gesture.mouse_press = false; + }; + clear( &mut self.main ); + for ss in self.overlays.values_mut() + { + clear( ss ); + } + } +} + +#[ cfg( test ) ] +mod resize_edge_tests +{ + use super::{ resize_edge_for_pos, ResizeEdge }; + use crate::types::Point; + + const W: f32 = 800.0; + const H: f32 = 600.0; + const B: f32 = 6.0; + + fn at( x: f32, y: f32 ) -> Option + { + resize_edge_for_pos( Point { x, y }, W, H, B ) + } + + #[ test ] + fn middle_is_none() + { + assert!( at( 400.0, 300.0 ).is_none() ); + } + + #[ test ] + fn just_inside_border_is_none() + { + // 6 px border; (6, 6) is one pixel inside on both axes. + assert!( at( 6.0, 6.0 ).is_none() ); + } + + #[ test ] + fn corners_take_precedence_over_single_edges() + { + assert_eq!( at( 1.0, 1.0 ), Some( ResizeEdge::TopLeft ) ); + assert_eq!( at( W - 1.0, 1.0 ), Some( ResizeEdge::TopRight ) ); + assert_eq!( at( 1.0, H - 1.0 ), Some( ResizeEdge::BottomLeft ) ); + assert_eq!( at( W - 1.0, H - 1.0 ), Some( ResizeEdge::BottomRight ) ); + } + + #[ test ] + fn straight_edges() + { + assert_eq!( at( 400.0, 1.0 ), Some( ResizeEdge::Top ) ); + assert_eq!( at( 400.0, H - 1.0 ), Some( ResizeEdge::Bottom ) ); + assert_eq!( at( 1.0, 300.0 ), Some( ResizeEdge::Left ) ); + assert_eq!( at( W - 1.0, 300.0 ), Some( ResizeEdge::Right ) ); + } +} diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs new file mode 100644 index 0000000..c5b8dbe --- /dev/null +++ b/src/event_loop/handlers.rs @@ -0,0 +1,495 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use smithay_client_toolkit:: +{ + compositor::CompositorHandler, + delegate_compositor, delegate_layer, delegate_output, delegate_registry, + delegate_seat, delegate_keyboard, delegate_pointer, delegate_touch, delegate_shm, + delegate_xdg_popup, delegate_xdg_shell, delegate_xdg_window, + output::{ OutputHandler, OutputState }, + registry::{ ProvidesRegistryState, RegistryState }, + registry_handlers, + seat::{ Capability, SeatHandler, SeatState }, + shell:: + { + WaylandSurface, + wlr_layer::{ LayerShellHandler, LayerSurface, LayerSurfaceConfigure }, + xdg::popup::{ Popup, PopupConfigure, PopupHandler }, + xdg::window::{ Window, WindowConfigure, WindowHandler }, + }, + shm::{ Shm, ShmHandler }, +}; +use smithay_client_toolkit::reexports::client:: +{ + protocol:: + { + wl_callback::{ self, WlCallback }, + wl_output::{ self, WlOutput }, + wl_surface::WlSurface, + wl_seat::WlSeat, + }, + Connection, Dispatch, QueueHandle, +}; +use wayland_protocols::wp::text_input::zv3::client:: +{ + zwp_text_input_manager_v3::ZwpTextInputManagerV3, + zwp_text_input_v3::{ self, ZwpTextInputV3 }, +}; + +use crate::app::App; +use super::app_data::AppData; + +impl CompositorHandler for AppData +{ + fn scale_factor_changed( &mut self, _: &Connection, _: &QueueHandle, surface: &WlSurface, new_factor: i32 ) + { + if new_factor <= 0 { return; } + let Some( focus ) = self.focus_for_surface( surface ) else { return }; + let ( pw, ph ) = { + let shm = &self.shm; + let ss = match focus + { + super::SurfaceFocus::Main => &mut self.main, + super::SurfaceFocus::Overlay( id ) => match self.overlays.get_mut( &id ) + { + Some( s ) => s, + None => return, + }, + }; + if new_factor == ss.scale_factor { return; } + ss.scale_factor = new_factor; + surface.set_buffer_scale( new_factor ); + if let Some( ref mut canvas ) = ss.canvas + { + canvas.set_dpi_scale( new_factor as f32 ); + } + let pw = ss.width * new_factor as u32; + let ph = ss.height * new_factor as u32; + // Resize whichever rendering target is active. The two are mutually + // exclusive so only one branch runs. + if let Some( ref es ) = ss.egl_surface + { + es.resize( pw as i32, ph as i32 ); + // Canvas FBO reallocation is deferred to the draw path: it needs + // `eglMakeCurrent` first. + } else { + ss.pool = Some( + smithay_client_toolkit::shm::slot::SlotPool::new( + ( pw * ph * 4 ) as usize, shm, + ).expect( "pool" ), + ); + if let Some( ref mut canvas ) = ss.canvas + { + canvas.resize( pw, ph ); + } + } + ss.request_redraw(); + ( pw, ph ) + }; + // Notify the app of the new physical dimensions. The previous + // `on_resize` it received was scaled with the OLD factor, so any + // app-side state keyed off those pixels is now stale. Only fire + // for the main surface — overlay resizes don't go through + // `App::on_resize`. + if matches!( focus, super::SurfaceFocus::Main ) + { + self.app.on_resize( pw, ph ); + self.dirty_caches(); + } + } + + fn transform_changed( &mut self, _: &Connection, _: &QueueHandle, _: &WlSurface, _: wl_output::Transform ) {} + + fn frame( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _surface: &WlSurface, + _time: u32, + ) + { + // Do NOT set needs_redraw here — that would create an infinite 60 fps loop + // (commit → frame callback → redraw → commit → ...). Redraws are driven + // exclusively by input events, poll_external messages, and configure events. + } + + fn surface_enter( &mut self, _: &Connection, _: &QueueHandle, _: &WlSurface, _: &WlOutput ) {} + + fn surface_leave( &mut self, _: &Connection, _: &QueueHandle, _: &WlSurface, _: &WlOutput ) {} +} + +impl LayerShellHandler for AppData +{ + fn closed( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + layer: &LayerSurface, + ) + { + match self.focus_for_surface( layer.wl_surface() ) + { + Some( super::SurfaceFocus::Main ) | None => + { + if self.app.on_close_requested() + { + self.exit_requested = true; + } + } + Some( super::SurfaceFocus::Overlay( id ) ) => + { + // Compositor asked us to destroy this overlay. Remove it; + // the next reconcile will not recreate it unless the app + // still returns its id from `App::overlays()`. + self.overlays.remove( &id ); + } + } + } + + fn configure( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + layer: &LayerSurface, + configure: LayerSurfaceConfigure, + _serial: u32, + ) + { + let ( w, h ) = configure.new_size; + let ( w, h ) = ( w.max( 1 ), h.max( 1 ) ); + match self.focus_for_surface( layer.wl_surface() ) + { + Some( super::SurfaceFocus::Main ) | None => + { + self.on_configure( w, h ); + self.app.on_resize( w, h ); + } + Some( super::SurfaceFocus::Overlay( id ) ) => + { + if let Some( ss ) = self.overlays.get_mut( &id ) + { + ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h ); + } + } + } + } +} + +impl WindowHandler for AppData +{ + fn request_close( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _window: &Window, + ) + { + if self.app.on_close_requested() + { + self.exit_requested = true; + } + } + + fn configure( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + window: &Window, + configure: WindowConfigure, + _serial: u32, + ) + { + // Mutter ignores set_fullscreen sent before the surface is + // mapped, so reapply on the first configure. + if self.pending_fullscreen + { + window.set_fullscreen( None ); + self.pending_fullscreen = false; + } + let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( 800 ); + let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( 600 ); + self.on_configure( w, h ); + self.app.on_resize( w, h ); + } +} + +impl ShmHandler for AppData +{ + fn shm_state( &mut self ) -> &mut Shm + { + &mut self.shm + } +} + +impl OutputHandler for AppData +{ + fn output_state( &mut self ) -> &mut OutputState + { + &mut self.output_state + } + + fn new_output( &mut self, _: &Connection, qh: &QueueHandle, output: WlOutput ) + { + // If we were waiting for an output to assign the layer surface to, create it now. + // Same for any overlays that were created before an output existed. + if let Some( ref layer_shell ) = self.layer_shell + { + self.main.surface.materialize( &self.compositor_state, layer_shell, qh, &output ); + for ss in self.overlays.values_mut() + { + ss.surface.materialize( &self.compositor_state, layer_shell, qh, &output ); + } + } + } + + fn update_output( &mut self, _: &Connection, _: &QueueHandle, _: WlOutput ) {} + fn output_destroyed( &mut self, _: &Connection, _: &QueueHandle, _: WlOutput ) {} +} + +impl SeatHandler for AppData +{ + fn seat_state( &mut self ) -> &mut SeatState + { + &mut self.seat_state + } + + fn new_seat( + &mut self, _conn: &Connection, _qh: &QueueHandle, _seat: WlSeat, + ) {} + + fn new_capability( + &mut self, + _conn: &Connection, + qh: &QueueHandle, + seat: WlSeat, + capability: Capability, + ) + { + match capability + { + Capability::Keyboard if self.keyboard.is_none() => + { + self.keyboard = Some( + self.seat_state + .get_keyboard( qh, &seat, None ) + .expect( "keyboard" ), + ); + } + Capability::Pointer if self.pointer.is_none() => + { + let pointer = self.seat_state + .get_pointer( qh, &seat ) + .expect( "pointer" ); + // Create a per-pointer cursor-shape device when the + // compositor advertises wp_cursor_shape_v1. The device + // outlives the WlPointer we just created and is what + // `set_shape(serial, shape)` is called on. + if let Some( ref mgr ) = self.cursor_shape_manager + { + self.cursor_shape_device = Some( mgr.get_shape_device( &pointer, qh ) ); + } + self.pointer = Some( pointer ); + } + Capability::Touch if self.touch.is_none() => + { + self.touch = Some( + self.seat_state + .get_touch( qh, &seat ) + .expect( "touch" ), + ); + } + _ => {} + } + } + + fn remove_capability( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _seat: WlSeat, + capability: Capability, + ) + { + match capability + { + Capability::Keyboard => { self.keyboard = None; } + Capability::Pointer => { self.pointer = None; } + Capability::Touch => { self.touch = None; } + _ => {} + } + } + + fn remove_seat( + &mut self, _conn: &Connection, _qh: &QueueHandle, _seat: WlSeat, + ) {} +} + +impl PopupHandler for AppData +{ + fn configure( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + popup: &Popup, + config: PopupConfigure, + ) + { + let ( w, h ) = ( config.width.max( 1 ) as u32, config.height.max( 1 ) as u32 ); + if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() ) + { + if let Some( ss ) = self.overlays.get_mut( &id ) + { + ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h ); + } + } + } + + fn done( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + popup: &Popup, + ) + { + if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() ) + { + if let Some( msg ) = self.overlay_dismiss_msg( id ) + { + self.pending_msgs.push( msg ); + } + self.overlays.remove( &id ); + } + } +} + +// --- Delegate macros --- + +delegate_compositor!( @ AppData ); +delegate_output!( @ AppData ); +delegate_shm!( @ AppData ); +delegate_seat!( @ AppData ); +delegate_keyboard!( @ AppData ); +delegate_pointer!( @ AppData ); +delegate_touch!( @ AppData ); +delegate_layer!( @ AppData ); +delegate_xdg_shell!( @ AppData ); +delegate_xdg_window!( @ AppData ); +delegate_xdg_popup!( @ AppData ); +delegate_registry!( @ AppData ); + +impl ProvidesRegistryState for AppData +{ + fn registry( &mut self ) -> &mut RegistryState + { + &mut self.registry_state + } + registry_handlers![ OutputState, SeatState ]; +} + +// --- Dispatch impls for zwp_text_input_v3 --- + +impl Dispatch for AppData +{ + fn event( + _state: &mut Self, + _proxy: &ZwpTextInputManagerV3, + _event: ::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) + { + // no events from manager + } +} + +// Frame-callback routing. +// +// Each `draw_*` path requests a `wl_surface.frame` with `SurfaceFocus` user- +// data identifying which `SurfaceState` it belongs to. The compositor fires +// the callback when the surface is ready for its next commit (display refresh, +// VRR cadence, "screen is on", …). Clearing `frame_pending` unblocks the run +// loop so the next iteration is allowed to draw that surface again. +// +// While the app is animating we re-arm the surface for redraw inline so the +// loop keeps ticking at the compositor's pace without needing a fixed-period +// timer. +impl Dispatch for AppData +{ + fn event( + state: &mut Self, + _proxy: &WlCallback, + _event: wl_callback::Event, + focus: &super::SurfaceFocus, + _conn: &Connection, + _qh: &QueueHandle, + ) + { + let is_animating = state.app.is_animating(); + match *focus + { + super::SurfaceFocus::Main => + { + state.main.frame_pending = false; + if is_animating + { + state.view_dirty = true; + state.main.request_redraw(); + } + } + super::SurfaceFocus::Overlay( id ) => + { + if let Some( ss ) = state.overlays.get_mut( &id ) + { + ss.frame_pending = false; + if is_animating + { + state.overlays_dirty = true; + ss.request_redraw(); + } + } + } + } + } +} + +impl Dispatch for AppData +{ + fn event( + state: &mut Self, + _proxy: &ZwpTextInputV3, + event: zwp_text_input_v3::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) + { + let focus = state.keyboard_focus; + match event + { + zwp_text_input_v3::Event::CommitString { text } => + { + if let Some( text ) = text + { + if !text.is_empty() + { + state.handle_text_insert( focus, &text ); + } + } + } + zwp_text_input_v3::Event::DeleteSurroundingText { before_length, .. } => + { + for _ in 0..before_length + { + state.handle_backspace( focus ); + } + } + zwp_text_input_v3::Event::Done { .. } => + { + state.surface_mut( focus ).request_redraw(); + } + _ => {} + } + } +} diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs new file mode 100644 index 0000000..aee1078 --- /dev/null +++ b/src/event_loop/mod.rs @@ -0,0 +1,814 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +pub( crate ) mod app_data; +mod handlers; + +pub( crate ) use app_data::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; + +use smithay_client_toolkit:: +{ + compositor::{ CompositorState, Surface }, + output::OutputState, + registry::RegistryState, + seat::SeatState, + shell:: + { + WaylandSurface, + wlr_layer::LayerShell, + xdg:: + { + XdgPositioner, XdgShell, XdgSurface, + popup::Popup, + window::WindowDecorations, + }, + }, + shm::Shm, +}; +use smithay_client_toolkit::reexports::client::Connection; +use smithay_client_toolkit::reexports::calloop::EventLoop; +use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource; +use calloop::timer::{ Timer, TimeoutAction }; +use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3; +use wayland_protocols::xdg::shell::client::xdg_positioner:: +{ + Anchor as PositionerAnchor, + ConstraintAdjustment, + Gravity, +}; + +use crate::app::{ App, InvalidationScope, SurfaceTarget }; +use crate::draw::draw_frame; +use crate::types::{ Point, Rect }; +use std::collections::HashSet; + +/// Reasons [`crate::try_run`] (and therefore [`crate::run`]) can fail +/// to bring up the event loop. +/// +/// Every variant maps to a fatal failure during init: the Wayland +/// connection, the calloop event loop, or one of the protocol bindings +/// the runtime cannot operate without (`wl_compositor`, `wl_shm`, +/// `xdg_wm_base`). Once init succeeds, runtime errors during the dispatch +/// loop still panic — they are non-recoverable from the caller's point +/// of view since the surface is already on screen. +/// +/// Embedders that want a software-rendered fallback or that need to +/// degrade gracefully should call [`crate::try_run`] and match on the +/// variants instead of letting [`crate::run`] panic. +#[ derive( Debug ) ] +pub enum RunError +{ + /// `WAYLAND_DISPLAY` is unset, the socket is missing, or the + /// compositor refused the handshake. Includes the detailed reason + /// from the underlying `wayland-client` error. + NoWaylandConnection( String ), + /// The Wayland registry could not be enumerated. Almost always a + /// compositor / driver bug — the registry is the first thing every + /// Wayland client touches. + RegistryInit( String ), + /// `calloop`'s `EventLoop::try_new` or its Wayland source insertion + /// failed (typically an `io` error talking to the kernel). + EventLoop( String ), + /// A required Wayland protocol is missing from the compositor. + /// `name` is the wire-format protocol name; `detail` is the + /// underlying bind error. + MissingProtocol + { + name: &'static str, + detail: String, + }, +} + +impl std::fmt::Display for RunError +{ + fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result + { + match self + { + Self::NoWaylandConnection( d ) => + write!( f, "Wayland connection failed: {d}" ), + Self::RegistryInit( d ) => + write!( f, "Wayland registry init failed: {d}" ), + Self::EventLoop( d ) => + write!( f, "event-loop setup failed: {d}" ), + Self::MissingProtocol { name, detail } => + write!( f, "Wayland protocol `{name}` unavailable: {detail}" ), + } + } +} + +impl std::error::Error for RunError {} + +/// Run the application, panicking on init failure. Thin wrapper over +/// [`try_run`] kept for backwards-compatibility — embedders that need +/// to recover from a missing compositor or a stripped-down driver +/// should call [`try_run`] instead. +pub( crate ) fn run( app: A ) +{ + if let Err( e ) = try_run( app ) + { + panic!( "ltk::run failed during init: {e}" ); + } +} + +/// Run the application, returning a typed error on init failure. +/// The dispatch loop's runtime errors still panic — they are non- +/// recoverable once the surface is on screen, and the surface state +/// machine cannot be unwound cleanly from this entry point. +pub( crate ) fn try_run( app: A ) -> Result<(), RunError> +{ + let conn = Connection::connect_to_env() + .map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?; + let ( globals, queue ) = + smithay_client_toolkit::reexports::client::globals::registry_queue_init( &conn ) + .map_err( |e| RunError::RegistryInit( format!( "{e:?}" ) ) )?; + let qh = queue.handle(); + + let mut event_loop: EventLoop> = EventLoop::try_new() + .map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?; + WaylandSource::new( conn.clone(), queue ) + .insert( event_loop.handle() ) + .map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?; + + let compositor = CompositorState::bind( &globals, &qh ) + .map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?; + let shm = Shm::bind( &globals, &qh ) + .map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?; + + // Try to bring EGL up. On failure (no libEGL, no compatible config, + // LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the + // reason and every surface falls back to the SHM path. + let egl_context = match crate::egl_context::EglContext::new( &conn ) + { + Ok( ctx ) => Some( std::sync::Arc::new( ctx ) ), + Err( reason ) => + { + crate::egl_context::log_software_fallback( &reason ); + None + } + }; + crate::render::set_software_render( egl_context.is_none() ); + + // Bind layer-shell up front. Both the main surface (when requested via + // ShellMode::Layer) and every overlay returned by App::overlays() share + // this single binding. + let layer_shell_opt: Option = LayerShell::bind( &globals, &qh ).ok(); + + // Backwards compatibility: window_config() overrides shell_mode() + let force_window = app.window_config() + .map( |( t, id )| ( t.to_string(), id.to_string() ) ); + + let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle>| + -> Result + { + XdgShell::bind( globals, qh ) + .map_err( |e| RunError::MissingProtocol { name: "xdg_wm_base", detail: format!( "{e:?}" ) } ) + }; + + // Pinning min == max is the standard idiom on xdg-shell for "I + // want this exact size". Skipped when going fullscreen — Mutter + // rejects the fullscreen request if a min_size constrains it. + let apply_size_hint = |window: &smithay_client_toolkit::shell::xdg::window::Window| + { + if app.start_fullscreen() { return; } + match app.window_size_hint() + { + Some( ( w, h ) ) => + { + // `set_min_size` doubles as the suggested initial size: + // most compositors send the first configure with this + // value, after which the surface remains user-resizable + // (the runtime adopts whatever size the compositor + // supplies on subsequent configures). `set_max_size` is + // deliberately *not* called — pinning min == max would + // lock the toplevel and refuse user-initiated resize. + window.set_min_size( Some( ( w, h ) ) ); + } + None => + { + window.set_min_size( Some( ( 360, 480 ) ) ); + } + } + }; + + let apply_fullscreen = |window: &smithay_client_toolkit::shell::xdg::window::Window| + { + if app.start_fullscreen() + { + window.set_fullscreen( None ); + } + }; + + let ( surface_kind, xdg_shell ) = if let Some( ( ref title, ref app_id ) ) = force_window + { + let xdg = bind_xdg( &globals, &qh )?; + let surface = compositor.create_surface( &qh ); + let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh ); + window.set_title( title.as_str() ); + window.set_app_id( app_id.as_str() ); + apply_size_hint( &window ); + apply_fullscreen( &window ); + window.commit(); + ( SurfaceKind::Window( window ), Some( xdg ) ) + } else { + // Use shell_mode() to determine surface type + use crate::app::ShellMode; + match app.shell_mode() + { + ShellMode::Window => { + let xdg = bind_xdg( &globals, &qh )?; + let surface = compositor.create_surface( &qh ); + let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh ); + window.set_title( "ltk" ); + window.set_app_id( "ltk" ); + apply_size_hint( &window ); + window.commit(); + ( SurfaceKind::Window( window ), Some( xdg ) ) + } + ShellMode::Layer( layer ) => { + if layer_shell_opt.is_some() + { + // Defer surface creation until new_output fires: if we create the layer + // surface before the compositor has any output ready (e.g. sway startup), + // the compositor cannot assign it and logs an error. + let cfg = LayerConfig { + layer: layer.to_wlr_layer(), + exclusive_zone: app.exclusive_zone(), + anchor: app.layer_anchor(), + size: app.layer_size(), + keyboard_exclusive: app.keyboard_exclusive(), + namespace: "ltk-sctk", + }; + ( SurfaceKind::Pending( cfg ), None ) + } else { + eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" ); + let xdg = bind_xdg( &globals, &qh )?; + let surface = compositor.create_surface( &qh ); + let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh ); + window.set_title( "ltk" ); + window.set_app_id( "ltk" ); + apply_size_hint( &window ); + window.commit(); + ( SurfaceKind::Window( window ), Some( xdg ) ) + } + } + } + }; + + let text_input_manager: Option = globals.bind( &qh, 1..=1, () ).ok(); + + let debug_layout = std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok(); + + let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 }; + let titlebar_title = force_window.map( |( t, _ )| t ).unwrap_or_default(); + + let pending_fullscreen = app.start_fullscreen(); + + let mut data = AppData + { + app, + registry_state: RegistryState::new( &globals ), + seat_state: SeatState::new( &globals, &qh ), + output_state: OutputState::new( &globals, &qh ), + compositor_state: compositor, + shm, + egl_context, + xdg_shell, + layer_shell: layer_shell_opt, + keyboard: None, + pointer: None, + touch: None, + pointer_pos: Point::default(), + cursor_shape_manager: + smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager::bind( &globals, &qh ).ok(), + cursor_shape_device: None, + last_pointer_enter_serial: 0, + current_cursor_shape: None, + text_input_manager, + text_input: None, + shift_pressed: false, + ctrl_pressed: false, + loop_handle: event_loop.handle(), + compositor_repeat_rate: 0, + compositor_repeat_delay: 0, + key_repeat: None, + button_repeat: None, + clipboard: String::new(), + last_press_time: None, + last_press_pos: None, + debug_layout, + pending_msgs: Vec::new(), + pending_drag_inits: Vec::new(), + qh: qh.clone(), + last_pointer_serial: 0, + last_input_serial: 0, + exit_requested: false, + pending_fullscreen, + main: SurfaceState::::new( surface_kind, titlebar_height, titlebar_title ), + overlays: std::collections::HashMap::new(), + pointer_focus: SurfaceFocus::Main, + keyboard_focus: SurfaceFocus::Main, + touch_focus: std::collections::HashMap::new(), + cached_view: None, + cached_overlays: None, + view_dirty: true, + overlays_dirty: true, + }; + + // Register a calloop channel so the app can send messages from any thread. + // Messages sent through the Sender wake the event loop immediately. + { + let ( sender, channel ) = calloop::channel::channel::(); + event_loop.handle() + .insert_source( + channel, + |event, _, data: &mut AppData| + { + if let calloop::channel::Event::Msg( msg ) = event + { + // Just queue the message — the run loop will run + // `App::invalidate_after` and apply the resulting + // scope, which is what decides which surfaces (if + // any) actually need to redraw. + data.pending_msgs.push( msg ); + } + }, + ) + .map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e:?}" ) ) )?; + data.app.set_channel_sender( sender ); + } + + // Register a periodic timer if the app wants one (e.g. clock tick every second). + // The timer fires independently of Wayland events, waking the event loop on schedule. + if let Some( dur ) = data.app.poll_interval() + { + event_loop.handle() + .insert_source( + Timer::from_duration( dur ), + |_, _, data: &mut AppData| + { + let msgs = data.app.poll_external(); + data.pending_msgs.extend( msgs ); + let next = data.app.poll_interval() + .unwrap_or( std::time::Duration::from_secs( 1 ) ); + TimeoutAction::ToDuration( next ) + }, + ) + .map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?; + } + + while !data.exit_requested + { + // Sleep until something interesting fires: + // * Wayland event (input, configure, frame callback, …) + // * calloop timer (poll_external) + // * calloop channel (App::set_channel_sender) + // Pacing is driven by `wl_surface.frame` callbacks, so an idle / + // off-screen / VRR display blocks indefinitely instead of polling + // at a fixed rate. The one exception is a pending long-press: + // cap the wait at its deadline so a perfectly still press still + // fires on time. + let timeout = data.next_long_press_wakeup(); + event_loop.dispatch( timeout, &mut data ).expect( "dispatch" ); + // Any surface whose press has now crossed `long_press_duration` + // emits its stored message and flips into drag mode for the rest + // of the gesture. + data.check_long_press_deadlines(); + + // Poll external messages (immediate async results, e.g. PAM auth channel) + let ext: Vec<_> = data.app.poll_external(); + data.pending_msgs.extend( ext ); + + // Process pending messages, folding their per-message invalidation + // scopes into a single decision before applying it. + let msgs: Vec<_> = data.pending_msgs.drain( .. ).collect(); + let had_msgs = !msgs.is_empty(); + if had_msgs + { + let mut scope = InvalidationScope::Only( Vec::new() ); + for msg in msgs + { + scope = scope.union( data.app.invalidate_after( &msg ) ); + data.app.update( msg ); + } + apply_invalidation( &mut data, scope ); + // `update()` may have flipped the busy / loading flag + // the app reads from inside `cursor_override`. Re-sync + // the pointer cursor so the change propagates without + // waiting for the next motion event. + let pf = data.pointer_focus; + data.dispatch_cursor_shape( pf ); + } + // Seed `on_drag_move` with the long-press origin for any drag that + // just started. Must run *after* `update()` so the app's drag state + // has already been set up by the paired long-press message — the + // coords would otherwise hit a dragging_app=None shell and be lost. + let drag_inits: Vec<_> = data.pending_drag_inits.drain( .. ).collect(); + if !drag_inits.is_empty() + { + for origin in drag_inits + { + data.app.on_drag_move( origin.x, origin.y ); + } + data.view_dirty = true; + data.overlays_dirty = true; + data.main.request_redraw(); + } + // After update() the app state is the source of truth — discard any + // pending text values so that the next keystroke reads the fresh state + // instead of a stale pre-update buffer (e.g. password cleared on auth failure). + if !data.main.pending_text_values.is_empty() + { + data.main.pending_text_values.clear(); + } + for ss in data.overlays.values_mut() + { + ss.pending_text_values.clear(); + } + + // Reconcile the overlay set against the app's current specs: drop any + // overlays whose id disappeared, create new ones for ids that just + // appeared. Specs are re-queried next frame for drawing. + reconcile_overlays( &mut data ); + + // Draw any surface that's configured, dirty, and not already waiting + // on a frame callback. The compositor decides our cadence — when no + // surface qualifies we just loop back to `dispatch(None)` and sleep. + let any_drawable = ( data.main.configured && data.main.needs_redraw && !data.main.frame_pending ) + || data.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending ); + if any_drawable + { + // Rebuild while motion is in progress and on the first frame + // after it ends, so the settle frame paints at full quality + // instead of freezing one step short of the target. + // `wants_low_quality_paint` is the source of truth so + // finger-tracked drags get the same treatment. Slider / + // scroll drags are owned by the runtime gesture machine, + // not by `App::update`, so OR with the gesture state to + // pick up that motion signal too. + let runtime_slider_motion = + data.main.gesture.dragging_slider.is_some() + || data.overlays.values().any( |ss| ss.gesture.dragging_slider.is_some() ); + if runtime_slider_motion + { + data.view_dirty = true; + data.overlays_dirty = true; + } + if data.view_dirty + { + data.cached_view = Some( data.app.view() ); + data.view_dirty = false; + } + if data.overlays_dirty + { + data.cached_overlays = Some( data.app.overlays() ); + data.overlays_dirty = false; + } + draw_frame( &mut data ); + } + + // Focus the widget with the requested WidgetId if the app requests it + if let Some( id ) = data.app.take_focus_request() + { + let found = data.main.widget_rects.iter() + .find( |w| w.id == Some( id ) ) + .map( |w| w.flat_idx ); + if let Some( flat_idx ) = found + { + let qh = data.qh.clone(); + data.set_focus( SurfaceFocus::Main, Some( flat_idx ), &qh ); + data.main.request_redraw(); + } + } + } + + Ok( () ) +} + +/// Apply a folded [`InvalidationScope`] from one message-batch iteration: +/// dirty the relevant cache(s) so the next draw rebuilds the view via +/// [`App::view`] / [`App::overlays`], and call `request_redraw` on each +/// affected surface so the run loop's "anything to draw" check picks it up. +/// +/// `InvalidationScope::All` (the default returned by `App::invalidate_after`) +/// matches the pre-hook behaviour of broadcasting to every surface. +fn apply_invalidation( data: &mut AppData, scope: InvalidationScope ) +{ + match scope + { + InvalidationScope::All => + { + data.view_dirty = true; + data.overlays_dirty = true; + data.main.request_redraw(); + for ss in data.overlays.values_mut() + { + ss.request_redraw(); + } + } + InvalidationScope::Only( targets ) => + { + for t in targets + { + match t + { + SurfaceTarget::Main => + { + data.view_dirty = true; + data.main.request_redraw(); + } + SurfaceTarget::Overlay( id ) => + { + // `overlays()` returns a single Vec, so any + // per-overlay change forces the whole list to be + // re-queried. Coarser than per-overlay caching but + // matches the existing API shape. + data.overlays_dirty = true; + if let Some( ss ) = data.overlays.get_mut( &id ) + { + ss.request_redraw(); + } + } + } + } + } + } +} + +/// Pure overlay-id diff. Given the current set of live overlay ids and the +/// list the app just returned from [`crate::app::App::overlays`], compute +/// `( added, removed )`. +/// +/// `added` preserves the order of `next` (so creation order is deterministic); +/// `removed` is unordered (driven by HashMap iteration in the caller). +pub fn diff_overlay_ids( + current: impl IntoIterator, + next: &[ crate::app::OverlayId ], +) -> ( Vec, Vec ) +{ + let current_set: HashSet = current.into_iter().collect(); + let next_set: HashSet = next.iter().copied().collect(); + + let added: Vec<_> = next.iter().copied().filter( |id| !current_set.contains( id ) ).collect(); + let removed: Vec<_> = current_set.into_iter().filter( |id| !next_set.contains( id ) ).collect(); + ( added, removed ) +} + +/// Sync `data.overlays` with the app's current `App::overlays()` spec list: +/// destroy any overlay whose id disappeared and create a fresh `SurfaceState` +/// for every id that just appeared. Created surfaces are materialized +/// immediately if an output is already available; otherwise they stay +/// [`SurfaceKind::Pending`] and the `new_output` handler will bring them up. +fn reconcile_overlays( data: &mut AppData ) +{ + let specs = data.app.overlays(); + let next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect(); + let wanted: HashSet = next_ids.iter().copied().collect(); + + // Drop overlays that disappeared from the spec list. If the overlay had + // an in-flight drag (long-press fired), migrate that state to `main` so + // the motion / release that follows still routes through the app's drag + // handlers — apps typically hide the overlay *because* of the long-press, + // and we must not lose the drag state with the surface. + let removed: Vec<_> = data.overlays.keys() + .filter( |id| !wanted.contains( id ) ) + .copied() + .collect(); + for id in removed + { + if let Some( ss ) = data.overlays.remove( &id ) + { + if ss.gesture.long_press_fired + { + data.main.gesture.long_press_fired = true; + data.main.gesture.long_press_origin = ss.gesture.long_press_origin; + } + } + } + // Clear any stale per-device focus pointing at a destroyed overlay. + if let SurfaceFocus::Overlay( id ) = data.pointer_focus + { + if !data.overlays.contains_key( &id ) { data.pointer_focus = SurfaceFocus::Main; } + } + if let SurfaceFocus::Overlay( id ) = data.keyboard_focus + { + if !data.overlays.contains_key( &id ) { data.keyboard_focus = SurfaceFocus::Main; } + } + // Rewrite touch_focus entries pointing at a destroyed overlay to Main + // rather than dropping them. Dropping would make subsequent motion/up + // events for the same touch id default to Main *without* the long-press + // drag state, turning a release into a stray tap. + for f in data.touch_focus.values_mut() + { + if let SurfaceFocus::Overlay( id ) = *f + { + if !data.overlays.contains_key( &id ) { *f = SurfaceFocus::Main; } + } + } + + // Create overlays that just appeared. Uses field-level borrow splitting so + // the shared borrows of `layer_shell` / `xdg_shell` / `compositor_state` + // / `output_state` / `qh` can coexist with the mutable borrow of + // `overlays` inside the loop. + let layer_shell_opt = data.layer_shell.as_ref(); + let xdg_shell_opt = data.xdg_shell.as_ref(); + let output_opt = data.output_state.outputs().next(); + let cs = &data.compositor_state; + let qh = &data.qh; + let grab_seat = data.seat_state.seats().next(); + let grab_serial = data.last_input_serial; + // Snapshot the parent xdg_surface (only an xdg toplevel can host + // xdg-popups; layer-shell parents would need a different code path + // via `LayerSurface::get_popup`, which we do not currently support). + let parent_xdg = match data.main.surface + { + SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ), + _ => None, + }; + let parent_scale = data.main.scale_factor.max( 1 ) as f32; + // Snapshot the previous-frame anchor lookup table so we can resolve + // `anchor_widget_id` → `Rect` without holding a borrow on `data.main` + // across the overlay-mut loop below. + let main_widget_rects = &data.main.widget_rects; + let overlays_m = &mut data.overlays; + for spec in &specs + { + if let Some( ss ) = overlays_m.get_mut( &spec.id ) + { + // Already-live overlay: propagate size changes to the layer + // surface so apps can animate an overlay's dimensions (e.g. a + // slide-down panel whose height grows each frame). The very + // first size was applied at `materialize` time and recorded in + // `last_requested_size`, so we only commit when it actually + // differs. Commit is needed after `set_size` so the compositor + // sends a configure; the usual `on_configure` path picks up the + // new dimensions and drives the redraw. Popups don't grow / + // shrink mid-life — close and reopen instead. + if spec.size != ss.last_requested_size + { + if let SurfaceKind::Layer( ref layer_surface ) = ss.surface + { + layer_surface.set_size( spec.size.0, spec.size.1 ); + layer_surface.commit(); + ss.last_requested_size = spec.size; + } + } + // Compare the anchor at integer logical-pixel resolution: + // floating-point jitter would otherwise call `reposition` + // every frame, which several compositors react to by + // dropping the popup grab. + if let SurfaceKind::Popup( ref popup ) = ss.surface + { + if let ( Some( anchor_id ), Some( xdg_shell ) ) = ( spec.anchor_widget_id, xdg_shell_opt ) + { + if let Some( anchor_rect ) = main_widget_rects.iter() + .find( |w| w.id == Some( anchor_id ) ) + .map( |w| w.rect ) + { + let to_logical = | r: Rect | + { + ( + ( r.x / parent_scale ).round() as i32, + ( r.y / parent_scale ).round() as i32, + ( r.width / parent_scale ).round().max( 1.0 ) as i32, + ( r.height / parent_scale ).round().max( 1.0 ) as i32, + ) + }; + let new_q = to_logical( anchor_rect ); + let moved = ss.last_popup_anchor.map( |r| to_logical( r ) != new_q ).unwrap_or( true ); + if moved + { + if let Ok( positioner ) = XdgPositioner::new( xdg_shell ) + { + let ( ax, ay, aw, ah ) = new_q; + let ( spec_w, spec_h ) = spec.size; + let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 }; + let popup_h = spec_h.max( 1 ) as i32; + positioner.set_size( popup_w, popup_h ); + positioner.set_anchor_rect( ax, ay, aw, ah ); + positioner.set_anchor( PositionerAnchor::Bottom ); + positioner.set_gravity( Gravity::Bottom ); + positioner.set_constraint_adjustment( + ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX, + ); + ss.popup_reposition_token = ss.popup_reposition_token.wrapping_add( 1 ); + popup.reposition( &positioner, ss.popup_reposition_token ); + ss.last_popup_anchor = Some( anchor_rect ); + } + } + } + } + } + continue; + } + // `anchor_widget_id = Some(_)` → xdg-popup path; `None` → + // wlr-layer-shell path. + if let Some( anchor_id ) = spec.anchor_widget_id + { + let Some( xdg_shell ) = xdg_shell_opt else + { + eprintln!( "ltk: ignoring popup overlay {:?} — main surface is not an xdg-shell window", spec.id ); + continue; + }; + let Some( ref parent ) = parent_xdg else + { + eprintln!( "ltk: ignoring popup overlay {:?} — no xdg parent surface", spec.id ); + continue; + }; + let Some( anchor_rect ) = main_widget_rects.iter() + .find( |w| w.id == Some( anchor_id ) ) + .map( |w| w.rect ) + else + { + eprintln!( "ltk: popup overlay {:?} could not find anchor widget id {:?}", spec.id, anchor_id ); + continue; + }; + let positioner = match XdgPositioner::new( xdg_shell ) + { + Ok( p ) => p, + Err( e ) => + { + eprintln!( "ltk: XdgPositioner::new failed for popup {:?}: {e}", spec.id ); + continue; + } + }; + // Convert the requested popup size and the anchor rect from + // physical pixels (the layout coordinate space) to logical + // pixels — the positioner expresses everything in window + // geometry, which is logical. `size.0 == 0` is the + // "match anchor width" convention (paralleling the + // layer-shell `0 = fill` semantic): the popup is sized to + // the trigger pill so combos / selects render flush with + // the field they belong to. + let ax = ( anchor_rect.x / parent_scale ).round() as i32; + let ay = ( anchor_rect.y / parent_scale ).round() as i32; + let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32; + let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32; + let ( spec_w, spec_h ) = spec.size; + let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 }; + let popup_h = spec_h.max( 1 ) as i32; + positioner.set_size( popup_w, popup_h ); + positioner.set_anchor_rect( ax, ay, aw, ah ); + positioner.set_anchor( PositionerAnchor::Bottom ); + positioner.set_gravity( Gravity::Bottom ); + positioner.set_constraint_adjustment( + ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX, + ); + // xdg_popup.grab must be issued before the first commit + // (error 0 `invalid_grab` otherwise). `Popup::new` commits + // internally, so the lower-level `from_surface` path is + // the only one that lets the grab land in time. + let surface = match Surface::new( cs, qh ) + { + Ok( s ) => s, + Err( e ) => + { + eprintln!( "ltk: Surface::new failed for popup overlay {:?}: {e}", spec.id ); + continue; + } + }; + let popup = match Popup::from_surface( Some( parent ), &positioner, qh, surface, xdg_shell ) + { + Ok( p ) => p, + Err( e ) => + { + eprintln!( "ltk: Popup::from_surface failed for overlay {:?}: {e}", spec.id ); + continue; + } + }; + if let Some( ref seat ) = grab_seat + { + popup.xdg_popup().grab( seat, grab_serial ); + } + popup.wl_surface().commit(); + let mut ss = SurfaceState::::new( SurfaceKind::Popup( popup ), 0.0, String::new() ); + ss.last_requested_size = spec.size; + ss.last_popup_anchor = Some( anchor_rect ); + overlays_m.insert( spec.id, ss ); + continue; + } + // wlr-layer-shell path. + let Some( layer_shell ) = layer_shell_opt else + { + eprintln!( "ltk: ignoring layer-shell overlay {:?} — wlr-layer-shell not available", spec.id ); + continue; + }; + let cfg = LayerConfig + { + layer: spec.layer.to_wlr_layer(), + exclusive_zone: spec.exclusive_zone, + anchor: spec.anchor, + size: spec.size, + keyboard_exclusive: spec.keyboard_exclusive, + namespace: "ltk-overlay", + }; + let mut surface = SurfaceKind::Pending( cfg ); + if let Some( ref output ) = output_opt + { + surface.materialize( cs, layer_shell, qh, output ); + } + let mut ss = SurfaceState::::new( surface, 0.0, String::new() ); + ss.last_requested_size = spec.size; + overlays_m.insert( spec.id, ss ); + } +} diff --git a/src/gles_render/clip.rs b/src/gles_render/clip.rs new file mode 100644 index 0000000..0858da5 --- /dev/null +++ b/src/gles_render/clip.rs @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! `glScissor`-based clipping + whole-canvas fill / clear for +//! [`GlesCanvas`]. When [`GlesCanvas::set_clip_rects`] receives +//! multiple rects the bounding-box union is used as the scissor — +//! coarse, but the partial-redraw path normally clusters 1–3 rects +//! so the union is barely larger than the sum. Disjoint regions +//! would want a stencil-buffer path; not implemented today. + +use glow::HasContext; + +use crate::types::{ Color, Rect }; + +use super::GlesCanvas; + +impl GlesCanvas +{ + pub fn set_clip_rects( &mut self, rects: &[Rect] ) + { + // Scissor is global GL state; rebind our FBO first so the scissor + // applies to this canvas and not whatever target was active before. + self.activate_target(); + if rects.is_empty() + { + self.clear_clip(); + return; + } + let mut x0 = f32::INFINITY; + let mut y0 = f32::INFINITY; + let mut x1 = -f32::INFINITY; + let mut y1 = -f32::INFINITY; + for r in rects + { + x0 = x0.min( r.x ); + y0 = y0.min( r.y ); + x1 = x1.max( r.x + r.width ); + y1 = y1.max( r.y + r.height ); + } + x0 = x0.max( 0.0 ); + y0 = y0.max( 0.0 ); + x1 = x1.min( self.width as f32 ); + y1 = y1.min( self.height as f32 ); + if x1 <= x0 || y1 <= y0 + { + // Empty union — install a zero-area scissor so subsequent draws + // are no-ops without disabling the test. + self.set_scissor( Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } ); + return; + } + self.set_scissor( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } ); + } + + pub fn clear_clip( &mut self ) + { + // SAFETY: see `primitives.rs` module doc. `disable( SCISSOR_TEST )` is + // pure global-state mutation; the cached `clip_scissor` is updated + // to match below. + unsafe { self.gl.disable( glow::SCISSOR_TEST ); } + self.clip_scissor = None; + } + + /// Snapshot of the active scissor as a `Vec` (empty when no + /// scissor is set). + pub fn clip_bounds_snapshot( &self ) -> Vec + { + self.clip_scissor.map_or_else( Vec::new, |r| vec![ r ] ) + } + + /// Apply `rect` as the current scissor (top-left coords, GL bottom-left). + fn set_scissor( &mut self, rect: Rect ) + { + let ( x, y, w, h ) = self.scissor_pixels( rect ); + // SAFETY: `scissor_pixels` clamps to non-negative integers; GL accepts + // arbitrary scissor rects (regions outside the framebuffer simply + // cull all fragments). State change is mirrored in `clip_scissor`. + unsafe + { + self.gl.enable( glow::SCISSOR_TEST ); + self.gl.scissor( x, y, w, h ); + } + self.clip_scissor = Some( rect ); + } + + /// Convert a top-left rect to the bottom-left integer pixel scissor that + /// GL expects. + pub( super ) fn scissor_pixels( &self, rect: Rect ) -> ( i32, i32, i32, i32 ) + { + let x = rect.x.floor() as i32; + let w = rect.width.ceil() as i32; + let h = rect.height.ceil() as i32; + // GL origin is bottom-left, our coords are top-left. + let y_top = rect.y.floor() as i32; + let y_bottom = self.height as i32 - y_top - h; + ( x, y_bottom.max( 0 ), w.max( 0 ), h.max( 0 ) ) + } + + /// Clear to a solid color. Honours the active scissor — if a clip is set, + /// only the clipped region is filled. + pub fn fill( &mut self, color: Color ) + { + self.activate_target(); + // SAFETY: `clear` writes the configured `clear_color` into every + // fragment that survives the scissor test (which `activate_target` + // has already configured to match `clip_scissor`). + unsafe + { + self.gl.clear_color( color.r, color.g, color.b, color.a ); + self.gl.clear( glow::COLOR_BUFFER_BIT ); + } + } + + /// Clear to fully transparent. Honours the active scissor. + pub fn clear( &mut self ) + { + self.activate_target(); + // SAFETY: same as `fill`, with a transparent clear colour. + unsafe + { + self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 ); + self.gl.clear( glow::COLOR_BUFFER_BIT ); + } + } + + /// Zero the pixels inside each rect (alpha+RGB → 0). + pub fn clear_rects_transparent( &mut self, rects: &[Rect] ) + { + self.activate_target(); + let saved = self.clip_scissor; + // SAFETY: enable scissor + set transparent clear colour once for + // the whole loop; per-rect we rewrite `scissor` and clear. After + // the loop we restore `saved` via `set_scissor` / `clear_clip` + // so the cached `clip_scissor` matches the actual GL state again. + unsafe + { + self.gl.enable( glow::SCISSOR_TEST ); + self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 ); + } + for r in rects + { + let ( x, y, w, h ) = self.scissor_pixels( *r ); + if w <= 0 || h <= 0 { continue; } + // SAFETY: per-rect scissor + clear; same invariants as above. + unsafe + { + self.gl.scissor( x, y, w, h ); + self.gl.clear( glow::COLOR_BUFFER_BIT ); + } + } + // Restore the previous scissor (or disable if none was active). + match saved + { + Some( r ) => self.set_scissor( r ), + None => self.clear_clip(), + } + } + +} diff --git a/src/gles_render/framebuffer.rs b/src/gles_render/framebuffer.rs new file mode 100644 index 0000000..20ae768 --- /dev/null +++ b/src/gles_render/framebuffer.rs @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! FBO / framebuffer management for [`GlesCanvas`]: sub-canvas blit, +//! main-FBO ⇄ default-framebuffer present, lazy auxiliary FBO for +//! snapshot-based effects (Overlay blend inset shadow), and the +//! externally-exposed borrowed-texture view. +//! +//! See `primitives.rs` module doc for the canvas-wide `unsafe` contract +//! shared by every block in this file. Per-block notes below only call +//! out what is specific to the operation. + +use glow::HasContext; + +use crate::types::Rect; + +use super::helpers::{ alloc_fbo_tex, native_framebuffer_id, native_texture_id, ortho_rect }; +use super::raii::{ FboBinding, ProgramBinding }; +use super::{ BorrowedGlesTexture, GlesCanvas }; + +impl GlesCanvas +{ + pub fn blit( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32 ) + { + self.blit_fade_bottom( src, dest_x, dest_y, 0.0 ); + } + + /// Blit `src` into this canvas at `( dest_x, dest_y )`, optionally feathering + /// the last `fade_bottom_px` source rows so the bottom edge dissolves into + /// transparency instead of cutting off cleanly. Used by viewports whose + /// bottom edge is the leading edge of a slide-down animation, where a hard + /// cut against the underlying layer reads as a knife. With `fade_bottom_px + /// == 0.0` this matches [`Self::blit`] exactly. + pub fn blit_fade_bottom( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32, fade_bottom_px: f32 ) + { + self.activate_target(); + let dest = Rect + { + x: dest_x as f32, + y: dest_y as f32, + width: src.width as f32, + height: src.height as f32, + }; + let mvp = ortho_rect( self.width, self.height, dest ); + let alpha = self.global_alpha; + let height_px = src.height as f32; + let fade_clamp = fade_bottom_px.max( 0.0 ).min( height_px ); + // SAFETY: `src.fbo_tex` is owned by `src` (a `&GlesCanvas` argument) + // and outlives the call. `src` and `self` share the same `Arc` + // — verified by construction (sub-canvases are built via `sub_canvas`, + // which clones `Arc::clone(&self.gl)`) — so sampling `src`'s texture + // from `self`'s FBO is well-defined. + unsafe + { + // Both the main canvas and the sub-canvas FBO hold premultiplied + // colour, and the global blend is `(ONE, ONE_MINUS_SRC_ALPHA)` — + // the premul over-composite formula this blit needs. No temporary + // blend switch necessary. + self.gl.use_program( Some( self.sub_blit_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_subblit_mvp ), false, &mvp ); + self.gl.uniform_1_f32( Some( &self.u_subblit_opacity ), alpha ); + self.gl.uniform_1_f32( Some( &self.u_subblit_fade_bottom ), fade_clamp ); + self.gl.uniform_1_f32( Some( &self.u_subblit_height_px ), height_px ); + self.gl.active_texture( glow::TEXTURE0 ); + self.gl.bind_texture( glow::TEXTURE_2D, Some( src.fbo_tex ) ); + self.gl.uniform_1_i32( Some( &self.u_subblit_sampler ), 0 ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + self.gl.bind_texture( glow::TEXTURE_2D, None ); + } + } + + /// Re-bind our FBO + viewport + scissor as the active GL state. Cheap and + /// idempotent, called at the top of every draw / clear / clip method so + /// that switching between canvases (e.g. main → sub-canvas → main) leaves + /// each one's state correct without explicit "make active" calls. + /// + /// Why this exists: GL state (FBO binding, scissor box, viewport) is + /// global — there is no implicit per-canvas state. When rendering + /// switches between targets, every method on the active canvas must + /// reassert its own FBO + viewport, plus re-enable its own scissor (or + /// disable scissor when the canvas has no clip). + pub( super ) fn activate_target( &self ) + { + // SAFETY: rebinds canvas-owned FBO + viewport + scissor. All values + // (`self.fbo`, `self.width`, `self.height`, `self.clip_scissor`) + // live as long as `&self`, and the bind is idempotent. + unsafe + { + self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) ); + self.gl.viewport( 0, 0, self.width as i32, self.height as i32 ); + match self.clip_scissor + { + Some( r ) => + { + let ( x, y, w, h ) = self.scissor_pixels( r ); + self.gl.enable( glow::SCISSOR_TEST ); + self.gl.scissor( x, y, w, h ); + } + None => + { + self.gl.disable( glow::SCISSOR_TEST ); + } + } + } + } + + /// Return a borrowed descriptor for the FBO color texture + /// containing the latest rendered pixels. + /// + /// `y_inverted` is `true`: the FBO uses GL's native lower-left + /// origin, so row 0 in texture memory is the bottom of the + /// rendered image. Consumers that follow the same convention flip + /// during sampling when this flag is set, producing a correctly- + /// oriented result. The CPU-side counterpart + /// [`Self::read_rgba_pixels`] does the same flip inline so the + /// byte buffer is top-down. + pub fn borrowed_texture( &self ) -> BorrowedGlesTexture + { + BorrowedGlesTexture + { + texture_id: native_texture_id( self.fbo_tex ), + framebuffer_id: native_framebuffer_id( self.fbo ), + texture: self.fbo_tex, + framebuffer: self.fbo, + width: self.width, + height: self.height, + premultiplied: true, + y_inverted: true, + } + } + + /// Read the FBO color attachment into `out` as tightly packed RGBA8, + /// top-left row first. + /// + /// This is a compatibility escape hatch. It forces a GPU→CPU sync and + /// should not be used in steady-state hot paths. + pub fn read_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String> + { + let needed = self.width as usize * self.height as usize * 4; + if out.len() < needed + { + return Err( format!( + "read_rgba_pixels needs {needed} bytes, got {}", + out.len(), + ) ); + } + + let mut raw = vec![ 0_u8; needed ]; + // SAFETY: `raw.len() == needed == width * height * 4` and PACK_ALIGNMENT + // is set to 1, so `read_pixels` writes exactly `needed` bytes into a + // buffer of exactly that size. `RGBA + UNSIGNED_BYTE` is the only + // guaranteed-readable format on every GLES2/3 driver. + unsafe + { + self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) ); + self.gl.pixel_store_i32( glow::PACK_ALIGNMENT, 1 ); + self.gl.read_pixels( + 0, + 0, + self.width as i32, + self.height as i32, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelPackData::Slice( Some( &mut raw ) ), + ); + } + let stride = self.width as usize * 4; + for y in 0..self.height as usize + { + let src = ( self.height as usize - 1 - y ) * stride; + let dst = y * stride; + out[ dst..dst + stride ].copy_from_slice( &raw[ src..src + stride ] ); + } + Ok( () ) + } + + /// Lazily allocate the auxiliary FBO+texture pair used as a snapshot of + /// `fbo` for framebuffer-fetch-style effects (Overlay blend, + /// backdrop-blur source). The pair is sized to match the canvas so + /// `gl_FragCoord.xy / canvas_size` samples the right texel. + /// + /// Returns the texture handle of `aux_a`. The FBO is only needed for + /// the backdrop blur passes that write into `aux_b`; Overlay only + /// reads from `aux_a`, so this helper keeps the blur-only `aux_b` + /// allocation deferred until it is actually needed. + fn ensure_aux_a( &mut self ) -> glow::Texture + { + if self.aux_a.is_none() + { + // SAFETY: `alloc_fbo_tex` is `unsafe fn`; its requirement (current + // GL context) holds. Same FBO build / completeness assertion as + // `setup.rs::new`. We deliberately leave `aux_a`'s FBO as the live + // binding — the next draw goes through `activate_target` which + // rebinds `self.fbo`. + unsafe + { + let fbo = self.gl.create_framebuffer().expect( "aux_a FBO" ); + let tex = alloc_fbo_tex( &self.gl, self.version, self.width, self.height ); + self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) ); + self.gl.framebuffer_texture_2d + ( + glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, + glow::TEXTURE_2D, Some( tex ), 0, + ); + let status = self.gl.check_framebuffer_status( glow::FRAMEBUFFER ); + assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "aux_a FBO incomplete: 0x{status:x}" ); + self.aux_a = Some( ( fbo, tex ) ); + } + } + self.aux_a.expect( "just allocated" ).1 + } + + /// Snapshot variant that additionally clamps `region` to the active + /// scissor. Safe only for shaders that sample the snapshot at + /// exactly one point per fragment. + pub( super ) fn snapshot_fbo_region_tight( &mut self, region: Rect ) + { + self.snapshot_fbo_region_impl( region, true ) + } + + fn snapshot_fbo_region_impl( &mut self, region: Rect, intersect_scissor: bool ) + { + let aux_tex = self.ensure_aux_a(); + // Clamp `region` to canvas bounds. `copy_tex_sub_image_2d` would + // generate `INVALID_VALUE` (or undefined behaviour on some + // drivers) if the source rect extends outside the framebuffer. + let cw = self.width as f32; + let ch = self.height as f32; + let mut x0 = region.x.max( 0.0 ); + let mut y0_top = region.y.max( 0.0 ); + let mut x1 = ( region.x + region.width ).min( cw ); + let mut y1_top = ( region.y + region.height ).min( ch ); + if intersect_scissor + { + if let Some( clip ) = self.clip_scissor + { + x0 = x0.max( clip.x ); + y0_top = y0_top.max( clip.y ); + x1 = x1.min( clip.x + clip.width ); + y1_top = y1_top.min( clip.y + clip.height ); + } + } + let w = ( x1 - x0 ).floor() as i32; + let h = ( y1_top - y0_top ).floor() as i32; + if w <= 0 || h <= 0 { return; } + // GL framebuffer origin is bottom-left; our rect is top-left. + let src_x = x0.floor() as i32; + let src_y = self.height as i32 - y0_top.floor() as i32 - h; + // SAFETY: the four bounds checks above guarantee `(src_x, src_y, w, h)` + // lies fully inside `self.fbo`'s colour attachment, so + // `copy_tex_sub_image_2d` will not raise INVALID_VALUE. `aux_tex` was + // allocated through `ensure_aux_a` to canvas dimensions, so the + // destination region is also in-bounds. + unsafe + { + self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) ); + self.gl.bind_texture( glow::TEXTURE_2D, Some( aux_tex ) ); + self.gl.copy_tex_sub_image_2d + ( + glow::TEXTURE_2D, 0, + src_x, src_y, + src_x, src_y, + w, h, + ); + self.gl.bind_texture( glow::TEXTURE_2D, None ); + } + } + + /// Drop both auxiliary FBO+texture pairs if allocated. Called from + /// [`Self::resize`] so the next effect re-allocates at the new size. + pub( super ) fn invalidate_aux( &mut self ) + { + // SAFETY: each (fbo, tex) pair was created through `self.gl` in + // `ensure_aux_a` / `ensure_aux_b`, so deleting through the same + // context is well-defined. `take()` ensures we never double-delete. + unsafe + { + if let Some( ( fbo, tex ) ) = self.aux_a.take() + { + self.gl.delete_framebuffer( fbo ); + self.gl.delete_texture( tex ); + } + if let Some( ( fbo, tex ) ) = self.aux_b.take() + { + self.gl.delete_framebuffer( fbo ); + self.gl.delete_texture( tex ); + } + } + } + + /// Blit the FBO color attachment onto the default framebuffer (the EGL + /// window). Caller is responsible for the `eglSwapBuffers` that + /// publishes the result. After present, the FBO is rebound so the next + /// frame's draws keep accumulating into the shadow canvas. + /// + /// The blit always covers the full surface — partial-redraw still saves + /// work upstream (only changed widget pixels are repainted into the + /// FBO), but the FBO→FB0 transfer itself is a single cheap fullscreen + /// op. + pub fn present( &mut self ) + { + // Scoped guards: bind the default framebuffer (id 0) and the + // blit program for the duration of this fn. On Drop they restore + // `self.fbo` and "no program", so any future early-return / panic + // in the blit body cannot leave the canvas pointing at the wrong + // FBO or program. The viewport, blend and scissor are restored + // inline below — they are non-resource state that does not need + // the guard treatment because `activate_target` rewrites them on + // every subsequent draw. + // + // SAFETY: GL context is current (canvas invariant). `self.fbo` is + // the canvas-owned FBO from `setup.rs::new`. `self.blit_program` + // was linked in `setup.rs::new`. Restoring "no program active" + // (`None`) is always sound. + let _fbo = unsafe { FboBinding::scoped( &self.gl, None, Some( self.fbo ) ) }; + let _prog = unsafe { ProgramBinding::scoped( &self.gl, Some( self.blit_program ), None ) }; + // SAFETY: see above. The block sets up the blit pipeline state, + // draws a fullscreen quad sampling `fbo_tex`, then restores blend + // and viewport so the next frame's draws inherit the canvas-wide + // defaults. FBO + program are restored by the guards on scope exit. + unsafe + { + self.gl.viewport( 0, 0, self.width as i32, self.height as i32 ); + self.gl.disable( glow::SCISSOR_TEST ); + self.gl.disable( glow::BLEND ); + self.gl.active_texture( glow::TEXTURE0 ); + self.gl.bind_texture( glow::TEXTURE_2D, Some( self.fbo_tex ) ); + self.gl.uniform_1_i32( Some( &self.u_blit_sampler ), 0 ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + self.gl.bind_texture( glow::TEXTURE_2D, None ); + self.gl.enable( glow::BLEND ); + self.gl.viewport( 0, 0, self.width as i32, self.height as i32 ); + } + // Scissor was disabled above; reflect that in our cached state. + self.clip_scissor = None; + // Guards drop here: FBO → self.fbo, program → None. + } +} diff --git a/src/gles_render/helpers.rs b/src/gles_render/helpers.rs new file mode 100644 index 0000000..e2428e6 --- /dev/null +++ b/src/gles_render/helpers.rs @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Backend-neutral free helpers for the GLES renderer: MVP matrix +//! construction, shader compilation, FBO / texture allocation, +//! system-font lookup, small typed-handle extractors. Visible only +//! within `crate::gles_render` — callers always go through +//! `GlesCanvas`'s public methods. + +use glow::HasContext; + +use crate::types::Rect; + +use super::GlesVersion; + +pub( super ) fn ortho_rect( vp_w: u32, vp_h: u32, rect: Rect ) -> [f32; 16] +{ + let w = vp_w as f32; + let h = vp_h as f32; + let sx = rect.width * 2.0 / w; + let sy = rect.height * 2.0 / h; + let tx = rect.x * 2.0 / w - 1.0; + let ty = 1.0 - rect.y * 2.0 / h - sy; + + [ + sx, 0.0, 0.0, 0.0, + 0.0, sy, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + tx, ty, 0.0, 1.0, + ] +} + +/// Upload `data` as a 2D RGBA texture, premultiplying alpha into the +/// upload buffer. +/// +/// `data` is straight-alpha (the format CPU PNG / JPG decoders +/// produce). GL_LINEAR sampling interpolates RGB and A independently +/// across texels: at the antialiased edge of an icon — say a fully +/// opaque black texel next to a fully transparent white texel — +/// straight-alpha interpolation midway gives `( 0.5, 0.5, 0.5, 0.5 )` +/// which composes onto the destination as a 50 % gray halo, while +/// premultiplied interpolation gives `( 0, 0, 0, 0.5 )` and composes +/// as transparent black. Premultiplying once at upload eliminates +/// the halo on every later draw. +/// +/// Defensive: when the declared `w × h × 4` size does not match +/// `data.len()`, the function refuses the upload, logs once via +/// stderr, and substitutes a 1×1 transparent placeholder. The GL +/// driver would otherwise read past the slice end inside +/// `tex_image_2d`, since it trusts the dimensions over the slice +/// length. +pub( super ) fn upload_rgba_texture( gl: &glow::Context, version: GlesVersion, data: &[u8], w: i32, h: i32 ) -> glow::Texture +{ + let expected = ( w as i64 ).saturating_mul( h as i64 ).saturating_mul( 4 ); + let valid = w > 0 && h > 0 && expected >= 0 && expected as usize == data.len(); + let placeholder: [u8; 4] = [ 0, 0, 0, 0 ]; + let ( safe_w, safe_h, safe_data ): ( i32, i32, &[u8] ) = if valid + { + ( w, h, data ) + } + else + { + eprintln!( + "[ltk] upload_rgba_texture: refusing malformed upload — \ + {w}×{h} declared, {} bytes provided, expected {}", + data.len(), + expected.max( 0 ), + ); + ( 1, 1, &placeholder[..] ) + }; + + let mut premul = Vec::with_capacity( safe_data.len() ); + for px in safe_data.chunks_exact( 4 ) + { + let a = px[3] as u32; + // `(c * a + 127) / 255` — round-to-nearest integer scale. + // Plain `c * a / 255` truncates, leaving fully-opaque pixels + // with rgb < their straight value (visibly darker icons). + premul.push( ( ( px[0] as u32 * a + 127 ) / 255 ) as u8 ); + premul.push( ( ( px[1] as u32 * a + 127 ) / 255 ) as u8 ); + premul.push( ( ( px[2] as u32 * a + 127 ) / 255 ) as u8 ); + premul.push( px[3] ); + } + // `RGBA8` (sized) on GLES 3 forces 8-bit-per-channel storage; the + // unsized `RGBA` token leaves the format up to the driver and some + // mobile GPUs pick a 4-bits-per-channel or 565+A4 layout for it, + // which shows up as banded / colour-quantised icons. ES 2 has no + // `RGBA8` constant, so we fall back to the unsized form there + // (matching `alloc_fbo_tex`). + let internal_format = match version + { + GlesVersion::V3 => glow::RGBA8 as i32, + GlesVersion::V2 => glow::RGBA as i32, + }; + // SAFETY: caller's GL context is current. The most important + // invariant is on the upload size: the validity check above + // guarantees `safe_w * safe_h * 4 == safe_data.len() == premul.len()`, + // or replaces the upload with a 1×1 transparent placeholder when the + // caller provided malformed dimensions. Without this guard the GLES + // driver trusts the dimensions and reads past the slice end inside + // `tex_image_2d` (the original UB this defensive code prevents). + // `RGBA + UNSIGNED_BYTE` is universally supported. We unbind on + // exit to avoid stranding TEXTURE_2D bound to the new texture. + unsafe + { + let tex = gl.create_texture().unwrap(); + gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32 ); + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32 ); + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 ); + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 ); + gl.tex_image_2d( + glow::TEXTURE_2D, 0, internal_format, + safe_w, safe_h, 0, glow::RGBA, glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice( Some( &premul ) ), + ); + gl.bind_texture( glow::TEXTURE_2D, None ); + tex + } +} + +/// Allocate a fresh color texture sized for the FBO. Internal format is sized +/// (`GL_RGBA8`) on ES3 and unsized (`GL_RGBA`) on ES2 — the unsized form is +/// required for color-renderable textures on ES2 drivers. +pub( super ) unsafe fn alloc_fbo_tex( gl: &glow::Context, version: GlesVersion, w: u32, h: u32 ) -> glow::Texture +{ + // SAFETY: caller guarantees the GL context bound to `gl` is current + // on this thread. `tex_image_2d` with a `None` data slice allocates + // uninitialised storage of size `w*h*4` bytes (RGBA8 / unsized RGBA); + // the size and format combination is valid on every GLES2 / GLES3 + // driver. The bind / unbind pair leaves TEXTURE_2D unbound on exit + // so we don't strand a binding the caller might rely on. + unsafe + { + let tex = gl.create_texture().expect( "create_texture" ); + gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); + let internal_format = match version + { + GlesVersion::V3 => glow::RGBA8 as i32, + GlesVersion::V2 => glow::RGBA as i32, + }; + gl.tex_image_2d( + glow::TEXTURE_2D, 0, internal_format, + w as i32, h as i32, 0, glow::RGBA, glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice( None ), + ); + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 ); + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 ); + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 ); + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 ); + gl.bind_texture( glow::TEXTURE_2D, None ); + tex + } +} + +pub( super ) fn upload_alpha_texture( gl: &glow::Context, data: &[u8], w: i32, h: i32 ) -> glow::Texture +{ + // SAFETY: caller's GL context is current. Caller is responsible for the + // `data.len() == w * h` invariant — `upload_alpha_texture` is only called + // from the glyph atlas path on bitmaps fontdue produced at the same + // dimensions. UNPACK_ALIGNMENT is set to 1 for the upload (1 byte/pixel) + // and restored to the GL default of 4 immediately after, so subsequent + // uploads are not affected. + unsafe + { + let tex = gl.create_texture().unwrap(); + gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); + // NEAREST, not LINEAR. Glyph atlases are drawn 1:1 with their bitmap + // (dest size = texture size, integer-aligned position). Mathematically + // LINEAR at an exact texel center collapses to the texel value, but + // mediump precision in the fragment shader (and the `1 - v_uv.y` flip) + // can drift the sample point a fraction of a texel off-center, and the + // LINEAR filter then blends with neighbours — visible as soft, washed + // stems, especially at small sizes. NEAREST snaps to the correct texel + // every time, matching the software path's pixel-perfect bitmap copy. + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 ); + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 ); + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 ); + gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 ); + // GL_LUMINANCE is 1 byte/pixel; default UNPACK_ALIGNMENT (4) misreads + // any row whose width is not a multiple of 4, scrambling those glyphs. + gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 1 ); + gl.tex_image_2d( + glow::TEXTURE_2D, 0, glow::LUMINANCE as i32, + w, h, 0, glow::LUMINANCE, glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice( Some( data ) ), + ); + gl.pixel_store_i32( glow::UNPACK_ALIGNMENT, 4 ); + gl.bind_texture( glow::TEXTURE_2D, None ); + tex + } +} + +pub( super ) fn compile_program( gl: &glow::Context, vert_src: &str, frag_src: &str ) -> glow::Program +{ + // SAFETY: caller's GL context is current. The shaders are compiled and + // linked inside this block; on success `program` is a fresh, fully linked + // GL program object. Vertex / fragment shaders are released with + // `delete_shader` after attach + link — they are flagged for deletion and + // freed when the program is deleted, which is the canonical pattern. + // Compile / link asserts panic with the driver's info log instead of + // returning a half-broken program (callers cannot recover anyway). + unsafe + { + let program = gl.create_program().unwrap(); + let vs = gl.create_shader( glow::VERTEX_SHADER ).unwrap(); + gl.shader_source( vs, vert_src ); + gl.compile_shader( vs ); + assert!( gl.get_shader_compile_status( vs ), "VS: {}", gl.get_shader_info_log( vs ) ); + + let fs = gl.create_shader( glow::FRAGMENT_SHADER ).unwrap(); + gl.shader_source( fs, frag_src ); + gl.compile_shader( fs ); + assert!( gl.get_shader_compile_status( fs ), "FS: {}", gl.get_shader_info_log( fs ) ); + + gl.attach_shader( program, vs ); + gl.attach_shader( program, fs ); + gl.bind_attrib_location( program, 0, "a_pos" ); + gl.link_program( program ); + assert!( gl.get_program_link_status( program ), "Link: {}", gl.get_program_info_log( program ) ); + + gl.delete_shader( vs ); + gl.delete_shader( fs ); + program + } +} + +pub( super ) fn bytemuck_cast_slice( floats: &[f32] ) -> &[u8] +{ + // SAFETY: `f32` has the same allocation provenance / validity as + // `[u8; 4]` for any bit pattern (every bit pattern is a valid byte; + // `f32` admits NaN payloads but those are valid `u8` reads). The + // returned slice's lifetime is tied to the input slice's lifetime + // through the function signature so the read window cannot outlive + // the underlying storage. `len * 4` cannot overflow `usize` because + // it would require an `f32` slice exceeding `usize::MAX / 4` bytes + // — physically impossible on any addressable target. + unsafe + { + std::slice::from_raw_parts( + floats.as_ptr() as *const u8, + floats.len() * 4, + ) + } +} + +pub( super ) fn native_texture_id( texture: glow::Texture ) -> u32 +{ + texture.0.get() +} + +pub( super ) fn native_framebuffer_id( framebuffer: glow::Framebuffer ) -> u32 +{ + framebuffer.0.get() +} + +const SYSTEM_FONT_CANDIDATES: &[&str] = +&[ + // Debian `fonts-sora` — the canonical path the `ltk-theme-default` + // package depends on. Listed first so Sora wins as the default + // font whenever that package is installed. + "/usr/share/fonts/opentype/sora/Sora-Regular.otf", + "/usr/share/fonts/truetype/sora/Sora-Regular.ttf", + "/usr/share/fonts/sora/Sora-Regular.ttf", + "/usr/share/fonts/TTF/Sora-Regular.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/freefont/FreeSans.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", +]; + +/// Load the bytes of a default system font. Tries +/// [`SYSTEM_FONT_CANDIDATES`] in order; falls back to the embedded +/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB, +/// OFL 1.1) when nothing matches or the file cannot be read. Always +/// returns usable bytes so canvas construction never panics on a +/// system without the expected fonts. +pub( super ) fn load_default_font_bytes() -> Vec +{ + for path in SYSTEM_FONT_CANDIDATES.iter() + { + if std::path::Path::new( path ).exists() + { + if let Ok( bytes ) = std::fs::read( path ) + { + return bytes; + } + } + } + crate::theme::fallback::FALLBACK_FONT.to_vec() +} diff --git a/src/gles_render/image.rs b/src/gles_render/image.rs new file mode 100644 index 0000000..52b1745 --- /dev/null +++ b/src/gles_render/image.rs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Raster-image draw path for [`GlesCanvas`]. Uploads the RGBA +//! bytes as a premultiplied-alpha texture (cached by content +//! fingerprint so repeated draws of the same buffer do not +//! re-upload) and composites it through the texture shader, +//! honouring the canvas' `global_alpha` via the opacity uniform. +//! +//! The cache is keyed by `(size, fingerprint)` where the fingerprint +//! is a 64-bit hash sampled from the RGBA bytes. This avoids the +//! address-reuse trap of a pointer-based key — when an `Arc>` +//! gets dropped and the allocator hands the same heap address to a +//! *different* buffer on the next frame, a pointer-keyed cache would +//! serve the stale texture for the new content. Content-keying makes +//! that impossible: identical bytes → identical key, regardless of +//! where they live in memory. + +use std::collections::hash_map::DefaultHasher; +use std::hash::{ Hash, Hasher }; + +use glow::HasContext; + +use crate::types::Rect; + +use super::helpers::{ ortho_rect, upload_rgba_texture }; +use super::GlesCanvas; + +/// Compute a 64-bit fingerprint of an RGBA buffer for the texture +/// cache. Hashes the full byte slice for small buffers (icons, +/// thumbnails — below 16 KB ≈ 64×64 RGBA), and falls back to a +/// strided 8 × 512-byte sample for anything larger so a wallpaper +/// blit does not pay an 8 MB hash on every frame. Both modes +/// distinguish the chevron-icon-style cases that motivated the move +/// to content-keying — the SVG-rasterised buffers differ across +/// most of their interior bytes, not just at the corners. +fn fingerprint_rgba( bytes: &[u8] ) -> u64 +{ + const FULL_HASH_THRESHOLD: usize = 16 * 1024; + const SAMPLE_CHUNKS: usize = 8; + const SAMPLE_CHUNK_BYTES: usize = 512; + + let mut h = DefaultHasher::new(); + let n = bytes.len(); + n.hash( &mut h ); + if n <= FULL_HASH_THRESHOLD + { + bytes.hash( &mut h ); + } else { + let stride = n / SAMPLE_CHUNKS; + for i in 0..SAMPLE_CHUNKS + { + let pos = ( i * stride ).min( n - SAMPLE_CHUNK_BYTES ); + bytes[ pos..pos + SAMPLE_CHUNK_BYTES ].hash( &mut h ); + } + } + h.finish() +} + +impl GlesCanvas +{ + /// Blit RGBA image data scaled to dest rect with opacity. + /// + /// Defensive: rejects buffers whose declared `img_w × img_h × 4` does not + /// match `rgba_data.len()`. The mismatch path logs a one-line warning + /// and returns without uploading or drawing — the same boundary that + /// the internal `upload_rgba_texture` helper enforces, raised one + /// level so the cache key is never seeded with a bogus mapping. + pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 ) + { + let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 ); + if img_w == 0 || img_h == 0 || rgba_data.len() != expected + { + eprintln!( + "[ltk] GlesCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}", + img_w, img_h, rgba_data.len(), expected, + ); + return; + } + + self.activate_target(); + // Content-fingerprint key — see the module doc for the + // rationale. The (w, h) prefix means a pathological pair of + // buffers with identical bytes but different declared sizes + // stays distinct (cannot happen for valid input, defence in + // depth). + let cache_key = ( img_w, img_h, fingerprint_rgba( rgba_data ) ); + + if !self.image_cache.contains_key( &cache_key ) + { + let tex = upload_rgba_texture( &self.gl, self.version, rgba_data, img_w as i32, img_h as i32 ); + self.image_cache.insert( cache_key, ( tex, img_w, img_h ) ); + } + + // Snap to integer pixels. With GL_LINEAR sampling, a + // fractional `dest.x` / `dest.y` makes every fragment sample + // at sub-texel offset — bilinear blends adjacent texels and + // the result reads as ~1 px softer than the source. At + // integer offset every fragment center maps to a texel + // centre and the bilinear collapses to identity, so a 1:1 + // sampled icon renders crisp. + let dest = Rect + { + x: dest.x.round(), + y: dest.y.round(), + width: dest.width.round(), + height: dest.height.round(), + }; + + if let Some( ( tex, _, _ ) ) = self.image_cache.get( &cache_key ) + { + let mvp = ortho_rect( self.width, self.height, dest ); + let alpha = opacity * self.global_alpha; + // SAFETY: see `primitives.rs` module doc. `*tex` is owned by + // `self.image_cache` so it outlives the call. The image cache + // stays valid as long as `&mut self` is held — no eviction + // path runs concurrently with the draw. + unsafe + { + self.gl.use_program( Some( self.tex_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_tex_mvp ), false, &mvp ); + self.gl.uniform_1_f32( Some( &self.u_tex_opacity ), alpha ); + self.gl.active_texture( glow::TEXTURE0 ); + self.gl.bind_texture( glow::TEXTURE_2D, Some( *tex ) ); + self.gl.uniform_1_i32( Some( &self.u_tex_sampler ), 0 ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + self.gl.bind_texture( glow::TEXTURE_2D, None ); + } + } + } + + /// Draw an externally-owned GL texture into `dest`. + /// + /// The caller owns the texture and is responsible for keeping it valid + /// for the duration of this call. No upload, no caching — used to + /// composite content rendered by another GL producer (web engine, + /// video decoder, …) into the LTK widget tree. + pub fn draw_external_texture( &mut self, texture: glow::Texture, dest: Rect, opacity: f32 ) + { + self.activate_target(); + let dest = Rect + { + x: dest.x.round(), + y: dest.y.round(), + width: dest.width.round(), + height: dest.height.round(), + }; + let mvp = ortho_rect( self.width, self.height, dest ); + let alpha = opacity * self.global_alpha; + // SAFETY: caller-owned texture must outlive this call. We only + // sample it; we never delete or reassign the GL name. + unsafe + { + self.gl.use_program( Some( self.tex_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_tex_mvp ), false, &mvp ); + self.gl.uniform_1_f32( Some( &self.u_tex_opacity ), alpha ); + self.gl.active_texture( glow::TEXTURE0 ); + self.gl.bind_texture( glow::TEXTURE_2D, Some( texture ) ); + self.gl.uniform_1_i32( Some( &self.u_tex_sampler ), 0 ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + self.gl.bind_texture( glow::TEXTURE_2D, None ); + } + } +} diff --git a/src/gles_render/mod.rs b/src/gles_render/mod.rs new file mode 100644 index 0000000..7984efd --- /dev/null +++ b/src/gles_render/mod.rs @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! GPU-accelerated rendering backend using EGL + GLES2 / GLES3. +//! +//! Mirrors the public surface of the software backend so that +//! [`crate::core::Canvas`] can route widget draw calls to either backend +//! by `match self`. The EGL context bootstrap lives in +//! [`crate::egl_context`] — this module is just the renderer that runs +//! once a context is current. +//! +//! Clipping is implemented with `glScissor`. When +//! [`GlesCanvas::set_clip_rects`] receives multiple rects the +//! bounding-box union is used as the scissor — coarse, but the +//! partial-redraw path normally clusters the dirty rects of 1–3 +//! widgets so the union is barely larger than the sum. Disjoint +//! regions would want a stencil-buffer path; not implemented today. +//! +//! # Submodule layout +//! +//! * `setup` — `GlesCanvas::{new, sub_canvas, resize, set_font_registry, +//! font_for, dpi/alpha accessors}`. +//! * `framebuffer` — `GlesCanvas::{blit, present, activate_target, +//! borrowed_texture, read_rgba_pixels, ensure_aux_*, snapshot_fbo_region, +//! fill_backdrop, invalidate_aux}` — everything that manipulates the +//! FBO or auxiliary snapshot textures. +//! * `clip` — `GlesCanvas::{set_clip_rects, clear_clip, set_scissor, +//! scissor_pixels, fill, clear, clear_rects_transparent}`. +//! * `primitives` — `GlesCanvas::{fill_rect, fill_linear_gradient_rect, +//! fill_radial_gradient_rect, fill_shadow_outer, fill_shadow_inset, +//! stroke_rect, draw_line}`. +//! * `text` — `GlesCanvas::{draw_text, measure_text, draw_glyph_texture}`. +//! * `image` — `GlesCanvas::draw_image_data`. +//! * `shaders` — GLSL ES 1.00 shader sources (const strings). +//! * `helpers` — free functions: `ortho_rect`, `compile_program`, +//! `alloc_fbo_tex`, `upload_*_texture`, handle extractors, +//! `find_font` + `SYSTEM_FONT_CANDIDATES`. +//! * `raii` — `FboBinding` / `ProgramBinding` scoped guards for the +//! handful of operations that change global GL state for a scope +//! and must guarantee restoration even on early return / panic +//! (presently only `present`). The renderer otherwise relies on +//! `activate_target`'s lazy re-bind at the entry of each draw +//! method — see the module's own doc for when to use the guards +//! and when not to. + +use std::collections::HashMap; +use std::sync::Arc; + +use fontdue::Font; +use glow::HasContext; + +use crate::theme::FontRegistry; +use crate::types::Rect; + +pub( crate ) mod shaders; +pub( crate ) mod helpers; +pub( crate ) mod setup; +pub( crate ) mod framebuffer; +pub( crate ) mod clip; +pub( crate ) mod primitives; +pub( crate ) mod text; +pub( crate ) mod image; +mod raii; + +// ─── Public types ──────────────────────────────────────────────────────────── + +/// Which GLES profile the active context is. Stored so per-frame +/// fast-paths can be selected without re-querying GL. +#[ derive( Clone, Copy, PartialEq, Eq, Debug ) ] +pub enum GlesVersion +{ + V2, + V3, +} + +/// Borrowed view of the texture backing a [`GlesCanvas`]. +/// +/// The texture and framebuffer remain owned by ltk. Consumers may +/// sample the texture while the canvas is alive, but must not delete +/// or take ownership of the GL names. A resize can replace both +/// names, so callers should query this after rendering/resizing, not +/// cache it indefinitely. +#[ derive( Clone, Copy, Debug ) ] +pub struct BorrowedGlesTexture +{ + /// Native GL texture name for the canvas color attachment. + pub texture_id: u32, + /// Native GL framebuffer name that owns `texture_id` as color attachment 0. + pub framebuffer_id: u32, + /// Glow texture handle for callers already using glow. + pub texture: glow::Texture, + /// Glow framebuffer handle for callers already using glow. + pub framebuffer: glow::Framebuffer, + /// Texture width in physical pixels. + pub width: u32, + /// Texture height in physical pixels. + pub height: u32, + /// The texture contains RGBA pixels with premultiplied alpha. + pub premultiplied: bool, + /// Whether consumers should treat the texture as vertically inverted. + pub y_inverted: bool, +} + +/// Cached glyph: pre-rasterized bitmap uploaded as a GL texture. +pub ( super ) struct GlyphEntry +{ + pub ( super ) texture: glow::Texture, + pub ( super ) metrics: fontdue::Metrics, + pub ( super ) tex_w: i32, + pub ( super ) tex_h: i32, +} + +// ─── GlesCanvas ────────────────────────────────────────────────────────────── + +/// GPU-accelerated canvas using EGL + GLES2/3. +/// +/// Renders into a persistent FBO (the "shadow canvas") so widget +/// pixels survive across frames — this mirrors the software pixmap +/// model and is what enables the partial-redraw path on the GPU side. +/// `Self::present` blits the FBO onto the default framebuffer; the +/// caller is responsible for the `eglSwapBuffers` that follows. +pub struct GlesCanvas +{ + pub gl: Arc, + pub version: GlesVersion, + /// Default font loaded from the system via `helpers::find_font`. + /// Kept as a fallback for callers that do not route through the + /// theme registry. + pub font: Arc, + /// Optional theme font registry. Populated by the runtime after + /// theme load; until then it is `None` and [`Self::font_for`] + /// falls back to [`Self::font`]. + pub font_registry: Option>, + pub dpi_scale: f32, + pub global_alpha: f32, + pub width: u32, + pub height: u32, + + // Shader programs. `Program` is a Copy handle; sub-canvases share + // these with their parent (no reference counting needed since they + // outlive the process). + rect_program: glow::Program, + tex_program: glow::Program, + glyph_program: glow::Program, + blit_program: glow::Program, + sub_blit_program: glow::Program, + linear_gradient_program: glow::Program, + radial_gradient_program: glow::Program, + shadow_outer_program: glow::Program, + shadow_inset_program: glow::Program, + + // Shared geometry (a unit quad as two triangles) + quad_vao: glow::VertexArray, + _quad_vbo: glow::Buffer, + + // Uniform locations for rect shader + u_rect_mvp: glow::UniformLocation, + u_rect_color: glow::UniformLocation, + u_rect_size: glow::UniformLocation, + u_rect_radii: glow::UniformLocation, + u_rect_stroke: glow::UniformLocation, + u_rect_pad: glow::UniformLocation, + + // Uniform locations for texture shader + u_tex_mvp: glow::UniformLocation, + u_tex_opacity: glow::UniformLocation, + u_tex_sampler: glow::UniformLocation, + + // Uniform locations for glyph shader + u_glyph_mvp: glow::UniformLocation, + u_glyph_color: glow::UniformLocation, + u_glyph_opacity: glow::UniformLocation, + u_glyph_sampler: glow::UniformLocation, + + // Uniform location for blit shader + u_blit_sampler: glow::UniformLocation, + + // Uniform locations for sub-canvas blit shader + u_subblit_mvp: glow::UniformLocation, + u_subblit_sampler: glow::UniformLocation, + u_subblit_opacity: glow::UniformLocation, + u_subblit_fade_bottom: glow::UniformLocation, + u_subblit_height_px: glow::UniformLocation, + + // Uniform locations for the linear gradient shader + u_lingrad_mvp: glow::UniformLocation, + u_lingrad_lut: glow::UniformLocation, + u_lingrad_dir: glow::UniformLocation, + u_lingrad_size: glow::UniformLocation, + u_lingrad_line_length: glow::UniformLocation, + u_lingrad_radii: glow::UniformLocation, + u_lingrad_pad: glow::UniformLocation, + u_lingrad_lut_domain_min: glow::UniformLocation, + u_lingrad_lut_domain_span: glow::UniformLocation, + + // Uniform locations for the radial gradient shader + u_radgrad_mvp: glow::UniformLocation, + u_radgrad_lut: glow::UniformLocation, + u_radgrad_center: glow::UniformLocation, + u_radgrad_radius_frac: glow::UniformLocation, + u_radgrad_size: glow::UniformLocation, + u_radgrad_radii: glow::UniformLocation, + u_radgrad_pad: glow::UniformLocation, + u_radgrad_lut_domain_min: glow::UniformLocation, + u_radgrad_lut_domain_span: glow::UniformLocation, + + // Uniform locations for the outer shadow shader + u_shadow_mvp: glow::UniformLocation, + u_shadow_size: glow::UniformLocation, + u_shadow_padding: glow::UniformLocation, + u_shadow_radii: glow::UniformLocation, + u_shadow_spread: glow::UniformLocation, + u_shadow_sigma: glow::UniformLocation, + u_shadow_color: glow::UniformLocation, + + // Uniform locations for the inner (inset) shadow shader + u_inset_mvp: glow::UniformLocation, + u_inset_size: glow::UniformLocation, + u_inset_padding: glow::UniformLocation, + u_inset_radii: glow::UniformLocation, + u_inset_spread: glow::UniformLocation, + u_inset_sigma: glow::UniformLocation, + u_inset_offset: glow::UniformLocation, + u_inset_color: glow::UniformLocation, + + /// Inset-shadow shader variant for `BlendMode::Overlay`. Distinct + /// from [`Self::shadow_inset_program`] because CSS Overlay cannot + /// be expressed with fixed-function blend — this shader samples + /// the just-snapshotted FBO content (via `aux_a`) and + /// computes the per-channel Overlay formula in-shader, then + /// outputs premultiplied. + shadow_inset_overlay_program: glow::Program, + u_inset_ov_mvp: glow::UniformLocation, + u_inset_ov_size: glow::UniformLocation, + u_inset_ov_padding: glow::UniformLocation, + u_inset_ov_radii: glow::UniformLocation, + u_inset_ov_spread: glow::UniformLocation, + u_inset_ov_sigma: glow::UniformLocation, + u_inset_ov_offset: glow::UniformLocation, + u_inset_ov_color: glow::UniformLocation, + u_inset_ov_snapshot: glow::UniformLocation, + u_inset_ov_canvas_size: glow::UniformLocation, + + /// Horizontal pass of the separable Gaussian used by + /// [`Self::fill_backdrop`](framebuffer). Samples + /// `aux_a` (snapshot of the main FBO) and writes the + /// horizontally-blurred result into `aux_b`. + backdrop_blur_h_program: glow::Program, + u_bd_h_source: glow::UniformLocation, + u_bd_h_texel: glow::UniformLocation, + u_bd_h_canvas_size: glow::UniformLocation, + u_bd_h_sigma: glow::UniformLocation, + + /// Vertical pass of the separable Gaussian combined with the SDF + /// clip to the surface shape and optional tint. Reads + /// `aux_b` (H-blurred) and writes to the main FBO at the + /// surface rect. + backdrop_composite_program: glow::Program, + u_bd_c_mvp: glow::UniformLocation, + u_bd_c_source: glow::UniformLocation, + u_bd_c_canvas_size: glow::UniformLocation, + u_bd_c_texel: glow::UniformLocation, + u_bd_c_sigma: glow::UniformLocation, + u_bd_c_size: glow::UniformLocation, + u_bd_c_padding: glow::UniformLocation, + u_bd_c_radii: glow::UniformLocation, + u_bd_c_tint: glow::UniformLocation, + + /// Fast (low-quality) horizontal Gaussian. Same role as + /// `backdrop_blur_h_program` but with a 9-tap kernel + /// (`RADIUS = 4`) instead of 41 taps. Used during animations / + /// drags via [`crate::render::low_quality_paint`]. + backdrop_fast_blur_h_program: glow::Program, + u_bd_fh_source: glow::UniformLocation, + u_bd_fh_texel: glow::UniformLocation, + u_bd_fh_canvas_size: glow::UniformLocation, + u_bd_fh_sigma: glow::UniformLocation, + + /// Fast (low-quality) vertical + SDF + tint composite. 9-tap + /// kernel counterpart to `backdrop_composite_program`. + backdrop_fast_composite_program: glow::Program, + u_bd_fc_mvp: glow::UniformLocation, + u_bd_fc_source: glow::UniformLocation, + u_bd_fc_canvas_size: glow::UniformLocation, + u_bd_fc_texel: glow::UniformLocation, + u_bd_fc_sigma: glow::UniformLocation, + u_bd_fc_size: glow::UniformLocation, + u_bd_fc_padding: glow::UniformLocation, + u_bd_fc_radii: glow::UniformLocation, + u_bd_fc_tint: glow::UniformLocation, + + // Glyph cache: (char, size_key, font_id) → GlyphEntry. The + // `font_id` is the address of the `Arc` used for the + // rasterisation, so distinct weights / families of the same + // (char, size) keep separate atlas entries. + glyph_cache: HashMap<(char, u32, usize), GlyphEntry>, + + // Reusable texture cache for images. Keyed by + // `(width, height, content fingerprint)` rather than the source + // buffer's heap address — pointer-keying produced ghosting when + // short-lived `Arc>` buffers got dropped and the + // allocator handed the same address to a different buffer next + // frame (the cache would happily serve the stale texture). + // Content-keying tolerates that case at the cost of one + // `DefaultHasher` pass over the bytes per draw call — fast for + // any reasonable icon size. + image_cache: HashMap<(u32, u32, u64), (glow::Texture, u32, u32)>, + + // Gradient LUT cache: FNV-ish hash of the 512×RGBA8 LUT bytes → texture. + // Gradients are theme-derived and constant across frames; caching avoids + // a glTexImage2D round-trip (create + upload + delete) on every draw call. + // Cleared via `clear_gradient_cache()` on theme changes. + gradient_lut_cache: HashMap, + + /// Current scissor: `Some(rect)` when a clip is installed + /// (GL_SCISSOR_TEST is enabled), `None` when cleared. + clip_scissor: Option, + + /// Persistent shadow framebuffer. All draw methods bind this; + /// [`Self::present`](framebuffer) is the only call that switches + /// to the default framebuffer. + fbo: glow::Framebuffer, + /// Color attachment for `fbo`. Reallocated on resize. + fbo_tex: glow::Texture, + + /// Auxiliary FBO + texture used as a snapshot of `fbo` + /// for framebuffer-fetch-style effects (CSS `Overlay` blend, + /// backdrop blur). Lazily allocated on first use; dropped on + /// resize so the next user re-allocates at the new size. + aux_a: Option<( glow::Framebuffer, glow::Texture )>, + /// Second auxiliary FBO, used as ping-pong target for the + /// separable Gaussian blur in backdrop compositing. Uses LINEAR + /// filtering for the V-pass bilinear sampling (vs `aux_a`'s + /// NEAREST). + aux_b: Option<( glow::Framebuffer, glow::Texture )>, +} + +// ─── Drop ──────────────────────────────────────────────────────────────────── + +/// Free this canvas's owned GL resources: FBO, color attachment, aux +/// FBOs if allocated, and any cached glyph / image textures. Shader +/// programs and the quad VAO/VBO are shared with sub-canvases and +/// intentionally NOT deleted here — they leak at process exit, which +/// is fine for a process-wide GL context. +impl Drop for GlesCanvas +{ + fn drop( &mut self ) + { + // SAFETY: every handle freed below was created through `self.gl` — + // either in `setup.rs::new` / `sub_canvas` (`fbo`, `fbo_tex`), + // `framebuffer.rs::ensure_aux_a` / `ensure_aux_b` (`aux_a`, `aux_b`), + // `text.rs::draw_text` (`glyph_cache`), `image.rs::draw_image_data` + // (`image_cache`), or `primitives.rs::ensure_lut_texture` + // (`gradient_lut_cache`). Each container `drain` / `take` is + // consumed once so no double-free is possible. Caller must keep + // the GL context current at drop time — this is documented on + // `core::UiSurface::from_current_gles_loader` and + // `from_canvas_with_egl_context`. + unsafe + { + self.gl.delete_framebuffer( self.fbo ); + self.gl.delete_texture( self.fbo_tex ); + if let Some( ( fbo, tex ) ) = self.aux_a.take() + { + self.gl.delete_framebuffer( fbo ); + self.gl.delete_texture( tex ); + } + if let Some( ( fbo, tex ) ) = self.aux_b.take() + { + self.gl.delete_framebuffer( fbo ); + self.gl.delete_texture( tex ); + } + for ( _, entry ) in self.glyph_cache.drain() + { + self.gl.delete_texture( entry.texture ); + } + for ( _, ( tex, _, _ ) ) in self.image_cache.drain() + { + self.gl.delete_texture( tex ); + } + for ( _, tex ) in self.gradient_lut_cache.drain() + { + self.gl.delete_texture( tex ); + } + } + } +} diff --git a/src/gles_render/primitives.rs b/src/gles_render/primitives.rs new file mode 100644 index 0000000..4f5a660 --- /dev/null +++ b/src/gles_render/primitives.rs @@ -0,0 +1,545 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Primitive draw ops for [`GlesCanvas`]: solid and gradient rect +//! fills, inner and outer shadows, stroke, line. All go through the +//! shared quad VAO + one of the pre-compiled shader programs from +//! [`super::shaders`], with uniforms set per-call. +//! +//! # Shared `unsafe` invariants +//! +//! Every `unsafe` block below relies on the same canvas-wide contract +//! and only adds one note per block when something specific applies: +//! +//! * The GL context behind `self.gl` is current on this thread — the +//! `GlesCanvas` constructors only return a value when this is true, +//! and every `&mut self` method runs on the construction thread. +//! * Every `program` / `uniform_*` / `vertex_array` / `texture` handle +//! stored on `self` was produced by the same context in `setup.rs` +//! and outlives the draw call. +//! * Each draw method calls `activate_target` first, which re-binds the +//! canvas FBO and re-applies viewport / scissor — so the unsafe block +//! never inherits a stranded binding from a sibling canvas. +//! * `bind_vertex_array(None)` and `bind_texture(_, None)` at the end +//! of the unsafe block leaves the global GL state in the same shape +//! the next draw method assumes (no stranded VAO / texture binding). + +use glow::HasContext; + +use crate::theme::{ gradient_lut, BlendMode, InsetShadow, LinearGradient, RadialGradient, Shadow }; +use crate::types::{ Color, Corners, Rect }; + +use super::helpers::ortho_rect; +use super::GlesCanvas; + +impl GlesCanvas +{ + /// Returns `true` when `rect` (expanded by `margin` on every side) + /// is entirely outside the active scissor — the GPU would cull + /// every fragment pre-shader anyway, so skipping the draw saves + /// the `activate_target` / `use_program` / uniform / VAO / draw + /// sequence. No scissor = no cull (the whole canvas is fair game). + fn rect_culled( &self, rect: Rect, margin: f32 ) -> bool + { + let Some( clip ) = self.clip_scissor else { return false }; + let r_x0 = rect.x - margin; + let r_y0 = rect.y - margin; + let r_x1 = rect.x + rect.width + margin; + let r_y1 = rect.y + rect.height + margin; + let c_x1 = clip.x + clip.width; + let c_y1 = clip.y + clip.height; + r_x1 <= clip.x || c_x1 <= r_x0 || r_y1 <= clip.y || c_y1 <= r_y0 + } + + pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners ) + { + if self.rect_culled( rect, 1.0 ) { return; } + self.activate_target(); + // Expand the quad 1 px on each side so the outer half of the SDF + // antialiasing band (d ∈ [0, 0.5]) has fragments to cover along the + // straight edges of pills / rounded rects. `u_size` and `u_radii` + // stay anchored to the original rect — `u_pad` lets the shader + // remap `v_uv` from the larger quad back into rect-local space. + let pad = 1.0_f32; + let expanded = Rect + { + x: rect.x - pad, + y: rect.y - pad, + width: rect.width + 2.0 * pad, + height: rect.height + 2.0 * pad, + }; + let mvp = ortho_rect( self.width, self.height, expanded ); + let alpha = color.a * self.global_alpha; + // SAFETY: see module doc. `u_rect_stroke = 0.0` triggers the fill + // branch of `RECT_FRAG_SRC`; the SDF reads `u_size` / `u_radii` of + // the original rect while `u_pad` remaps `v_uv` from the expanded + // quad — so the rasteriser sees the padded geometry but the shader + // computes coverage in original-rect coordinates. + unsafe + { + self.gl.use_program( Some( self.rect_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_rect_mvp ), false, &mvp ); + self.gl.uniform_4_f32( Some( &self.u_rect_color ), color.r, color.g, color.b, alpha ); + self.gl.uniform_2_f32( Some( &self.u_rect_size ), rect.width, rect.height ); + self.gl.uniform_4_f32_slice( Some( &self.u_rect_radii ), &corners.to_uniform() ); + self.gl.uniform_1_f32( Some( &self.u_rect_stroke ), 0.0 ); + self.gl.uniform_1_f32( Some( &self.u_rect_pad ), pad ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + } + } + + /// Return the cached gradient LUT texture for `lut_bytes`, uploading + /// it on the first call for each unique byte sequence. Subsequent calls + /// with the same bytes skip `glTexImage2D` entirely. The texture lives + /// until `clear_gradient_cache` is called (e.g. on theme change) or + /// the canvas is dropped. + fn ensure_lut_texture( &mut self, lut_bytes: &[u8] ) -> glow::Texture + { + use std::hash::{ Hash, Hasher }; + let mut h = std::collections::hash_map::DefaultHasher::new(); + lut_bytes.hash( &mut h ); + let key = h.finish(); + + if let Some( &tex ) = self.gradient_lut_cache.get( &key ) + { + return tex; + } + + // SAFETY: see module doc. `lut_bytes` is the contiguous + // `LUT_SAMPLES * 4` byte LUT produced by `gradient_lut::build_lut_bytes` + // (RGBA8, one row); the texture allocation matches that exact shape. + // We unbind TEXTURE_2D on exit to keep the unit-0 binding shape the + // rest of the canvas assumes. + unsafe + { + let tex = self.gl.create_texture().expect( "gradient LUT texture" ); + self.gl.active_texture( glow::TEXTURE0 ); + self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); + self.gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::RGBA as i32, + gradient_lut::LUT_SAMPLES as i32, + 1, + 0, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice( Some( lut_bytes ) ), + ); + self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32 ); + self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32 ); + self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 ); + self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 ); + self.gl.bind_texture( glow::TEXTURE_2D, None ); + self.gradient_lut_cache.insert( key, tex ); + tex + } + } + + /// Fill a rectangle with a linear gradient. + /// + /// Bakes a CPU-side LUT from `g.stops` and fetches (or creates) the + /// corresponding cached GPU texture via `ensure_lut_texture`, + /// then draws the quad with the gradient shader. + pub fn fill_linear_gradient_rect( &mut self, rect: Rect, g: &LinearGradient, corners: Corners ) + { + if self.rect_culled( rect, 1.0 ) { return; } + let lut_bytes = gradient_lut::build_lut_bytes( &g.stops, g.space ); + let tex = self.ensure_lut_texture( &lut_bytes ); + let theta = g.angle_deg.to_radians(); + // CSS convention: 0° points up. dir.y is negative-up in screen space. + let dir_x = theta.sin(); + let dir_y = -theta.cos(); + let line_length = ( rect.width * dir_x ).abs() + ( rect.height * dir_y ).abs(); + let line_length = if line_length.abs() < 1e-3 { 1e-3 } else { line_length }; + + self.activate_target(); + // See fill_rect for the rationale on the 1 px quad pad. + let pad = 1.0_f32; + let expanded = Rect + { + x: rect.x - pad, + y: rect.y - pad, + width: rect.width + 2.0 * pad, + height: rect.height + 2.0 * pad, + }; + let mvp = ortho_rect( self.width, self.height, expanded ); + // SAFETY: see module doc. `tex` is the cached LUT for `g.stops` + // produced by `ensure_lut_texture` above (RGBA8, sampler unit 0); + // `dir_x`, `dir_y`, `line_length` are derived from finite inputs + // (`line_length` is clamped above 1e-3 so the shader's divide is + // well-defined). + unsafe + { + self.gl.active_texture( glow::TEXTURE0 ); + self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); + self.gl.use_program( Some( self.linear_gradient_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_lingrad_mvp ), false, &mvp ); + self.gl.uniform_1_i32( Some( &self.u_lingrad_lut ), 0 ); + self.gl.uniform_2_f32( Some( &self.u_lingrad_dir ), dir_x, dir_y ); + self.gl.uniform_2_f32( Some( &self.u_lingrad_size ), rect.width, rect.height ); + self.gl.uniform_1_f32( Some( &self.u_lingrad_line_length ), line_length ); + self.gl.uniform_4_f32_slice( Some( &self.u_lingrad_radii ), &corners.to_uniform() ); + self.gl.uniform_1_f32( Some( &self.u_lingrad_pad ), pad ); + self.gl.uniform_1_f32( Some( &self.u_lingrad_lut_domain_min ), gradient_lut::LUT_DOMAIN.0 ); + self.gl.uniform_1_f32( Some( &self.u_lingrad_lut_domain_span ), gradient_lut::LUT_DOMAIN.1 - gradient_lut::LUT_DOMAIN.0 ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + self.gl.bind_texture( glow::TEXTURE_2D, None ); + } + } + + /// Fill a rectangle with a radial gradient. + /// + /// `g.center` is interpreted in box-relative fractions (as declared by + /// the theme), `g.radius` is the fractional radial extent. Same cached + /// LUT strategy as [`Self::fill_linear_gradient_rect`]. + pub fn fill_radial_gradient_rect( &mut self, rect: Rect, g: &RadialGradient, corners: Corners ) + { + if self.rect_culled( rect, 1.0 ) { return; } + let lut_bytes = gradient_lut::build_lut_bytes( &g.stops, g.space ); + let tex = self.ensure_lut_texture( &lut_bytes ); + + self.activate_target(); + // See fill_rect for the rationale on the 1 px quad pad. + let pad = 1.0_f32; + let expanded = Rect + { + x: rect.x - pad, + y: rect.y - pad, + width: rect.width + 2.0 * pad, + height: rect.height + 2.0 * pad, + }; + let mvp = ortho_rect( self.width, self.height, expanded ); + // SAFETY: see module doc. Same LUT contract as the linear path + // above. `g.center` and `g.radius` are finite fractional values + // from the theme parser (validated at load time). + unsafe + { + self.gl.active_texture( glow::TEXTURE0 ); + self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); + self.gl.use_program( Some( self.radial_gradient_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_radgrad_mvp ), false, &mvp ); + self.gl.uniform_1_i32( Some( &self.u_radgrad_lut ), 0 ); + self.gl.uniform_2_f32( Some( &self.u_radgrad_center ), g.center[0], g.center[1] ); + self.gl.uniform_1_f32( Some( &self.u_radgrad_radius_frac ), g.radius ); + self.gl.uniform_2_f32( Some( &self.u_radgrad_size ), rect.width, rect.height ); + self.gl.uniform_4_f32_slice( Some( &self.u_radgrad_radii ), &corners.to_uniform() ); + self.gl.uniform_1_f32( Some( &self.u_radgrad_pad ), pad ); + self.gl.uniform_1_f32( Some( &self.u_radgrad_lut_domain_min ), gradient_lut::LUT_DOMAIN.0 ); + self.gl.uniform_1_f32( Some( &self.u_radgrad_lut_domain_span ), gradient_lut::LUT_DOMAIN.1 - gradient_lut::LUT_DOMAIN.0 ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + self.gl.bind_texture( glow::TEXTURE_2D, None ); + } + } + + /// Paint an outer drop shadow behind a rounded rect. + /// + /// Analytic Gaussian approximation over the shape SDF — see the note + /// above `SHADOW_OUTER_FRAG_SRC`. The drawing quad is expanded on each + /// side by `max(blur, 0) + max(spread, 0) + 1` (the `+ 1` leaves a + /// single antialias pixel of slack) and offset by `shadow.offset` so + /// the fragment shader sees the full falloff region. + /// + /// Only `BlendMode::Normal` is honoured today; other modes silently + /// fall through to `Normal` because the analytic shader only outputs + /// `over`. + pub fn fill_shadow_outer( &mut self, target: Rect, shadow: &Shadow, corners: Corners ) + { + let blur_margin = shadow.blur.max( 0.0 ); + let spread_margin = shadow.spread.max( 0.0 ); + let margin = blur_margin + spread_margin + 1.0; + + // Outer shadows draw a quad expanded by `margin` on each side + // (to capture the Gaussian falloff outside the shape); offset + // the target by `shadow.offset` for the cull test so a shadow + // that sits off-centre is not skipped prematurely. + let culled_rect = Rect + { + x: target.x + shadow.offset[0], + y: target.y + shadow.offset[1], + width: target.width, + height: target.height, + }; + if self.rect_culled( culled_rect, margin ) { return; } + + let quad = Rect + { + x: target.x + shadow.offset[0] - margin, + y: target.y + shadow.offset[1] - margin, + width: target.width + 2.0 * margin, + height: target.height + 2.0 * margin, + }; + let sigma = shadow.sigma().max( 0.5 ); + let alpha = shadow.color.a * self.global_alpha; + + self.activate_target(); + let mvp = ortho_rect( self.width, self.height, quad ); + // SAFETY: see module doc. `sigma` is clamped above 0.5 so the + // shader's divide is well-defined; `margin` covers the full + // Gaussian falloff so the rasteriser sees every fragment the + // SDF wants to shade. + unsafe + { + self.gl.use_program( Some( self.shadow_outer_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_shadow_mvp ), false, &mvp ); + self.gl.uniform_2_f32( Some( &self.u_shadow_size ), target.width, target.height ); + self.gl.uniform_2_f32( Some( &self.u_shadow_padding ), margin, margin ); + self.gl.uniform_4_f32_slice( Some( &self.u_shadow_radii ), &corners.to_uniform() ); + self.gl.uniform_1_f32( Some( &self.u_shadow_spread ), shadow.spread ); + self.gl.uniform_1_f32( Some( &self.u_shadow_sigma ), sigma ); + self.gl.uniform_4_f32( Some( &self.u_shadow_color ), + shadow.color.r, shadow.color.g, shadow.color.b, alpha ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + } + } + + /// Paint an inner (inset) shadow inside a rounded rect. + /// + /// Differences versus [`Self::fill_shadow_outer`]: + /// + /// * The drawing quad matches the target rect exactly — the inset is + /// clipped to the outer SDF by the shader, so no external padding + /// is needed and there is no spatial offset of the geometry. + /// * The shader carries the per-shadow `offset` as a uniform rather + /// than translating the quad, because the inset is biased *inside* + /// the shape rather than cast outside it. + /// * The pipeline blend state is switched for the duration of the + /// draw to honour `InsetShadow::blend` and restored afterwards. + /// + /// Blend modes: `Normal` stays on the pipeline default + /// `(ONE, ONE_MINUS_SRC_ALPHA)`. `PlusLighter` uses `(ONE, ONE)` — + /// pure additive on premultiplied inputs, naturally clamped by the + /// framebuffer to `[0, 1]`, which is exactly the CSS definition. + /// `Multiply` uses `(DST_COLOR, ZERO)` on RGB and `(DST_ALPHA, ZERO)` + /// on alpha — a straight multiplicative blend. `Screen` uses + /// `(ONE_MINUS_DST_COLOR, ONE)`, the canonical `a + b − a·b` form. + /// `Overlay` cannot be expressed with GL's fixed-function blend + /// state alone — it needs to read the destination pixel. This + /// branch snapshots the current FBO into `aux_a` via + /// `glCopyTexSubImage2D`, then draws through + /// `shadow_inset_overlay_program` which samples that snapshot at + /// `gl_FragCoord.xy / canvas_size`, computes the per-channel CSS + /// Overlay formula in-shader, and emits premultiplied + /// `(overlay * mask, mask)` — so the usual premul over blend + /// composes the blended colour on top of the base. One FBO + /// snapshot per Overlay shadow. + pub fn fill_shadow_inset( &mut self, target: Rect, shadow: &InsetShadow, corners: Corners ) + { + // Inset shadows draw a quad at `target` ± 1 px AA pad; the + // shape lives entirely inside. If that quad is outside the + // scissor, every fragment is culled — skip the whole path + // (including the Overlay snapshot, which is the expensive + // bit). + if self.rect_culled( target, 1.0 ) { return; } + + let sigma = shadow.sigma().max( 0.5 ); + let alpha = shadow.color.a * self.global_alpha; + + // Overlay goes through the framebuffer-fetch path. Everything + // else uses the original SDF inset shader with a blend-state + // swap. + if matches!( shadow.blend, BlendMode::Overlay ) + { + // Snapshot the inset's draw rect plus the 1 px AA pad so the + // quad's expanded edge still samples valid snapshot data. + // The shader samples `aux_a` at `gl_FragCoord.xy / + // canvas_size`, so reads outside the snapshotted region + // would pull stale content from a previous frame. + // + // Use the scissor-tight variant: Overlay samples at exactly + // one point per fragment, and any fragment outside the + // active scissor is culled before the shader runs, so the + // snapshot only needs to cover the intersection. + let pad = 1.0_f32; + let snap_rect = Rect + { + x: target.x - pad, + y: target.y - pad, + width: target.width + 2.0 * pad, + height: target.height + 2.0 * pad, + }; + self.snapshot_fbo_region_tight( snap_rect ); + self.activate_target(); + let expanded = Rect + { + x: target.x - pad, + y: target.y - pad, + width: target.width + 2.0 * pad, + height: target.height + 2.0 * pad, + }; + let mvp = ortho_rect( self.width, self.height, expanded ); + let aux_tex = self.aux_a.expect( "snapshotted" ).1; + // SAFETY: see module doc. `aux_tex` was just populated by + // `snapshot_fbo_region_tight` so it carries a valid copy of + // the live FBO at full canvas resolution; the shader samples + // it through `gl_FragCoord.xy / canvas_size`. We unbind unit-0 + // after the draw to avoid stranding the snapshot binding. + unsafe + { + self.gl.use_program( Some( self.shadow_inset_overlay_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_inset_ov_mvp ), false, &mvp ); + self.gl.uniform_2_f32( Some( &self.u_inset_ov_size ), target.width, target.height ); + self.gl.uniform_2_f32( Some( &self.u_inset_ov_padding ), pad, pad ); + self.gl.uniform_4_f32_slice( Some( &self.u_inset_ov_radii ), &corners.to_uniform() ); + self.gl.uniform_1_f32( Some( &self.u_inset_ov_spread ), shadow.spread ); + self.gl.uniform_1_f32( Some( &self.u_inset_ov_sigma ), sigma ); + self.gl.uniform_2_f32( Some( &self.u_inset_ov_offset ), shadow.offset[0], shadow.offset[1] ); + self.gl.uniform_4_f32( Some( &self.u_inset_ov_color ), + shadow.color.r, shadow.color.g, shadow.color.b, alpha ); + self.gl.uniform_2_f32( Some( &self.u_inset_ov_canvas_size ), self.width as f32, self.height as f32 ); + self.gl.active_texture( glow::TEXTURE0 ); + self.gl.bind_texture( glow::TEXTURE_2D, Some( aux_tex ) ); + self.gl.uniform_1_i32( Some( &self.u_inset_ov_snapshot ), 0 ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + self.gl.bind_texture( glow::TEXTURE_2D, None ); + } + return; + } + + self.activate_target(); + // 1 px AA pad on the quad so the outer-silhouette clip + // (`outer_coverage`) renders its full smoothstep band instead + // of terminating at the surface rect. Same rationale as + // fill_rect. `u_size` / `u_radii` stay anchored to `target`. + let pad = 1.0_f32; + let expanded = Rect + { + x: target.x - pad, + y: target.y - pad, + width: target.width + 2.0 * pad, + height: target.height + 2.0 * pad, + }; + let mvp = ortho_rect( self.width, self.height, expanded ); + // SAFETY: see module doc. We swap the global blend state for the + // duration of one draw and restore the canvas-wide default + // `(ONE, ONE_MINUS_SRC_ALPHA)` at the end of the block so the + // next draw inherits the expected pipeline blend. + unsafe + { + // Switch the blend state for this one draw. + match shadow.blend + { + BlendMode::Normal => { /* already the pipeline default */ } + BlendMode::PlusLighter => self.gl.blend_func( glow::ONE, glow::ONE ), + BlendMode::Multiply => self.gl.blend_func_separate + ( + glow::DST_COLOR, glow::ZERO, + glow::DST_ALPHA, glow::ZERO, + ), + BlendMode::Screen => self.gl.blend_func( glow::ONE_MINUS_DST_COLOR, glow::ONE ), + BlendMode::Overlay => unreachable!( "Overlay handled above via snapshot" ), + } + + self.gl.use_program( Some( self.shadow_inset_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_inset_mvp ), false, &mvp ); + self.gl.uniform_2_f32( Some( &self.u_inset_size ), target.width, target.height ); + self.gl.uniform_2_f32( Some( &self.u_inset_padding ), pad, pad ); + self.gl.uniform_4_f32_slice( Some( &self.u_inset_radii ), &corners.to_uniform() ); + self.gl.uniform_1_f32( Some( &self.u_inset_spread ), shadow.spread ); + self.gl.uniform_1_f32( Some( &self.u_inset_sigma ), sigma ); + self.gl.uniform_2_f32( Some( &self.u_inset_offset ), shadow.offset[0], shadow.offset[1] ); + self.gl.uniform_4_f32( Some( &self.u_inset_color ), + shadow.color.r, shadow.color.g, shadow.color.b, alpha ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + + // Restore the pipeline default. + if !matches!( shadow.blend, BlendMode::Normal ) + { + self.gl.blend_func( glow::ONE, glow::ONE_MINUS_SRC_ALPHA ); + } + } + } + + /// Stroke a rectangle outline. The stroke is centered on the (rounded) + /// boundary, matching tiny-skia's stroke_path so software and GPU paths + /// produce the same shape (e.g. a circular focus ring around an icon + /// button stays circular). + /// + /// The drawing quad is expanded by `width / 2` so the outer half of the + /// stroke — which lies *outside* the original rect — has fragments to + /// cover; the SDF in the rect shader then clamps to the ring. + pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: Corners ) + { + let half = width * 0.5; + if self.rect_culled( rect, half + 1.0 ) { return; } + self.activate_target(); + // Expand the *quad* outward so the outer half of the stroke has + // fragments to cover, plus 1 px extra so the 2 px AA band on the + // outer side of the stroke (half_w + 1 in the shader) has + // fragments too. `u_size` and `u_radii` keep their ORIGINAL + // values — they define the SDF, and the stroke's centerline must + // sit on the SDF zero-line (the original rect boundary). `u_pad` + // tells the fragment shader to remap `v_uv` from the larger quad + // back into original-rect space, so the SDF stays anchored to + // the original geometry. Growing `u_size`/`u_radii` instead + // would shift the zero-line outward and, in the circle case + // (radius = size/2), turn the result into a rounded square. + let pad = half + 1.0; + let expanded = Rect + { + x: rect.x - pad, + y: rect.y - pad, + width: rect.width + 2.0 * pad, + height: rect.height + 2.0 * pad, + }; + let mvp = ortho_rect( self.width, self.height, expanded ); + let alpha = color.a * self.global_alpha; + // SAFETY: see module doc. `u_rect_stroke = width > 0.0` triggers + // the stroke branch of `RECT_FRAG_SRC`. Same SDF-anchored-to-original + // remap as `fill_rect`; here the quad is padded by `half + 1.0` so + // the outer half of the stroke plus its 1 px AA band have fragments. + unsafe + { + self.gl.use_program( Some( self.rect_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_rect_mvp ), false, &mvp ); + self.gl.uniform_4_f32( Some( &self.u_rect_color ), color.r, color.g, color.b, alpha ); + self.gl.uniform_2_f32( Some( &self.u_rect_size ), rect.width, rect.height ); + self.gl.uniform_4_f32_slice( Some( &self.u_rect_radii ), &corners.to_uniform() ); + self.gl.uniform_1_f32( Some( &self.u_rect_stroke ), width ); + self.gl.uniform_1_f32( Some( &self.u_rect_pad ), pad ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + } + } + + /// Draw a line as a thin axis-aligned rect (diagonal lines fall back to + /// stamping small squares). + pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 ) + { + let dx = x1 - x0; + let dy = y1 - y0; + let len = ( dx * dx + dy * dy ).sqrt(); + if len < 0.1 { return; } + let min_x = x0.min( x1 ); + let min_y = y0.min( y1 ); + if dy.abs() < 0.1 + { + self.fill_rect( Rect { x: min_x, y: min_y - width / 2.0, width: dx.abs(), height: width }, color, Corners::ZERO ); + } else if dx.abs() < 0.1 { + self.fill_rect( Rect { x: min_x - width / 2.0, y: min_y, width, height: dy.abs() }, color, Corners::ZERO ); + } else { + let steps = len.ceil() as usize; + for i in 0..steps + { + let t = i as f32 / len; + let px = x0 + dx * t; + let py = y0 + dy * t; + self.fill_rect( Rect { x: px - width / 2.0, y: py - width / 2.0, width, height: width }, color, Corners::ZERO ); + } + } + } + +} diff --git a/src/gles_render/raii.rs b/src/gles_render/raii.rs new file mode 100644 index 0000000..2d8d2e1 --- /dev/null +++ b/src/gles_render/raii.rs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! RAII guards for scoped GL bindings. +//! +//! The renderer normally restores GL state lazily: every draw method +//! calls [`super::GlesCanvas::activate_target`] at entry, which +//! re-binds the canvas FBO and reasserts viewport / scissor. So a +//! method that temporarily binds a different FBO (e.g. `aux_a` for a +//! snapshot) does not need to restore the previous binding — the next +//! draw will. That is a deliberate optimisation; introducing a RAII +//! restore at every site would double-bind the canvas FBO on every +//! frame. +//! +//! These guards exist for the *opposite* shape — operations that +//! genuinely change global state for the duration of a scope and +//! must guarantee restoration **even on early return / panic**. +//! Today that fits exactly one site: [`super::GlesCanvas::present`], +//! which binds the default framebuffer (id 0) and the blit program, +//! draws a fullscreen quad, and rebinds the canvas FBO + previous +//! program. Without RAII, an early return between the two binds +//! would leave the GL context with the default FBO and the wrong +//! program active — the next draw on this canvas would render to +//! the wrong target until `activate_target` ran (FBO is corrected +//! by `activate_target`; `use_program` is **not**). +//! +//! When in doubt: do not wrap `bind_framebuffer` / `use_program` in +//! a guard "for safety". The lazy-restore convention is part of the +//! renderer's contract and the guard pays for itself only when the +//! scope can exit through a path that bypasses `activate_target`. + +use glow::HasContext; + +/// Scoped framebuffer binding. Binds `target` on construction and +/// restores `previous` on Drop. Caller passes `previous` explicitly +/// because every site that needs this guard already knows what it +/// wants to restore (typically `self.fbo`) — querying +/// `GL_FRAMEBUFFER_BINDING` would force a sync round-trip we do not +/// need. +pub( super ) struct FboBinding<'a> +{ + gl: &'a glow::Context, + previous: Option, +} + +impl<'a> FboBinding<'a> +{ + /// Bind `target` immediately; remember `previous` to rebind on Drop. + /// + /// # Safety + /// + /// The GL context behind `gl` must be current on the calling thread + /// for the entire lifetime of the returned guard. Both `target` and + /// `previous` must be names produced by this same context (or + /// `None` for the default framebuffer). + pub( super ) unsafe fn scoped( gl: &'a glow::Context, target: Option, previous: Option ) -> Self + { + // SAFETY: forwarded from the fn's own `# Safety` contract. + unsafe { gl.bind_framebuffer( glow::FRAMEBUFFER, target ); } + Self { gl, previous } + } +} + +impl<'a> Drop for FboBinding<'a> +{ + fn drop( &mut self ) + { + // SAFETY: the construction-time invariant (current context) is the + // guard's lifetime invariant. Restoring `previous` is a pure + // state-machine mutation. + unsafe { self.gl.bind_framebuffer( glow::FRAMEBUFFER, self.previous ); } + } +} + +/// Scoped program binding. Same shape as [`FboBinding`] but for +/// `glUseProgram`. Caller passes the program to restore explicitly +/// (typically the program the next pipeline stage will need, or +/// `None` to leave nothing bound). +pub( super ) struct ProgramBinding<'a> +{ + gl: &'a glow::Context, + previous: Option, +} + +impl<'a> ProgramBinding<'a> +{ + /// Activate `target` immediately; remember `previous` to restore on Drop. + /// + /// # Safety + /// + /// Same as [`FboBinding::scoped`]. + pub( super ) unsafe fn scoped( gl: &'a glow::Context, target: Option, previous: Option ) -> Self + { + // SAFETY: forwarded from the fn's own `# Safety` contract. + unsafe { gl.use_program( target ); } + Self { gl, previous } + } +} + +impl<'a> Drop for ProgramBinding<'a> +{ + fn drop( &mut self ) + { + // SAFETY: see `FboBinding::drop`. + unsafe { self.gl.use_program( self.previous ); } + } +} diff --git a/src/gles_render/setup.rs b/src/gles_render/setup.rs new file mode 100644 index 0000000..4466f29 --- /dev/null +++ b/src/gles_render/setup.rs @@ -0,0 +1,657 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Construction + accessors for [`GlesCanvas`]. +//! +//! The bulk of `GlesCanvas::new` is one big shader-compile + +//! uniform-lookup block: each of the shader programs gets compiled +//! from [`super::shaders`], its uniform locations are pulled out via +//! `get_uniform_location`, and both end up as `Copy` handles on the +//! struct so sub-canvases can share them for free. `sub_canvas` is +//! the same struct-literal again with the `new`-only bootstrap +//! elided (programs, VAO, default font all come from the parent). + +use std::collections::HashMap; +use std::sync::{ Arc, OnceLock }; + +use fontdue::{ Font, FontSettings, LineMetrics, Metrics }; +use glow::HasContext; + +use crate::theme::{ FontRegistry, FontStyle }; + +use super::helpers::{ alloc_fbo_tex, bytemuck_cast_slice, compile_program, load_default_font_bytes }; +use super::shaders:: +{ + BACKDROP_BLUR_H_FRAG_SRC, BACKDROP_COMPOSITE_FRAG_SRC, + BACKDROP_FAST_BLUR_H_FRAG_SRC, BACKDROP_FAST_COMPOSITE_FRAG_SRC, + BLIT_FRAG_SRC, BLIT_VERT_SRC, + GLYPH_FRAG_SRC, + LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC, + RECT_FRAG_SRC, + SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC, + SUB_BLIT_FRAG_SRC, + TEX_FRAG_SRC, VERT_SRC, +}; +use super::{ GlesCanvas, GlesVersion }; + +/// Process-wide cache of the GLES path's default font. Avoids +/// re-reading + re-parsing the small Sora face on every surface +/// bring-up. The fallback chain (Noto Sans / CJK / Devanagari / …) +/// is owned by the crate-private system-fonts module and loaded +/// lazily per codepoint, not per canvas. +static DEFAULT_FONT_GLES: OnceLock> = OnceLock::new(); + +fn default_font_gles() -> Arc +{ + Arc::clone( DEFAULT_FONT_GLES.get_or_init( || + { + let bytes = load_default_font_bytes(); + let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ) + .expect( "bad font" ); + Arc::new( font ) + } ) ) +} + +impl GlesCanvas +{ + pub fn new( gl: Arc, version: GlesVersion, width: u32, height: u32 ) -> Self + { + let font = default_font_gles(); + + let rect_program = compile_program( &gl, VERT_SRC, RECT_FRAG_SRC ); + let tex_program = compile_program( &gl, VERT_SRC, TEX_FRAG_SRC ); + let glyph_program = compile_program( &gl, VERT_SRC, GLYPH_FRAG_SRC ); + let blit_program = compile_program( &gl, BLIT_VERT_SRC, BLIT_FRAG_SRC ); + let sub_blit_program = compile_program( &gl, VERT_SRC, SUB_BLIT_FRAG_SRC ); + let linear_gradient_program = compile_program( &gl, VERT_SRC, LINEAR_GRADIENT_FRAG_SRC ); + let radial_gradient_program = compile_program( &gl, VERT_SRC, RADIAL_GRADIENT_FRAG_SRC ); + let shadow_outer_program = compile_program( &gl, VERT_SRC, SHADOW_OUTER_FRAG_SRC ); + let shadow_inset_program = compile_program( &gl, VERT_SRC, SHADOW_INSET_FRAG_SRC ); + let shadow_inset_overlay_program = compile_program( &gl, VERT_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC ); + let backdrop_blur_h_program = compile_program( &gl, BLIT_VERT_SRC, BACKDROP_BLUR_H_FRAG_SRC ); + let backdrop_composite_program = compile_program( &gl, VERT_SRC, BACKDROP_COMPOSITE_FRAG_SRC ); + let backdrop_fast_blur_h_program = compile_program( &gl, BLIT_VERT_SRC, BACKDROP_FAST_BLUR_H_FRAG_SRC ); + let backdrop_fast_composite_program = compile_program( &gl, VERT_SRC, BACKDROP_FAST_COMPOSITE_FRAG_SRC ); + + // SAFETY: every program in scope has just been linked successfully by + // `compile_program` (which panics on link failure), so `get_uniform_location` + // on these programs is well-defined. Each name argument is a `'static` + // ASCII literal — `glow` will not invoke UB on a malformed C string. + // `get_uniform_location` does not mutate the GL state machine, so this + // block has no interaction with whatever bindings precede it. + let ( + u_rect_mvp, u_rect_color, u_rect_size, u_rect_radii, u_rect_stroke, u_rect_pad, + u_tex_mvp, u_tex_opacity, u_tex_sampler, + u_glyph_mvp, u_glyph_color, u_glyph_opacity, u_glyph_sampler, + u_blit_sampler, + u_subblit_mvp, u_subblit_sampler, u_subblit_opacity, u_subblit_fade_bottom, u_subblit_height_px, + u_lingrad_mvp, u_lingrad_lut, u_lingrad_dir, u_lingrad_size, u_lingrad_line_length, + u_lingrad_radii, u_lingrad_pad, u_lingrad_lut_domain_min, u_lingrad_lut_domain_span, + u_radgrad_mvp, u_radgrad_lut, u_radgrad_center, u_radgrad_radius_frac, u_radgrad_size, + u_radgrad_radii, u_radgrad_pad, u_radgrad_lut_domain_min, u_radgrad_lut_domain_span, + u_shadow_mvp, u_shadow_size, u_shadow_padding, u_shadow_radii, u_shadow_spread, u_shadow_sigma, u_shadow_color, + u_inset_mvp, u_inset_size, u_inset_padding, u_inset_radii, u_inset_spread, u_inset_sigma, u_inset_offset, u_inset_color, + u_inset_ov_mvp, u_inset_ov_size, u_inset_ov_padding, u_inset_ov_radii, + u_inset_ov_spread, u_inset_ov_sigma, u_inset_ov_offset, u_inset_ov_color, + u_inset_ov_snapshot, u_inset_ov_canvas_size, + u_bd_h_source, u_bd_h_texel, u_bd_h_canvas_size, u_bd_h_sigma, + u_bd_c_mvp, u_bd_c_source, u_bd_c_canvas_size, u_bd_c_texel, u_bd_c_sigma, + u_bd_c_size, u_bd_c_padding, u_bd_c_radii, u_bd_c_tint, + u_bd_fh_source, u_bd_fh_texel, u_bd_fh_canvas_size, u_bd_fh_sigma, + u_bd_fc_mvp, u_bd_fc_source, u_bd_fc_canvas_size, u_bd_fc_texel, u_bd_fc_sigma, + u_bd_fc_size, u_bd_fc_padding, u_bd_fc_radii, u_bd_fc_tint, + ) = unsafe + {( + gl.get_uniform_location( rect_program, "u_mvp" ).unwrap(), + gl.get_uniform_location( rect_program, "u_color" ).unwrap(), + gl.get_uniform_location( rect_program, "u_size" ).unwrap(), + gl.get_uniform_location( rect_program, "u_radii" ).unwrap(), + gl.get_uniform_location( rect_program, "u_stroke" ).unwrap(), + gl.get_uniform_location( rect_program, "u_pad" ).unwrap(), + gl.get_uniform_location( tex_program, "u_mvp" ).unwrap(), + gl.get_uniform_location( tex_program, "u_opacity" ).unwrap(), + gl.get_uniform_location( tex_program, "u_sampler" ).unwrap(), + gl.get_uniform_location( glyph_program, "u_mvp" ).unwrap(), + gl.get_uniform_location( glyph_program, "u_color" ).unwrap(), + gl.get_uniform_location( glyph_program, "u_opacity" ).unwrap(), + gl.get_uniform_location( glyph_program, "u_sampler" ).unwrap(), + gl.get_uniform_location( blit_program, "u_sampler" ).unwrap(), + gl.get_uniform_location( sub_blit_program, "u_mvp" ).unwrap(), + gl.get_uniform_location( sub_blit_program, "u_sampler" ).unwrap(), + gl.get_uniform_location( sub_blit_program, "u_opacity" ).unwrap(), + gl.get_uniform_location( sub_blit_program, "u_fade_bottom_px" ).unwrap(), + gl.get_uniform_location( sub_blit_program, "u_height_px" ).unwrap(), + gl.get_uniform_location( linear_gradient_program, "u_mvp" ).unwrap(), + gl.get_uniform_location( linear_gradient_program, "u_lut" ).unwrap(), + gl.get_uniform_location( linear_gradient_program, "u_dir" ).unwrap(), + gl.get_uniform_location( linear_gradient_program, "u_size" ).unwrap(), + gl.get_uniform_location( linear_gradient_program, "u_line_length" ).unwrap(), + gl.get_uniform_location( linear_gradient_program, "u_radii" ).unwrap(), + gl.get_uniform_location( linear_gradient_program, "u_pad" ).unwrap(), + gl.get_uniform_location( linear_gradient_program, "u_lut_domain_min" ).unwrap(), + gl.get_uniform_location( linear_gradient_program, "u_lut_domain_span" ).unwrap(), + gl.get_uniform_location( radial_gradient_program, "u_mvp" ).unwrap(), + gl.get_uniform_location( radial_gradient_program, "u_lut" ).unwrap(), + gl.get_uniform_location( radial_gradient_program, "u_center" ).unwrap(), + gl.get_uniform_location( radial_gradient_program, "u_radius_frac" ).unwrap(), + gl.get_uniform_location( radial_gradient_program, "u_size" ).unwrap(), + gl.get_uniform_location( radial_gradient_program, "u_radii" ).unwrap(), + gl.get_uniform_location( radial_gradient_program, "u_pad" ).unwrap(), + gl.get_uniform_location( radial_gradient_program, "u_lut_domain_min" ).unwrap(), + gl.get_uniform_location( radial_gradient_program, "u_lut_domain_span" ).unwrap(), + gl.get_uniform_location( shadow_outer_program, "u_mvp" ).unwrap(), + gl.get_uniform_location( shadow_outer_program, "u_size" ).unwrap(), + gl.get_uniform_location( shadow_outer_program, "u_padding" ).unwrap(), + gl.get_uniform_location( shadow_outer_program, "u_radii" ).unwrap(), + gl.get_uniform_location( shadow_outer_program, "u_spread" ).unwrap(), + gl.get_uniform_location( shadow_outer_program, "u_sigma" ).unwrap(), + gl.get_uniform_location( shadow_outer_program, "u_color" ).unwrap(), + gl.get_uniform_location( shadow_inset_program, "u_mvp" ).unwrap(), + gl.get_uniform_location( shadow_inset_program, "u_size" ).unwrap(), + gl.get_uniform_location( shadow_inset_program, "u_padding" ).unwrap(), + gl.get_uniform_location( shadow_inset_program, "u_radii" ).unwrap(), + gl.get_uniform_location( shadow_inset_program, "u_spread" ).unwrap(), + gl.get_uniform_location( shadow_inset_program, "u_sigma" ).unwrap(), + gl.get_uniform_location( shadow_inset_program, "u_offset" ).unwrap(), + gl.get_uniform_location( shadow_inset_program, "u_color" ).unwrap(), + gl.get_uniform_location( shadow_inset_overlay_program, "u_mvp" ).unwrap(), + gl.get_uniform_location( shadow_inset_overlay_program, "u_size" ).unwrap(), + gl.get_uniform_location( shadow_inset_overlay_program, "u_padding" ).unwrap(), + gl.get_uniform_location( shadow_inset_overlay_program, "u_radii" ).unwrap(), + gl.get_uniform_location( shadow_inset_overlay_program, "u_spread" ).unwrap(), + gl.get_uniform_location( shadow_inset_overlay_program, "u_sigma" ).unwrap(), + gl.get_uniform_location( shadow_inset_overlay_program, "u_offset" ).unwrap(), + gl.get_uniform_location( shadow_inset_overlay_program, "u_color" ).unwrap(), + gl.get_uniform_location( shadow_inset_overlay_program, "u_snapshot" ).unwrap(), + gl.get_uniform_location( shadow_inset_overlay_program, "u_canvas_size" ).unwrap(), + gl.get_uniform_location( backdrop_blur_h_program, "u_source" ).unwrap(), + gl.get_uniform_location( backdrop_blur_h_program, "u_texel" ).unwrap(), + gl.get_uniform_location( backdrop_blur_h_program, "u_canvas_size" ).unwrap(), + gl.get_uniform_location( backdrop_blur_h_program, "u_sigma" ).unwrap(), + gl.get_uniform_location( backdrop_composite_program, "u_mvp" ).unwrap(), + gl.get_uniform_location( backdrop_composite_program, "u_source" ).unwrap(), + gl.get_uniform_location( backdrop_composite_program, "u_canvas_size" ).unwrap(), + gl.get_uniform_location( backdrop_composite_program, "u_texel" ).unwrap(), + gl.get_uniform_location( backdrop_composite_program, "u_sigma" ).unwrap(), + gl.get_uniform_location( backdrop_composite_program, "u_size" ).unwrap(), + gl.get_uniform_location( backdrop_composite_program, "u_padding" ).unwrap(), + gl.get_uniform_location( backdrop_composite_program, "u_radii" ).unwrap(), + gl.get_uniform_location( backdrop_composite_program, "u_tint" ).unwrap(), + gl.get_uniform_location( backdrop_fast_blur_h_program, "u_source" ).unwrap(), + gl.get_uniform_location( backdrop_fast_blur_h_program, "u_texel" ).unwrap(), + gl.get_uniform_location( backdrop_fast_blur_h_program, "u_canvas_size" ).unwrap(), + gl.get_uniform_location( backdrop_fast_blur_h_program, "u_sigma" ).unwrap(), + gl.get_uniform_location( backdrop_fast_composite_program, "u_mvp" ).unwrap(), + gl.get_uniform_location( backdrop_fast_composite_program, "u_source" ).unwrap(), + gl.get_uniform_location( backdrop_fast_composite_program, "u_canvas_size" ).unwrap(), + gl.get_uniform_location( backdrop_fast_composite_program, "u_texel" ).unwrap(), + gl.get_uniform_location( backdrop_fast_composite_program, "u_sigma" ).unwrap(), + gl.get_uniform_location( backdrop_fast_composite_program, "u_size" ).unwrap(), + gl.get_uniform_location( backdrop_fast_composite_program, "u_padding" ).unwrap(), + gl.get_uniform_location( backdrop_fast_composite_program, "u_radii" ).unwrap(), + gl.get_uniform_location( backdrop_fast_composite_program, "u_tint" ).unwrap(), + )}; + + let quad_vertices: [f32; 12] = [ + 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, + 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, + ]; + // SAFETY: the GL context is current (caller contract). We allocate a + // VAO + VBO, upload `quad_vertices` (24 bytes, statically known size, + // matches `STATIC_DRAW` semantics), and configure attribute 0 to read + // 2-float vertices from the VBO at offset 0 with stride 8. The vertex + // attrib pointer is bound to `vbo` because `vbo` is the current + // `ARRAY_BUFFER` binding when `vertex_attrib_pointer_f32` runs. We + // unbind the VAO at the end so we don't strand a binding the rest of + // `new` might inherit; the VBO remains attached to the VAO and is not + // touched again until `Drop`. + let ( quad_vao, quad_vbo ) = unsafe + { + let vao = gl.create_vertex_array().unwrap(); + let vbo = gl.create_buffer().unwrap(); + gl.bind_vertex_array( Some( vao ) ); + gl.bind_buffer( glow::ARRAY_BUFFER, Some( vbo ) ); + gl.buffer_data_u8_slice( + glow::ARRAY_BUFFER, + bytemuck_cast_slice( &quad_vertices ), + glow::STATIC_DRAW, + ); + gl.enable_vertex_attrib_array( 0 ); + gl.vertex_attrib_pointer_f32( 0, 2, glow::FLOAT, false, 8, 0 ); + gl.bind_vertex_array( None ); + ( vao, vbo ) + }; + + // Build the persistent FBO (shadow canvas) and bind it as the active + // draw target. From here on, every draw method writes into the FBO; + // `present` is the only call that switches to the default framebuffer. + // + // SAFETY: the GL context is current. `create_framebuffer` allocates a + // fresh FBO name. `alloc_fbo_tex` (an `unsafe fn`) requires the same + // invariant and returns a colour-renderable RGBA texture sized to the + // caller's width × height — the call is sound because both invariants + // are established. The bind + framebuffer_texture_2d pair attach the + // texture to COLOR_ATTACHMENT0; `check_framebuffer_status` is the + // canonical post-condition check and will assert before we return a + // handle to a broken FBO. The trailing GL state (`BLEND`, `blend_func`, + // `viewport`, `clear`) is the canvas-wide default state every draw + // method assumes — see the comment block beside `blend_func` for why + // `(ONE, ONE_MINUS_SRC_ALPHA)` is the correct pair for premultiplied + // shaders. + let ( fbo, fbo_tex ) = unsafe + { + let fbo = gl.create_framebuffer().expect( "create_framebuffer" ); + let fbo_tex = alloc_fbo_tex( &gl, version, width, height ); + gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) ); + gl.framebuffer_texture_2d( + glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, + glow::TEXTURE_2D, Some( fbo_tex ), 0, + ); + let status = gl.check_framebuffer_status( glow::FRAMEBUFFER ); + assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "FBO incomplete: 0x{status:x}" ); + + gl.enable( glow::BLEND ); + // Premultiplied-alpha "over" composite: `result = src + dst * (1 - src.a)` + // applied uniformly to both colour and alpha. All shaders in this + // pipeline emit premul colour (see the note above `RECT_FRAG_SRC`), + // so `(ONE, ONE_MINUS_SRC_ALPHA)` is correct for both channels. + // Premultiplied inputs are also a requirement for the plus-lighter + // and overlay blend modes. + gl.blend_func( glow::ONE, glow::ONE_MINUS_SRC_ALPHA ); + gl.viewport( 0, 0, width as i32, height as i32 ); + // Clear the FBO once at startup so the first frame is not garbage. + gl.clear_color( 0.0, 0.0, 0.0, 0.0 ); + gl.clear( glow::COLOR_BUFFER_BIT ); + ( fbo, fbo_tex ) + }; + + Self + { + gl, + version, + font, + font_registry: None, + dpi_scale: 1.0, + global_alpha: 1.0, + width, + height, + rect_program, + tex_program, + glyph_program, + blit_program, + sub_blit_program, + linear_gradient_program, + radial_gradient_program, + shadow_outer_program, + shadow_inset_program, + shadow_inset_overlay_program, + quad_vao, + _quad_vbo: quad_vbo, + u_rect_mvp, + u_rect_color, + u_rect_size, + u_rect_radii, + u_rect_stroke, + u_rect_pad, + u_tex_mvp, + u_tex_opacity, + u_tex_sampler, + u_glyph_mvp, + u_glyph_color, + u_glyph_opacity, + u_glyph_sampler, + u_blit_sampler, + u_subblit_mvp, + u_subblit_sampler, + u_subblit_opacity, + u_subblit_fade_bottom, + u_subblit_height_px, + u_lingrad_mvp, + u_lingrad_lut, + u_lingrad_dir, + u_lingrad_size, + u_lingrad_line_length, + u_lingrad_radii, + u_lingrad_pad, + u_lingrad_lut_domain_min, + u_lingrad_lut_domain_span, + u_radgrad_mvp, + u_radgrad_lut, + u_radgrad_center, + u_radgrad_radius_frac, + u_radgrad_size, + u_radgrad_radii, + u_radgrad_pad, + u_radgrad_lut_domain_min, + u_radgrad_lut_domain_span, + u_shadow_mvp, + u_shadow_size, + u_shadow_padding, + u_shadow_radii, + u_shadow_spread, + u_shadow_sigma, + u_shadow_color, + u_inset_mvp, + u_inset_size, + u_inset_padding, + u_inset_radii, + u_inset_spread, + u_inset_sigma, + u_inset_offset, + u_inset_color, + u_inset_ov_mvp, + u_inset_ov_size, + u_inset_ov_padding, + u_inset_ov_radii, + u_inset_ov_spread, + u_inset_ov_sigma, + u_inset_ov_offset, + u_inset_ov_color, + u_inset_ov_snapshot, + u_inset_ov_canvas_size, + backdrop_blur_h_program, + u_bd_h_source, + u_bd_h_texel, + u_bd_h_canvas_size, + u_bd_h_sigma, + backdrop_composite_program, + u_bd_c_mvp, + u_bd_c_source, + u_bd_c_canvas_size, + u_bd_c_texel, + u_bd_c_sigma, + u_bd_c_size, + u_bd_c_padding, + u_bd_c_radii, + u_bd_c_tint, + backdrop_fast_blur_h_program, + u_bd_fh_source, + u_bd_fh_texel, + u_bd_fh_canvas_size, + u_bd_fh_sigma, + backdrop_fast_composite_program, + u_bd_fc_mvp, + u_bd_fc_source, + u_bd_fc_canvas_size, + u_bd_fc_texel, + u_bd_fc_sigma, + u_bd_fc_size, + u_bd_fc_padding, + u_bd_fc_radii, + u_bd_fc_tint, + glyph_cache: HashMap::new(), + image_cache: HashMap::new(), + gradient_lut_cache: HashMap::new(), + clip_scissor: None, + fbo, + fbo_tex, + aux_a: None, + aux_b: None, + } + } + + /// Build a sub-canvas: a separate render target sharing this canvas's GL + /// context, font, shader programs, geometry, and uniform locations, but + /// with its own FBO + color texture sized to `width × height`. Used to + /// render content into an off-screen target that can then be composited + /// back via [`Self::blit`]. + /// + /// The returned canvas inherits the parent's `dpi_scale` and `global_alpha` + /// (so glyphs render at the same pixel size). Its glyph cache starts empty + /// — re-rasterising on first use is the cost of not sharing GL textures + /// across canvases. + pub fn sub_canvas( &self, width: u32, height: u32 ) -> GlesCanvas + { + let gl = Arc::clone( &self.gl ); + // SAFETY: `self.gl` is the same context held by `self`; if `self` was + // constructed soundly its context is current on this thread. We + // allocate a new FBO + colour texture and attach them, then assert + // completeness. We deliberately leave `gl` with the new FBO bound + // instead of restoring the parent's binding — every draw method goes + // through `activate_target`, which re-binds the canvas's own FBO + // before issuing any draw call, so the transient binding cannot be + // observed by other code on this thread. + let ( fbo, fbo_tex ) = unsafe + { + let fbo = gl.create_framebuffer().expect( "create_framebuffer" ); + let fbo_tex = alloc_fbo_tex( &gl, self.version, width, height ); + gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) ); + gl.framebuffer_texture_2d( + glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, + glow::TEXTURE_2D, Some( fbo_tex ), 0, + ); + let status = gl.check_framebuffer_status( glow::FRAMEBUFFER ); + assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "sub-FBO incomplete: 0x{status:x}" ); + ( fbo, fbo_tex ) + }; + + GlesCanvas + { + gl, + version: self.version, + font: Arc::clone( &self.font ), + font_registry: self.font_registry.as_ref().map( Arc::clone ), + dpi_scale: self.dpi_scale, + global_alpha: self.global_alpha, + width, + height, + rect_program: self.rect_program, + tex_program: self.tex_program, + glyph_program: self.glyph_program, + blit_program: self.blit_program, + sub_blit_program: self.sub_blit_program, + linear_gradient_program: self.linear_gradient_program, + radial_gradient_program: self.radial_gradient_program, + shadow_outer_program: self.shadow_outer_program, + shadow_inset_program: self.shadow_inset_program, + shadow_inset_overlay_program: self.shadow_inset_overlay_program, + backdrop_blur_h_program: self.backdrop_blur_h_program, + backdrop_composite_program: self.backdrop_composite_program, + backdrop_fast_blur_h_program: self.backdrop_fast_blur_h_program, + backdrop_fast_composite_program: self.backdrop_fast_composite_program, + quad_vao: self.quad_vao, + _quad_vbo: self._quad_vbo, + u_rect_mvp: self.u_rect_mvp, + u_rect_color: self.u_rect_color, + u_rect_size: self.u_rect_size, + u_rect_radii: self.u_rect_radii, + u_rect_stroke: self.u_rect_stroke, + u_rect_pad: self.u_rect_pad, + u_tex_mvp: self.u_tex_mvp, + u_tex_opacity: self.u_tex_opacity, + u_tex_sampler: self.u_tex_sampler, + u_glyph_mvp: self.u_glyph_mvp, + u_glyph_color: self.u_glyph_color, + u_glyph_opacity: self.u_glyph_opacity, + u_glyph_sampler: self.u_glyph_sampler, + u_blit_sampler: self.u_blit_sampler, + u_subblit_mvp: self.u_subblit_mvp, + u_subblit_sampler: self.u_subblit_sampler, + u_subblit_opacity: self.u_subblit_opacity, + u_subblit_fade_bottom: self.u_subblit_fade_bottom, + u_subblit_height_px: self.u_subblit_height_px, + u_lingrad_mvp: self.u_lingrad_mvp, + u_lingrad_lut: self.u_lingrad_lut, + u_lingrad_dir: self.u_lingrad_dir, + u_lingrad_size: self.u_lingrad_size, + u_lingrad_line_length: self.u_lingrad_line_length, + u_lingrad_radii: self.u_lingrad_radii, + u_lingrad_pad: self.u_lingrad_pad, + u_lingrad_lut_domain_min: self.u_lingrad_lut_domain_min, + u_lingrad_lut_domain_span: self.u_lingrad_lut_domain_span, + u_radgrad_mvp: self.u_radgrad_mvp, + u_radgrad_lut: self.u_radgrad_lut, + u_radgrad_center: self.u_radgrad_center, + u_radgrad_radius_frac: self.u_radgrad_radius_frac, + u_radgrad_size: self.u_radgrad_size, + u_radgrad_radii: self.u_radgrad_radii, + u_radgrad_pad: self.u_radgrad_pad, + u_radgrad_lut_domain_min: self.u_radgrad_lut_domain_min, + u_radgrad_lut_domain_span: self.u_radgrad_lut_domain_span, + u_shadow_mvp: self.u_shadow_mvp, + u_shadow_size: self.u_shadow_size, + u_shadow_padding: self.u_shadow_padding, + u_shadow_radii: self.u_shadow_radii, + u_shadow_spread: self.u_shadow_spread, + u_shadow_sigma: self.u_shadow_sigma, + u_shadow_color: self.u_shadow_color, + u_inset_mvp: self.u_inset_mvp, + u_inset_size: self.u_inset_size, + u_inset_padding: self.u_inset_padding, + u_inset_radii: self.u_inset_radii, + u_inset_spread: self.u_inset_spread, + u_inset_sigma: self.u_inset_sigma, + u_inset_offset: self.u_inset_offset, + u_inset_color: self.u_inset_color, + u_inset_ov_mvp: self.u_inset_ov_mvp, + u_inset_ov_size: self.u_inset_ov_size, + u_inset_ov_padding: self.u_inset_ov_padding, + u_inset_ov_radii: self.u_inset_ov_radii, + u_inset_ov_spread: self.u_inset_ov_spread, + u_inset_ov_sigma: self.u_inset_ov_sigma, + u_inset_ov_offset: self.u_inset_ov_offset, + u_inset_ov_color: self.u_inset_ov_color, + u_inset_ov_snapshot: self.u_inset_ov_snapshot, + u_inset_ov_canvas_size: self.u_inset_ov_canvas_size, + u_bd_h_source: self.u_bd_h_source, + u_bd_h_texel: self.u_bd_h_texel, + u_bd_h_canvas_size: self.u_bd_h_canvas_size, + u_bd_h_sigma: self.u_bd_h_sigma, + u_bd_c_mvp: self.u_bd_c_mvp, + u_bd_c_source: self.u_bd_c_source, + u_bd_c_canvas_size: self.u_bd_c_canvas_size, + u_bd_c_texel: self.u_bd_c_texel, + u_bd_c_sigma: self.u_bd_c_sigma, + u_bd_c_size: self.u_bd_c_size, + u_bd_c_padding: self.u_bd_c_padding, + u_bd_c_radii: self.u_bd_c_radii, + u_bd_c_tint: self.u_bd_c_tint, + u_bd_fh_source: self.u_bd_fh_source, + u_bd_fh_texel: self.u_bd_fh_texel, + u_bd_fh_canvas_size: self.u_bd_fh_canvas_size, + u_bd_fh_sigma: self.u_bd_fh_sigma, + u_bd_fc_mvp: self.u_bd_fc_mvp, + u_bd_fc_source: self.u_bd_fc_source, + u_bd_fc_canvas_size: self.u_bd_fc_canvas_size, + u_bd_fc_texel: self.u_bd_fc_texel, + u_bd_fc_sigma: self.u_bd_fc_sigma, + u_bd_fc_size: self.u_bd_fc_size, + u_bd_fc_padding: self.u_bd_fc_padding, + u_bd_fc_radii: self.u_bd_fc_radii, + u_bd_fc_tint: self.u_bd_fc_tint, + glyph_cache: HashMap::new(), + image_cache: HashMap::new(), + gradient_lut_cache: HashMap::new(), + clip_scissor: None, + fbo, + fbo_tex, + aux_a: None, + aux_b: None, + } + } + + pub fn size( &self ) -> ( u32, u32 ) { ( self.width, self.height ) } + + /// Discard all cached gradient LUT textures. Call after a theme change + /// so stale LUTs for old palette colours are freed and rebuilt fresh. + pub fn clear_gradient_cache( &mut self ) + { + // SAFETY: GL context is current (canvas invariant). Each texture in + // `gradient_lut_cache` was created through this same context in + // `gradient.rs::ensure_lut`, so deleting them through the same context + // is well-defined. `drain` consumes the entries so the same name is + // never deleted twice. + unsafe + { + for ( _, tex ) in self.gradient_lut_cache.drain() + { + self.gl.delete_texture( tex ); + } + } + } + + pub fn dpi_scale( &self ) -> f32 { self.dpi_scale } + + pub fn set_dpi_scale( &mut self, s: f32 ) { self.dpi_scale = s; } + + pub fn global_alpha( &self ) -> f32 { self.global_alpha } + + pub fn set_global_alpha( &mut self, a: f32 ) { self.global_alpha = a; } + + pub fn font( &self ) -> &Font { &self.font } + + /// Install a theme font registry so [`Self::font_for`] can resolve + /// family+weight+style triples declared by the theme's `fonts` block. + pub fn set_font_registry( &mut self, registry: Arc ) + { + self.font_registry = Some( registry ); + } + + /// Resolve a specific font from the theme registry, falling back to the + /// canvas' default [`Self::font`] when no registry is installed or the + /// triple cannot be satisfied. + pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc + { + self.font_registry + .as_ref() + .and_then( |r| r.resolve( family, weight, style ) ) + .unwrap_or_else( || Arc::clone( &self.font ) ) + } + + /// Pick the right font for `ch`. Tries the primary [`Self::font`] + /// first; on a miss, delegates to the crate-private system-fonts + /// fallback chain (lazy load of the relevant Noto pack). Falls + /// back to the primary (which paints a `.notdef` box) when no + /// installed fallback covers the codepoint. + pub fn font_for_char( &self, ch: char ) -> Arc + { + if self.font.lookup_glyph_index( ch ) != 0 + { + return Arc::clone( &self.font ); + } + crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) ) + } + + pub fn font_metrics( &self, ch: char, size: f32 ) -> Metrics + { + self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ) + } + + pub fn font_line_metrics( &self, size: f32 ) -> Option + { + self.font.horizontal_line_metrics( size ) + } + + /// Resize the FBO and viewport. The previous color attachment is freed and + /// a fresh one of the new size is attached — frame-N pixels are dropped, so + /// the caller should expect to do a full redraw immediately after a resize. + pub fn resize( &mut self, width: u32, height: u32 ) + { + if width == self.width && height == self.height { return; } + self.width = width; + self.height = height; + // SAFETY: GL context is current (canvas invariant). Sequence: + // release the old colour attachment (created via this context in + // `new`/`sub_canvas`/last `resize`), allocate a fresh one of the + // new size, swap it in for `COLOR_ATTACHMENT0` of the existing FBO, + // resize the viewport to match, and clear so the first frame after + // resize is well-defined RGBA. `self.fbo` is unchanged and remains + // valid; only its colour attachment is replaced. + unsafe + { + self.gl.delete_texture( self.fbo_tex ); + self.fbo_tex = alloc_fbo_tex( &self.gl, self.version, width, height ); + self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) ); + self.gl.framebuffer_texture_2d( + glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, + glow::TEXTURE_2D, Some( self.fbo_tex ), 0, + ); + self.gl.viewport( 0, 0, width as i32, height as i32 ); + self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 ); + self.gl.clear( glow::COLOR_BUFFER_BIT ); + } + // Auxiliary textures were sized for the old dimensions — drop them so + // the next effect that needs them re-allocates at the new size. + self.invalidate_aux(); + } +} diff --git a/src/gles_render/shaders.rs b/src/gles_render/shaders.rs new file mode 100644 index 0000000..fe89431 --- /dev/null +++ b/src/gles_render/shaders.rs @@ -0,0 +1,812 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! GLES 2/3 shader sources used by `GlesCanvas`. All fragment shaders +//! emit premultiplied-alpha colour so they compose correctly under +//! the pipeline's default `glBlendFunc(ONE, ONE_MINUS_SRC_ALPHA)`. +//! +//! Every constant is `pub(super)` so only files inside +//! `crate::gles_render` can reach them — callers always go through +//! `GlesCanvas`'s public methods, not the raw shader source. + +// Vertex shader shared by both programs (just transforms a unit quad). +// GLSL ES 1.00 — works on both GLES2 and GLES3 contexts (forward compatible +// when no `#version` directive is present). +pub( super ) const VERT_SRC: &str = r#" +attribute vec2 a_pos; +varying vec2 v_uv; +uniform mat4 u_mvp; +void main() +{ + v_uv = a_pos; + gl_Position = u_mvp * vec4(a_pos, 0.0, 1.0); +} +"#; + +// Fragment shader for solid/rounded rects (signed-distance smoothstep at edge). +// +// `u_stroke == 0` ⇒ filled rect. The shader fills the rounded shape with +// `u_color`, antialiased over a 1-pixel band at the edge. +// `u_stroke > 0` ⇒ stroked outline of width `u_stroke`, centered on the +// rounded boundary, so outlines keep their corner radius. +// +// Distance formula is Iñigo Quílez's exact SDF for a rounded box, extended +// to per-corner radii by picking `r` per-quadrant: +// r = u_radii[ corner_index_from_sign( p - center ) ] +// q = abs(p - center) - (size/2 - r) +// d = min(max(q.x, q.y), 0) + length(max(q, 0)) - r +// `u_radii` is ordered `(tl, tr, br, bl)` clockwise from top-left and is +// uploaded as `Corners::to_uniform()`. A uniform-radii fill is the +// degenerate case where every component is equal — the per-quadrant +// branch collapses to the single-`r` formula. +// +// `u_pad` lets the caller draw the quad larger than `u_size` (used by +// `stroke_rect`, which expands the quad by `stroke/2` on each side so the +// outer half of the stroke has fragments to cover). The shader maps `v_uv` +// from the larger quad back into the original-rect space: +// p = v_uv * (size + 2*pad) - pad +// so `p` ranges `[-pad, size+pad]`. Keeping `u_size` and `u_radii` at the +// *original* values is essential — expanding them shifts the SDF zero-line +// outward and breaks the circle case (radius = size/2). +// +// Each component of `u_radii` is clamped to `min(size.x, size.y) * 0.5` +// before use. Callers frequently pass very large values (e.g. +// `theme::RADIUS = 100`) as a "please make this a pill / capsule" +// sentinel. Without the clamp, `size/2 - r` goes negative in the shorter +// dimension and the rounded-box formula degenerates — rendering an +// ellipse in the middle of the rect instead of a pill. +// +// Emits premultiplied-alpha colour. The pipeline blend is +// `(ONE, ONE_MINUS_SRC_ALPHA)`, so every shader that writes colour must +// premultiply its RGB by the final alpha. This matters for non-opaque +// fills (coverage from the SDF, translucent `u_color.a`) where straight- +// alpha output would under-saturate antialiased edges. +pub( super ) const RECT_FRAG_SRC: &str = r##" +precision mediump float; +varying vec2 v_uv; +uniform vec4 u_color; +uniform vec2 u_size; +uniform vec4 u_radii; +uniform float u_stroke; +uniform float u_pad; + +// Per-fragment corner radius lookup. `c` is the fragment position +// relative to the rect's centre. Inside the shader, p.y grows UPWARD: +// `ortho_rect` flips the v_uv axis so v_uv.y=0 maps to the bottom edge +// of the rect in screen space and v_uv.y=1 to the top. So with `c = +// p - size/2`: +// c.x < 0, c.y > 0 → top-left → r.x +// c.x > 0, c.y > 0 → top-right → r.y +// c.x > 0, c.y < 0 → bottom-right → r.z +// c.x < 0, c.y < 0 → bottom-left → r.w +float corner_radius(vec2 c, vec4 r) +{ + float top = (c.x < 0.0) ? r.x : r.y; + float bottom = (c.x < 0.0) ? r.w : r.z; + return (c.y > 0.0) ? top : bottom; +} + +void main() +{ + float r_max = max(max(u_radii.x, u_radii.y), max(u_radii.z, u_radii.w)); + if (r_max <= 0.0 && u_stroke <= 0.0 && u_pad <= 0.0) + { + float a = u_color.a; + gl_FragColor = vec4(u_color.rgb * a, a); + return; + } + vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad); + vec2 c = p - u_size * 0.5; + float r = corner_radius(c, u_radii); + r = min(r, min(u_size.x, u_size.y) * 0.5); + vec2 q = abs(c) - (u_size * 0.5 - vec2(r)); + float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; + // 2-px-wide AA band (half-width 1.0). A narrower (±0.5) band is + // only sampled when pixel centres happen to fall inside it — when + // the rasterizer grid aligns with the edge at subpixel ±0.5 the + // band is jumped over and the transition becomes a binary step + // (visible stair-stepping on curved edges). Doubling the band + // guarantees at least one partial-coverage sample per scanline + // regardless of alignment. The quad pad set by the caller is 1 px + // so this still fits inside the geometry at d ≤ 1. + float coverage; + if (u_stroke > 0.0) + { + float half_w = u_stroke * 0.5; + coverage = 1.0 - smoothstep(half_w - 1.0, half_w + 1.0, abs(d)); + } else { + coverage = 1.0 - smoothstep(-1.0, 1.0, d); + } + float a = u_color.a * coverage; + gl_FragColor = vec4(u_color.rgb * a, a); +} +"##; + +// Fragment shader for RGBA textured quads (images / icons). +// +// Flips uv.y because images are uploaded top-down (CPU convention: row 0 = +// top of source) but `glTexImage2D` puts the first row at the texture's +// lower-left. With the quad orientation produced by `ortho_rect`, sampling +// `v_uv` directly would draw the source upside-down on screen. +// +// Texture data arrives PREMULTIPLIED — `upload_rgba_texture` premuls the +// straight-alpha CPU buffer once at upload. GL_LINEAR sampling can then +// blend across texel boundaries without halo artefacts (a fully opaque +// black texel next to a fully transparent white texel interpolates to +// `( 0, 0, 0, 0.5 )` instead of the halo-producing `( 0.5, 0.5, 0.5, 0.5 )` +// of straight-alpha interpolation). Multiplying premul by uniform opacity +// preserves the invariant `rgb == rgb_straight * a`. +pub( super ) const TEX_FRAG_SRC: &str = r##" +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_sampler; +uniform float u_opacity; +void main() +{ + gl_FragColor = texture2D(u_sampler, vec2(v_uv.x, 1.0 - v_uv.y)) * u_opacity; +} +"##; + +// Fragment shader for single-channel glyph textures with color tint. Same +// Y-flip rationale as `TEX_FRAG_SRC`. The texture is `GL_LUMINANCE`, which +// replicates the uploaded byte into `.r`, `.g`, `.b` (with `.a = 1`), so the +// coverage value is read from `.r`. We deliberately avoid `GL_ALPHA` here: +// some Mesa GLES3 paths handle the legacy alpha-only format inconsistently +// (sampled `.a` returns near-zero in glyph interiors, leaving only the +// antialias edges visible — text appears as thin faded outlines instead of +// solid strokes). `LUMINANCE` is also a legacy format but its mapping to +// `.r=.g=.b=data` is well-supported across ES2/ES3 drivers. +pub( super ) const GLYPH_FRAG_SRC: &str = r##" +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_sampler; +uniform vec4 u_color; +uniform float u_opacity; +void main() +{ + float coverage = texture2D(u_sampler, vec2(v_uv.x, 1.0 - v_uv.y)).r; + float a = u_color.a * coverage * u_opacity; + gl_FragColor = vec4(u_color.rgb * a, a); +} +"##; + +// Vertex shader for the present-blit: maps the unit quad to fullscreen NDC +// without going through an MVP. UVs are equal to a_pos so the FBO appears +// the same way up on the default framebuffer as it was rendered into the FBO +// (both use GL pixel coords with origin at bottom-left). +pub( super ) const BLIT_VERT_SRC: &str = r#" +attribute vec2 a_pos; +varying vec2 v_uv; +void main() +{ + v_uv = a_pos; + gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0); +} +"#; + +// Fragment shader for the present-blit: straight texture sample, no opacity. +pub( super ) const BLIT_FRAG_SRC: &str = r#" +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_sampler; +void main() +{ + gl_FragColor = texture2D(u_sampler, v_uv); +} +"#; + +// Fragment shader for inter-FBO blits (used by `blit` to composite a sub-canvas +// back onto its parent). Reuses `VERT_SRC` so the quad is positioned via +// `u_mvp` like any other textured draw. No Y-flip: source and destination are +// both FBOs storing content in GL's native bottom-up convention, so sampling +// at `v_uv` puts the visually-top row of the source on the top of the dest. +// +// `u_fade_bottom_px` feathers the bottom edge of the blit: the last +// `u_fade_bottom_px` visible rows ramp the source alpha linearly from 1.0 +// (at the inner edge of the band) to 0.0 (at the very bottom row), so a +// growing viewport does not look like a knife cut against whatever is +// behind it. `v_uv.y == 1.0` is the visually-top row of the source (FBO +// origin is bottom-left), so `v_uv.y * u_height_px` is the distance in +// source pixels from the bottom; the linear ramp falls out of dividing +// that by `u_fade_bottom_px` and clamping. With `u_fade_bottom_px == 0` +// the divide is skipped and the blit stays hard-edged. Linear (not +// smoothstep) because premultiplied output multiplied by a linear alpha +// already reads as a soft fade — smoothstep would compress the ramp into +// fewer effective rows and reintroduce a faint shoulder. +pub( super ) const SUB_BLIT_FRAG_SRC: &str = r#" +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_sampler; +uniform float u_opacity; +uniform float u_fade_bottom_px; +uniform float u_height_px; +void main() +{ + vec4 c = texture2D(u_sampler, v_uv); + float fade = 1.0; + if (u_fade_bottom_px > 0.0) + { + float y_from_bottom = v_uv.y * u_height_px; + fade = clamp(y_from_bottom / u_fade_bottom_px, 0.0, 1.0); + } + gl_FragColor = c * (u_opacity * fade); +} +"#; + +// Linear gradient shader. Samples a CPU-baked 1D LUT (`u_lut`, size +// `gradient_lut::LUT_SAMPLES × 1`, RGBA8, straight-alpha) along the +// CSS linear-gradient convention: the gradient line passes through the +// centre of the rect, `0°` points up, positive angles rotate clockwise. +// Stop positions outside `[0, 1]` are already baked into the LUT via +// linear extrapolation, so the shader only remaps `t` from the extended +// domain `[u_lut_domain_min, u_lut_domain_min + u_lut_domain_span]` +// into the texture's `[0, 1]` sampling range. +// +// The same SDF as `RECT_FRAG_SRC` is reused for the rounded-rect / pill +// silhouette; the coverage multiplies the fragment's alpha before +// premultiplying. +pub( super ) const LINEAR_GRADIENT_FRAG_SRC: &str = r##" +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_lut; +uniform vec2 u_dir; +uniform vec2 u_size; +uniform float u_line_length; +uniform vec4 u_radii; +uniform float u_pad; +uniform float u_lut_domain_min; +uniform float u_lut_domain_span; + +// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the +// quadrant convention. +float corner_radius(vec2 c, vec4 r) +{ + float top = (c.x < 0.0) ? r.x : r.y; + float bottom = (c.x < 0.0) ? r.w : r.z; + return (c.y > 0.0) ? top : bottom; +} + +void main() +{ + vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad); + vec2 c = p - u_size * 0.5; + float r = corner_radius(c, u_radii); + r = min(r, min(u_size.x, u_size.y) * 0.5); + vec2 q = abs(c) - (u_size * 0.5 - vec2(r)); + float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; + // 2-px AA band — see RECT_FRAG_SRC for the grid-alignment rationale. + float coverage = 1.0 - smoothstep(-1.0, 1.0, d); + + // Gradient evaluated in rect-local pixel space. `p` already accounts + // for `u_pad` (set by the caller to expand the quad for AA), so the + // gradient direction stays anchored to the original rect even when + // the quad spills outside. + float dist = dot(c, u_dir); + float t = 0.5 + dist / u_line_length; + + float t_lut = (t - u_lut_domain_min) / u_lut_domain_span; + vec4 grad = texture2D(u_lut, vec2(clamp(t_lut, 0.0, 1.0), 0.5)); + + float a = grad.a * coverage; + gl_FragColor = vec4(grad.rgb * a, a); +} +"##; + +// Radial gradient shader. `u_center` is in box-relative fractions +// (`[0, 1]` on each axis), `u_radius_frac` is the radial extent in the +// same fractional space. `t = distance(v_uv, u_center) / u_radius_frac` +// so stops at `position == 1.0` fall exactly at the chosen radius. +pub( super ) const RADIAL_GRADIENT_FRAG_SRC: &str = r##" +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_lut; +uniform vec2 u_center; +uniform float u_radius_frac; +uniform vec2 u_size; +uniform vec4 u_radii; +uniform float u_pad; +uniform float u_lut_domain_min; +uniform float u_lut_domain_span; + +// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the +// quadrant convention. +float corner_radius(vec2 c, vec4 r) +{ + float top = (c.x < 0.0) ? r.x : r.y; + float bottom = (c.x < 0.0) ? r.w : r.z; + return (c.y > 0.0) ? top : bottom; +} + +void main() +{ + vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad); + vec2 c = p - u_size * 0.5; + float r = corner_radius(c, u_radii); + r = min(r, min(u_size.x, u_size.y) * 0.5); + vec2 q = abs(c) - (u_size * 0.5 - vec2(r)); + float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; + // 2-px AA band — see RECT_FRAG_SRC for the grid-alignment rationale. + float coverage = 1.0 - smoothstep(-1.0, 1.0, d); + + // Centre/extent in box-relative fractions evaluated against `p` + // (which already accounts for `u_pad`), so the radial centre stays + // anchored to the original rect when the quad spills outside. + vec2 t_uv = p / u_size; + float t = distance(t_uv, u_center) / max(u_radius_frac, 1e-6); + float t_lut = (t - u_lut_domain_min) / u_lut_domain_span; + vec4 grad = texture2D(u_lut, vec2(clamp(t_lut, 0.0, 1.0), 0.5)); + + float a = grad.a * coverage; + gl_FragColor = vec4(grad.rgb * a, a); +} +"##; + +// Outer drop shadow shader. +// +// Rather than baking a blurred shape into an intermediate texture via a +// separable Gaussian pass, this uses the analytical soft-shadow +// approximation `exp(-d² / (2σ²))` over the rounded-rect SDF. The maths: +// +// • `d` is the signed distance from the fragment to the (possibly +// spread-expanded) shape. `d < 0` is inside, `d > 0` is outside. +// • `intensity = 1.0` inside the shape, `exp(-d²/2σ²)` outside. +// • `σ = shadow.blur / 2` (CSS blur radius → Gaussian sigma). +// +// The result is visually indistinguishable from a true Gaussian blur for +// small to moderate σ. For very large σ the tail of `exp()` departs from +// a real Gaussian, at which point a real two-pass blur or a correction +// factor would be needed. +// +// Running the shadow as a single analytic pass means there's no need for +// a per-shadow FBO, no ping-pong, and no framebuffer readback: it is all +// one cheap draw call. +// Inner (inset) shadow shader. +// +// Two SDFs cooperate here: +// +// • `d_outer` — the signed distance to the surface itself. Positive +// outside, negative inside. We multiply the final intensity by the +// smooth-step coverage of this SDF so the inset never leaks past the +// shape's own silhouette. +// +// • `d_inner` — the signed distance to the shape shifted by +// `shadow.offset` and eroded by `shadow.spread`. This is the "hole" +// the inset falls into: `d_inner ≥ 0` means the pixel is on the +// shadow side of the offset edge (full intensity); `d_inner < 0` +// means the pixel is deeper into the unshadowed interior, where the +// intensity decays as `exp(-d_inner² / (2σ²))`. +// +// Together they reproduce the CSS `inset` semantics: the shadow sits +// inside the rounded rect, biased toward the side opposite `offset`, +// and fades toward the middle with a Gaussian falloff. Premul output +// matches the rest of the pipeline. +pub( super ) const SHADOW_INSET_FRAG_SRC: &str = r##" +precision mediump float; +varying vec2 v_uv; +uniform vec2 u_size; +uniform vec2 u_padding; +uniform vec4 u_radii; +uniform float u_spread; +uniform float u_sigma; +uniform vec2 u_offset; +uniform vec4 u_color; + +// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the +// quadrant convention. +float corner_radius(vec2 c, vec4 r) +{ + float top = (c.x < 0.0) ? r.x : r.y; + float bottom = (c.x < 0.0) ? r.w : r.z; + return (c.y > 0.0) ? top : bottom; +} + +void main() +{ + vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding; + vec2 c = p - u_size * 0.5; + + // Outer SDF: the shape itself, untransformed. Used to clip the inset + // to the silhouette. 2-px AA band — see RECT_FRAG_SRC. + vec2 half_outer = u_size * 0.5; + float r_outer = corner_radius(c, u_radii); + r_outer = min(r_outer, min(half_outer.x, half_outer.y)); + vec2 q_outer = abs(c) - (half_outer - vec2(r_outer)); + float d_outer = min(max(q_outer.x, q_outer.y), 0.0) + length(max(q_outer, 0.0)) - r_outer; + float outer_coverage = 1.0 - smoothstep(-1.0, 1.0, d_outer); + + // Inner SDF: the shape shifted by offset and eroded by spread. Its + // distance drives the Gaussian falloff. The inner radii match the + // outer per-corner shape, eroded by `u_spread` (clamped at zero). + vec2 p_shifted = p - u_offset; + vec2 c_shifted = p_shifted - u_size * 0.5; + vec2 half_inner = half_outer - vec2(u_spread); + // Degenerate case: spread larger than half the shape erodes it to a + // point. Guard so `half_inner` never goes negative. + half_inner = max(half_inner, vec2(1e-3)); + vec4 inner_radii = max(u_radii - vec4(u_spread), vec4(0.0)); + float r_inner = corner_radius(c_shifted, inner_radii); + r_inner = min(r_inner, min(half_inner.x, half_inner.y)); + vec2 q_inner = abs(c_shifted) - (half_inner - vec2(r_inner)); + float d_inner = min(max(q_inner.x, q_inner.y), 0.0) + length(max(q_inner, 0.0)) - r_inner; + + float intensity; + if (d_inner >= 0.0) + { + intensity = 1.0; + } + else + { + float s = max(u_sigma, 0.5); + intensity = exp(-(d_inner * d_inner) / (2.0 * s * s)); + } + intensity *= outer_coverage; + + float a = u_color.a * intensity; + gl_FragColor = vec4(u_color.rgb * a, a); +} +"##; + +pub( super ) const SHADOW_OUTER_FRAG_SRC: &str = r##" +precision mediump float; +varying vec2 v_uv; +uniform vec2 u_size; +uniform vec2 u_padding; +uniform vec4 u_radii; +uniform float u_spread; +uniform float u_sigma; +uniform vec4 u_color; + +// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the +// quadrant convention. +float corner_radius(vec2 c, vec4 r) +{ + float top = (c.x < 0.0) ? r.x : r.y; + float bottom = (c.x < 0.0) ? r.w : r.z; + return (c.y > 0.0) ? top : bottom; +} + +void main() +{ + vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding; + vec2 c = p - u_size * 0.5; + vec2 half_sz = u_size * 0.5 + vec2(u_spread); + // Per-corner shadow radius — outer shape grown uniformly by spread. + vec4 r_base = max(u_radii + vec4(u_spread), vec4(0.0)); + float r = corner_radius(c, r_base); + r = min(r, min(half_sz.x, half_sz.y)); + vec2 q = abs(c) - (half_sz - vec2(r)); + float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; + + float intensity; + if (d <= 0.0) + { + intensity = 1.0; + } + else + { + float s = max(u_sigma, 0.5); + intensity = exp(-(d * d) / (2.0 * s * s)); + } + + float a = u_color.a * intensity; + gl_FragColor = vec4(u_color.rgb * a, a); +} +"##; + +// Horizontal Gaussian blur for the backdrop pipeline. Part one of the +// separable-Gaussian pair: samples the aux_a snapshot horizontally and +// writes to aux_b at the fragment's pixel position. Drawn over a +// vertically-extended surface rect so the vertical pass — which reads +// aux_b up to ±3σ away from each target pixel — has valid data wherever +// it samples. `u_canvas_size` maps `gl_FragCoord` into the aux texture, +// and the aux pair is sized to the main FBO so that mapping is trivial. +// +// Kernel: 41 taps (`RADIUS = 20`), weights computed inline as +// `exp(-i²/(2σ²))` and normalized by the sum. At σ ≈ 11 this captures +// ~93 % of the Gaussian mass — the remainder is a faint tail that is +// visually imperceptible. For σ past ~15 the kernel radius would need +// to grow or a downsample pass would be required; everything else in +// the pipeline already deals in σ and would not change. +pub( super ) const BACKDROP_BLUR_H_FRAG_SRC: &str = r#" +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_source; +uniform vec2 u_texel; +uniform vec2 u_canvas_size; +uniform float u_sigma; +void main() +{ + const int RADIUS = 20; + vec2 uv = gl_FragCoord.xy / u_canvas_size; + vec4 total = vec4(0.0); + float total_w = 0.0; + for (int i = -RADIUS; i <= RADIUS; i++) + { + float fi = float(i); + float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma)); + total += texture2D(u_source, uv + fi * u_texel) * w; + total_w += w; + } + gl_FragColor = total / total_w; +} +"#; + +// Vertical Gaussian blur + SDF clip + tint, the last pass of the +// backdrop pipeline. Runs on a quad matching the surface rect and +// writes to the main FBO. +// +// Per fragment: (1) computes the rounded-rect SDF coverage just like +// the rect shader so the backdrop is clipped to the surface shape with +// a 1-pixel anti-aliased edge; (2) samples `u_source` (the H-blurred +// aux_b) vertically with the same 41-tap Gaussian as the H pass; (3) +// applies the optional `u_tint` over the blurred sample using standard +// premul-over math; (4) outputs premultiplied with `alpha = coverage`. +// +// Alpha handling. The output is `(result_rgb * coverage, coverage)` — +// not `(result_rgb * coverage * result_a, coverage * result_a)`. The +// snapshot holds premultiplied content that is effectively opaque +// inside the canvas (every pixel has been written by some draw, even +// if that draw was `clear(0,0,0,0)`; the FBO is never sampled outside +// the canvas bounds). Treating the blurred sample's alpha as 1.0 at +// output time means the composite pass REPLACES the base pixel inside +// the surface shape with the tinted blurred content, rather than +// alpha-blending on top of it. That is the correct semantics for +// `backdrop-filter`: you want the original content to disappear +// entirely where the surface covers it, replaced by the blurred +// version. +// +// `fill_backdrop` runs this before outer shadows / fill / insets so +// later passes composite on top of the blurred backdrop. +pub( super ) const BACKDROP_COMPOSITE_FRAG_SRC: &str = r#" +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_source; +uniform vec2 u_canvas_size; +uniform vec2 u_texel; +uniform float u_sigma; +uniform vec2 u_size; +uniform vec2 u_padding; +uniform vec4 u_radii; +uniform vec4 u_tint; + +// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the +// quadrant convention. +float corner_radius(vec2 c, vec4 r) +{ + float top = (c.x < 0.0) ? r.x : r.y; + float bottom = (c.x < 0.0) ? r.w : r.z; + return (c.y > 0.0) ? top : bottom; +} + +void main() +{ + const int RADIUS = 20; + + // Rounded-rect SDF clip. 2-px AA band — see RECT_FRAG_SRC. + vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding; + vec2 c = p - u_size * 0.5; + vec2 half_sz = u_size * 0.5; + float r = corner_radius(c, u_radii); + r = min(r, min(half_sz.x, half_sz.y)); + vec2 q = abs(c) - (half_sz - vec2(r)); + float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; + float coverage = 1.0 - smoothstep(-1.0, 1.0, d); + if (coverage <= 0.0) { discard; } + + // Vertical Gaussian on the H-blurred source. + vec2 uv = gl_FragCoord.xy / u_canvas_size; + vec4 total = vec4(0.0); + float total_w = 0.0; + for (int i = -RADIUS; i <= RADIUS; i++) + { + float fi = float(i); + float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma)); + total += texture2D(u_source, uv + fi * u_texel) * w; + total_w += w; + } + vec4 blurred = total / total_w; + + // Optional tint, "tint over blurred" in premul space. + vec3 tint_premul = u_tint.rgb * u_tint.a; + vec3 result_rgb = tint_premul + blurred.rgb * (1.0 - u_tint.a); + + gl_FragColor = vec4(result_rgb * coverage, coverage); +} +"#; + +// ── Fast (low-quality) variants of the backdrop shaders ──────────────── +// +// Same separable-Gaussian pipeline as the full-quality pair above, but +// with `RADIUS = 4` instead of `RADIUS = 20`. That cuts each pass from +// 41 taps to 9 — a ~4.5× reduction in fragment-shader work — and +// shrinks the snapshot region the renderer has to copy from the main +// FBO from `target ± 21` to `target ± 5` pixels. Used during motion +// via [`super::low_quality_paint`]; the static frame is painted with +// the full-quality pair. +// +// `u_sigma` is still a uniform so the CPU side can clamp it to a +// value compatible with the smaller kernel (typically ≤ 2.0), keeping +// the kernel a sensible Gaussian rather than a sharply truncated one. +// Visually this means a thinner blur band during motion, which fades +// back to the full blur on the static frame. +pub( super ) const BACKDROP_FAST_BLUR_H_FRAG_SRC: &str = r#" +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_source; +uniform vec2 u_texel; +uniform vec2 u_canvas_size; +uniform float u_sigma; +void main() +{ + const int RADIUS = 4; + vec2 uv = gl_FragCoord.xy / u_canvas_size; + vec4 total = vec4(0.0); + float total_w = 0.0; + for (int i = -RADIUS; i <= RADIUS; i++) + { + float fi = float(i); + float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma)); + total += texture2D(u_source, uv + fi * u_texel) * w; + total_w += w; + } + gl_FragColor = total / total_w; +} +"#; + +pub( super ) const BACKDROP_FAST_COMPOSITE_FRAG_SRC: &str = r#" +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_source; +uniform vec2 u_canvas_size; +uniform vec2 u_texel; +uniform float u_sigma; +uniform vec2 u_size; +uniform vec2 u_padding; +uniform vec4 u_radii; +uniform vec4 u_tint; + +// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the +// quadrant convention. +float corner_radius(vec2 c, vec4 r) +{ + float top = (c.x < 0.0) ? r.x : r.y; + float bottom = (c.x < 0.0) ? r.w : r.z; + return (c.y > 0.0) ? top : bottom; +} + +void main() +{ + const int RADIUS = 4; + + // Rounded-rect SDF clip — identical to the full-quality variant. + vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding; + vec2 c = p - u_size * 0.5; + vec2 half_sz = u_size * 0.5; + float r = corner_radius(c, u_radii); + r = min(r, min(half_sz.x, half_sz.y)); + vec2 q = abs(c) - (half_sz - vec2(r)); + float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; + float coverage = 1.0 - smoothstep(-1.0, 1.0, d); + if (coverage <= 0.0) { discard; } + + vec2 uv = gl_FragCoord.xy / u_canvas_size; + vec4 total = vec4(0.0); + float total_w = 0.0; + for (int i = -RADIUS; i <= RADIUS; i++) + { + float fi = float(i); + float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma)); + total += texture2D(u_source, uv + fi * u_texel) * w; + total_w += w; + } + vec4 blurred = total / total_w; + + vec3 tint_premul = u_tint.rgb * u_tint.a; + vec3 result_rgb = tint_premul + blurred.rgb * (1.0 - u_tint.a); + + gl_FragColor = vec4(result_rgb * coverage, coverage); +} +"#; + +// Inset shadow with CSS `Overlay` blend. Uses the same SDF dance as +// `SHADOW_INSET_FRAG_SRC` to compute `intensity` (outer-silhouette clip +// + Gaussian falloff from the offset-shifted inner SDF), then samples +// the snapshotted FBO content under the fragment and applies the +// per-channel Overlay formula: +// +// overlay(base, src) = base < 0.5 ? 2 * base * src +// : 1 - 2 * (1 - base) * (1 - src) +// +// Output is premultiplied with `mask = u_color.a * intensity` — so the +// standard `(ONE, ONE_MINUS_SRC_ALPHA)` blend on top of the main FBO +// yields `result = overlay_rgb * mask + base * (1 - mask)`, i.e. the +// Overlay effect modulated by the shadow mask, composed onto the +// original backdrop. The snapshot texture holds premultiplied content +// matching the main FBO, so we unpremultiply before applying Overlay. +// +// `u_canvas_size` is the main FBO's pixel size. `gl_FragCoord` has its +// origin at bottom-left in GLES, which matches the FBO's native +// orientation, so `gl_FragCoord.xy / u_canvas_size` gives the texture +// coordinates of the pixel we're about to write to. No Y-flip needed. +pub( super ) const SHADOW_INSET_OVERLAY_FRAG_SRC: &str = r##" +precision mediump float; +varying vec2 v_uv; +uniform vec2 u_size; +uniform vec2 u_padding; +uniform vec4 u_radii; +uniform float u_spread; +uniform float u_sigma; +uniform vec2 u_offset; +uniform vec4 u_color; +uniform sampler2D u_snapshot; +uniform vec2 u_canvas_size; + +// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the +// quadrant convention. +float corner_radius(vec2 c, vec4 r) +{ + float top = (c.x < 0.0) ? r.x : r.y; + float bottom = (c.x < 0.0) ? r.w : r.z; + return (c.y > 0.0) ? top : bottom; +} + +void main() +{ + vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding; + vec2 c = p - u_size * 0.5; + + // Outer silhouette clip — same as SHADOW_INSET_FRAG_SRC, 2-px AA band. + vec2 half_outer = u_size * 0.5; + float r_outer = corner_radius(c, u_radii); + r_outer = min(r_outer, min(half_outer.x, half_outer.y)); + vec2 q_outer = abs(c) - (half_outer - vec2(r_outer)); + float d_outer = min(max(q_outer.x, q_outer.y), 0.0) + length(max(q_outer, 0.0)) - r_outer; + float outer_coverage = 1.0 - smoothstep(-1.0, 1.0, d_outer); + + // Offset-shifted inner SDF → Gaussian intensity. Per-corner inner + // radii match the outer shape eroded by `u_spread`. + vec2 p_shifted = p - u_offset; + vec2 c_shifted = p_shifted - u_size * 0.5; + vec2 half_inner = half_outer - vec2(u_spread); + half_inner = max(half_inner, vec2(1e-3)); + vec4 inner_radii = max(u_radii - vec4(u_spread), vec4(0.0)); + float r_inner = corner_radius(c_shifted, inner_radii); + r_inner = min(r_inner, min(half_inner.x, half_inner.y)); + vec2 q_inner = abs(c_shifted) - (half_inner - vec2(r_inner)); + float d_inner = min(max(q_inner.x, q_inner.y), 0.0) + length(max(q_inner, 0.0)) - r_inner; + + float intensity; + if (d_inner >= 0.0) + { + intensity = 1.0; + } + else + { + float s = max(u_sigma, 0.5); + intensity = exp(-(d_inner * d_inner) / (2.0 * s * s)); + } + intensity *= outer_coverage; + + float mask = u_color.a * intensity; + + // Sample the snapshot at the fragment's FBO position. The snapshot + // holds premultiplied content; divide by alpha before applying the + // straight-alpha Overlay formula. Guard alpha=0 to avoid NaNs. + vec2 snap_uv = gl_FragCoord.xy / u_canvas_size; + vec4 snap = texture2D(u_snapshot, snap_uv); + vec3 base = snap.a > 0.0 ? snap.rgb / snap.a : vec3(0.0); + vec3 src = u_color.rgb; + + // Per-channel Overlay. `step(0.5, base)` yields 0 where base < 0.5 + // and 1 otherwise; `mix` picks multiply vs screen accordingly. + vec3 multiply = 2.0 * base * src; + vec3 screen = 1.0 - 2.0 * (1.0 - base) * (1.0 - src); + vec3 overlay = mix(multiply, screen, step(0.5, base)); + + // Output premul: `(overlay * mask, mask)`. Combined with the premul + // over blend this replaces the base by `overlay` wherever mask == 1 + // and leaves it untouched where mask == 0. + gl_FragColor = vec4(overlay * mask, mask); +} +"##; + diff --git a/src/gles_render/text.rs b/src/gles_render/text.rs new file mode 100644 index 0000000..87dda4f --- /dev/null +++ b/src/gles_render/text.rs @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Text rendering for [`GlesCanvas`]: glyph-atlas cache + per-glyph +//! draw call. Each glyph is rasterised once via fontdue, uploaded as +//! a one-off `GL_LUMINANCE` texture, and stored on the canvas; the +//! per-frame hot path picks positions and issues one draw call per +//! glyph. +//! +//! `GL_LUMINANCE` is deliberate — `GL_ALPHA` has inconsistent +//! handling across Mesa GLES3 drivers (sampled `.a` returns near-zero +//! in glyph interiors, leaving only antialias edges visible), and +//! luminance's `.r = .g = .b = data` mapping is well-supported +//! across ES2/ES3. + +use std::sync::Arc; + +use fontdue::Font; +use glow::HasContext; + +use crate::types::{ Color, Rect }; + +use super::helpers::{ ortho_rect, upload_alpha_texture }; +use super::{ GlesCanvas, GlyphEntry }; + +const GLYPH_CACHE_SOFT_CAP: usize = 8192; + +fn font_id( font: &Arc ) -> usize +{ + Arc::as_ptr( font ) as usize +} + +impl GlesCanvas +{ + pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color ) + { + self.draw_text_inner( text, x, y, size, color, None ); + } + + /// Draw `text` using `font` instead of the canvas default + lazy + /// system-font fallback chain. Glyphs from this font live under + /// their own atlas keys. + pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc ) + { + self.draw_text_inner( text, x, y, size, color, Some( font ) ); + } + + fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc> ) + { + self.activate_target(); + let scaled = size * self.dpi_scale; + let mut cursor_x = x; + + for ch in text.chars() + { + let size_key = (scaled * 10.0) as u32; + let ( id, font_arc ): ( usize, Arc ) = match font + { + Some( f ) => + { + if f.lookup_glyph_index( ch ) != 0 + { + ( font_id( f ), Arc::clone( f ) ) + } + else + { + self.font_id_for_char( ch ) + } + } + None => self.font_id_for_char( ch ), + }; + let key = ( ch, size_key, id ); + + if !self.glyph_cache.contains_key( &key ) + { + if self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP + { + self.evict_glyph_cache_half(); + } + let ( metrics, bitmap ) = font_arc.rasterize( ch, scaled ); + if metrics.width > 0 && metrics.height > 0 + { + let tex = upload_alpha_texture( &self.gl, &bitmap, metrics.width as i32, metrics.height as i32 ); + self.glyph_cache.insert( key, GlyphEntry + { + texture: tex, + metrics, + tex_w: metrics.width as i32, + tex_h: metrics.height as i32, + } ); + } else { + cursor_x += metrics.advance_width; + continue; + } + } + + if let Some( entry ) = self.glyph_cache.get( &key ) + { + let gx = ( cursor_x + entry.metrics.xmin as f32 ).round(); + let gy = ( y - entry.metrics.height as f32 - entry.metrics.ymin as f32 + 1.0 ).round(); + let dest = Rect + { + x: gx, + y: gy, + width: entry.tex_w as f32, + height: entry.tex_h as f32, + }; + self.draw_glyph_texture( entry.texture, dest, color, self.global_alpha ); + cursor_x += entry.metrics.advance_width; + } + } + } + + pub fn measure_text( &self, text: &str, size: f32 ) -> f32 + { + text.chars().map( |ch| + { + self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width + } ).sum() + } + + pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc ) -> f32 + { + text.chars().map( |ch| + { + let f = if font.lookup_glyph_index( ch ) != 0 + { + Arc::clone( font ) + } + else + { + self.font_for_char( ch ) + }; + f.metrics( ch, size * self.dpi_scale ).advance_width + } ).sum() + } + + fn font_id_for_char( &self, ch: char ) -> ( usize, Arc ) + { + let font = self.font_for_char( ch ); + let id = Arc::as_ptr( &font ) as usize; + ( id, font ) + } + + fn evict_glyph_cache_half( &mut self ) + { + let drop_n = self.glyph_cache.len() / 2; + let victims: Vec<_> = self.glyph_cache.keys().copied().take( drop_n ).collect(); + for key in victims + { + if let Some( entry ) = self.glyph_cache.remove( &key ) + { + unsafe { self.gl.delete_texture( entry.texture ); } + } + } + } + + fn draw_glyph_texture( &self, texture: glow::Texture, dest: Rect, color: Color, opacity: f32 ) + { + let mvp = ortho_rect( self.width, self.height, dest ); + unsafe + { + self.gl.use_program( Some( self.glyph_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_glyph_mvp ), false, &mvp ); + self.gl.uniform_4_f32( Some( &self.u_glyph_color ), color.r, color.g, color.b, color.a ); + self.gl.uniform_1_f32( Some( &self.u_glyph_opacity ), opacity ); + self.gl.active_texture( glow::TEXTURE0 ); + self.gl.bind_texture( glow::TEXTURE_2D, Some( texture ) ); + self.gl.uniform_1_i32( Some( &self.u_glyph_sampler ), 0 ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + self.gl.bind_texture( glow::TEXTURE_2D, None ); + } + } +} diff --git a/src/input/dispatch.rs b/src/input/dispatch.rs new file mode 100644 index 0000000..651bd6c --- /dev/null +++ b/src/input/dispatch.rs @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Dispatch helpers shared by the pointer and touch handlers. +//! +//! The gesture state machine returns abstract outcomes; this file is +//! where those outcomes turn into concrete side-effects on `AppData`: +//! pending-message pushes, app-level callbacks (`on_swipe_*`, +//! `on_drag_move`, `on_drop`, `on_tap`, `overlay_dismiss_msg`), and +//! the redraw / cache invalidation flags that keep the loop moving +//! after a non-Message-producing gesture. +//! +//! Splitting these into a dedicated file means pointer.rs and touch.rs +//! only carry the Wayland-event translation. A future input source +//! (stylus, gamepad) can reuse the same dispatcher by feeding the +//! state machine the same way. + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::types::Point; +use crate::widget::WidgetHandlers; + +use super::gesture::{ MoveOutcome, ReleaseEvent }; + +impl AppData +{ + /// If the press at `pos` lands on a password-toggle icon zone of + /// the widget at `idx`, push the toggle message and return + /// `true` so the caller can skip the rest of the cursor / + /// selection placement that would otherwise consume the press. + /// Returns `false` when there is no toggle on that widget or + /// the press fell outside the icon's hit area, leaving the + /// caller to dispatch the press normally. + pub( super ) fn handle_password_toggle_press + ( + &mut self, + focus: SurfaceFocus, + idx: usize, + pos: Point, + ) -> bool + { + let toggle_msg = self.surface( focus ).widget_rects.iter() + .find( |w| w.flat_idx == idx ) + .and_then( |w| match &w.handlers + { + WidgetHandlers::TextEdit { password_toggle_msg: Some( msg ), .. } => + { + let zone = crate::widget::text_edit::password_toggle_hit_zone( w.rect ); + if zone.contains( pos ) { Some( msg.clone() ) } else { None } + } + _ => None, + } ); + if let Some( msg ) = toggle_msg + { + self.pending_msgs.push( msg ); + self.surface_mut( focus ).request_redraw(); + true + } + else + { + false + } + } +} + +impl AppData +{ + /// Apply the outcome of a motion event. Non-blocking side-effects + /// only: push messages, call app callbacks, set redraw flags. + pub( super ) fn apply_move_outcome + ( + &mut self, + focus: SurfaceFocus, + outcome: MoveOutcome, + ) + { + match outcome + { + MoveOutcome::Idle => {} + MoveOutcome::Drag { pos } => + { + self.app.on_drag_move( pos.x, pos.y ); + self.dirty_caches(); + self.surface_mut( focus ).request_redraw(); + self.main.request_redraw(); + } + MoveOutcome::Slider { msg } => + { + if let Some( m ) = msg + { + self.pending_msgs.push( m ); + } + self.surface_mut( focus ).request_redraw(); + } + MoveOutcome::Scroll => + { + self.surface_mut( focus ).request_redraw(); + } + MoveOutcome::Swipe { up, down, horizontal } => + { + if let Some( v ) = up { self.app.on_swipe_progress( v ); } + if let Some( v ) = down { self.app.on_swipe_down_progress( v ); } + if let Some( v ) = horizontal { self.app.on_swipe_horizontal_progress( v ); } + // Swipe-progress callbacks mutate app state outside + // `update`, so the cached view tree is stale. + self.dirty_caches(); + self.surface_mut( focus ).request_redraw(); + for ss in self.overlays.values_mut() + { + ss.request_redraw(); + } + } + } + } + + /// Apply the ordered list of events from a release. A single + /// release can emit more than one event (e.g. horizontal + /// fall-through followed by a vertical commit or fall-through), so + /// we walk the list and run each event's side-effects in order. + pub( super ) fn apply_release_events + ( + &mut self, + focus: SurfaceFocus, + events: Vec>, + ) + { + for event in events + { + self.apply_release_event( focus, event ); + } + } + + fn apply_release_event + ( + &mut self, + focus: SurfaceFocus, + event: ReleaseEvent, + ) + { + match event + { + ReleaseEvent::Drop { pos } => + { + if let Some( msg ) = self.app.on_drop( pos.x, pos.y ) + { + self.pending_msgs.push( msg ); + } + self.clear_long_press_drag(); + self.dirty_caches(); + self.main.request_redraw(); + self.main.frame_pending = false; + } + ReleaseEvent::SwipeLeft => + { + if let Some( msg ) = self.app.on_swipe_left() + { + self.pending_msgs.push( msg ); + } + // When the app handles the release internally (settle + // animation, state-only mutation) it returns no + // Message, so the update-driven redraw never fires. + // Kick the main surface here so `is_animating()` has a + // frame to latch onto. + self.dirty_caches(); + self.main.request_redraw(); + self.main.frame_pending = false; + } + ReleaseEvent::SwipeRight => + { + if let Some( msg ) = self.app.on_swipe_right() + { + self.pending_msgs.push( msg ); + } + self.dirty_caches(); + self.main.request_redraw(); + self.main.frame_pending = false; + } + ReleaseEvent::SwipeUp => + { + if let Some( msg ) = self.app.on_swipe_up() + { + self.pending_msgs.push( msg ); + } + self.dirty_caches(); + self.main.request_redraw(); + self.main.frame_pending = false; + for ss in self.overlays.values_mut() + { + ss.request_redraw(); + ss.frame_pending = false; + } + } + ReleaseEvent::SwipeDown => + { + if let Some( msg ) = self.app.on_swipe_down() + { + self.pending_msgs.push( msg ); + } + self.dirty_caches(); + self.main.request_redraw(); + self.main.frame_pending = false; + for ss in self.overlays.values_mut() + { + ss.request_redraw(); + ss.frame_pending = false; + } + } + ReleaseEvent::HorizontalFellThrough => + { + // Below threshold: pulse horizontal 0 so a + // vertical-dominant gesture that drifted laterally + // does not leave the app stuck on a stale horizontal + // progress. + self.app.on_swipe_horizontal_progress( 0.0 ); + self.dirty_caches(); + self.main.request_redraw(); + self.main.frame_pending = false; + } + ReleaseEvent::VerticalFellThrough => + { + self.app.on_swipe_progress( 0.0 ); + self.app.on_swipe_down_progress( 0.0 ); + self.dirty_caches(); + } + ReleaseEvent::PushMsg( msg ) => + { + self.pending_msgs.push( msg ); + } + ReleaseEvent::EmptyRelease => + { + match focus + { + SurfaceFocus::Main => + { + if let Some( msg ) = self.app.on_tap() + { + self.pending_msgs.push( msg ); + } + } + SurfaceFocus::Overlay( id ) => + { + if let Some( msg ) = self.overlay_dismiss_msg( id ) + { + self.pending_msgs.push( msg ); + } + } + } + } + } + } +} diff --git a/src/input/gesture.rs b/src/input/gesture.rs new file mode 100644 index 0000000..51cc3fd --- /dev/null +++ b/src/input/gesture.rs @@ -0,0 +1,1070 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Per-surface gesture state machine. Source-agnostic — both +//! `PointerHandler` (Wayland `wl_pointer`) and `TouchHandler` +//! (`wl_touch`) feed into the same machine and apply the decisions it +//! emits. +//! +//! ## Lifecycle +//! +//! 1. [`GestureState::on_press`] — records the gesture origin, snapshots +//! the hit widget's long-press AND drag-start handlers, identifies +//! slider / scroll targets. Returns a [`PressOutcome`] with the hit +//! index and any initial slider message. +//! 2. [`GestureState::on_move`] — cancels the long-press candidate if +//! the finger strays past 6 px, updates slider values, mutates +//! scroll offsets, computes swipe progress. Returns a +//! [`MoveOutcome`] telling the handler which app callbacks to fire. +//! 3. [`GestureState::on_release`] — decides between drop (long-press +//! was already fired), slider / scroll done, horizontal / vertical +//! swipe commit or fall-through, tap or button press. Returns an +//! ordered [`Vec`] — a single release can emit more +//! than one event when a horizontal swipe falls through and the +//! handler still has to check the vertical branch. +//! 4. [`GestureState::on_cancel`] — resets everything (touch-cancel +//! only; the compositor is stealing the gesture). +//! +//! The long-press DEADLINE itself is not polled here. `AppData` +//! walks every surface's `gesture.long_press_start` against the app's +//! `long_press_duration()` and flips `gesture.long_press_fired` when +//! the deadline elapses — this module only records the start instant +//! and reacts to the flip. + +use std::collections::HashMap; +use std::time::Instant; + +use crate::tree::{ find_handlers, find_widget, find_widget_at }; +use crate::types::{ Point, Rect }; +use crate::widget::LaidOutWidget; + +// ─── State ─────────────────────────────────────────────────────────────────── + +/// Per-surface gesture tracking. Lives on `SurfaceState::gesture`. +/// +/// Every field is `pub` because `AppData`'s long-press deadline +/// checker in `event_loop/app_data.rs` needs to read the start instant +/// and set `long_press_fired` from outside the state machine. The +/// lifecycle methods ([`Self::on_press`], [`Self::on_move`], +/// [`Self::on_release`], [`Self::on_cancel`]) are the supported way +/// for input handlers to drive the machine; direct mutation is only +/// for the deadline path. +#[ derive( Debug, Clone ) ] +pub struct GestureState +{ + /// Position where the current press / touch-down landed. `None` + /// between gestures. + pub start: Option, + /// Widget index under the press, if any. Set on press; taken on + /// release. + pub pressed_idx: Option, + /// Index of the Scroll viewport that owns the current gesture, if + /// the press landed inside one. + pub scrolling_widget: Option, + /// Scroll-viewport drag exceeded the 8 px start tolerance — the + /// release will be consumed as a scroll instead of a tap. + pub scroll_drag_started: bool, + /// Horizontal drag exceeded the 8 px threshold. Release runs the + /// horizontal-swipe commit check instead of the button path. + pub horizontal_drag_started: bool, + /// Vertical drag exceeded the 8 px threshold. Release runs the + /// swipe-up / swipe-down commit check instead of the button path. + pub vertical_drag_started: bool, + /// Slider widget being dragged for a live-value update. + pub dragging_slider: Option, + /// Instant when the long-press window opened. `None` if the hit + /// widget has no long-press handler (or the candidate was + /// cancelled by movement). + pub long_press_start: Option, + /// Original press position for the long-press candidate — used by + /// [`Self::on_move`] to cancel if the finger strays more than 6 px. + pub long_press_origin: Option, + /// Message to fire when the deadline elapses (touch hold) or when + /// the user right-clicks. Drained by the deadline checker on touch + /// and by the right-click handler on mouse. Firing this on its own + /// does NOT put the gesture into drag mode — that is governed by + /// [`Self::drag_start_msg`]. + pub long_press_msg: Option, + /// Drag-arm message captured from the hit widget on press. Fired + /// by the touch deadline alongside `long_press_msg` and by the + /// mouse-motion drag-promotion path on its own. When fired, the + /// gesture transitions into drag mode (`long_press_fired = true`) + /// and subsequent motion / release run through `on_drag_move` / + /// `on_drop`. + pub drag_start_msg: Option, + /// `widget_rects` index of a TextEdit the press landed on, when + /// no user `on_long_press` is configured. Lets the runtime show + /// the built-in Copy / Cut / Paste menu after the same delay + /// without forcing every text field to opt in. + pub long_press_text_idx: Option, + /// `true` once the gesture has transitioned into drag mode — + /// subsequent motion fires `app.on_drag_move`, release fires + /// `app.on_drop`. Set by the touch deadline when `drag_start_msg` + /// is present, or by the mouse-motion promotion path. + pub long_press_fired: bool, + /// `true` when the press came from a mouse (`wl_pointer`) rather + /// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated + /// on this: touch needs the cancel so a hold that turns into a + /// swipe / scroll doesn't keep the long-press candidate armed, + /// but mouse motion is always intentional and never competes with + /// a swipe gesture, so the candidate stays alive until the + /// pointer-side promotion threshold or the deadline fires. Set by + /// the pointer Press handler right after [`Self::on_press`]; left + /// `false` for touch presses. + pub mouse_press: bool, +} + +impl Default for GestureState +{ + fn default() -> Self { Self::new() } +} + +impl GestureState +{ + /// Fresh state — no press in flight. + pub fn new() -> Self + { + Self + { + start: None, + pressed_idx: None, + scrolling_widget: None, + scroll_drag_started: false, + horizontal_drag_started: false, + vertical_drag_started: false, + dragging_slider: None, + long_press_start: None, + long_press_origin: None, + long_press_msg: None, + drag_start_msg: None, + long_press_text_idx: None, + long_press_fired: false, + mouse_press: false, + } + } + + /// Press / touch-down: record the gesture origin, capture long-press + /// handler if the hit widget has one, identify slider / scroll + /// targets. Returns the hit index (for keyboard focus update) and + /// any initial slider message (the slider applies its value + /// immediately on press so the thumb tracks the finger from pixel + /// one). + pub fn on_press + ( + &mut self, + pos: Point, + widget_rects: &[LaidOutWidget], + scroll_rects: &[( Rect, usize )], + ) -> PressOutcome + { + let hit = find_widget_at( widget_rects, pos ); + let ( lp_msg, ds_msg ) = hit.map( |idx| + { + let h = find_handlers( widget_rects, idx ); + ( + h.as_ref().and_then( |h| h.long_press_msg() ), + h.as_ref().and_then( |h| h.drag_start_msg() ), + ) + }).unwrap_or( ( None, None ) ); + + self.start = Some( pos ); + self.scrolling_widget = scroll_rects.iter().rev() + .find( |( r, _ )| r.contains( pos ) ) + .map( |( _, idx )| *idx ); + self.scroll_drag_started = false; + self.horizontal_drag_started = false; + self.vertical_drag_started = false; + self.pressed_idx = hit; + // A fresh press resets any stale long-press / source state + // from a prior gesture that never released cleanly. The + // pointer Press handler flips `mouse_press` back to true + // right after this call when the press came from a mouse; + // touch leaves it `false`. + self.long_press_fired = false; + self.mouse_press = false; + // Built-in long-press for text inputs: arms the same timer + // even when the widget has no user-set `on_long_press`, so + // the runtime can show the Copy / Cut / Paste menu after the + // usual delay. + let text_idx = hit.and_then( |idx| + { + find_handlers( widget_rects, idx ) + .and_then( |h| if h.is_text_input() { Some( idx ) } else { None } ) + } ); + // Arm the long-press timer if anything is pending: the menu + // message, the drag-start message, or the built-in text menu. + // A widget that only carries `on_drag_start` (no menu) still + // needs the timer so a touch hold can promote into a drag. + let arm_long_press = lp_msg.is_some() || ds_msg.is_some() || text_idx.is_some(); + self.long_press_start = arm_long_press.then( Instant::now ); + self.long_press_origin = arm_long_press.then_some( pos ); + self.long_press_msg = lp_msg; + self.drag_start_msg = ds_msg; + self.long_press_text_idx = text_idx; + + // Slider drag: if the hit widget is a slider, arm the drag and + // apply the press-point value immediately. + let mut initial_slider_msg = None; + if let Some( idx ) = hit + { + if let Some( w ) = find_widget( widget_rects, idx ) + { + if w.handlers.is_slider() + { + self.dragging_slider = Some( idx ); + let value = w.handlers.slider_value_from_pos( w.rect, pos ); + initial_slider_msg = w.handlers.slider_change_msg( value ); + } + } + } + + PressOutcome { hit_idx: hit, initial_slider_msg } + } + + /// Move / touch-motion: classify the motion and mutate scroll + /// offsets in place. `global_drag` is `true` when another surface + /// has an active long-press drag (cross-surface drags route every + /// motion through `on_drag_move` even on surfaces that didn't + /// start the gesture). + pub fn on_move + ( + &mut self, + pos: Point, + widget_rects: &[LaidOutWidget], + scroll_offsets: &mut HashMap, + swipe: &SwipeConfig, + global_drag: bool, + ) -> MoveOutcome + { + // Drag phase: once the long-press has fired, every motion + // goes to the app's drag handler and bypasses slider / scroll / + // swipe logic. + if self.long_press_fired || global_drag + { + return MoveOutcome::Drag { pos }; + } + + // Cancel the long-press candidate if the finger strayed > 6 px + // — but ONLY for touch. Touch needs this so a hold that turns + // into a swipe / scroll doesn't keep the menu candidate + // armed and fire mid-swipe; mouse motion is always + // intentional and never competes with a swipe, so the + // candidate stays alive until the pointer-side promotion at + // 24 px (or the hold-timer deadline) consumes it. Without + // this gate, mouse motion in the 6–24 px band would clear + // `drag_start_msg` before promotion could fire and the user + // would have to wait out the full 500 ms hold timer to drag. + if !self.mouse_press + { + if let Some( origin ) = self.long_press_origin + { + let d = ( pos.x - origin.x ).hypot( pos.y - origin.y ); + if d > 6.0 + { + self.long_press_start = None; + self.long_press_origin = None; + self.long_press_msg = None; + self.drag_start_msg = None; + self.long_press_text_idx = None; + } + } + } + + // Slider drag: continuously update value. + if let Some( slider_idx ) = self.dragging_slider + { + if let Some( w ) = find_widget( widget_rects, slider_idx ) + { + let value = w.handlers.slider_value_from_pos( w.rect, pos ); + let msg = w.handlers.slider_change_msg( value ); + return MoveOutcome::Slider { msg }; + } + return MoveOutcome::Idle; + } + + // Scroll viewport drag: mutate offset in place and advance the + // gesture origin so the next delta is frame-to-frame, not + // press-to-now (otherwise the first 8 px trip the threshold + // and the entire scroll gets absorbed into one delta). + if let Some( scroll_idx ) = self.scrolling_widget + { + if let Some( start ) = self.start + { + let dy = pos.y - start.y; + let entry = scroll_offsets.entry( scroll_idx ).or_insert( 0.0 ); + *entry = ( *entry - dy ).max( 0.0 ); + if dy.abs() > 8.0 { self.scroll_drag_started = true; } + self.start = Some( pos ); + return MoveOutcome::Scroll; + } + return MoveOutcome::Idle; + } + + // Swipe progress — independent on both axes so a vertical- + // dominant gesture that picks up horizontal drift still drives + // the pager. + if let Some( start ) = self.start + { + let dy = start.y - pos.y; + let dx = pos.x - start.x; + + let ( up, down ) = if swipe.surface_height > 0 + { + let h = swipe.surface_height as f32; + if dy > 0.0 + { + let threshold = h * swipe.up_thresh; + if dy.abs() > 8.0 { self.vertical_drag_started = true; } + ( Some( ( dy / threshold ).clamp( 0.0, 1.0 ) ), None ) + } + else if start.y <= h * swipe.down_edge + { + let threshold = h * swipe.down_thresh; + if dy.abs() > 8.0 { self.vertical_drag_started = true; } + // Unclamped on purpose — lets follow-the-finger + // panels track past the commit threshold. + ( None, Some( ( -dy / threshold ).max( 0.0 ) ) ) + } + else { ( None, None ) } + } + else { ( None, None ) }; + + let horizontal = if swipe.surface_width > 0 + { + let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh; + let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 }; + if dx.abs() > 8.0 { self.horizontal_drag_started = true; } + Some( h_progress ) + } + else { None }; + + if up.is_some() || down.is_some() || horizontal.is_some() + { + return MoveOutcome::Swipe { up, down, horizontal }; + } + } + + MoveOutcome::Idle + } + + /// Release / touch-up: decide between drop / slider done / scroll + /// done / swipe commit / swipe fall-through / button press / tap. + /// + /// A release can emit multiple events: a horizontal swipe that + /// didn't commit still fires `on_swipe_horizontal_progress(0.0)` + /// before falling through to the vertical branch, and a below- + /// threshold vertical swipe pulses the vertical progress + /// callbacks. The handler dispatches the events in order. + /// + /// `global_drag` mirrors the parameter of [`Self::on_move`]. + pub fn on_release + ( + &mut self, + pos: Point, + widget_rects: &[LaidOutWidget], + swipe: &SwipeConfig, + global_drag: bool, + ) -> Vec> + { + let pressed = self.pressed_idx.take(); + let was_dragging_slider = self.dragging_slider.is_some(); + let long_press_fired = self.long_press_fired; + let horizontal_drag_started = self.horizontal_drag_started; + let vertical_drag_started = self.vertical_drag_started; + + // Drain all the drag / long-press slots. A release ALWAYS + // closes the long-press window: if the deadline fired we've + // already consumed the msg; if not, the candidate is cancelled. + self.dragging_slider = None; + self.long_press_start = None; + self.long_press_origin = None; + self.long_press_msg = None; + self.drag_start_msg = None; + self.long_press_text_idx = None; + self.long_press_fired = false; + self.mouse_press = false; + self.horizontal_drag_started = false; + self.vertical_drag_started = false; + + let mut events = Vec::new(); + + // Drop — long-press had fired earlier in the gesture. + if long_press_fired || global_drag + { + self.start = None; + events.push( ReleaseEvent::Drop { pos } ); + return events; + } + + // Slider drag complete — not a swipe, not a tap. + if was_dragging_slider + { + self.scroll_drag_started = false; + self.start = None; + return events; + } + + // Scroll gesture consumed. + let scroll_consumed = self.scrolling_widget.take().is_some() + && self.scroll_drag_started; + self.scroll_drag_started = false; + if scroll_consumed + { + self.start = None; + return events; + } + + // Horizontal swipe commit / fall-through. On commit we return + // immediately; on fall-through we send the 0.0 pulse, keep + // `self.start` for the vertical branch, and continue. + if horizontal_drag_started + { + if let Some( start ) = self.start + { + let dx = pos.x - start.x; + let width = swipe.surface_width; + let committed = width > 0 + && dx.abs() >= width as f32 * swipe.horizontal_thresh; + if committed + { + self.start = None; + events.push( if dx < 0.0 { ReleaseEvent::SwipeLeft } else { ReleaseEvent::SwipeRight } ); + return events; + } + events.push( ReleaseEvent::HorizontalFellThrough ); + } + } + + // Vertical swipe commit / fall-through. Consumes `self.start`. + if vertical_drag_started || pressed.is_none() + { + if let Some( start ) = self.start.take() + { + let dy = start.y - pos.y; + let height = swipe.surface_height as f32; + let up_thr = height * swipe.up_thresh; + let down_thr = height * swipe.down_thresh; + let down_edge = height * swipe.down_edge; + if dy >= up_thr + { + events.push( ReleaseEvent::SwipeUp ); + return events; + } + else if -dy >= down_thr && start.y <= down_edge + { + events.push( ReleaseEvent::SwipeDown ); + return events; + } + else + { + events.push( ReleaseEvent::VerticalFellThrough ); + } + } + } + else + { + self.start = None; + } + + // Any drag started — skip the button path so a gesture that + // fell through without committing does not also trigger a tap. + if horizontal_drag_started || vertical_drag_started + { + return events; + } + + // Button / slider press on release: the release must land on + // the same widget the press did. + let release_hit = find_widget_at( widget_rects, pos ); + if let Some( idx ) = pressed + { + if release_hit == Some( idx ) + { + if let Some( w ) = find_widget( widget_rects, idx ) + { + let value = w.handlers.slider_value_from_pos( w.rect, pos ); + if let Some( msg ) = w.handlers.slider_change_msg( value ) + { + events.push( ReleaseEvent::PushMsg( msg ) ); + return events; + } + // Repeating buttons fire `press_msg` on *press* (and + // keep firing through the calloop repeat timer + // while held) — suppressing the release-time fire + // avoids a double tap on a quick click. + if !w.handlers.is_repeating() + { + if let Some( msg ) = w.handlers.press_msg() + { + events.push( ReleaseEvent::PushMsg( msg ) ); + } + } + } + } + } + else if release_hit.is_none() + { + // Release on empty area — handler decides Tap vs + // overlay-dismiss based on focus. + events.push( ReleaseEvent::EmptyRelease ); + } + + events + } + + /// Touch-cancel: drop all in-flight gesture state. + pub fn on_cancel( &mut self ) { *self = Self::new(); } +} + +// ─── Outcomes ──────────────────────────────────────────────────────────────── + +/// Result of [`GestureState::on_press`]. Handler uses `hit_idx` to set +/// keyboard focus; pushes `initial_slider_msg` to the pending queue +/// when present. +pub struct PressOutcome +{ + pub hit_idx: Option, + pub initial_slider_msg: Option, +} + +/// Result of [`GestureState::on_move`]. Handler turns this into app +/// callbacks + redraw flags. +pub enum MoveOutcome +{ + /// Nothing gesture-level fired. Hovered index may still have + /// changed — pointer handler updates it separately before calling + /// on_move. + Idle, + /// Long-press drag in flight — fire `app.on_drag_move(pos)`. + Drag { pos: Point }, + /// Slider value changed. `None` when the slider has no change + /// handler for this value (typically unreachable — sliders always + /// carry a change msg — but the gesture machine stays general). + Slider { msg: Option }, + /// Scroll viewport offset updated. Handler just requests a redraw. + Scroll, + /// Swipe progress. Each axis is `Some(value)` when it has a valid + /// progress reading for this move (not every motion updates all + /// axes). Handler fires the matching app callback. + Swipe { up: Option, down: Option, horizontal: Option }, +} + +/// Events emitted by [`GestureState::on_release`], in the order the +/// handler should dispatch them. Multiple events per release are +/// possible (see the horizontal fall-through case). +pub enum ReleaseEvent +{ + /// Long-press drop — `app.on_drop(pos)`, kick main redraw, clear + /// long-press drag. + Drop { pos: Point }, + /// Horizontal swipe committed left. + SwipeLeft, + /// Horizontal swipe committed right. + SwipeRight, + /// Vertical swipe committed up. + SwipeUp, + /// Vertical swipe committed down. + SwipeDown, + /// Horizontal swipe did not commit — fire + /// `app.on_swipe_horizontal_progress(0.0)` + main redraw. + HorizontalFellThrough, + /// Vertical swipe did not commit — fire + /// `app.on_swipe_progress(0.0)` + `app.on_swipe_down_progress(0.0)`. + VerticalFellThrough, + /// Push a widget-level message (button press or final slider + /// value on release). + PushMsg( Msg ), + /// Release on empty area — handler calls `app.on_tap()` on Main + /// focus or `overlay_dismiss_msg(id)` on Overlay focus. + EmptyRelease, +} + +/// Swipe thresholds + surface dimensions snapshotted from the app + +/// current surface, passed into gesture methods so the machine stays +/// decoupled from `App` and `SurfaceState`. +pub struct SwipeConfig +{ + /// Up-swipe commit fraction of surface height (e.g. 0.3 = 30 %). + pub up_thresh: f32, + /// Down-swipe commit fraction of surface height. + pub down_thresh: f32, + /// Down-swipe start zone fraction — the press must land within + /// `surface_height * down_edge` of the top for a down-swipe to + /// arm. + pub down_edge: f32, + /// Horizontal commit fraction of surface width. + pub horizontal_thresh: f32, + pub surface_width: u32, + pub surface_height: u32, +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::widget::{ LaidOutWidget, WidgetHandlers }; + + #[ derive( Clone, Debug, PartialEq, Eq ) ] + enum Msg + { + Pressed, + LongPressed, + } + + fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect + { + Rect { x, y, width: w, height: h } + } + + fn pt( x: f32, y: f32 ) -> Point + { + Point { x, y } + } + + fn button( idx: usize, r: Rect, on_press: Option, on_long_press: Option ) + -> LaidOutWidget + { + button_full( idx, r, on_press, on_long_press, None ) + } + + fn button_full( + idx: usize, r: Rect, + on_press: Option, on_long_press: Option, on_drag_start: Option, + ) -> LaidOutWidget + { + LaidOutWidget + { + rect: r, + flat_idx: idx, + id: None, + paint_rect: r, + handlers: WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape: None, repeating: false }, + keyboard_focusable: true, + cursor: crate::types::CursorShape::Default, + } + } + + fn cfg_full( w: u32, h: u32 ) -> SwipeConfig + { + SwipeConfig + { + up_thresh: 0.30, + down_thresh: 0.30, + down_edge: 0.10, + horizontal_thresh: 0.30, + surface_width: w, + surface_height: h, + } + } + + // ── on_press ────────────────────────────────────────────────────────────── + + #[ test ] + fn press_records_origin_and_hit_idx() + { + let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ]; + let mut g = GestureState::::new(); + let out = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] ); + assert_eq!( out.hit_idx, Some( 1 ) ); + assert!( out.initial_slider_msg.is_none() ); + assert_eq!( g.start, Some( pt( 50.0, 50.0 ) ) ); + assert_eq!( g.pressed_idx, Some( 1 ) ); + } + + #[ test ] + fn press_outside_any_widget_records_origin_with_no_hit() + { + let widgets = vec![ button( 1, rect( 0.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ) ]; + let mut g = GestureState::::new(); + let out = g.on_press( pt( 200.0, 200.0 ), &widgets, &[] ); + assert_eq!( out.hit_idx, None ); + assert_eq!( g.start, Some( pt( 200.0, 200.0 ) ) ); + assert!( g.long_press_start.is_none() ); + } + + #[ test ] + fn press_arms_long_press_when_handler_is_present() + { + let widgets = vec![ button( 7, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); + assert!( g.long_press_start.is_some() ); + assert_eq!( g.long_press_msg, Some( Msg::LongPressed ) ); + assert_eq!( g.long_press_origin, Some( pt( 10.0, 10.0 ) ) ); + assert!( !g.long_press_fired ); + } + + #[ test ] + fn press_does_not_arm_long_press_without_handler() + { + let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); + assert!( g.long_press_start.is_none() ); + assert!( g.long_press_msg.is_none() ); + assert!( g.drag_start_msg.is_none() ); + } + + #[ test ] + fn press_arms_long_press_when_only_drag_start_is_set() + { + // A draggable-but-menu-less widget (e.g. launcher icons) still + // needs the timer so a touch hold can promote into a drag. + let widgets = vec![ button_full( 3, rect( 0.0, 0.0, 100.0, 100.0 ), None, None, Some( Msg::Pressed ) ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); + assert!( g.long_press_start.is_some() ); + assert!( g.long_press_msg.is_none() ); + assert_eq!( g.drag_start_msg, Some( Msg::Pressed ) ); + } + + #[ test ] + fn move_past_six_pixels_cancels_drag_start_msg_too() + { + let widgets = vec![ button_full( 4, rect( 0.0, 0.0, 200.0, 200.0 ), + None, Some( Msg::LongPressed ), Some( Msg::Pressed ) ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); + let mut offsets = HashMap::new(); + let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); + assert!( g.long_press_msg.is_none() ); + assert!( g.drag_start_msg.is_none() ); + } + + #[ test ] + fn mouse_press_skips_six_pixel_cancel_so_promotion_can_fire() + { + // With `mouse_press = true` the gesture state must keep the + // drag-start / long-press slots alive past the 6 px touch + // tolerance — otherwise the pointer-side promotion at 24 px + // would never see them and a mouse drag past the cancel + // distance would do nothing until the hold-timer deadline. + let widgets = vec![ button_full( 5, rect( 0.0, 0.0, 200.0, 200.0 ), + None, Some( Msg::LongPressed ), Some( Msg::Pressed ) ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); + g.mouse_press = true; + let mut offsets = HashMap::new(); + let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); + assert_eq!( g.long_press_msg, Some( Msg::LongPressed ) ); + assert_eq!( g.drag_start_msg, Some( Msg::Pressed ) ); + assert!( g.long_press_origin.is_some() ); + } + + #[ test ] + fn press_identifies_scroll_target() + { + let widgets: Vec> = vec![]; + let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42 ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &scrolls ); + assert_eq!( g.scrolling_widget, Some( 42 ) ); + } + + #[ test ] + fn press_resets_stale_long_press_fired_flag() + { + let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ]; + let mut g = GestureState::::new(); + g.long_press_fired = true; // stale from a prior gesture + let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); + assert!( !g.long_press_fired ); + } + + // ── on_move: long-press cancellation ────────────────────────────────────── + + #[ test ] + fn move_within_six_pixels_keeps_long_press_armed() + { + let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); + let mut offsets = HashMap::new(); + let _ = g.on_move( pt( 14.0, 12.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); + assert!( g.long_press_start.is_some() ); + assert!( g.long_press_msg.is_some() ); + } + + #[ test ] + fn move_past_six_pixels_cancels_long_press() + { + let widgets = vec![ button( 1, rect( 0.0, 0.0, 200.0, 200.0 ), None, Some( Msg::LongPressed ) ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); + let mut offsets = HashMap::new(); + let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); + assert!( g.long_press_start.is_none() ); + assert!( g.long_press_msg.is_none() ); + assert!( g.long_press_origin.is_none() ); + } + + // ── on_move: drag fork ──────────────────────────────────────────────────── + + #[ test ] + fn move_after_long_press_fired_returns_drag() + { + let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); + g.long_press_fired = true; + let mut offsets = HashMap::new(); + let out = g.on_move( pt( 50.0, 50.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); + assert!( matches!( out, MoveOutcome::Drag { .. } ) ); + } + + #[ test ] + fn move_with_global_drag_returns_drag() + { + let widgets: Vec> = vec![]; + let mut g = GestureState::::new(); + g.start = Some( pt( 0.0, 0.0 ) ); + let mut offsets = HashMap::new(); + let out = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), true ); + assert!( matches!( out, MoveOutcome::Drag { .. } ) ); + } + + // ── on_move: scroll ─────────────────────────────────────────────────────── + + #[ test ] + fn move_inside_scroll_widget_mutates_offset_in_place() + { + let widgets: Vec> = vec![]; + let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7 ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls ); + let mut offsets = HashMap::new(); + // Drag finger upward → content scrolls down → offset increases. + let out = g.on_move( pt( 100.0, 60.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); + assert!( matches!( out, MoveOutcome::Scroll ) ); + assert_eq!( offsets.get( &7 ).copied(), Some( 40.0 ) ); + assert!( g.scroll_drag_started, "drag past 8 px must arm scroll_drag_started" ); + } + + #[ test ] + fn move_inside_scroll_widget_clamps_offset_at_zero() + { + let widgets: Vec> = vec![]; + let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9 ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls ); + let mut offsets = HashMap::new(); + // Drag finger downward → content tries to scroll up past origin → clamp at 0. + let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); + assert_eq!( offsets.get( &9 ).copied(), Some( 0.0 ) ); + } + + #[ test ] + fn move_below_eight_pixels_does_not_arm_scroll_drag() + { + let widgets: Vec> = vec![]; + let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1 ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls ); + let mut offsets = HashMap::new(); + let _ = g.on_move( pt( 100.0, 95.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); + assert!( !g.scroll_drag_started ); + } + + // ── on_move: swipe progress ─────────────────────────────────────────────── + + #[ test ] + fn move_below_eight_pixels_emits_swipe_progress_without_arming_drag() + { + // The 8 px threshold only gates `*_drag_started`; the Swipe outcome + // itself fires on every motion that has a non-empty axis reading. + let widgets: Vec> = vec![]; + let mut g = GestureState::::new(); + g.start = Some( pt( 100.0, 100.0 ) ); + let mut offsets = HashMap::new(); + let out = g.on_move( pt( 102.0, 99.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); + assert!( matches!( out, MoveOutcome::Swipe { .. } ) ); + assert!( !g.horizontal_drag_started ); + assert!( !g.vertical_drag_started ); + } + + #[ test ] + fn move_up_eleven_pixels_arms_vertical_drag() + { + let widgets: Vec> = vec![]; + let mut g = GestureState::::new(); + g.start = Some( pt( 100.0, 600.0 ) ); + let mut offsets = HashMap::new(); + let _ = g.on_move( pt( 100.0, 589.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); + assert!( g.vertical_drag_started ); + } + + #[ test ] + fn move_horizontal_eleven_pixels_arms_horizontal_drag() + { + let widgets: Vec> = vec![]; + let mut g = GestureState::::new(); + g.start = Some( pt( 100.0, 600.0 ) ); + let mut offsets = HashMap::new(); + let _ = g.on_move( pt( 111.5, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); + assert!( g.horizontal_drag_started ); + } + + #[ test ] + fn move_up_progress_clamps_to_unit_interval() + { + // Surface 1000 px tall, threshold 30 % = 300 px. A 600 px upward drag + // must clamp progress to 1.0 instead of overshooting to 2.0. + let widgets: Vec> = vec![]; + let mut g = GestureState::::new(); + g.start = Some( pt( 100.0, 800.0 ) ); + let mut offsets = HashMap::new(); + let out = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false ); + match out + { + MoveOutcome::Swipe { up: Some( v ), .. } => + assert!( ( v - 1.0 ).abs() < 1e-6, "expected clamp to 1.0, got {v}" ), + _ => panic!( "expected Swipe with up=Some" ), + } + } + + // ── on_release ──────────────────────────────────────────────────────────── + + #[ test ] + fn release_on_button_emits_press_msg() + { + let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] ); + let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false ); + assert_eq!( events.len(), 1 ); + assert!( matches!( events[ 0 ], ReleaseEvent::PushMsg( Msg::Pressed ) ) ); + } + + #[ test ] + fn release_on_different_widget_does_not_emit_press_msg() + { + let widgets = vec![ + button( 1, rect( 0.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ), + button( 2, rect( 100.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ), + ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 25.0, 25.0 ), &widgets, &[] ); + let events = g.on_release( pt( 125.0, 25.0 ), &widgets, &cfg_full( 800, 1200 ), false ); + assert!( + events.iter().all( |e| !matches!( e, ReleaseEvent::PushMsg( _ ) ) ), + "press_msg must not fire when release lands on a different widget" + ); + } + + #[ test ] + fn release_on_empty_area_emits_empty_release() + { + let widgets: Vec> = vec![]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] ); + let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false ); + assert!( events.iter().any( |e| matches!( e, ReleaseEvent::EmptyRelease ) ) ); + } + + #[ test ] + fn release_after_long_press_fired_emits_drop() + { + let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] ); + g.long_press_fired = true; + let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false ); + assert!( matches!( events[ 0 ], ReleaseEvent::Drop { .. } ) ); + } + + #[ test ] + fn release_with_global_drag_emits_drop() + { + let widgets: Vec> = vec![]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); + let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), true ); + assert!( matches!( events[ 0 ], ReleaseEvent::Drop { .. } ) ); + } + + #[ test ] + fn release_committing_swipe_up_emits_swipe_up() + { + let widgets: Vec> = vec![]; + let mut g = GestureState::::new(); + // No press_idx (release on empty area) so the vertical-swipe branch runs. + let _ = g.on_press( pt( 400.0, 900.0 ), &widgets, &[] ); + let mut offsets = HashMap::new(); + // Drag well past 30 % of 1000 px to trip vertical_drag_started. + let _ = g.on_move( pt( 400.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false ); + let events = g.on_release( pt( 400.0, 200.0 ), &widgets, &cfg_full( 800, 1000 ), false ); + assert!( events.iter().any( |e| matches!( e, ReleaseEvent::SwipeUp ) ) ); + } + + #[ test ] + fn release_horizontal_below_threshold_falls_through() + { + let widgets: Vec> = vec![]; + let mut g = GestureState::::new(); + let _ = g.on_press( pt( 100.0, 600.0 ), &widgets, &[] ); + let mut offsets = HashMap::new(); + // 50 px horizontal — below 30 % of 800 = 240 — but past 8 px, so + // horizontal_drag_started flips to true. + let _ = g.on_move( pt( 150.0, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false ); + let events = g.on_release( pt( 150.0, 600.0 ), &widgets, &cfg_full( 800, 1000 ), false ); + assert!( events.iter().any( |e| matches!( e, ReleaseEvent::HorizontalFellThrough ) ) ); + assert!( events.iter().all( |e| !matches!( e, ReleaseEvent::SwipeLeft | ReleaseEvent::SwipeRight ) ) ); + } + + #[ test ] + fn release_after_slider_drag_emits_no_events() + { + let widgets: Vec> = vec![]; + let mut g = GestureState::::new(); + g.start = Some( pt( 50.0, 50.0 ) ); + g.dragging_slider = Some( 1 ); + let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false ); + assert!( events.is_empty() ); + assert!( g.dragging_slider.is_none() ); + } + + #[ test ] + fn release_after_consumed_scroll_emits_no_events() + { + let widgets: Vec> = vec![]; + let mut g = GestureState::::new(); + g.start = Some( pt( 50.0, 50.0 ) ); + g.scrolling_widget = Some( 1 ); + g.scroll_drag_started = true; + let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false ); + assert!( events.is_empty() ); + assert!( g.scrolling_widget.is_none() ); + assert!( !g.scroll_drag_started ); + } + + // ── on_cancel ───────────────────────────────────────────────────────────── + + #[ test ] + fn cancel_resets_every_field() + { + let mut g = GestureState::::new(); + g.start = Some( pt( 1.0, 2.0 ) ); + g.pressed_idx = Some( 99 ); + g.scrolling_widget = Some( 99 ); + g.scroll_drag_started = true; + g.horizontal_drag_started = true; + g.vertical_drag_started = true; + g.dragging_slider = Some( 99 ); + g.long_press_fired = true; + g.long_press_msg = Some( Msg::LongPressed ); + g.drag_start_msg = Some( Msg::Pressed ); + + g.on_cancel(); + + assert!( g.start.is_none() ); + assert!( g.pressed_idx.is_none() ); + assert!( g.scrolling_widget.is_none() ); + assert!( !g.scroll_drag_started ); + assert!( !g.horizontal_drag_started ); + assert!( !g.vertical_drag_started ); + assert!( g.dragging_slider.is_none() ); + assert!( !g.long_press_fired ); + assert!( g.long_press_msg.is_none() ); + assert!( g.drag_start_msg.is_none() ); + } +} diff --git a/src/input/keyboard.rs b/src/input/keyboard.rs new file mode 100644 index 0000000..a12b704 --- /dev/null +++ b/src/input/keyboard.rs @@ -0,0 +1,637 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Wayland keyboard → ltk dispatch. +//! +//! Translates `wl_keyboard` events into focus / text-insertion / +//! widget-submit actions. Does not share state with the gesture state +//! machine — keyboard tracks its own focus (`AppData::keyboard_focus`) +//! and modifier flags (`shift_pressed`, `ctrl_pressed`). +//! +//! The only cross-cutting state it reads is `SurfaceState::focused_idx` +//! (set by pointer / touch focus updates) so keyboard navigation can +//! branch on "is a widget focused?" — Return submits / Space presses / +//! typing inserts all funnel through that check. + +use std::time::Duration; + +use smithay_client_toolkit::seat::keyboard:: +{ + KeyboardHandler, KeyEvent, Keysym, Modifiers, RawModifiers, RepeatInfo, +}; +use smithay_client_toolkit::reexports::client:: +{ + protocol::{ wl_keyboard::WlKeyboard, wl_surface::WlSurface }, + Connection, QueueHandle, +}; +use calloop::timer::{ Timer, TimeoutAction }; + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::event_loop::app_data::KeyRepeatState; +use crate::tree::{ find_handlers, next_focusable_index }; + +/// Built-in fallback for the initial key-repeat delay when the +/// compositor does not advertise one and the app does not override +/// [`crate::App::key_repeat_delay`]. +const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis( 500 ); + +/// Built-in fallback for the inter-repeat interval when the compositor +/// does not advertise a rate. ~30 Hz, matching the GNOME / KDE default +/// for "fast" without straying into uncomfortably-twitchy territory. +const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis( 33 ); + +impl KeyboardHandler for AppData +{ + fn enter( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + surface: &WlSurface, + _serial: u32, + _raw: &[u32], + _keysyms: &[Keysym], + ) + { + let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main ); + self.keyboard_focus = focus; + self.surface_mut( focus ).request_redraw(); + } + + fn leave( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + _surface: &WlSurface, + _serial: u32, + ) + { + self.stop_key_repeat(); + self.keyboard_focus = SurfaceFocus::Main; + } + + fn press_key( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + _serial: u32, + event: KeyEvent, + ) + { + let focus = self.keyboard_focus; + // Raw observer hook (e.g. forwarding to an embedded WPE view). + // Fires before the focus-aware dispatch. + self.app.on_raw_key( event.keysym, event.raw_code, true, self.ctrl_pressed, self.shift_pressed ); + // A new press always cancels any in-flight repeat — the user + // has either released the previous key or is pressing a + // different one. Either way, the prior timer's keysym should + // not keep firing. + self.stop_key_repeat(); + self.dispatch_key( focus, event.clone() ); + self.start_key_repeat( focus, event ); + } + + fn release_key( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + _serial: u32, + event: KeyEvent, + ) + { + self.app.on_raw_key( event.keysym, event.raw_code, false, self.ctrl_pressed, self.shift_pressed ); + // Only stop if the released key matches the one currently + // repeating; releasing a non-repeating key (e.g. a shift that + // snuck through, or any key we never armed) leaves the timer + // untouched. + if let Some( ref state ) = self.key_repeat + { + if state.event.keysym == event.keysym + { + self.stop_key_repeat(); + } + } + } + + fn update_modifiers( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + _serial: u32, + modifiers: Modifiers, + _raw_modifiers: RawModifiers, + _layout: u32, + ) + { + self.shift_pressed = modifiers.shift; + self.ctrl_pressed = modifiers.ctrl; + } + + fn update_repeat_info( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + info: RepeatInfo, + ) + { + match info + { + RepeatInfo::Repeat { rate, delay } => + { + self.compositor_repeat_rate = rate.get(); + self.compositor_repeat_delay = delay; + } + RepeatInfo::Disable => + { + self.compositor_repeat_rate = 0; + self.compositor_repeat_delay = 0; + self.stop_key_repeat(); + } + } + } + + fn repeat_key( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _keyboard: &WlKeyboard, + _serial: u32, + event: KeyEvent, + ) + { + // Compositor-driven repeat (wl_keyboard v10). When the + // compositor takes the repeat job we just re-dispatch — and + // suppress our internal timer to avoid double-firing. + self.stop_key_repeat(); + let focus = self.keyboard_focus; + self.dispatch_key( focus, event ); + } +} + +impl AppData +{ + /// Run the same dispatch logic that the Wayland press-key handler + /// runs, but without the trait-callback signature — used both by + /// the trait method and by the key-repeat timer. + fn dispatch_key( &mut self, focus: SurfaceFocus, event: KeyEvent ) + { + // `set_focus` (Tab handling) needs a `QueueHandle`. Cloning is + // cheap (an `Arc`-equivalent under the hood) and avoids + // threading the qh through every helper. + let qh = self.qh.clone(); + let qh = &qh; + let focused = self.surface( focus ).focused_idx; + match event.keysym + { + Keysym::BackSpace => + { + if focused.is_some() { self.handle_backspace( focus ); } + else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) + { + self.pending_msgs.push( msg ); + } + } + Keysym::Delete => + { + // Supr / Forward-Delete: same shape as Backspace but + // removes the char *after* the cursor. When no widget + // is focused, the keysym bubbles to the app. + if focused.is_some() { self.handle_delete_forward( focus ); } + else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) + { + self.pending_msgs.push( msg ); + } + } + Keysym::Return | Keysym::KP_Enter => + { + // Enter targets, in order: (a) the focused widget's submit + // message (TextEdit), (b) its press message (Button etc), + // (c) the press message of the keyboard-hovered list item + // in the topmost scroll. The hovered fallback lets users + // confirm a combo / list choice with the keyboard after + // arrow-navigating to it without ever taking widget focus. + // + // Multiline text-input override: when the focused widget + // is a `text_edit().multiline( true )`, Enter inserts a + // literal `\n` into the buffer instead of submitting, so + // the user can compose paragraphs. + let is_multi = focused.and_then( |idx| + find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_multiline_text_input() ) ).unwrap_or( false ); + if is_multi + { + self.handle_text_insert( focus, "\n" ); + } else { + let target = focused.or( self.surface( focus ).hovered_idx ); + if let Some( idx ) = target + { + let msg = find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.submit_msg().or_else( || h.press_msg() ) ); + if let Some( m ) = msg + { + self.pending_msgs.push( m ); + } + } else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) + { + self.pending_msgs.push( msg ); + } + } + } + Keysym::Down | Keysym::Up => + { + // When a text input is focused, Up/Down move the cursor + // between lines first. They only fall through to list + // hover-navigation (combo / scrollable list) when the + // cursor was already on the topmost / bottommost line — + // that lets a user keyboard-step out of a multiline + // `text_edit` into surrounding navigable items without + // changing focus first. + let is_text = focused.and_then( |idx| + find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ) ).unwrap_or( false ); + let reverse = event.keysym == Keysym::Up; + let extend = self.shift_pressed; + let consumed = if is_text + { + if reverse { self.handle_cursor_up( focus, extend ) } + else { self.handle_cursor_down( focus, extend ) } + } else { false }; + if !consumed && !self.move_keyboard_hover( focus, reverse ) + { + if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) + { + self.pending_msgs.push( msg ); + } + } + } + Keysym::Left | Keysym::Right => + { + let is_text = focused.and_then( |idx| + find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ) ).unwrap_or( false ); + let extend = self.shift_pressed; + if is_text + { + if event.keysym == Keysym::Left + { + self.handle_cursor_left( focus, extend ); + } else { + self.handle_cursor_right( focus, extend ); + } + } else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) + { + self.pending_msgs.push( msg ); + } + } + Keysym::Home => + { + let is_text = focused.and_then( |idx| + find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ) ).unwrap_or( false ); + if is_text { self.handle_cursor_home( focus, self.shift_pressed ); } + } + Keysym::End => + { + let is_text = focused.and_then( |idx| + find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ) ).unwrap_or( false ); + if is_text { self.handle_cursor_end( focus, self.shift_pressed ); } + } + Keysym::a | Keysym::A if self.ctrl_pressed => + { + let is_text = focused.and_then( |idx| + find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ) ).unwrap_or( false ); + if is_text { self.handle_select_all( focus ); } + } + Keysym::c | Keysym::C if self.ctrl_pressed => + { + let is_text = focused.and_then( |idx| + find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ) ).unwrap_or( false ); + if is_text { self.handle_copy( focus ); } + } + Keysym::x | Keysym::X if self.ctrl_pressed => + { + let is_text = focused.and_then( |idx| + find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ) ).unwrap_or( false ); + if is_text { self.handle_cut( focus ); } + } + Keysym::v | Keysym::V if self.ctrl_pressed => + { + let is_text = focused.and_then( |idx| + find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ) ).unwrap_or( false ); + if is_text { self.handle_paste( focus ); } + } + Keysym::Tab | Keysym::ISO_Left_Tab => + { + let reverse = event.keysym == Keysym::ISO_Left_Tab || self.shift_pressed; + let ss = self.surface( focus ); + let next_idx = next_focusable_index( &ss.widget_rects, ss.focused_idx, reverse ); + if let Some( next_idx ) = next_idx + { + self.set_focus( focus, Some( next_idx ), qh ); + } + } + Keysym::Escape => + { + // Esc peels off transient UI one layer at a time. + // Order: (1) open xdg-popup overlays → dismiss them; + // (2) context menu → close it; (3) active selection in + // a focused text input → collapse to cursor; (4) any + // laid-out pressable carrying an `on_escape` message + // (typically the topmost `dialog`'s cancel) → fire + // that; (5) otherwise → drop focus and let the app see + // Esc. + if !self.overlays.is_empty() && self.app.overlays().iter().any( | s | s.anchor_widget_id.is_some() ) + { + self.dismiss_all_popups(); + } else if self.surface( focus ).context_menu.is_some() + { + self.hide_context_menu( focus ); + } else if self.collapse_selection_if_any( focus ) + { + // Selection collapsed — keep focus, no app msg. + } else if let Some( msg ) = self.surface( focus ).widget_rects.iter() + .rev() + .find_map( |w| w.handlers.escape_msg() ) + { + self.pending_msgs.push( msg ); + } else { + self.set_focus( focus, None, qh ); + if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) + { + self.pending_msgs.push( msg ); + } + } + } + Keysym::space => + { + if let Some( idx ) = focused + { + let press = find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.press_msg() ); + if let Some( msg ) = press + { + self.pending_msgs.push( msg ); + } else { + // Focused widget is a TextEdit (or has no on_press) — insert space as text + self.handle_text_insert( focus, " " ); + } + } else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) + { + self.pending_msgs.push( msg ); + } + } + _ => + { + if self.ctrl_pressed + { + if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, true, self.shift_pressed ) + { + self.pending_msgs.push( msg ); + } + } else if focused.is_some() + { + if let Some( txt ) = event.utf8 + { + if !txt.is_empty() && txt.chars().all( |c| !c.is_control() ) + { + self.handle_text_insert( focus, &txt ); + } + } + } else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, false, self.shift_pressed ) + { + self.pending_msgs.push( msg ); + } + } + } + self.surface_mut( focus ).request_redraw(); + } + + /// Compute the effective initial repeat delay — app override wins, + /// then compositor info, then a built-in default. + pub( crate ) fn effective_repeat_delay( &self ) -> Duration + { + if let Some( d ) = self.app.key_repeat_delay() { return d; } + if self.compositor_repeat_delay > 0 + { + Duration::from_millis( self.compositor_repeat_delay as u64 ) + } else { + DEFAULT_REPEAT_DELAY + } + } + + /// Compute the effective inter-repeat interval. Same precedence as + /// [`Self::effective_repeat_delay`]: app override → compositor → + /// built-in default. Returns `None` when repeat is disabled by the + /// active source (compositor `RepeatInfo::Disable` with no app + /// override, or an app override of `Some(Duration::ZERO)`). + pub( crate ) fn effective_repeat_interval( &self ) -> Option + { + if let Some( d ) = self.app.key_repeat_interval() + { + if d.is_zero() { return None; } + return Some( d ); + } + if self.compositor_repeat_rate == 0 + { + // Compositor explicitly disabled repeat, app did not + // override — fall back to a built-in default rather than + // disabling, because most environments where the + // compositor reports 0 also fail to send the event in + // the first place. The default keeps the feel close to + // what people expect from a desktop toolkit. + return Some( DEFAULT_REPEAT_INTERVAL ); + } + let ms = ( 1000 / self.compositor_repeat_rate ).max( 1 ); + Some( Duration::from_millis( ms as u64 ) ) + } + + /// Schedule a key-repeat timer for the given event. No-op when the + /// app's [`crate::App::key_repeats`] gate returns `false` for this + /// keysym, when repeat is disabled, or when the calloop timer + /// insertion fails (in which case held keys simply do not repeat). + pub( crate ) fn start_key_repeat( &mut self, focus: SurfaceFocus, event: KeyEvent ) + { + if !self.app.key_repeats( event.keysym ) { return; } + let interval = match self.effective_repeat_interval() + { + Some( i ) => i, + None => return, + }; + let delay = self.effective_repeat_delay(); + + let event_for_timer = event.clone(); + let timer = Timer::from_duration( delay ); + let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData| + { + data.dispatch_key( focus, event_for_timer.clone() ); + TimeoutAction::ToDuration( interval ) + } ); + match token + { + Ok( token ) => { self.key_repeat = Some( KeyRepeatState { event, token } ); } + Err( _ ) => {} + } + } + + /// Cancel any active key-repeat timer. + pub( crate ) fn stop_key_repeat( &mut self ) + { + if let Some( state ) = self.key_repeat.take() + { + self.loop_handle.remove( state.token ); + } + } + + /// Schedule a button-press repeat timer. The runtime fires the + /// `on_press` message *immediately* on press too — this fn only + /// arms the held-down repeat path; the first fire happens at + /// the call site so a quick tap still registers as a single + /// press. + /// + /// Each timer tick re-reads the live `on_press` from the + /// current widget tree (via the snapshotted `flat_idx`) rather + /// than replaying a captured message. This is what makes + /// stepper-style buttons work: a stepper builds an `on_press` + /// like `"go to value + 5"` at view-build time, so replaying + /// the press-time snapshot after the first fire would re-issue + /// the same target and the value would freeze. By reading + /// `on_press` afresh each tick we pick up the new target the + /// view rebuilt with the updated value. + /// + /// No-op when [`Self::effective_repeat_interval`] reports + /// repeat disabled (zero rate, app override of + /// `Duration::ZERO`). Self-cancels on the first tick where the + /// widget at `idx` no longer exists or no longer carries a + /// press message. + pub( crate ) fn start_button_repeat( &mut self, focus: SurfaceFocus, idx: usize ) + { + // Cancel any pre-existing repeat first — only one button can + // be in repeat mode at a time, and a fresh press should + // supersede a previous one. + self.stop_button_repeat(); + // Button repeat ticks at a fixed 120 ms (~8 Hz). The keyboard + // repeat interval is ~33 ms (30 Hz), which suits cursor / + // character entry but is too aggressive for pointer steppers + // — a date / time picker would walk a full minute in under + // two seconds. 120 ms is fast enough to ramp through values, + // slow enough that the user can still release on the value + // they want. + if matches!( self.effective_repeat_interval(), None ) { return; } + let interval = std::time::Duration::from_millis( 120 ); + let delay = self.effective_repeat_delay(); + + let timer = Timer::from_duration( delay ); + let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData| + { + let live_msg = crate::tree::find_handlers( + &data.surface( focus ).widget_rects, + idx, + ) + .and_then( |h| h.press_msg() ); + match live_msg + { + Some( m ) => + { + data.pending_msgs.push( m ); + TimeoutAction::ToDuration( interval ) + } + None => + { + // Widget gone (view restructured) or no longer + // has an on_press → drop ourselves so the timer + // does not fire forever against a stale slot. + data.button_repeat = None; + TimeoutAction::Drop + } + } + } ); + if let Ok( token ) = token + { + self.button_repeat = Some( crate::event_loop::app_data::ButtonRepeatState { token } ); + } + } + + /// Cancel any active button-press repeat timer. + pub( crate ) fn stop_button_repeat( &mut self ) + { + if let Some( state ) = self.button_repeat.take() + { + self.loop_handle.remove( state.token ); + } + } + + /// Step the topmost scroll's `hovered_idx` one item up or down with + /// the keyboard. Returns `true` when the move was applied (a scroll + /// with at least one navigable item was on screen and the new item + /// is different from the current hover) so the caller can fall + /// through to the application's own `on_key` only on a no-op. + /// + /// The "topmost scroll" is the last entry in `scroll_rects`, which + /// matches Stack-overlay layout order: a popup pushed after the + /// main content sits above it. Auto-scrolls the new item into view + /// by adjusting `scroll_offsets[scroll_idx]`. + pub( crate ) fn move_keyboard_hover( &mut self, focus: SurfaceFocus, reverse: bool ) -> bool + { + // Find the topmost scroll that has a navigable item list. + let scroll_meta = { + let ss = self.surface( focus ); + ss.scroll_rects.iter().rev() + .find_map( |( rect, idx )| + { + ss.scroll_navigable_items.get( idx ) + .filter( |list| !list.is_empty() ) + .map( |list| ( *rect, *idx, list.clone() ) ) + } ) + }; + let Some( ( scroll_rect, scroll_idx, items ) ) = scroll_meta else { return false; }; + + let current = self.surface( focus ).hovered_idx; + let pos = current.and_then( |h| items.iter().position( |( i, _, _ )| *i == h ) ); + let next_pos = match ( reverse, pos ) + { + ( false, None ) => 0, + ( false, Some( p ) ) => ( p + 1 ).min( items.len() - 1 ), + ( true, None ) => items.len() - 1, + ( true, Some( p ) ) => p.saturating_sub( 1 ), + }; + let ( new_idx, content_y, content_h ) = items[ next_pos ]; + + // Auto-scroll to bring the new item fully into view. Item Y is + // in pre-offset content coordinates, so the offset that places + // the item flush with the top of the viewport is `content_y`, + // and flush with the bottom is `content_y + content_h - viewport_h`. + let viewport_h = scroll_rect.height; + let current_offset = self.surface( focus ) + .scroll_offsets.get( &scroll_idx ) + .copied().unwrap_or( 0.0 ); + let new_offset = if content_y < current_offset + { + content_y + } + else if content_y + content_h > current_offset + viewport_h + { + ( content_y + content_h - viewport_h ).max( 0.0 ) + } + else + { + current_offset + }; + + let ss = self.surface_mut( focus ); + ss.hovered_idx = Some( new_idx ); + ss.scroll_offsets.insert( scroll_idx, new_offset ); + ss.request_redraw(); + true + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs new file mode 100644 index 0000000..dfbd89b --- /dev/null +++ b/src/input/mod.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Input-event layer. +//! +//! Three Wayland input sources — `wl_keyboard`, `wl_pointer`, +//! `wl_touch` — land in their respective submodules and translate +//! into ltk-level actions. Keyboard is standalone (it drives focus +//! and text insertion, no gesture lifecycle); pointer and touch share +//! the [`gesture::GestureState`] state machine so press → move → +//! release logic (long-press, slider drag, scroll, swipe commit, tap, +//! drop) is written once and fed by both sources. +//! +//! The [`dispatch`] submodule turns gesture outcomes into concrete +//! side-effects on [`AppData`](crate::event_loop::AppData): pending +//! messages, app callbacks, surface redraws, cache invalidation. It +//! is `pub( super )`-scoped so pointer.rs and touch.rs can call it +//! without exposing the helpers at the crate root. +//! +//! With [`GestureState`] owning the press / move / release lifecycle +//! and [`dispatch`] owning the side-effects, the Wayland-facing +//! handlers stay small and a hypothetical new input source (stylus, +//! gamepad) can plug in by feeding the state machine the same way. + +pub( crate ) mod gesture; +pub( crate ) mod keyboard; +pub( crate ) mod pointer; +pub( crate ) mod touch; +pub( crate ) mod dispatch; + +// `GestureState` is the only type consumers outside `input/` need — +// `AppData` stores one per `SurfaceState` and the long-press deadline +// poller reaches through it. The rest (`MoveOutcome`, `PressOutcome`, +// `ReleaseEvent`, `SwipeConfig`) stay at `super::gesture::*` because +// only the sibling modules in `input/` touch them. +pub use gesture::GestureState; diff --git a/src/input/pointer.rs b/src/input/pointer.rs new file mode 100644 index 0000000..2068615 --- /dev/null +++ b/src/input/pointer.rs @@ -0,0 +1,495 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Wayland pointer → ltk dispatch. +//! +//! Each `wl_pointer` event (motion / press / release / axis) translates +//! into a call on the surface's [`GestureState`](super::gesture::GestureState) +//! plus the follow-up side-effects that can only live at the +//! [`AppData`] level (pending-message push, surface redraw, dirty-cache +//! invalidation, window move). The pointer handler adds three things +//! touch does not have: +//! +//! * **Hover tracking** — motion updates `SurfaceState::hovered_idx` +//! before delegating the motion to the gesture machine, since the +//! hovered widget drives visual state independent of whether a press +//! is active. +//! * **Title-bar interaction** — a press inside the client-side title +//! bar either closes the window (close button hit) or initiates a +//! compositor-driven window move. Neither path goes through the +//! gesture machine. +//! * **Wheel / axis scroll** — routed directly into the per-viewport +//! `scroll_offsets` map; axis events do not have a press/release +//! lifecycle so they bypass the state machine entirely. + +use smithay_client_toolkit::seat::pointer::{ PointerEvent, PointerEventKind, PointerHandler }; +use smithay_client_toolkit::reexports::client:: +{ + protocol::{ wl_pointer, wl_pointer::WlPointer }, + Connection, QueueHandle, +}; + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus }; +use crate::tree::{ find_handlers, find_widget_at }; +use crate::widget::WidgetHandlers; + +use super::gesture::SwipeConfig; + +impl PointerHandler for AppData +{ + fn pointer_frame( + &mut self, + _conn: &Connection, + qh: &QueueHandle, + _pointer: &WlPointer, + events: &[PointerEvent], + ) + { + for event in events + { + let focus = self.focus_for_surface( &event.surface ) + .unwrap_or( SurfaceFocus::Main ); + self.pointer_focus = focus; + match event.kind + { + PointerEventKind::Enter { serial } => + { + // Pointer just entered our surface — capture the + // serial that wp_cursor_shape_device_v1::set_shape + // requires, then push the initial cursor shape + // *unconditionally*. Resetting `current_cursor_shape` + // to `None` is what forces the dispatch: the + // compositor was showing whatever the previous + // client asked for (e.g. a text I-beam from a + // terminal under our window), and we must claim + // the cursor for our surface even when the target + // equals what we last pushed. + self.last_pointer_enter_serial = serial; + self.current_cursor_shape = None; + self.dispatch_cursor_shape( focus ); + } + PointerEventKind::Motion { .. } => + { + let pp = self.surface( focus ).to_physical( event.position.0, event.position.1 ); + self.pointer_pos = pp; + self.app.on_pointer_move( pp.x, pp.y ); + + // Hover tracking — pointer-only (touch has no hover). + // Runs before the gesture motion so the cache-dirty + // below picks up any hover-dependent redraw request. + let new_hover = find_widget_at( &self.surface( focus ).widget_rects, pp ); + let old_hover = self.surface( focus ).hovered_idx; + if new_hover != old_hover + { + let redraw = hover_affects_paint( &self.surface( focus ).widget_rects, old_hover ) + || hover_affects_paint( &self.surface( focus ).widget_rects, new_hover ); + let ss = self.surface_mut( focus ); + ss.hovered_idx = new_hover; + if redraw { ss.needs_redraw = true; } + } + + // Mouse drag-promotion: a left-button press whose hit + // widget carries `on_drag_start` should arm a drag as + // soon as the cursor moves past the threshold, without + // waiting for the touch hold timer. Touch keeps its + // hold-then-drag path because scroll / swipe gestures + // need the in-between motion budget; mouse has a + // dedicated right-click for the menu so left-button + // can be drag-only. + // + // Threshold of 24 px (logical) sits comfortably above + // the gesture machine's 6 px long-press cancel + // tolerance and above crustace's 16 px drag-commit + // threshold, so by the time we promote the next + // motion sample is already past the app's commit + // distance and drag mode latches without flashing + // any half-state. + // + // Promotion is synchronous (`self.app.update(...)` + // directly) so the app's drag state is armed BEFORE + // the `on_drag_move` call below runs — otherwise the + // seed coords land on a `dragging_item = None` + // shell and get lost. We pay the cost of bypassing + // `invalidate_after` for this one msg, but the next + // frame will repaint everything anyway because the + // drag is in flight. + let promote = { + let ss = self.surface( focus ); + match ( ss.gesture.long_press_origin, ss.gesture.drag_start_msg.is_some() ) + { + ( Some( o ), true ) => ( pp.x - o.x ).hypot( pp.y - o.y ) > 24.0, + _ => false, + } + }; + if promote + { + let ( ds_msg, origin ) = { + let ss = self.surface_mut( focus ); + let m = ss.gesture.drag_start_msg.take().expect( "promote checked is_some" ); + let o = ss.gesture.long_press_origin.expect( "promote checked Some(origin)" ); + ss.gesture.long_press_start = None; + ss.gesture.long_press_origin = None; + ss.gesture.long_press_msg = None; + ss.gesture.long_press_text_idx = None; + ss.gesture.long_press_fired = true; + ss.request_redraw(); + ( m, o ) + }; + self.app.update( ds_msg ); + self.app.on_drag_move( origin.x, origin.y ); + self.dirty_caches(); + self.stop_button_repeat(); + } + + // `global_drag` must be sampled AFTER the promotion + // above — promotion flips `long_press_fired` and we + // want the gesture machine to take the drag branch + // for the same motion event that triggered the + // promotion. + let global_drag = self.has_active_long_press_drag(); + let swipe = self.swipe_config( focus ); + let outcome = + { + let ss = self.surface_mut( focus ); + ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag ) + }; + self.apply_move_outcome( focus, outcome ); + + // Drag-to-select inside a TextEdit. Runs after the + // gesture machine so the gesture's "did we leave + // the press's hit rect?" reasoning still applies + // to the press itself; for text fields the answer + // is "fine, keep selecting" because we only widen + // the selection while the pointer is still inside + // the same widget rect. + let pressed_text = self.surface( focus ).gesture.pressed_idx + .and_then( |idx| + { + let is_text = find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ).unwrap_or( false ); + if is_text { Some( idx ) } else { None } + } ); + if let Some( idx ) = pressed_text + { + self.handle_text_pointer_drag( focus, idx, pp ); + } + // Cursor shape: hover may have changed → push the + // new shape to the compositor (no-op when + // unchanged). + self.dispatch_cursor_shape( focus ); + } + PointerEventKind::Press { button: 0x111, .. } => + { + // Right-click: the desktop equivalent of a touch + // long-press. Three cases on the press target: + // + // * Widget with `on_long_press` (Button or + // Pressable that opted in) — fire the message. + // The drag-arm slot (`on_drag_start`) is NOT + // consumed: right-click never enters drag mode, + // so an icon's context menu opens but no drag + // is armed. + // * TextEdit with no user `on_long_press` — open + // the built-in Copy / Cut / Paste menu near the + // click. Selection is preserved (we deliberately + // skip `handle_text_pointer_down`) so the common + // "select text → right-click → Copy" flow works. + // * Anywhere else — dismiss any already-open menu. + let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); + self.pointer_pos = pos; + self.app.on_pointer_move( pos.x, pos.y ); + let hit_idx = find_widget_at( &self.surface( focus ).widget_rects, pos ); + let lp_msg = hit_idx.and_then( |idx| + find_handlers( &self.surface( focus ).widget_rects, idx ) + .and_then( |h| h.long_press_msg() ) ); + if let Some( msg ) = lp_msg + { + self.pending_msgs.push( msg ); + self.surface_mut( focus ).request_redraw(); + } else { + let is_text = hit_idx.and_then( |idx| + find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ) ).unwrap_or( false ); + if is_text + { + let idx = hit_idx.unwrap(); + if self.surface( focus ).focused_idx != Some( idx ) + { + self.set_focus( focus, hit_idx, qh ); + } + self.show_context_menu( focus, idx, pos ); + } else { + self.hide_context_menu( focus ); + } + } + } + PointerEventKind::Press { button: 0x110, serial, .. } => + { + self.last_pointer_serial = serial; + self.last_input_serial = serial; + let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); + self.pointer_pos = pos; + self.app.on_pointer_move( pos.x, pos.y ); + if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty() + { + self.dismiss_main_outside_popups( pos ); + } + + // Built-in context menu intercepts the press + // before the regular gesture machine. Either an + // item activates (Copy / Cut / Paste) or the + // click is outside the menu and dismisses it — + // in both cases we consume the event. + if self.surface( focus ).context_menu.is_some() + { + if self.handle_context_menu_press( focus, pos ) + { + continue; + } + } + + // Client-side title bar interaction — pointer-only + // (touch never hits a titlebar; layer-shell surfaces + // have titlebar_height == 0). + let sf = self.surface( focus ).scale_factor.max( 1 ) as f32; + let tb_h = self.surface( focus ).titlebar_height * sf; + if tb_h > 0.0 && pos.y < tb_h + { + let close_rect = self.surface( focus ).titlebar_close_rect; + if close_rect.contains( pos ) + { + if self.app.on_close_requested() + { + self.exit_requested = true; + } + return; + } + } + + // Resize-edge interception. Wins over the titlebar + // drag-move (top-left / top-right corners overlap + // the titlebar) and over the gesture machine. + if let Some( edge ) = self.resize_edge_under_pointer( focus ) + { + if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface + { + let seats: Vec<_> = self.seat_state.seats().collect(); + if let Some( seat ) = seats.into_iter().next() + { + window.resize( &seat, serial, edge ); + } + } + continue; + } + + if tb_h > 0.0 && pos.y < tb_h + { + // Drag to move the window. + if matches!( focus, SurfaceFocus::Main ) + { + if let crate::event_loop::SurfaceKind::Window( ref window ) = self.main.surface + { + let seats: Vec<_> = self.seat_state.seats().collect(); + if let Some( seat ) = seats.into_iter().next() + { + window.move_( &seat, serial ); + } + } + } + continue; + } + + // Past every chrome interception — surface the + // press to the app so embeddings (e.g. an + // embedded WPE WebView) see real button-down + // events that were not consumed by the window + // frame. + self.app.on_pointer_button( pos.x, pos.y, true ); + + let outcome = + { + let ss = self.surface_mut( focus ); + let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects ); + // Mark this gesture as mouse-driven so the + // gesture machine's 6 px stray-cancel skips + // the drag-start / long-press slots — mouse + // motion is intentional and should never + // drop the candidate before the pointer-side + // promotion at 24 px gets to fire. + ss.gesture.mouse_press = true; + ss.needs_redraw = true; + result + }; + self.set_focus( focus, outcome.hit_idx, qh ); + if let Some( msg ) = outcome.initial_slider_msg + { + self.pending_msgs.push( msg ); + } + // Press-and-hold repeat: when the hit is a button + // that opted into `.repeating( true )`, fire the + // `on_press` message immediately and arm the + // repeat timer. The release handler in + // `gesture.rs` knows to suppress the regular tap- + // on-release fire for repeating buttons so a + // quick click still counts as exactly one press. + // The timer re-reads `on_press` from the live + // widget tree on every tick — see + // `start_button_repeat` for why. + if let Some( idx ) = outcome.hit_idx + { + let immediate = { + let handlers = find_handlers( &self.surface( focus ).widget_rects, idx ); + if matches!( handlers, Some( WidgetHandlers::Button { repeating: true, .. } ) ) + { + handlers.and_then( |h| h.press_msg() ) + } else { None } + }; + if let Some( msg ) = immediate + { + self.pending_msgs.push( msg ); + self.start_button_repeat( focus, idx ); + } + } + // Click-to-position the text cursor when the press + // landed on a TextEdit. `set_focus` above moves the + // cursor to the end of the value; this overrides + // it with the byte offset under the pointer and + // collapses the selection there so a subsequent + // drag widens the selection from the click point. + // + // Double-click on a TextEdit selects the word + // under the cursor instead of just positioning. + if let Some( idx ) = outcome.hit_idx + { + let is_text = find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ).unwrap_or( false ); + if is_text + { + // Eye icon hit on a password field + // short-circuits the text-edit + // dispatch — fire the toggle msg and + // skip cursor placement. + if self.handle_password_toggle_press( focus, idx, pos ) + { + let _ = self.note_press_for_double_click( pos ); + } + else + { + let is_double = self.note_press_for_double_click( pos ); + if is_double + { + self.handle_text_select_word( focus, idx, pos ); + } else { + self.handle_text_pointer_down( focus, idx, pos ); + } + } + } else { + let _ = self.note_press_for_double_click( pos ); + } + } else { + let _ = self.note_press_for_double_click( pos ); + } + // Slider press → drag may have started; refresh + // the cursor shape so it switches to `Grabbing`. + self.dispatch_cursor_shape( focus ); + } + PointerEventKind::Release { button: 0x110, .. } => + { + let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); + self.pointer_pos = pos; + self.app.on_pointer_move( pos.x, pos.y ); + self.app.on_pointer_button( pos.x, pos.y, false ); + let global_drag = self.has_active_long_press_drag(); + let swipe = self.swipe_config( focus ); + let events_out = + { + let ss = self.surface_mut( focus ); + ss.needs_redraw = true; + ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag ) + }; + self.apply_release_events( focus, events_out ); + // Cancel any held-button repeat — the press is + // over, so the timer no longer has anything to + // fire against. + self.stop_button_repeat(); + // Slider drag (if any) just ended — cursor reverts + // from `Grabbing` to whatever the hovered widget + // asks for. + self.dispatch_cursor_shape( focus ); + } + PointerEventKind::Axis { horizontal, vertical, source, .. } => + { + let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); + let scroll_idx_opt = + { + let ss = self.surface( focus ); + ss.scroll_rects.iter().rev() + .find( |( r, _ )| r.contains( pos ) ) + .map( |( _, idx )| *idx ) + }; + if let Some( scroll_idx ) = scroll_idx_opt + { + let multiplier = match source + { + Some( wl_pointer::AxisSource::Wheel ) => 10.0, + _ => 1.0, + }; + let step = vertical.absolute as f32 * multiplier; + let ss = self.surface_mut( focus ); + let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( 0.0 ); + *entry = ( *entry + step ).max( 0.0 ); + ss.request_redraw(); + } else { + // No LTK scroll viewport under the cursor — + // surface the raw axis to the app so embedded + // content (e.g. a WPE view) can handle scrolling + // itself. + let multiplier = match source + { + Some( wl_pointer::AxisSource::Wheel ) => 10.0, + _ => 1.0, + }; + let dx = horizontal.absolute as f32 * multiplier; + let dy = vertical.absolute as f32 * multiplier; + self.app.on_pointer_axis( pos.x, pos.y, dx, dy ); + } + } + _ => {} + } + } + } +} + +fn hover_affects_paint( + widget_rects: &[crate::widget::LaidOutWidget], + idx: Option, +) -> bool +{ + idx.and_then( |i| find_handlers( widget_rects, i ) ) + .map( |h| !h.is_slider() ) + .unwrap_or( false ) +} + +/// Pointer-side helpers on `AppData`. Split out so touch can call the +/// same swipe-config factory; `apply_*` helpers live in `dispatch.rs` +/// because they are shared. +impl AppData +{ + /// Snapshot the swipe thresholds + surface dimensions into a + /// [`SwipeConfig`] for the gesture machine. Called once per + /// motion / release event. + pub( super ) fn swipe_config( &self, focus: SurfaceFocus ) -> SwipeConfig + { + let ss = self.surface( focus ); + SwipeConfig + { + up_thresh: self.app.swipe_threshold(), + down_thresh: self.app.swipe_down_threshold(), + down_edge: self.app.swipe_down_edge(), + horizontal_thresh: self.app.swipe_horizontal_threshold(), + surface_width: ss.physical_width(), + surface_height: ss.physical_height(), + } + } +} diff --git a/src/input/touch.rs b/src/input/touch.rs new file mode 100644 index 0000000..9782e77 --- /dev/null +++ b/src/input/touch.rs @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Wayland touch → ltk dispatch. +//! +//! `wl_touch` down / up / motion map directly onto the three +//! lifecycle methods of [`GestureState`](super::gesture::GestureState), +//! same as pointer press / release / motion. Touch has no hover and +//! no scroll-axis event, so this file is shorter than `pointer.rs`; +//! the two handlers share `apply_move_outcome` / +//! `apply_release_events` in `dispatch.rs`. +//! +//! The only touch-specific state is `AppData::touch_focus`, a +//! `HashMap` so a finger that landed on an +//! overlay continues to route to that overlay even if it drifts over +//! the main surface. Multi-finger tracking is not yet modelled — the +//! gesture machine is single-gesture; a second finger arriving while +//! the first is pressed overwrites the same slot. Good enough for +//! sliders, swipes and taps; a proper multi-touch rewrite is a +//! separate refactor. + +use smithay_client_toolkit::seat::touch::TouchHandler; +use smithay_client_toolkit::reexports::client:: +{ + protocol::{ wl_surface::WlSurface, wl_touch::WlTouch }, + Connection, QueueHandle, +}; + +use crate::app::App; +use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState }; +use crate::tree::find_handlers; + +impl TouchHandler for AppData +{ + fn down( + &mut self, + _conn: &Connection, + qh: &QueueHandle, + _touch: &WlTouch, + serial: u32, + _time: u32, + surface: WlSurface, + id: i32, + position: ( f64, f64 ), + ) + { + self.last_input_serial = serial; + let focus = self.focus_for_surface( &surface ).unwrap_or( SurfaceFocus::Main ); + self.touch_focus.insert( id, focus ); + let pos = self.surface( focus ).to_physical( position.0, position.1 ); + self.pointer_pos = pos; + if matches!( focus, SurfaceFocus::Main ) && !self.overlays.is_empty() + { + self.dismiss_main_outside_popups( pos ); + } + + // Built-in context menu intercepts the touch before the + // regular gesture machine — same logic as the pointer path. + if self.surface( focus ).context_menu.is_some() + { + if self.handle_context_menu_press( focus, pos ) + { + return; + } + } + + let outcome = + { + let ss = self.surface_mut( focus ); + let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects ); + ss.needs_redraw = true; + result + }; + self.set_focus( focus, outcome.hit_idx, qh ); + if let Some( msg ) = outcome.initial_slider_msg + { + self.pending_msgs.push( msg ); + } + // Press-and-hold repeat — same wiring as the pointer path. + if let Some( idx ) = outcome.hit_idx + { + let immediate = { + let handlers = find_handlers( &self.surface( focus ).widget_rects, idx ); + if matches!( handlers, Some( crate::widget::WidgetHandlers::Button { repeating: true, .. } ) ) + { + handlers.and_then( |h| h.press_msg() ) + } else { None } + }; + if let Some( msg ) = immediate + { + self.pending_msgs.push( msg ); + self.start_button_repeat( focus, idx ); + } + } + // Click-to-position the text cursor for touch presses too, + // and double-tap selects the word under the press. + if let Some( idx ) = outcome.hit_idx + { + let is_text = find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ).unwrap_or( false ); + if is_text + { + // Eye icon hit on a password field short-circuits + // the text-edit dispatch — fire the toggle msg and + // skip cursor placement. + if self.handle_password_toggle_press( focus, idx, pos ) + { + let _ = self.note_press_for_double_click( pos ); + } + else + { + let is_double = self.note_press_for_double_click( pos ); + if is_double + { + self.handle_text_select_word( focus, idx, pos ); + } else { + self.handle_text_pointer_down( focus, idx, pos ); + } + } + } else { + let _ = self.note_press_for_double_click( pos ); + } + } else { + let _ = self.note_press_for_double_click( pos ); + } + } + + fn up( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _touch: &WlTouch, + _serial: u32, + _time: u32, + id: i32, + ) + { + let focus = self.touch_focus.remove( &id ).unwrap_or( SurfaceFocus::Main ); + // Touch-up does not carry a position in wl_touch — the last + // motion's position is the release point. + let pos = self.pointer_pos; + let global_drag = self.has_active_long_press_drag(); + let swipe = self.swipe_config( focus ); + let events_out = + { + let ss = self.surface_mut( focus ); + ss.needs_redraw = true; + ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag ) + }; + self.apply_release_events( focus, events_out ); + self.stop_button_repeat(); + } + + fn motion( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _touch: &WlTouch, + _time: u32, + id: i32, + position: ( f64, f64 ), + ) + { + let focus = *self.touch_focus.get( &id ).unwrap_or( &SurfaceFocus::Main ); + let pp = self.surface( focus ).to_physical( position.0, position.1 ); + self.pointer_pos = pp; + + let global_drag = self.has_active_long_press_drag(); + let swipe = self.swipe_config( focus ); + let outcome = + { + let ss = self.surface_mut( focus ); + ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag ) + }; + self.apply_move_outcome( focus, outcome ); + + // Drag-to-select inside a TextEdit (touch path). + let pressed_text = self.surface( focus ).gesture.pressed_idx + .and_then( |idx| + { + let is_text = find_handlers( &self.surface( focus ).widget_rects, idx ) + .map( |h| h.is_text_input() ).unwrap_or( false ); + if is_text { Some( idx ) } else { None } + } ); + if let Some( idx ) = pressed_text + { + self.handle_text_pointer_drag( focus, idx, pp ); + } + } + + fn shape( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _touch: &WlTouch, + _id: i32, + _major: f64, + _minor: f64, + ) {} + + fn orientation( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _touch: &WlTouch, + _id: i32, + _orientation: f64, + ) {} + + fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle, _touch: &WlTouch ) + { + self.touch_focus.clear(); + // The compositor is stealing every active touch — drop all + // in-flight gesture state across every surface. + let clear = |ss: &mut SurfaceState| { ss.gesture.on_cancel(); }; + clear( &mut self.main ); + for ss in self.overlays.values_mut() + { + clear( ss ); + } + self.stop_button_repeat(); + } +} diff --git a/src/layout/column.rs b/src/layout/column.rs new file mode 100644 index 0000000..b21ab2c --- /dev/null +++ b/src/layout/column.rs @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Rect; +use crate::render::Canvas; +use crate::widget::Element; + +/// A vertical layout container. +/// +/// Children are arranged top-to-bottom with optional spacing and padding. +/// Spacers absorb remaining vertical space, enabling push-to-bottom layouts. +/// +/// ```rust,no_run +/// # use ltk::{ button, column, spacer, text, Element }; +/// # #[ derive( Clone ) ] enum Msg { Ok } +/// # fn _ex() -> Element { +/// column() +/// .padding( 24.0 ) +/// .spacing( 12.0 ) +/// .push( text( "Title" ) ) +/// .push( spacer() ) +/// .push( button( "OK" ).on_press( Msg::Ok ) ) +/// .into() +/// # } +/// ``` +pub struct Column +{ + pub children: Vec>, + pub spacing: f32, + pub padding: f32, + pub align_center_x: bool, + pub center_y: bool, + pub max_width: Option, + pub fit_content: bool, +} + +impl Column +{ + pub fn new() -> Self + { + Self + { + children: Vec::new(), + spacing: 8.0, + padding: 16.0, + align_center_x: true, + center_y: false, + max_width: None, + fit_content: false, + } + } + + /// Append a child widget or layout. + pub fn push( mut self, e: impl Into> ) -> Self + { + self.children.push( e.into() ); + self + } + + /// Set the vertical gap between children in pixels. Default: `8.0`. + pub fn spacing( mut self, s: f32 ) -> Self + { + self.spacing = s; + self + } + + /// Set the padding (all sides) in pixels. Default: `16.0`. + pub fn padding( mut self, p: f32 ) -> Self + { + self.padding = p; + self + } + + /// When `true` (default), children are centered horizontally. + pub fn align_center_x( mut self, c: bool ) -> Self + { + self.align_center_x = c; + self + } + + /// When `true`, center the content block vertically (only when no spacers are present). + pub fn center_y( mut self, c: bool ) -> Self + { + self.center_y = c; + self + } + + /// Limit the content width in pixels. The column still reports `max_width` as + /// its preferred width so the parent allocates the full available rect. + pub fn max_width( mut self, w: f32 ) -> Self + { + self.max_width = Some( w ); + self + } + + /// Report the intrinsic content width as preferred width instead of filling + /// the available `max_width`. Use this when the column represents a card + /// or widget meant to sit side-by-side with other children inside a + /// [`Row`](crate::layout::row::Row) — without this flag, two columns in a + /// row each claim the full row width and overflow their siblings. + /// + /// The preferred width is computed as the max of children's preferred + /// widths plus padding, capped by the external `max_width` the parent + /// offers and by any `max_width` setting on the column itself. + pub fn fit_content( mut self ) -> Self + { + self.fit_content = true; + self + } + + fn inner_w( &self, available: f32 ) -> f32 + { + let w = available - self.padding * 2.0; + self.max_width.map( |m| w.min( m ) ).unwrap_or( w ) + } + + fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32 + { + // Spacers contribute 0 to natural height; spacing still applies between all children. + self.children.iter() + .map( |c| match c + { + Element::Spacer( s ) => s.fixed_height.unwrap_or( 0.0 ), + other => other.preferred_size( inner_w, canvas ).1, + } ) + .sum::() + + self.spacing * (self.children.len().saturating_sub( 1 )) as f32 + } + + /// Return the preferred `(width, height)` given available `max_width`. + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) + { + let inner_w = self.inner_w( max_width ); + let total_h = self.content_h( inner_w, canvas ) + self.padding * 2.0; + + let w = if self.fit_content + { + // "Filler" widgets (Spacer, Separator, Scroll, ProgressBar, Slider, + // Toggle, TextEdit) all report `max_width` as their preferred width: + // they stretch across whatever rect the parent allocates. Including + // them when picking the intrinsic content width would claim + // `max_width` and defeat the flag, so skip them — only content-sized + // children (Text, Button, Image, nested fit-content Columns/Rows) + // drive the natural width. + let content_w = self.children.iter() + .map( |c| match c + { + Element::Spacer( _ ) => 0.0, + Element::Separator( _ ) => 0.0, + Element::Scroll( _ ) => 0.0, + Element::ProgressBar( _ ) => 0.0, + Element::Slider( _ ) => 0.0, + // TextEdit defaults to claiming `max_width`, but + // a field built with `.fixed_width( w )` reports + // a pinned natural size — let those through so a + // numeric digit field inside a `fit_content` + // stepper column can drive the column's width. + Element::TextEdit( t ) => if t.fixed_width.is_some() + { + t.preferred_size( inner_w, canvas ).0 + } else { 0.0 }, + other => other.preferred_size( inner_w, canvas ).0, + } ) + .fold( 0.0_f32, f32::max ); + ( content_w + self.padding * 2.0 ).min( max_width ) + } else { + max_width + }; + + ( w, total_h ) + } + + pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {} + + /// Layout children within rect and return (rect, child_index) pairs. + pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)> + { + let inner_w = self.inner_w( rect.width ); + + // Flexible spacers and Scroll widgets claim remaining vertical space. + // Fixed-height spacers behave like normal fixed-size children. + let total_weight: u32 = self.children.iter() + .map( |c| match c + { + Element::Spacer( s ) if s.fixed_height.is_none() => s.weight, + Element::Scroll( _ ) => 1, + _ => 0, + } ) + .sum(); + + let fixed_h: f32 = self.children.iter() + .map( |c| + { + if matches!( c, Element::Scroll( _ ) ) + { + 0.0 + } else if let Element::Spacer( s ) = c { + s.fixed_height.unwrap_or( 0.0 ) + } else { + c.preferred_size( inner_w, canvas ).1 + } + } ) + .sum::() + + self.spacing * (self.children.len().saturating_sub( 1 )) as f32; + + let avail_h = rect.height - self.padding * 2.0; + let avail_spare = (avail_h - fixed_h).max( 0.0 ); + + // `center_y` only applies when there are no spacers. + let start_y = if total_weight == 0 && self.center_y + { + rect.y + self.padding + avail_spare / 2.0 + } else { + rect.y + self.padding + }; + + let start_x = rect.x + (rect.width - inner_w) / 2.0; + + let mut y = start_y; + let mut result = Vec::new(); + for ( i, child ) in self.children.iter().enumerate() + { + let ( w, h ) = match child + { + Element::Spacer( s ) => + { + let h = if let Some( fixed ) = s.fixed_height + { + fixed + } else if total_weight > 0 + { + avail_spare * s.weight as f32 / total_weight as f32 + } else { + 0.0 + }; + ( inner_w, h ) + }, + Element::Scroll( _ ) => + { + let h = if total_weight > 0 + { + avail_spare / total_weight as f32 + } else { + 0.0 + }; + ( inner_w, h ) + }, + other => other.preferred_size( inner_w, canvas ), + }; + let x = if self.align_center_x && !matches!( child, Element::Spacer( _ ) ) + { + start_x + (inner_w - w) / 2.0 + } else { + start_x + }; + result.push( ( Rect { x, y, width: w, height: h }, i ) ); + y += h + self.spacing; + } + result + } + + pub( crate ) fn map_msg( self, f: &crate::widget::MapFn ) -> Column + where + U: Clone + 'static, + Msg: 'static, + { + Column + { + children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(), + spacing: self.spacing, + padding: self.padding, + align_center_x: self.align_center_x, + center_y: self.center_y, + max_width: self.max_width, + fit_content: self.fit_content, + } + } +} + +/// Create an empty column layout. +pub fn column() -> Column +{ + Column::new() +} + +impl Default for Column +{ + fn default() -> Self + { + Self::new() + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::render::Canvas; + + fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } + + #[ test ] + fn preferred_size_width_equals_max_width() + { + let canvas = make_canvas(); + let col = column::<()>().padding( 10.0 ); + let ( w, _ ) = col.preferred_size( 200.0, &canvas ); + assert_eq!( w, 200.0 ); + } + + #[ test ] + fn empty_column_height_is_two_paddings() + { + let canvas = make_canvas(); + let col = column::<()>().padding( 10.0 ); + let ( _, h ) = col.preferred_size( 200.0, &canvas ); + assert_eq!( h, 20.0 ); + } + + #[ test ] + fn max_width_caps_inner_w_not_preferred_w() + { + let canvas = make_canvas(); + // preferred_size always returns available max_width; max_width caps inner layout only + let col = column::<()>().padding( 0.0 ).max_width( 100.0 ); + let ( w, _ ) = col.preferred_size( 200.0, &canvas ); + assert_eq!( w, 200.0 ); + } + + #[ test ] + fn inner_w_respects_padding_and_max_width() + { + let col = column::<()>().padding( 20.0 ).max_width( 100.0 ); + // available = 200, minus padding*2 = 160, capped at max_width = 100 + assert_eq!( col.inner_w( 200.0 ), 100.0 ); + } + + #[ test ] + fn inner_w_without_max_width_subtracts_padding() + { + let col = column::<()>().padding( 10.0 ); + assert_eq!( col.inner_w( 200.0 ), 180.0 ); + } + + #[ test ] + fn spacing_between_children_accumulates() + { + let canvas = make_canvas(); + // Three zero-height spacers, two 8 px gaps between them = 16. + let col = column::<()>() + .padding( 0.0 ) + .spacing( 8.0 ) + .push( crate::spacer() ) + .push( crate::spacer() ) + .push( crate::spacer() ); + let ( _, h ) = col.preferred_size( 100.0, &canvas ); + assert_eq!( h, 16.0 ); + } +} diff --git a/src/layout/mod.rs b/src/layout/mod.rs new file mode 100644 index 0000000..a9bc5f8 --- /dev/null +++ b/src/layout/mod.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Layouts — composable arrangers for [`Element`](crate::Element) trees. +//! +//! Layouts decide *where* their children sit; they don't paint anything of +//! their own. Each layout exposes a free constructor (`column()`, `row()`, +//! `stack()`, `grid(N)`, `spacer()`) and a builder-style API for spacing, +//! padding, alignment and sizing. Layouts and [widgets](crate::widget) +//! share the same [`Element`](crate::Element) tree — anything that +//! converts into an `Element` can be pushed into any layout. +//! +//! ## What's available +//! +//! * **Flow** — [`column::Column`] (top-to-bottom), +//! [`row::Row`] (left-to-right). Both honour `padding`, `spacing`, +//! `max_width`, `align_center_*` and inline `Spacer` distribution. +//! * **Overlay** — [`stack::Stack`] for FrameLayout-style layering with +//! per-child [`HAlign`](stack::HAlign) / [`VAlign`](stack::VAlign) and +//! margin / pixel-translation overrides. Useful for foreground HUD on +//! top of a background image, or a floating action button anchored to +//! the bottom-right. +//! * **Grid** — [`wrap_grid::WrapGrid`] for fixed-column-count grids that +//! wrap their children into rows (icon launchers, photo galleries). +//! * **Filler** — [`spacer::Spacer`], an invisible child that absorbs +//! leftover space along the parent's main axis. Pair with +//! [`flex::Flex`](crate::Flex) when the filler is non-trivial (a card +//! that should stretch a row). +//! +//! Most layouts default to "fill the parent's available rect" and only +//! shrink to their content with `fit_content()`. +//! +//! ## Sizing model +//! +//! Layouts implement `preferred_size( max_width, &Canvas ) -> ( w, h )` +//! the same way widgets do. The convention is: the layout claims the +//! parent-supplied `max_width` (or the explicit `max_width(...)` setting) +//! and reports the height needed for its children. Spacers and +//! `Scroll`/`Flex` declare zero intrinsic main-axis size and absorb the +//! leftover space the parent has after fixed-size siblings have been laid +//! out. + +pub mod column; +pub mod row; +pub mod spacer; +pub mod stack; +pub mod wrap_grid; diff --git a/src/layout/row.rs b/src/layout/row.rs new file mode 100644 index 0000000..273da49 --- /dev/null +++ b/src/layout/row.rs @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Rect; +use crate::render::Canvas; +use crate::widget::Element; + +/// A horizontal layout container. +/// +/// Children are arranged left-to-right. Use [`Row::align_right`] to +/// push the content block to the right edge of the available width. +/// +/// ```rust,no_run +/// # use std::sync::Arc; +/// # use ltk::{ icon_button, row, Element }; +/// # #[ derive( Clone ) ] enum Msg { A, B } +/// # fn _ex( a_rgba: Arc>, b_rgba: Arc>, w: u32, h: u32 ) -> Element { +/// row() +/// .spacing( 16.0 ) +/// .align_right() +/// .push( icon_button( a_rgba, w, h ).on_press( Msg::A ) ) +/// .push( icon_button( b_rgba, w, h ).on_press( Msg::B ) ) +/// .into() +/// # } +/// ``` +pub struct Row +{ + pub children: Vec>, + pub spacing: f32, + pub padding: f32, + pub align_right: bool, +} + +impl Row +{ + pub fn new() -> Self + { + Self { children: Vec::new(), spacing: 8.0, padding: 0.0, align_right: false } + } + + /// Append a child widget or layout. + pub fn push( mut self, e: impl Into> ) -> Self + { + self.children.push( e.into() ); + self + } + + /// Set the horizontal gap between children in pixels. Default: `8.0`. + pub fn spacing( mut self, s: f32 ) -> Self + { + self.spacing = s; + self + } + + /// Set the padding (all sides) in pixels. Default: `0.0`. + pub fn padding( mut self, p: f32 ) -> Self + { + self.padding = p; + self + } + + /// Push the content block to the right edge of the available width. + pub fn align_right( mut self ) -> Self + { + self.align_right = true; + self + } + + /// Return the preferred `(width, height)` given available `max_width`. + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) + { + // Width contribution of every fixed (non-flex, non-flex-spacer) child. + // Used to compute the residual width that wrap-style children will + // actually render in, so their reported height matches the layout. + let inner_w = ( max_width - self.padding * 2.0 ).max( 0.0 ); + let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32; + let fixed_w: f32 = self.children.iter() + .filter( |c| match c + { + Element::Flex( _ ) => false, + Element::Spacer( s ) => s.fixed_width.is_some(), + _ => true, + } ) + .map( |c| c.preferred_size( max_width, canvas ).0 ) + .sum(); + let residual = ( inner_w - fixed_w - gaps ).max( 0.0 ); + + let max_h: f32 = self.children.iter() + .map( |c| match c + { + Element::Flex( _ ) => c.preferred_size( residual, canvas ).1, + Element::Spacer( _ ) => c.preferred_size( max_width, canvas ).1, + _ => c.preferred_size( max_width, canvas ).1, + } ) + .fold( 0.0_f32, f32::max ); + + // `align_right` and any flex / weight-only spacer child make the row + // claim the full `max_width`: in those cases the row's rendered width + // comes from leftover distribution (or the right-edge anchor), not + // from the sum of children's preferred widths. Reporting only the + // natural sum would leave the parent allocating a too-narrow rect and + // the flex children would collapse to 0. + let has_flex = self.children.iter().any( |c| match c + { + Element::Flex( _ ) => true, + Element::Spacer( s ) => s.fixed_width.is_none(), + _ => false, + } ); + + let w = if self.align_right || has_flex + { + max_width + } else { + let total_w: f32 = self.children.iter() + .map( |c| c.preferred_size( max_width, canvas ).0 ) + .sum::() + + gaps + + self.padding * 2.0; + total_w.min( max_width ) + }; + + ( w, max_h + self.padding * 2.0 ) + } + + pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {} + + /// Layout children within rect and return `(rect, child_index)` pairs. + /// + /// Flexible [`Spacer`](crate::layout::spacer::Spacer) children claim the + /// leftover horizontal space: non-spacer widgets keep their preferred + /// width, the remaining width (after subtracting spacing + padding) is + /// distributed between spacers in proportion to their `weight`. When no + /// spacers are present the cluster is centered (or right-aligned via + /// [`Row::align_right`]). + pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)> + { + let inner_h = rect.height - self.padding * 2.0; + + // Spacers and `Flex` wrappers report 0 width here; their real width + // comes from the flex distribution below. + let sizes: Vec<(f32, f32)> = self.children.iter() + .map( |c| c.preferred_size( rect.width, canvas ) ) + .collect(); + + let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32; + let fixed_w: f32 = self.children.iter().zip( sizes.iter() ) + .filter( |( c, _ )| match c + { + // Pure flex children (`Flex` and weight-only `Spacer`) take + // width from the leftover pool; everything else, including + // `Spacer::width(...)`-pinned spacers, contributes to the + // fixed-width tally. + Element::Flex( _ ) => false, + Element::Spacer( s ) => s.fixed_width.is_some(), + _ => true, + } ) + .map( |( _, ( w, _ ) )| *w ) + .sum(); + + let total_weight: u32 = self.children.iter() + .filter_map( |c| match c { + Element::Spacer( s ) if s.fixed_width.is_none() => Some( s.weight ), + Element::Flex( f ) => Some( f.weight ), + _ => None, + } ) + .sum(); + + let inner_w = ( rect.width - self.padding * 2.0 ).max( 0.0 ); + let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 ); + let has_spacers = total_weight > 0; + + let ( start_x, flex_unit ) = if has_spacers + { + // Spacers and `Flex` wrappers claim the leftover; the cluster + // sits flush to the left edge of the inner rect. + ( rect.x + self.padding, leftover / total_weight as f32 ) + } + else if self.align_right + { + ( rect.x + rect.width - (fixed_w + gaps) - self.padding, 0.0 ) + } + else + { + ( rect.x + (rect.width - fixed_w - gaps) / 2.0, 0.0 ) + }; + + let mut x = start_x; + let mut result = Vec::with_capacity( self.children.len() ); + for ( i, ( (w, h), child) ) in sizes.into_iter().zip( self.children.iter() ).enumerate() + { + let width = match child + { + Element::Spacer( s ) => match s.fixed_width + { + Some( fw ) => fw, + None => flex_unit * s.weight as f32, + }, + Element::Flex( f ) => flex_unit * f.weight as f32, + _ => w, + }; + let y = rect.y + self.padding + (inner_h - h) / 2.0; + result.push( ( Rect { x, y, width, height: h }, i ) ); + x += width + self.spacing; + } + result + } + + pub( crate ) fn map_msg( self, f: &crate::widget::MapFn ) -> Row + where + U: Clone + 'static, + Msg: 'static, + { + Row + { + children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(), + spacing: self.spacing, + padding: self.padding, + align_right: self.align_right, + } + } +} + +/// Create an empty row layout. +pub fn row() -> Row +{ + Row::new() +} + +impl Default for Row +{ + fn default() -> Self + { + Self::new() + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::render::Canvas; + use crate::types::Rect; + + fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } + + #[ test ] + fn align_right_returns_full_max_width() + { + let canvas = make_canvas(); + let r = row::<()>().align_right(); + let ( w, _ ) = r.preferred_size( 500.0, &canvas ); + assert_eq!( w, 500.0 ); + } + + #[ test ] + fn align_right_true_regardless_of_children() + { + let canvas = make_canvas(); + let r = row::<()>().align_right().spacing( 999.0 ); + let ( w, _ ) = r.preferred_size( 300.0, &canvas ); + assert_eq!( w, 300.0 ); + } + + #[ test ] + fn centered_empty_row_returns_zero_width() + { + let canvas = make_canvas(); + let r = row::<()>(); + let ( w, _ ) = r.preferred_size( 500.0, &canvas ); + assert_eq!( w, 0.0 ); + } + + #[ test ] + fn centered_empty_row_returns_zero_height() + { + let canvas = make_canvas(); + let r = row::<()>().padding( 0.0 ); + let ( _, h ) = r.preferred_size( 500.0, &canvas ); + assert_eq!( h, 0.0 ); + } + + #[ test ] + fn padding_adds_to_height() + { + let canvas = make_canvas(); + let r = row::<()>().padding( 8.0 ); + let ( _, h ) = r.preferred_size( 500.0, &canvas ); + assert_eq!( h, 16.0 ); + } + + #[ test ] + fn layout_of_empty_row_is_empty() + { + let canvas = make_canvas(); + let r = row::<()>().align_right(); + let rect = Rect { x: 0., y: 0., width: 400., height: 48. }; + assert!( r.layout( rect, &canvas ).is_empty() ); + } +} diff --git a/src/layout/spacer.rs b/src/layout/spacer.rs new file mode 100644 index 0000000..4c682ba --- /dev/null +++ b/src/layout/spacer.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::widget::Element; + +/// A flexible, invisible spacer that expands to fill available space. +/// +/// The optional `weight` controls how much of the remaining space this spacer +/// claims relative to other spacers in the same layout. A spacer with `weight = 2` +/// takes twice as much space as one with `weight = 1`. +/// +/// Place a `Spacer` between two widgets inside a [`Column`](crate::layout::column::Column) +/// or [`Row`](crate::layout::row::Row) to push them apart: +/// +/// ```rust,no_run +/// # use ltk::{ column, spacer, text, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex() -> Element { +/// column() +/// .push( text( "Top" ) ) +/// .push( spacer() ) // pushes "Bottom" to the bottom +/// .push( text( "Bottom" ) ) +/// .into() +/// # } +/// ``` +/// +/// Use [`.weight(n)`](Spacer::weight) to replace several consecutive spacers: +/// +/// ```rust,no_run +/// # use ltk::{ column, spacer, Column }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex() { +/// // These two are equivalent: +/// let _: Column = column().push( spacer().weight( 3 ) ); +/// let _: Column = column().push( spacer() ).push( spacer() ).push( spacer() ); +/// # } +/// ``` +/// +/// Use [`.height(px)`](Spacer::height) to create a fixed-size vertical spacer: +/// +/// ```rust,no_run +/// # use ltk::{ column, spacer, text, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex() -> Element { +/// column() +/// .push( text( "Header" ) ) +/// .push( spacer().height( 20.0 ) ) // Exactly 20px gap +/// .push( text( "Content" ) ) +/// .into() +/// # } +/// ``` +pub struct Spacer +{ + /// Relative weight of this spacer (default 1). + pub weight: u32, + /// Fixed height in pixels (overrides flexible behavior in a column). + pub fixed_height: Option, + /// Fixed width in pixels (overrides flexible behavior in a row). + pub fixed_width: Option, +} + +impl Spacer +{ + /// Set the relative weight of this spacer (default 1). + pub fn weight( mut self, w: u32 ) -> Self + { + self.weight = w; + self + } + + /// Set a fixed height for this spacer in pixels. + /// When set, the spacer will occupy exactly this much vertical space + /// instead of expanding flexibly. + pub fn height( mut self, h: f32 ) -> Self + { + self.fixed_height = Some( h ); + self + } + + /// Set a fixed width for this spacer in pixels. + /// When set, the spacer occupies exactly this much horizontal space + /// inside a [`Row`](crate::layout::row::Row) instead of expanding + /// flexibly. Mirrors [`Self::height`] for the horizontal axis — useful + /// to reserve a precise visual margin while a sibling + /// [`Flex`](crate::Flex) claims the remaining width. + pub fn width( mut self, w: f32 ) -> Self + { + self.fixed_width = Some( w ); + self + } + + /// Returns `( fixed_width, fixed_height )`, falling back to `0.0` on the + /// axes that were not pinned. The parent layout distributes leftover + /// along its main axis among the still-flexible spacers and `Flex` + /// wrappers, weighted by `weight`. + pub fn preferred_size( &self ) -> (f32, f32) + { + ( self.fixed_width.unwrap_or( 0.0 ), self.fixed_height.unwrap_or( 0.0 ) ) + } + + /// No-op — spacers are invisible. + pub fn draw( &self ) {} +} + +impl From for Element +{ + fn from( s: Spacer ) -> Self + { + Element::Spacer( s ) + } +} + +/// Create a flexible spacer with weight 1. +/// +/// Call [`.weight(n)`](Spacer::weight) to set a relative weight greater than 1: +/// +/// ```rust,no_run +/// # use ltk::{ column, spacer, Column }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex() { +/// // These two are equivalent: +/// let _: Column = column().push( spacer().weight( 3 ) ); +/// let _: Column = column().push( spacer() ).push( spacer() ).push( spacer() ); +/// # } +/// ``` +/// +/// Call [`.height(px)`](Spacer::height) to create a fixed-size vertical gap: +/// +/// ```rust,no_run +/// # use ltk::{ column, spacer, text, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex() -> Element { +/// column() +/// .push( text( "First" ) ) +/// .push( spacer().height( 24.0 ) ) // Fixed 24px gap +/// .push( text( "Second" ) ) +/// .into() +/// # } +/// ``` +pub fn spacer() -> Spacer +{ + Spacer { weight: 1, fixed_height: None, fixed_width: None } +} diff --git a/src/layout/stack.rs b/src/layout/stack.rs new file mode 100644 index 0000000..c275921 --- /dev/null +++ b/src/layout/stack.rs @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Rect; +use crate::render::Canvas; +use crate::widget::Element; + +/// Horizontal alignment of a child within a [`Stack`] rect. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub enum HAlign +{ + /// Align to the left edge. + Start, + /// Center horizontally. + Center, + /// Align to the right edge. + End, + /// Stretch to fill the full width. + Fill, +} + +/// Vertical alignment of a child within a [`Stack`] rect. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub enum VAlign +{ + /// Align to the top edge. + Top, + /// Center vertically. + Center, + /// Align to the bottom edge. + Bottom, + /// Stretch to fill the full height. + Fill, +} + +/// A layout that draws all its children stacked on top of each other. +/// Each child can be positioned within the Stack rect via [`HAlign`]/[`VAlign`]. +/// +/// Useful for overlaying a foreground widget on top of a background image: +/// +/// ```rust,no_run +/// # use std::sync::Arc; +/// # use ltk::{ column, img_widget, stack, text, Element, HAlign, VAlign }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex( bg_rgba: Arc>, w: u32, h: u32 ) -> Element { +/// stack() +/// .push( img_widget( bg_rgba, w, h ) ) +/// .push_aligned( column().push( text( "Bottom right" ) ), HAlign::End, VAlign::Bottom ) +/// .into() +/// # } +/// ``` +pub struct Stack +{ + /// Children with their alignment, margin, and extra `(x, y)` translation + /// applied after alignment. Drawn in order — last child is on top. + pub children: Vec<( Element, HAlign, VAlign, f32, f32, f32 )>, +} + +impl Stack +{ + /// Create an empty stack. + pub fn new() -> Self + { + Self { children: Vec::new() } + } + + /// Append a child that fills the entire Stack rect (Android FrameLayout default). + pub fn push( self, e: impl Into> ) -> Self + { + self.push_aligned_margin( e, HAlign::Fill, VAlign::Fill, 0.0 ) + } + + /// Append a child with explicit horizontal and vertical alignment. + pub fn push_aligned( + self, + e: impl Into>, + h_align: HAlign, + v_align: VAlign, + ) -> Self + { + self.push_aligned_margin( e, h_align, v_align, 0.0 ) + } + + /// Append a child with alignment and a uniform margin (inset from the Stack edges). + pub fn push_aligned_margin( + mut self, + e: impl Into>, + h_align: HAlign, + v_align: VAlign, + margin: f32, + ) -> Self + { + self.children.push( ( e.into(), h_align, v_align, margin, 0.0, 0.0 ) ); + self + } + + /// Append a child with alignment plus an extra `(x, y)` translation in + /// logical pixels. Useful when a child needs to shift outside the normal + /// alignment grid without giving up the margin or alignment shorthand. + /// Positive `x` / `y` move the child right / down. + pub fn push_translated( + mut self, + e: impl Into>, + h_align: HAlign, + v_align: VAlign, + offset_x: f32, + offset_y: f32, + ) -> Self + { + self.children.push( ( e.into(), h_align, v_align, 0.0, offset_x, offset_y ) ); + self + } + + /// Return the preferred `(width, height)` — the maximum height among children. + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) + { + let max_h = self.children.iter() + .map( |( c, _, _, _, _, _ )| c.preferred_size( max_width, canvas ).1 ) + .fold( 0.0_f32, f32::max ); + ( max_width, max_h ) + } + + /// Return `(rect, child_index)` pairs, computing each child's rect from its alignment. + pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)> + { + self.children.iter().enumerate().map( |( i, ( child, h_align, v_align, margin, ox, oy ) )| + { + let inner_w = ( rect.width - margin * 2.0 ).max( 0.0 ); + let inner_h = ( rect.height - margin * 2.0 ).max( 0.0 ); + let ( pref_w, pref_h ) = child.preferred_size( inner_w, canvas ); + + let ( x, width ) = match h_align + { + HAlign::Start => ( rect.x + margin, pref_w ), + HAlign::Center => ( rect.x + ( rect.width - pref_w ) / 2.0, pref_w ), + HAlign::End => ( rect.x + rect.width - pref_w - margin, pref_w ), + HAlign::Fill => ( rect.x + margin, inner_w ), + }; + + let ( y, height ) = match v_align + { + VAlign::Top => ( rect.y + margin, pref_h ), + VAlign::Center => ( rect.y + ( rect.height - pref_h ) / 2.0, pref_h ), + VAlign::Bottom => ( rect.y + rect.height - pref_h - margin, pref_h ), + VAlign::Fill => ( rect.y + margin, inner_h ), + }; + + ( Rect { x: x + ox, y: y + oy, width, height }, i ) + } ).collect() + } + + /// No-op — children are drawn directly by the event loop during layout. + pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {} + + pub( crate ) fn map_msg( self, f: &crate::widget::MapFn ) -> Stack + where + U: Clone + 'static, + Msg: 'static, + { + Stack + { + children: self.children.into_iter() + .map( |( child, ha, va, margin, ox, oy )| + ( child.map_arc( f ), ha, va, margin, ox, oy ) ) + .collect(), + } + } +} + +impl Default for Stack +{ + fn default() -> Self + { + Self::new() + } +} + +/// Create an empty [`Stack`]. +pub fn stack() -> Stack +{ + Stack::new() +} diff --git a/src/layout/wrap_grid.rs b/src/layout/wrap_grid.rs new file mode 100644 index 0000000..e3e20d4 --- /dev/null +++ b/src/layout/wrap_grid.rs @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::render::Canvas; +use crate::types::Rect; +use crate::widget::Element; + +/// A grid layout that wraps children into rows of a fixed column count. +/// +/// All cells in a row share the same height (the tallest item in that row). +/// Column widths are equal, dividing the available width minus padding and spacing. +/// +/// Designed for app-drawer style layouts — combine with [`scroll()`](crate::widget::scroll::scroll) +/// for vertically scrollable grids: +/// +/// ```rust,no_run +/// # use std::sync::Arc; +/// # use ltk::{ grid, icon_button, scroll, Element }; +/// # #[ derive( Clone ) ] enum Msg { Open( usize ) } +/// # fn _ex( data: Arc>, w: u32, h: u32 ) -> Element { +/// scroll( +/// grid( 4 ) +/// .padding( 16.0 ) +/// .spacing( 12.0 ) +/// .push( icon_button( data.clone(), w, h ).on_press( Msg::Open( 0 ) ) ) +/// .push( icon_button( data, w, h ).on_press( Msg::Open( 1 ) ) ) +/// // ... +/// ) +/// .into() +/// # } +/// ``` +pub struct WrapGrid +{ + /// Child widgets laid out in row-major order. + pub children: Vec>, + /// Number of columns per row. + pub columns: usize, + /// Horizontal gap between cells (pixels). + pub spacing_x: f32, + /// Vertical gap between rows (pixels). + pub spacing_y: f32, + /// Padding on all sides (pixels). + pub padding: f32, +} + +impl WrapGrid +{ + /// Append a child widget to the grid. + pub fn push( mut self, child: impl Into> ) -> Self + { + self.children.push( child.into() ); + self + } + + /// Set both horizontal and vertical gap between cells (default 8.0). + pub fn spacing( mut self, s: f32 ) -> Self + { + self.spacing_x = s; + self.spacing_y = s; + self + } + + /// Set only the horizontal gap between cells; leaves vertical spacing untouched. + pub fn spacing_x( mut self, s: f32 ) -> Self + { + self.spacing_x = s; + self + } + + /// Set only the vertical gap between rows; leaves horizontal spacing untouched. + pub fn spacing_y( mut self, s: f32 ) -> Self + { + self.spacing_y = s; + self + } + + /// Set the padding on all sides (default 0.0). + pub fn padding( mut self, p: f32 ) -> Self + { + self.padding = p; + self + } + + /// Compute the preferred size given an available width. + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) + { + if self.children.is_empty() || self.columns == 0 + { + return ( max_width, 0.0 ); + } + let cols = self.columns; + let inner_w = (max_width - self.padding * 2.0).max( 0.0 ); + let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32; + let row_count = (self.children.len() + cols - 1) / cols; + + let mut total_h = self.padding * 2.0; + for row in 0..row_count + { + let start = row * cols; + let end = (start + cols).min( self.children.len() ); + let row_h = self.children[start..end] + .iter() + .map( |c| c.preferred_size( cell_w, canvas ).1 ) + .fold( 0.0_f32, f32::max ); + total_h += row_h; + if row + 1 < row_count { total_h += self.spacing_y; } + } + ( max_width, total_h ) + } + + /// Compute child rects. Returns `(child_rect, index_in_children)` pairs. + pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)> + { + if self.children.is_empty() || self.columns == 0 + { + return Vec::new(); + } + let cols = self.columns; + let inner_w = (rect.width - self.padding * 2.0).max( 0.0 ); + let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32; + let x0 = rect.x + self.padding; + let mut y = rect.y + self.padding; + + let row_count = (self.children.len() + cols - 1) / cols; + let mut out = Vec::with_capacity( self.children.len() ); + + for row in 0..row_count + { + let start = row * cols; + let end = (start + cols).min( self.children.len() ); + let row_h = self.children[start..end] + .iter() + .map( |c| c.preferred_size( cell_w, canvas ).1 ) + .fold( 0.0_f32, f32::max ); + + for col in 0..(end - start) + { + let x = x0 + col as f32 * (cell_w + self.spacing_x); + let crect = Rect { x, y, width: cell_w, height: row_h }; + out.push( ( crect, start + col ) ); + } + y += row_h + self.spacing_y; + } + out + } + + pub( crate ) fn map_msg( self, f: &crate::widget::MapFn ) -> WrapGrid + where + U: Clone + 'static, + Msg: 'static, + { + WrapGrid + { + children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(), + columns: self.columns, + spacing_x: self.spacing_x, + spacing_y: self.spacing_y, + padding: self.padding, + } + } +} + +impl From> for Element +{ + fn from( g: WrapGrid ) -> Self + { + Element::WrapGrid( g ) + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + use crate::render::Canvas; + use crate::layout::spacer::spacer; + + + fn canvas() -> Canvas { Canvas::new( 1, 1 ) } + + // Helper: build a grid of N spacer children with the given settings. + fn spacer_grid( cols: usize, n: usize, spacing: f32, padding: f32 ) -> WrapGrid<()> + { + let mut g = grid( cols ).spacing( spacing ).padding( padding ); + for _ in 0..n { g = g.push( spacer() ); } + g + } + + // --- preferred_size --- + + #[test] + fn empty_grid_height_is_zero() + { + let g: WrapGrid<()> = grid( 4 ); + let ( _, h ) = g.preferred_size( 400.0, &canvas() ); + assert_eq!( h, 0.0 ); + } + + #[test] + fn preferred_width_equals_max_width() + { + let g = spacer_grid( 4, 8, 0.0, 0.0 ); + let ( w, _ ) = g.preferred_size( 320.0, &canvas() ); + assert_eq!( w, 320.0 ); + } + + // --- layout: cell widths --- + + #[test] + fn cell_width_no_spacing_no_padding() + { + // 400px / 4 cols = 100px each + let g = spacer_grid( 4, 4, 0.0, 0.0 ); + let c = canvas(); + let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 }; + let rects = g.layout( rect, &c ); + assert_eq!( rects.len(), 4 ); + for ( r, _ ) in &rects { assert!( (r.width - 100.0).abs() < 0.01 ); } + } + + #[test] + fn cell_width_with_spacing() + { + // (400 - 3 * 10) / 4 = 370 / 4 = 92.5 + let g = spacer_grid( 4, 4, 10.0, 0.0 ); + let c = canvas(); + let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 }; + let rects = g.layout( rect, &c ); + for ( r, _ ) in &rects { assert!( (r.width - 92.5).abs() < 0.01 ); } + } + + #[test] + fn cell_width_with_padding() + { + // inner = 400 - 2*20 = 360; 360 / 4 = 90 + let g = spacer_grid( 4, 4, 0.0, 20.0 ); + let c = canvas(); + let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 }; + let rects = g.layout( rect, &c ); + for ( r, _ ) in &rects { assert!( (r.width - 90.0).abs() < 0.01 ); } + } + + // --- layout: child count and indices --- + + #[test] + fn layout_yields_one_rect_per_child() + { + let g = spacer_grid( 4, 7, 0.0, 0.0 ); + let c = canvas(); + let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 400.0 }; + let rects = g.layout( rect, &c ); + assert_eq!( rects.len(), 7 ); + } + + #[test] + fn layout_indices_are_sequential() + { + let g = spacer_grid( 3, 5, 0.0, 0.0 ); + let c = canvas(); + let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 300.0 }; + let rects = g.layout( rect, &c ); + let indices: Vec = rects.iter().map( |( _, i )| *i ).collect(); + assert_eq!( indices, vec![ 0, 1, 2, 3, 4 ] ); + } + + // --- layout: column x-positions --- + + #[test] + fn column_x_positions_no_spacing() + { + // 300px / 3 cols = 100px each, starting at x=0 + let g = spacer_grid( 3, 3, 0.0, 0.0 ); + let c = canvas(); + let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 100.0 }; + let rects = g.layout( rect, &c ); + let xs: Vec = rects.iter().map( |( r, _ )| r.x ).collect(); + assert!( (xs[0] - 0.0).abs() < 0.01 ); + assert!( (xs[1] - 100.0).abs() < 0.01 ); + assert!( (xs[2] - 200.0).abs() < 0.01 ); + } + + #[test] + fn column_x_positions_with_spacing() + { + // (300 - 2*10) / 3 = 280/3 ≈ 93.33; x[0]=0, x[1]=103.33, x[2]=206.67 + let g = spacer_grid( 3, 3, 10.0, 0.0 ); + let c = canvas(); + let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 100.0 }; + let rects = g.layout( rect, &c ); + let cell_w = 280.0_f32 / 3.0; + let xs: Vec = rects.iter().map( |( r, _ )| r.x ).collect(); + assert!( (xs[0] - 0.0).abs() < 0.01 ); + assert!( (xs[1] - (cell_w + 10.0)).abs() < 0.01 ); + assert!( (xs[2] - (2.0 * (cell_w + 10.0))).abs() < 0.01 ); + } + + // --- layout: partial last row --- + + #[test] + fn partial_last_row_has_correct_count() + { + // 7 children, 4 cols => row 0: 4, row 1: 3. + let g = spacer_grid( 4, 7, 0.0, 0.0 ); + let c = canvas(); + let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 400.0 }; + let rects = g.layout( rect, &c ); + assert_eq!( rects.len(), 7 ); + for ( r, _ ) in &rects[..4] { assert!( r.y.abs() < 0.01 ); } + } + + // --- layout: rect origin offset --- + + #[test] + fn layout_respects_rect_origin() + { + let g = spacer_grid( 2, 2, 0.0, 0.0 ); + let c = canvas(); + let rect = Rect { x: 50.0, y: 30.0, width: 200.0, height: 100.0 }; + let rects = g.layout( rect, &c ); + assert!( (rects[0].0.x - 50.0).abs() < 0.01 ); + assert!( (rects[0].0.y - 30.0).abs() < 0.01 ); + } +} + +/// Create a grid layout with the given number of columns. +/// +/// Use [`.push()`](WrapGrid::push), [`.spacing()`](WrapGrid::spacing), and +/// [`.padding()`](WrapGrid::padding) to populate and style the grid. +/// +/// ```rust,no_run +/// # use ltk::{ button, grid, WrapGrid }; +/// # #[ derive( Clone ) ] enum Msg { A } +/// # fn _ex() -> WrapGrid { +/// grid( 4 ).padding( 16.0 ).spacing( 8.0 ).push( button( "A" ).on_press( Msg::A ) ) +/// # } +/// ``` +pub fn grid( columns: usize ) -> WrapGrid +{ + WrapGrid + { + children: Vec::new(), + columns, + spacing_x: 8.0, + spacing_y: 8.0, + padding: 0.0, + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100755 index 0000000..d00bd92 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +// Inside `unsafe fn` bodies, every unsafe op must still be wrapped in +// its own `unsafe { ... }` block. Without this lint the compiler treats +// the whole body as one implicit `unsafe` scope and a SAFETY: comment +// per call site becomes impossible to enforce — the kind of drift that +// hides UB in renderer code where every glow::* call is `unsafe fn`. +#![ deny( unsafe_op_in_unsafe_fn ) ] + +//! # ltk — Liberux ToolKit +//! +//! A lightweight Wayland UI toolkit built on top of +//! [smithay-client-toolkit](https://crates.io/crates/smithay-client-toolkit), +//! [tiny-skia](https://crates.io/crates/tiny-skia) and +//! [fontdue](https://crates.io/crates/fontdue). +//! +//! ltk is the UI toolkit for the Liberux desktop. The Liberux compositor (Forge) +//! handles window management, decorations, and positioning — ltk focuses on +//! rendering the content of each Wayland surface. +//! +//! `ltk` is also a public library for third-party developers building native +//! Wayland applications. If you are approaching the crate through `cargo doc`, +//! the API is grouped conceptually into three navigation modules: +//! +//! - [`window`] — the basic application window path most apps should start with +//! - [`shell`] — layer-shell and overlay APIs for shell-like surfaces +//! - [`runtime`] — advanced runtime hooks, invalidation, channels, and +//! runtime-free embedding via [`core::UiSurface`] +//! +//! ## Quick start +//! +//! ```rust,no_run +//! use ltk::{App, Element, column, text, button, spacer, Color, ButtonVariant}; +//! +//! #[derive(Clone)] +//! enum Msg { Quit } +//! +//! struct MyApp; +//! +//! impl App for MyApp +//! { +//! type Message = Msg; +//! +//! fn view( &self ) -> Element +//! { +//! column() +//! .push( text( "Hello, ltk!" ).size( 32.0 ).color( Color::WHITE ) ) +//! .push( spacer() ) +//! .push( button( "Quit" ).on_press( Msg::Quit ) ) +//! .into() +//! } +//! +//! fn update( &mut self, msg: Msg ) +//! { +//! match msg { Msg::Quit => std::process::exit( 0 ) } +//! } +//! } +//! +//! fn main() { ltk::run( MyApp ); } +//! ``` +//! +//! ## Architecture +//! +//! - **[`App`]** trait — implement this to define your application. +//! - **[`Element`]** enum — the widget tree returned by [`App::view`]. +//! - Widgets, layouts and primitive types are listed below in their own +//! sidebar sections; the [`widgets`], [`layouts`] and [`types`] modules +//! are concept-oriented landing pages that `cargo doc` exposes for the +//! same set, grouped by category. +//! +//! ## Widgets +//! +//! The interactive and decorative leaves of the [`Element`] tree: +//! +//! - **Buttons / activations** — [`button()`], [`icon_button`], +//! [`pressable`], [`window_button`], [`list_item()`]. +//! - **Stateful binary controls** — [`toggle()`], [`checkbox()`], +//! [`radio()`]. +//! - **Continuous controls** — [`slider()`], [`vslider()`], +//! [`progress_bar()`]. +//! - **Text** — [`text()`], [`text_edit()`]. +//! - **Decoration** — [`container()`], [`separator()`], [`img_widget()`]. +//! - **Clipping wrappers** — [`scroll()`], [`viewport()`], [`flex()`]. +//! - **Modal overlays** — [`dialog()`] (centered confirmation card with +//! optional title, subtitle, custom body and action row; built-in +//! scrim, ESC-to-cancel, and tap-outside-to-dismiss for the +//! non-modal variant). +//! +//! See [`widgets`] for the grouped landing page and +//! `docs/widgets.md` for the per-widget catalogue. +//! +//! ## Layouts +//! +//! Composable arrangers for [`Element`] trees: +//! +//! - [`column()`] — vertical flow. +//! - [`row()`] — horizontal flow. +//! - [`stack()`] — z-order overlay with per-child alignment. +//! - [`grid()`] — fixed-column-count wrapping grid. +//! - [`spacer()`] — invisible flexible filler. +//! +//! See [`layouts`] for the grouped landing page. +//! +//! ## Types +//! +//! Geometry and primitive values that flow through every builder: +//! +//! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`]. +//! +//! See [`types`] for the full module with `//!` description. +//! +//! ## Runtime-free embedding +//! +//! Use [`core::UiSurface`] when you need ltk's layout, drawing and +//! hit-testing without [`run()`] — typically for compositor-side +//! decorations, embedding ltk widgets in another render loop, or +//! offscreen rendering / previews. +//! +//! ## Licence and third-party assets +//! +//! `ltk` itself is distributed under `LGPL-2.1-only`. The default +//! theme bundles two third-party asset sets that travel under their +//! own licences and must be credited when the toolkit (or a binary +//! that embeds the default theme) is redistributed: +//! +//! - **Symbolic icons** under `themes/default/icons/catalogue/` — +//! Streamline's *Core Line Free* set, [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), +//! © Streamline. Some files modified for the symbolic-tinting +//! pipeline; details in `themes/default/icons/catalogue/LICENSE.md`. +//! Upstream: . +//! - **Sora Regular** (`src/theme/fallback/Sora-Regular.otf`) — the +//! embedded font fallback, [SIL OFL 1.1](https://scripts.sil.org/OFL), +//! © The Sora Project Authors, Jonathan Barnbrook, Julián Moncada. +//! +//! The remaining artwork in the default theme (wallpapers, lockscreens, +//! launcher logo, brand-mark variants, per-application icons) is +//! original to Liberux Labs and travels under the toolkit's own +//! `LGPL-2.1-only` licence. The full Debian-style declaration lives in +//! `debian/copyright` of the source tree. + +// Load YAML locale files from `ltk/locales/*.yaml`. The `t!()` macro is +// re-exported (see below) so consuming applications can also use it; their +// own `i18n!()` invocations merge into the same runtime registry. The +// fallback locale is English — built-in widget strings always have an +// English entry. +rust_i18n::i18n!( "locales", fallback = "en" ); + +pub mod types; +pub( crate ) mod render; +pub( crate ) mod system_fonts; +pub( crate ) mod widget; +pub( crate ) mod layout; +pub( crate ) mod app; +pub mod theme; +pub mod wallpaper; + +pub( crate ) mod tree; +pub( crate ) mod draw; +pub( crate ) mod input; +pub( crate ) mod event_loop; +pub( crate ) mod secure_mem; +pub mod gles_render; +pub mod egl_context; +pub mod core; + +pub use app:: +{ + Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec, + InvalidationScope, SurfaceTarget, +}; +pub use theme:: +{ + Palette, ThemeMode, ThemePreference, ThemeError, + ThemeDocument, Mode, SlotStore, + WallpaperSpec, WallpaperFit, LauncherSpec, WindowControlsSpec, + active_document, active_mode, active_theme_id, + is_fallback_active, + set_active_document, set_active_mode, + tint_symbolic, +}; +pub use theme::{ color as theme_color, color_or as theme_color_or }; +pub use theme::{ paint as theme_paint, shadows as theme_shadows }; +pub use theme::{ surface as theme_surface, text_style as theme_text_style }; +pub use theme::resolve_surface as theme_resolve_surface; +pub use theme::palette as theme_palette; +pub use theme::window_controls as theme_window_controls; +pub use theme::wallpaper as theme_wallpaper; +pub use theme::lockscreen as theme_lockscreen; +pub use theme::app_icon as theme_app_icon; +pub use theme::app_default_icon as theme_app_default_icon; +pub use theme::launcher_icon as theme_launcher_icon; +pub use theme::logo as theme_logo; +pub use theme::logo_square as theme_logo_square; +pub use theme::logo_horizontal as theme_logo_horizontal; +pub use theme::branding_asset as theme_branding_asset; +pub use theme::branding_raster as theme_branding_raster; +pub use theme::branding_image as theme_branding_image; +pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as theme_icon_rgba }; +pub use gles_render::{ BorrowedGlesTexture, GlesVersion }; +pub use render::is_software_render; +pub use wallpaper::{ WallpaperBundle, ImageData }; +pub use types::{ Color, Corners, CursorShape, Point, Rect, Size, WidgetId }; +pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container }; +pub use widget::button::ButtonVariant; +pub use widget::slider::{ Slider, slider, SliderAxis }; +pub use widget::vslider::{ VSlider, vslider }; +pub use widget::text::TextAlign; +pub use widget::toggle::{ Toggle, toggle }; +pub use widget::separator::{ Separator, separator }; +pub use widget::progress_bar::{ ProgressBar, progress_bar }; +pub use widget::checkbox::{ Checkbox, checkbox }; +pub use widget::radio::{ Radio, radio }; +pub use widget::list_item::{ ListItem, list_item }; +pub use widget::window_button::{ WindowButton, WindowButtonKind, window_button, window_controls }; +pub use widget::pressable::{ Pressable, pressable }; +pub use widget::flex::{ Flex, flex }; +pub use widget::combo::{ Combo, ComboState, combo }; +pub use widget::spinner::{ Spinner, spinner }; +pub use widget::tab_bar::{ TabBar, tabs }; +pub use widget::toast::{ Toast, toast }; +pub use widget::tooltip::{ Tooltip, tooltip }; +pub use widget::notebook::{ Notebook, NotebookPage, notebook }; +pub use widget::external::{ External, ExternalSource }; +pub use widget::external as widget_external; +pub use widget::date_picker:: +{ + Date, DatePicker, Locale as DateLocale, date_picker, + is_leap_year, days_in_month, day_of_week, add_months, +}; +pub use widget::time_picker::{ Time, TimePicker, time_picker }; +pub use widget::color_picker:: +{ + ColorPicker, color_picker, color_to_hex, parse_hex, +}; +pub use widget::dialog::{ Dialog, dialog }; +pub use layout::spacer::{ Spacer, spacer }; +pub use layout::column::{ Column, column }; +pub use layout::row::{ Row, row }; +pub use layout::stack::{ Stack, stack, HAlign, VAlign }; +// push_aligned_margin is available as a method on Stack — no separate re-export needed. +pub use layout::wrap_grid::{ WrapGrid, grid }; +pub use widget::scroll::scroll; +pub use widget::viewport::{ Viewport, viewport }; +pub use app::run; +pub use app::{ try_run, RunError }; +pub use smithay_client_toolkit::seat::keyboard::Keysym; + +/// Widgets — the interactive and decorative leaves of the [`Element`] +/// tree. +/// +/// Concept-oriented sidebar entry: every widget the toolkit ships is also +/// available at the crate root (`ltk::button`, `ltk::toggle`, …); this +/// module groups them so `cargo doc` shows a single landing page when you +/// are looking for "what controls can I draw". +/// +/// See [`docs/widgets.md`](https://github.com/liberux/ltk/blob/master/docs/widgets.md) +/// for the per-widget catalogue with usage notes and minimal examples. +pub mod widgets +{ + pub use crate:: + { + // Buttons / activations + button, icon_button, + Pressable, pressable, + WindowButton, WindowButtonKind, window_button, window_controls, + ListItem, list_item, + // Stateful binary controls + Toggle, toggle, + Checkbox, checkbox, + Radio, radio, + // Continuous controls + Slider, slider, SliderAxis, + VSlider, vslider, + ProgressBar, progress_bar, + // Composite picker + Combo, ComboState, combo, + // Activity / hint indicators + Spinner, spinner, + // Segmented selector + paginated tabs + TabBar, tabs, + Notebook, NotebookPage, notebook, + // Date / time / color pickers + Date, DatePicker, DateLocale, date_picker, + Time, TimePicker, time_picker, + ColorPicker, color_picker, color_to_hex, parse_hex, + // Transient overlays (return OverlaySpec) + Toast, toast, + Tooltip, tooltip, + // Modal / non-modal centered overlays + Dialog, dialog, + // Text input and display + text, text_edit, TextAlign, + // Decoration and chrome + container, Separator, separator, + img_widget, + // Clipping wrappers + scroll, + Viewport, viewport, + Flex, flex, + // Button styling token + ButtonVariant, + }; +} + +/// Layouts — composable arrangers for [`Element`] trees. +/// +/// Concept-oriented sidebar entry. Each layout has a free constructor +/// (`column()`, `row()`, …) and a builder-style API for spacing, padding +/// and alignment. Layouts and [widgets] share the same `Element` +/// tree. +pub mod layouts +{ + pub use crate:: + { + Column, column, + Row, row, + Stack, stack, HAlign, VAlign, + WrapGrid, grid, + Spacer, spacer, + }; +} + +/// Basic application-window API. +/// +/// Start here if you are building a normal Wayland client window. +/// +/// This module is documentation-first: it re-exports the common entry points +/// that most applications need so `cargo doc` presents a smaller and more +/// approachable surface before the user gets into overlays, gestures, and +/// shell-specific features. +/// +/// The default path is: +/// +/// 1. implement [`App`] +/// 2. return an [`Element`] tree from [`App::view`] +/// 3. mutate state in [`App::update`] +/// 4. start the event loop with [`run`] +/// +/// If you are looking for layer-shell, overlays, or advanced runtime hooks, +/// move on to [`crate::shell`] or [`crate::runtime`]. +pub mod window +{ + pub use crate:: + { + App, ButtonVariant, Color, Element, Keysym, Point, Rect, Size, + Column, Row, Stack, WrapGrid, Spacer, + button, icon_button, text, text_edit, img_widget, + container, checkbox, radio, toggle, separator, + progress_bar, list_item, slider, vslider, scroll, viewport, + column, row, stack, grid, spacer, + TextAlign, SliderAxis, + run, + }; +} + +/// Shell and layer-surface API. +/// +/// Use this module when you are building shell-like surfaces rather than a +/// plain application window: +/// +/// - panels +/// - docks +/// - homescreens +/// - greeters +/// - lock screens +/// - transient overlays +/// +/// These items are also available at the crate root; this module exists so +/// `cargo doc` exposes a concept-oriented entry point for layer-shell users. +pub mod shell +{ + pub use crate:: + { + Anchor, App, Layer, OverlayId, OverlaySpec, ShellMode, + SurfaceTarget, InvalidationScope, + WindowButton, WindowButtonKind, window_button, window_controls, + }; +} + +/// Advanced runtime and embedding API. +/// +/// This module groups the hooks that are useful once the basic app-window flow +/// is no longer enough: +/// +/// - external wakeups via [`ChannelSender`] +/// - redraw narrowing via [`InvalidationScope`] +/// - surface-level invalidation targets via [`SurfaceTarget`] +/// - runtime-free embedding with [`crate::core::UiSurface`] +/// - direct theme/runtime state access +/// +/// Most applications do not need to start here. +pub mod runtime +{ + pub use crate:: + { + ChannelSender, InvalidationScope, SurfaceTarget, + Palette, ThemeDocument, ThemeError, ThemeMode, ThemePreference, + WallpaperBundle, ImageData, + active_document, active_mode, active_theme_id, + is_fallback_active, + set_active_document, set_active_mode, + theme_color, theme_color_or, theme_paint, theme_palette, + theme_resolve_surface, theme_shadows, theme_surface, theme_text_style, + theme_wallpaper, theme_lockscreen, theme_window_controls, + theme_branding_asset, theme_branding_raster, theme_branding_image, + tint_symbolic, + }; + pub use crate::core::{ RenderOptions, RenderOutput, UiSurface }; +} + +/// Internal helpers re-exported for the integration tests under `tests/` and +/// the criterion benches under `benches/`. The items themselves are `pub` but +/// live inside `pub(crate)` modules, so the only path that reaches them from +/// outside the crate is `ltk::test_support::*` — which is exactly what test +/// crates (and benches, which are also external) need. +/// +/// **Not part of the stable public API.** Anything in here may change between +/// patch releases without notice. Hidden from generated docs via +/// `#[doc(hidden)]` for the same reason. +#[ doc( hidden ) ] +pub mod test_support +{ + pub use crate::tree::{ find_widget_at, find_widget, find_handlers, next_focusable_index }; + pub use crate::widget::{ LaidOutWidget, WidgetHandlers }; + pub use crate::widget::slider::{ value_from_x_in_rect, value_from_pos_in_rect, SliderAxis }; + pub use crate::widget::vslider::value_from_y_in_rect; + pub use crate::event_loop::diff_overlay_ids; +} diff --git a/src/render/clip.rs b/src/render/clip.rs new file mode 100644 index 0000000..26cf043 --- /dev/null +++ b/src/render/clip.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Clip-mask management for [`SoftwareCanvas`]. The partial-redraw +//! path calls `set_clip_rects` before every repaint so only pixels +//! inside the dirty rects are touched. + +use tiny_skia::{ FillRule, Mask, PathBuilder, Transform }; + +use crate::types::Rect; + +use super::SoftwareCanvas; + +impl SoftwareCanvas +{ + /// Set the active clip region to the union of `rects` (physical pixels). + pub fn set_clip_rects( &mut self, rects: &[Rect] ) + { + let w = self.pixmap.width(); + let h = self.pixmap.height(); + let Some( mut mask ) = Mask::new( w, h ) else + { + self.clip_mask = None; + self.clip_bounds = Vec::new(); + return; + }; + let mut pb = PathBuilder::new(); + for r in rects + { + let x0 = r.x.max( 0.0 ).min( w as f32 ); + let y0 = r.y.max( 0.0 ).min( h as f32 ); + let x1 = ( r.x + r.width ).max( 0.0 ).min( w as f32 ); + let y1 = ( r.y + r.height ).max( 0.0 ).min( h as f32 ); + if x1 <= x0 || y1 <= y0 { continue; } + pb.push_rect( tiny_skia::Rect::from_ltrb( x0, y0, x1, y1 ) + .expect( "valid rect" ) ); + } + if let Some( path ) = pb.finish() + { + mask.fill_path( &path, FillRule::Winding, false, Transform::identity() ); + self.clip_mask = Some( mask ); + self.clip_bounds = rects.to_vec(); + } else { + self.clip_mask = None; + self.clip_bounds = Vec::new(); + } + } + + /// Remove the active clip so subsequent paints cover the full canvas. + pub fn clear_clip( &mut self ) + { + self.clip_mask = None; + self.clip_bounds = Vec::new(); + } + + pub ( super ) fn has_clip( &self ) -> bool + { + self.clip_mask.is_some() + } + + /// Snapshot of the active clip bounds (empty when no clip is set). + pub fn clip_bounds_snapshot( &self ) -> Vec + { + if self.has_clip() { self.clip_bounds.clone() } else { Vec::new() } + } + + /// True when a horizontal strip `y` in `[y0, y1]` touches any clip bound. + pub ( super ) fn strip_intersects_clip( &self, y0: f32, y1: f32 ) -> bool + { + if self.clip_bounds.is_empty() { return !self.has_clip(); } + self.clip_bounds.iter().any( |r| + { + y1 > r.y && y0 < r.y + r.height + } ) + } + + /// Zero the alpha+RGB bytes inside each rect, used by the + /// partial-redraw path when the surface background is fully + /// transparent. + pub fn clear_rects_transparent( &mut self, rects: &[Rect] ) + { + let pw = self.pixmap.width() as i32; + let ph = self.pixmap.height() as i32; + let bytes = self.pixmap.data_mut(); + for r in rects + { + let x0 = ( r.x as i32 ).max( 0 ); + let y0 = ( r.y as i32 ).max( 0 ); + let x1 = ( ( r.x + r.width ).ceil() as i32 ).min( pw ); + let y1 = ( ( r.y + r.height ).ceil() as i32 ).min( ph ); + if x1 <= x0 || y1 <= y0 { continue; } + for py in y0..y1 + { + let row_start = ( py * pw + x0 ) as usize * 4; + let row_end = ( py * pw + x1 ) as usize * 4; + bytes[ row_start..row_end ].fill( 0 ); + } + } + } +} diff --git a/src/render/helpers.rs b/src/render/helpers.rs new file mode 100644 index 0000000..28af782 --- /dev/null +++ b/src/render/helpers.rs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Backend-neutral helpers for the software renderer: rounded-rect +//! path construction + system-font lookup. + +use tiny_skia::{ Path, PathBuilder }; + +use crate::types::Corners; + +/// Cubic bezier control-point factor for a quarter-circle approximation +/// (`(4/3) * (sqrt(2) - 1) ≈ 0.5523`). +const KAPPA: f32 = 0.5523_f32; + +/// Build a rounded rectangle path with independent per-corner radii +/// using cubic bezier curves. Each corner is clamped against the +/// inscribed-circle limit `min(width, height) / 2` before drawing, +/// so callers can pass theme pill sentinels (e.g. `RADIUS = 100`) and +/// still get a well-formed pill on a small rect. +pub ( super ) fn build_rounded_rect( rect: tiny_skia::Rect, corners: Corners ) -> Option +{ + let c = corners.clamp_to_size( rect.width(), rect.height() ); + let tl = c.tl; + let tr = c.tr; + let br = c.br; + let bl = c.bl; + + let x0 = rect.left(); + let y0 = rect.top(); + let x1 = rect.right(); + let y1 = rect.bottom(); + + let mut pb = PathBuilder::new(); + pb.move_to( x0 + tl, y0 ); + pb.line_to( x1 - tr, y0 ); + if tr > 0.0 + { + let kk = tr * KAPPA; + pb.cubic_to( x1 - tr + kk, y0, x1, y0 + tr - kk, x1, y0 + tr ); + } + pb.line_to( x1, y1 - br ); + if br > 0.0 + { + let kk = br * KAPPA; + pb.cubic_to( x1, y1 - br + kk, x1 - br + kk, y1, x1 - br, y1 ); + } + pb.line_to( x0 + bl, y1 ); + if bl > 0.0 + { + let kk = bl * KAPPA; + pb.cubic_to( x0 + bl - kk, y1, x0, y1 - bl + kk, x0, y1 - bl ); + } + pb.line_to( x0, y0 + tl ); + if tl > 0.0 + { + let kk = tl * KAPPA; + pb.cubic_to( x0, y0 + tl - kk, x0 + tl - kk, y0, x0 + tl, y0 ); + } + pb.close(); + pb.finish() +} + +/// System-font search chain, ordered by preference. Shared by +/// [`find_font`] (which panics when none match) and +/// [`find_font_opt`] (which returns `None` — used by tests that +/// want to skip gracefully on images without the usual fonts +/// installed). +const SYSTEM_FONT_CANDIDATES: &[&str] = +&[ + // Debian `fonts-sora` — the canonical path `ltk-theme-default` + // depends on. Listed first so Sora wins as the default font + // whenever the package is installed. + "/usr/share/fonts/opentype/sora/Sora-Regular.otf", + "/usr/share/fonts/truetype/sora/Sora-Regular.ttf", + "/usr/share/fonts/sora/Sora-Regular.ttf", + "/usr/share/fonts/TTF/Sora-Regular.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/freefont/FreeSans.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", +]; + +/// Resolve the first system font available from +/// [`SYSTEM_FONT_CANDIDATES`], or `None` if none exist. Used by +/// tests; runtime code uses [`find_font`]. +pub ( crate ) fn find_font_opt() -> Option +{ + SYSTEM_FONT_CANDIDATES.iter() + .find( |p| std::path::Path::new( p ).exists() ) + .copied() + .map( str::to_string ) +} + +/// Load the bytes of a default system font. Tries the candidate chain +/// via [`find_font_opt`]; falls back to the embedded +/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB, +/// OFL 1.1) when nothing matches or the file cannot be read. Always +/// returns usable bytes so canvas construction never panics on a +/// system without the expected fonts. +pub ( super ) fn load_default_font_bytes() -> Vec +{ + if let Some( path ) = find_font_opt() + { + if let Ok( bytes ) = std::fs::read( &path ) + { + return bytes; + } + } + crate::theme::fallback::FALLBACK_FONT.to_vec() +} diff --git a/src/render/image.rs b/src/render/image.rs new file mode 100644 index 0000000..535cb46 --- /dev/null +++ b/src/render/image.rs @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Image draw + SHM serialisation for [`SoftwareCanvas`]. +//! +//! `draw_image_data` premultiplies the incoming straight-alpha RGBA +//! into a thread-local scratch buffer, wraps the result in a +//! short-lived tiny-skia pixmap, and composites it into `self.pixmap` +//! honouring the active clip mask + global alpha + `opacity`. +//! +//! `write_to_wayland_buf` is the serialisation path to the `wl_shm` +//! pool used by the software draw path. Either memcpys straight +//! (Abgr8888 matches tiny-skia's byte order) or swaps R/B in blocks +//! of four pixels (Argb8888 fallback). + +use tiny_skia::{ Pixmap, PixmapPaint, Transform }; + +use crate::types::Rect; + +use super::SoftwareCanvas; + +impl SoftwareCanvas +{ + pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 ) + { + let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 ); + if img_w == 0 || img_h == 0 || rgba_data.len() != expected + { + eprintln!( + "[ltk] SoftwareCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}", + img_w, img_h, rgba_data.len(), expected, + ); + return; + } + + let Some( int_size ) = tiny_skia::IntSize::from_wh( img_w, img_h ) else { return }; + + thread_local! { + static PREMUL_BUF: std::cell::RefCell> = std::cell::RefCell::new( Vec::new() ); + } + + PREMUL_BUF.with( |cell| + { + let mut premul = cell.borrow_mut(); + let needed = rgba_data.len(); + premul.resize( needed, 0 ); + for ( dst, src ) in premul.chunks_exact_mut( 4 ).zip( rgba_data.chunks_exact( 4 ) ) + { + let a = (src[3] as f32 / 255.0) * opacity * self.global_alpha; + dst[0] = (src[0] as f32 * a) as u8; + dst[1] = (src[1] as f32 * a) as u8; + dst[2] = (src[2] as f32 * a) as u8; + dst[3] = (a * 255.0) as u8; + } + + if let Some( src_pixmap ) = Pixmap::from_vec( std::mem::take( &mut *premul ), int_size ) + { + let sx = dest.width / img_w as f32; + let sy = dest.height / img_h as f32; + let t = Transform::from_scale( sx, sy ).post_translate( dest.x, dest.y ); + let paint = PixmapPaint + { + quality: tiny_skia::FilterQuality::Bilinear, + ..PixmapPaint::default() + }; + self.pixmap.draw_pixmap( 0, 0, src_pixmap.as_ref(), &paint, t, self.clip_mask.as_ref() ); + *premul = src_pixmap.take(); + } + } ); + } + + pub fn write_to_wayland_buf( &self, buf: &mut [u8], swap_rb: bool ) + { + let src = self.pixmap.data(); + let len = src.len().min( buf.len() ); + + if !swap_rb + { + buf[..len].copy_from_slice( &src[..len] ); + return; + } + + let chunks = len / 16; + let remainder = len % 16; + let mut i = 0; + for _ in 0..chunks + { + buf[i] = src[i + 2]; + buf[i + 1] = src[i + 1]; + buf[i + 2] = src[i]; + buf[i + 3] = src[i + 3]; + buf[i + 4] = src[i + 6]; + buf[i + 5] = src[i + 5]; + buf[i + 6] = src[i + 4]; + buf[i + 7] = src[i + 7]; + buf[i + 8] = src[i + 10]; + buf[i + 9] = src[i + 9]; + buf[i + 10] = src[i + 8]; + buf[i + 11] = src[i + 11]; + buf[i + 12] = src[i + 14]; + buf[i + 13] = src[i + 13]; + buf[i + 14] = src[i + 12]; + buf[i + 15] = src[i + 15]; + i += 16; + } + for _ in 0..(remainder / 4) + { + buf[i] = src[i + 2]; + buf[i + 1] = src[i + 1]; + buf[i + 2] = src[i]; + buf[i + 3] = src[i + 3]; + i += 4; + } + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs new file mode 100644 index 0000000..e9dbd45 --- /dev/null +++ b/src/render/mod.rs @@ -0,0 +1,634 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Rendering surface used by every widget. +//! +//! [`Canvas`] is a thin enum wrapper over the per-frame rendering +//! backend. The CPU backend is [`SoftwareCanvas`] (tiny-skia + fontdue +//! rasterised into a `Pixmap`). The GPU backend is +//! [`crate::gles_render::GlesCanvas`] (EGL + GLES2/3). +//! +//! Widgets only ever see `&mut Canvas` — they call `fill_rect`, +//! `draw_text`, etc. The enum dispatches by `match self` (no `dyn`, +//! so the call sites stay monomorphic and inlinable). Field-style +//! access to backend internals (`pixmap`, `font`, `dpi_scale`…) is +//! replaced by accessor methods that the GPU variant can also +//! implement. +//! +//! # Submodule layout +//! +//! * [`setup`] — `SoftwareCanvas::{new, sub_canvas, resize, blit, +//! set_font_registry, font_for}` (construction + accessors). +//! * [`clip`] — `SoftwareCanvas::{set_clip_rects, clear_clip, +//! has_clip, strip_intersects_clip, clear_rects_transparent}`. +//! * [`primitives`] — `SoftwareCanvas::{clear, fill, fill_rect, +//! stroke_rect, draw_line}`. +//! * [`text`] — `SoftwareCanvas::{draw_text, measure_text, +//! rasterize_cached}`. +//! * [`image`] — `SoftwareCanvas::{draw_image_data, +//! write_to_wayland_buf}`. +//! * [`helpers`] — free functions: `build_rounded_rect`, +//! `find_font`, `find_font_opt`, `SYSTEM_FONT_CANDIDATES`. + +use std::cell::Cell; +use std::sync::Arc; + +use fontdue::{ Font, LineMetrics, Metrics }; +use tiny_skia::{ Mask, Pixmap }; + +use crate::gles_render::{ BorrowedGlesTexture, GlesCanvas, GlesVersion }; +use crate::theme::{ FontRegistry, FontStyle, InsetShadow, Paint as ThemePaint, Shadow }; +use crate::types::{ Color, Corners, Rect }; + +pub( crate ) mod setup; +pub( crate ) mod clip; +pub( crate ) mod primitives; +pub( crate ) mod text; +pub( crate ) mod image; +pub( crate ) mod helpers; + +// ─── Backend flag ──────────────────────────────────────────────────────────── + +thread_local! +{ + /// `true` when this thread's surfaces are rendered through the + /// software (tiny-skia / SHM) path, `false` when they go through + /// the GLES path. Set once at startup based on EGL availability + /// and read by view code that needs to branch on backend (e.g. a + /// layout that costs something specific to one path and isn't + /// worth replicating on the other). Stays a thread-local so view + /// code does not need to plumb a flag through every layout call. + static SOFTWARE_RENDER: Cell = const { Cell::new( false ) }; +} + +/// Toggle the software-render flag for this thread. Consumers read +/// with [`is_software_render`]. +pub fn set_software_render( on: bool ) +{ + SOFTWARE_RENDER.with( | c | c.set( on ) ); +} + +/// `true` when the active surfaces on this thread render through the +/// software path. Used by view code that wants to avoid pipeline +/// effects the software backend doesn't implement. +pub fn is_software_render() -> bool +{ + SOFTWARE_RENDER.with( | c | c.get() ) +} + +// ─── Glyph cache ───────────────────────────────────────────────────────────── + +/// Cache key for a rasterized glyph. `size_bits` is the f32 bit +/// pattern of `size * dpi_scale`; `font_id` is the address of the +/// `Arc` used for the rasterisation, so distinct weights / +/// families of the same `(char, size)` do not collide on the cache. +#[ derive( Hash, PartialEq, Eq, Clone, Copy ) ] +pub ( super ) struct GlyphKey +{ + pub ( super ) ch: char, + pub ( super ) size_bits: u32, + pub ( super ) font_id: usize, +} + +/// Cached glyph bitmap and metrics. Fontdue's rasterize call is the +/// dominant per-frame CPU cost for text-heavy UIs; reusing across +/// frames avoids that work. +pub ( super ) struct GlyphEntry +{ + pub ( super ) metrics: Metrics, + pub ( super ) bitmap: Vec, +} + +// ─── SoftwareCanvas ────────────────────────────────────────────────────────── + +/// Software rendering backend backed by a tiny-skia [`Pixmap`] and a +/// fontdue [`Font`]. +/// +/// Wrapped by [`Canvas`] so the GPU backend can be slotted in by the +/// runtime without changing widget code. Widgets themselves never see +/// `SoftwareCanvas` directly. +pub struct SoftwareCanvas +{ + /// The pixel buffer drawn into each frame. + pub pixmap: Pixmap, + /// The loaded system font used for all text rendering. + /// + /// Kept as the default fallback so widgets that do not yet ask for a + /// specific family through [`SoftwareCanvas::font_for`] keep + /// working. Populated from + /// [`crate::render::helpers::find_font`] at construction time. + pub font: Arc, + /// Optional theme font registry. When present, + /// [`SoftwareCanvas::font_for`] consults it before falling back + /// to `font`. Populated by the caller once the theme's `fonts` + /// block has been loaded. + pub font_registry: Option>, + /// DPI scale factor applied to font sizes. + pub dpi_scale: f32, + /// Global alpha multiplier for all drawing operations (0.0 = + /// transparent, 1.0 = opaque). + pub global_alpha: f32, + /// Persistent cache of rasterized glyphs, indexed by (char, scaled size). + /// Grows on demand; not LRU-bounded since typical UIs use few sizes. + glyph_cache: std::collections::HashMap, + /// Optional clip mask applied to all paint operations. Set via + /// [`Canvas::set_clip_rects`] during a partial redraw so only + /// pixels inside the dirty rects are touched. `None` means "draw + /// everywhere". + clip_mask: Option, + /// Bounding boxes of the clip rects in physical pixels. Used by + /// [`SoftwareCanvas::draw_text`] to do an early reject without + /// poking the mask byte by byte (the Mask buffer is still + /// authoritative inside the pixel loop). + clip_bounds: Vec, +} + +// ─── Canvas enum + dispatch ───────────────────────────────────────────────── + +/// Per-frame rendering surface. Wraps a backend (software or GPU) +/// behind an enum so widgets can stay backend-agnostic. +/// +/// All drawing methods are dispatched by `match self` — no `dyn` +/// indirection, so the backend branch stays predictable and +/// inlinable in the hot path. +pub enum Canvas +{ + /// CPU rasterisation via tiny-skia + fontdue, written to a + /// `wl_shm` buffer. + Software( SoftwareCanvas ), + /// GPU rasterisation via EGL + GLES 2/3. Presents via + /// `eglSwapBuffers`; [`Canvas::write_to_wayland_buf`] is a no-op + /// for this variant. + Gles( GlesCanvas ), +} + +impl Canvas +{ + /// Build a software canvas. The GPU backend requires an EGL + /// context — see [`Canvas::new_gles`]. + pub fn new( width: u32, height: u32 ) -> Self + { + Canvas::Software( SoftwareCanvas::new( width, height ) ) + } + + /// Build a GPU canvas on an already-current EGL context. + pub fn new_gles( + gl: Arc, version: GlesVersion, width: u32, height: u32, + ) -> Self + { + Canvas::Gles( GlesCanvas::new( gl, version, width, height ) ) + } + + /// `(width, height)` of the underlying surface in physical pixels. + pub fn size( &self ) -> ( u32, u32 ) + { + match self + { + Canvas::Software( c ) => ( c.pixmap.width(), c.pixmap.height() ), + Canvas::Gles( c ) => c.size(), + } + } + + /// Borrow the GLES texture backing this canvas, when the canvas + /// is GPU-backed. + pub fn borrowed_gles_texture( &self ) -> Option + { + match self + { + Canvas::Software( _ ) => None, + Canvas::Gles( c ) => Some( c.borrowed_texture() ), + } + } + + /// Read a GLES canvas into tightly packed RGBA8, top-left row + /// first. Intentionally unavailable for software canvases because + /// the software backend's canonical export path is + /// [`Self::write_to_wayland_buf`]. + pub fn read_gles_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String> + { + match self + { + Canvas::Software( _ ) => Err( "read_gles_rgba_pixels requires Canvas::Gles".to_string() ), + Canvas::Gles( c ) => c.read_rgba_pixels( out ), + } + } + + /// Composite an externally-owned GL texture into `dest`. No-op on + /// the software backend (no GL state to sample from). Used by + /// widgets that host content rendered by an external producer — + /// the producer keeps ownership of the texture name; this call + /// only samples it through the standard texture program. + pub fn draw_external_texture( &mut self, texture: glow::Texture, dest: Rect, opacity: f32 ) + { + match self + { + Canvas::Software( _ ) => {} + Canvas::Gles( c ) => c.draw_external_texture( texture, dest, opacity ), + } + } + + pub fn dpi_scale( &self ) -> f32 + { + match self + { + Canvas::Software( c ) => c.dpi_scale, + Canvas::Gles( c ) => c.dpi_scale(), + } + } + + pub fn set_dpi_scale( &mut self, s: f32 ) + { + match self + { + Canvas::Software( c ) => c.dpi_scale = s, + Canvas::Gles( c ) => c.set_dpi_scale( s ), + } + } + + pub fn global_alpha( &self ) -> f32 + { + match self + { + Canvas::Software( c ) => c.global_alpha, + Canvas::Gles( c ) => c.global_alpha(), + } + } + + pub fn set_global_alpha( &mut self, a: f32 ) + { + match self + { + Canvas::Software( c ) => c.global_alpha = a, + Canvas::Gles( c ) => c.set_global_alpha( a ), + } + } + + /// Shared font handle. Exposed so widgets that need raw `fontdue` + /// access (e.g. `Text` for ascent/descent) do not have to go + /// through wrappers for every metric they read. + pub fn font( &self ) -> &Font + { + match self + { + Canvas::Software( c ) => &c.font, + Canvas::Gles( c ) => c.font(), + } + } + + /// Install a theme font registry on the active backend. + pub fn set_font_registry( &mut self, registry: Arc ) + { + match self + { + Canvas::Software( c ) => c.set_font_registry( registry ), + Canvas::Gles( c ) => c.set_font_registry( registry ), + } + } + + /// Resolve a specific font via the theme registry, falling back + /// to the system-default [`Self::font`] when no registry is + /// installed or the triple cannot be satisfied. + pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc + { + match self + { + Canvas::Software( c ) => c.font_for( family, weight, style ), + Canvas::Gles( c ) => c.font_for( family, weight, style ), + } + } + + /// Convenience wrapper around `font().metrics(...)` already + /// pre-scaled by `dpi_scale`. Most callers want this rather than + /// the raw font handle. + pub fn font_metrics( &self, ch: char, size: f32 ) -> Metrics + { + self.font().metrics( ch, size * self.dpi_scale() ) + } + + /// Convenience wrapper around `font().horizontal_line_metrics(...)`. + pub fn font_line_metrics( &self, size: f32 ) -> Option + { + self.font().horizontal_line_metrics( size ) + } + + pub fn resize( &mut self, width: u32, height: u32 ) + { + match self + { + Canvas::Software( c ) => c.resize( width, height ), + Canvas::Gles( c ) => c.resize( width, height ), + } + } + + pub fn sub_canvas( &self, width: u32, height: u32 ) -> Canvas + { + match self + { + Canvas::Software( c ) => Canvas::Software( c.sub_canvas( width, height ) ), + Canvas::Gles( c ) => Canvas::Gles( c.sub_canvas( width, height ) ), + } + } + + pub fn blit( &mut self, src: &Canvas, dest_x: i32, dest_y: i32 ) + { + self.blit_fade_bottom( src, dest_x, dest_y, 0.0 ) + } + + /// Like [`Self::blit`] but feathers the last `fade_bottom_px` source + /// rows so the bottom edge fades to transparent. The software backend + /// currently ignores `fade_bottom_px`, so the dissolve is GLES-only. + pub fn blit_fade_bottom( &mut self, src: &Canvas, dest_x: i32, dest_y: i32, fade_bottom_px: f32 ) + { + match ( self, src ) + { + ( Canvas::Software( dst ), Canvas::Software( s ) ) => + { + let _ = fade_bottom_px; + dst.blit( s, dest_x, dest_y ); + } + ( Canvas::Gles( dst ), Canvas::Gles( s ) ) => + { + dst.blit_fade_bottom( s, dest_x, dest_y, fade_bottom_px ); + } + // Cross-backend blits would need an SHM↔texture upload. + // The toolkit only ever creates sub-canvases of the same + // kind as their parent, so this is unreachable in practice. + _ => unimplemented!( "cross-backend blit not supported" ), + } + } + + pub fn set_clip_rects( &mut self, rects: &[Rect] ) + { + match self + { + Canvas::Software( c ) => c.set_clip_rects( rects ), + Canvas::Gles( c ) => c.set_clip_rects( rects ), + } + } + + /// Snapshot the currently installed clip bounds (empty when no clip + /// is active). Used by widgets that need to install a tighter clip + /// for a single primitive and then restore whatever the outer + /// partial-redraw or sub-canvas clip was — there is no stack + /// internally, so round-tripping through + /// [`Self::set_clip_rects`] with the snapshot is how to compose. + pub fn clip_bounds( &self ) -> Vec + { + match self + { + Canvas::Software( c ) => c.clip_bounds_snapshot(), + Canvas::Gles( c ) => c.clip_bounds_snapshot(), + } + } + + pub fn clear_clip( &mut self ) + { + match self + { + Canvas::Software( c ) => c.clear_clip(), + Canvas::Gles( c ) => c.clear_clip(), + } + } + + pub fn clear( &mut self ) + { + match self + { + Canvas::Software( c ) => c.clear(), + Canvas::Gles( c ) => c.clear(), + } + } + + pub fn fill( &mut self, color: Color ) + { + match self + { + Canvas::Software( c ) => c.fill( color ), + Canvas::Gles( c ) => c.fill( color ), + } + } + + pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: impl Into ) + { + let corners = corners.into(); + match self + { + Canvas::Software( c ) => c.fill_rect( rect, color, corners ), + Canvas::Gles( c ) => c.fill_rect( rect, color, corners ), + } + } + + /// Paint-driven rectangle fill. + /// + /// Dispatches on the [`crate::theme::Paint`] variant. Solid + /// fills go straight through [`Self::fill_rect`]. Gradients + /// (linear and radial) are routed to dedicated shaders on the + /// GPU backend; on the Software backend they still collapse to a + /// flat fill from the first stop — tiny-skia can render + /// gradients natively, but wiring that up is left for a + /// follow-up. + pub fn fill_paint_rect( &mut self, rect: Rect, paint: &ThemePaint, corners: impl Into ) + { + let corners = corners.into(); + match paint + { + ThemePaint::Solid( c ) => self.fill_rect( rect, *c, corners ), + ThemePaint::Linear( g ) => + { + match self + { + Canvas::Software( sc ) => + { + let c = g.stops.first().map( |s| s.color ).unwrap_or( Color::TRANSPARENT ); + sc.fill_rect( rect, c, corners ); + } + Canvas::Gles( gc ) => gc.fill_linear_gradient_rect( rect, g, corners ), + } + } + ThemePaint::Radial( g ) => + { + match self + { + Canvas::Software( sc ) => + { + let c = g.stops.first().map( |s| s.color ).unwrap_or( Color::TRANSPARENT ); + sc.fill_rect( rect, c, corners ); + } + Canvas::Gles( gc ) => gc.fill_radial_gradient_rect( rect, g, corners ), + } + } + } + } + + pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: impl Into ) + { + let corners = corners.into(); + match self + { + Canvas::Software( c ) => c.stroke_rect( rect, color, width, corners ), + Canvas::Gles( c ) => c.stroke_rect( rect, color, width, corners ), + } + } + + /// Paint an outer drop shadow behind the rounded rect `target`. + /// + /// On the GPU backend this runs an analytic soft-shadow shader + /// in one draw call — no FBO, no cache, no readback. On the + /// Software backend it is a no-op today. + pub fn fill_shadow_outer( &mut self, target: Rect, shadow: &Shadow, corners: impl Into ) + { + let corners = corners.into(); + match self + { + Canvas::Software( _ ) => { /* TODO: tiny-skia BlurDropShadow */ } + Canvas::Gles( c ) => c.fill_shadow_outer( target, shadow, corners ), + } + } + + /// Paint an inner (inset) shadow inside the rounded rect + /// `target`. + /// + /// On the GPU backend, uses a dedicated shader whose inner SDF + /// encodes `shadow.offset` and `shadow.spread`. The blend state + /// is switched per-call to honour `shadow.blend`: `Normal`, + /// `PlusLighter`, `Multiply` and `Screen` map to fixed-function + /// blend modes; `Overlay` routes through a dedicated shader that + /// snapshots the FBO and computes the CSS Overlay formula + /// in-shader. + /// + /// On the Software backend this is a no-op today. + pub fn fill_shadow_inset( &mut self, target: Rect, shadow: &InsetShadow, corners: impl Into ) + { + let corners = corners.into(); + match self + { + Canvas::Software( _ ) => { /* TODO: tiny-skia inner shadow */ } + Canvas::Gles( c ) => c.fill_shadow_inset( target, shadow, corners ), + } + } + + /// Unified surface painter. Composes a themed surface in the canonical + /// paint order: outer shadows → fill → insets. + pub fn fill_surface + ( + &mut self, + rect: Rect, + fill: &ThemePaint, + outer_shadows: &[Shadow], + inset_shadows: &[InsetShadow], + corners: impl Into, + ) + { + let corners = corners.into(); + + for shadow in outer_shadows + { + self.fill_shadow_outer( rect, shadow, corners ); + } + + self.fill_paint_rect( rect, fill, corners ); + + for inset in inset_shadows + { + self.fill_shadow_inset( rect, inset, corners ); + } + } + + pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 ) + { + match self + { + Canvas::Software( c ) => c.draw_line( x0, y0, x1, y1, color, width ), + Canvas::Gles( c ) => c.draw_line( x0, y0, x1, y1, color, width ), + } + } + + pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color ) + { + match self + { + Canvas::Software( c ) => c.draw_text( text, x, y, size, color ), + Canvas::Gles( c ) => c.draw_text( text, x, y, size, color ), + } + } + + /// Draw `text` with an explicitly supplied font instead of the + /// canvas default. Use [`Self::font_for`] to resolve a `(family, + /// weight, style)` triple from the active theme registry first. + pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc ) + { + match self + { + Canvas::Software( c ) => c.draw_text_with_font( text, x, y, size, color, font ), + Canvas::Gles( c ) => c.draw_text_with_font( text, x, y, size, color, font ), + } + } + + pub fn measure_text( &self, text: &str, size: f32 ) -> f32 + { + match self + { + Canvas::Software( c ) => c.measure_text( text, size ), + Canvas::Gles( c ) => c.measure_text( text, size ), + } + } + + /// Width of `text` rendered with `font`. Mirrors + /// [`Self::measure_text`] but bypasses the canvas default font so + /// text laid out at one weight and drawn at another stays aligned. + pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc ) -> f32 + { + match self + { + Canvas::Software( c ) => c.measure_text_with_font( text, size, font ), + Canvas::Gles( c ) => c.measure_text_with_font( text, size, font ), + } + } + + pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 ) + { + match self + { + Canvas::Software( c ) => c.draw_image_data( rgba_data, img_w, img_h, dest, opacity ), + Canvas::Gles( c ) => c.draw_image_data( rgba_data, img_w, img_h, dest, opacity ), + } + } + + /// Zero pixels inside each rect — used by the partial-redraw + /// path when the surface background is fully transparent. + pub fn clear_rects_transparent( &mut self, rects: &[Rect] ) + { + match self + { + Canvas::Software( c ) => c.clear_rects_transparent( rects ), + Canvas::Gles( c ) => c.clear_rects_transparent( rects ), + } + } + + /// Copy / present the rendered frame. For software this fills a + /// `wl_shm` buffer (with optional R/B swap for Argb8888). For + /// GPU the commit happens via `eglSwapBuffers` elsewhere — this + /// call is a no-op. + pub fn write_to_wayland_buf( &self, buf: &mut [u8], swap_rb: bool ) + { + match self + { + Canvas::Software( c ) => c.write_to_wayland_buf( buf, swap_rb ), + Canvas::Gles( _ ) => {} + } + } + + /// Publish the in-progress GPU frame: blit the FBO onto the EGL + /// window's default framebuffer. The follow-up `eglSwapBuffers` + /// (done outside the canvas) is what actually commits to the + /// compositor. No-op on software, where presentation is the SHM + /// `attach_to`/`commit` pair. + pub fn present( &mut self ) + { + match self + { + Canvas::Software( _ ) => {} + Canvas::Gles( c ) => c.present(), + } + } +} diff --git a/src/render/primitives.rs b/src/render/primitives.rs new file mode 100644 index 0000000..344b3f6 --- /dev/null +++ b/src/render/primitives.rs @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Primitive draw ops for [`SoftwareCanvas`]: clear, solid fill, +//! rounded-rect fill, stroke, line. tiny-skia does the heavy +//! lifting; this file just converts from ltk's `Rect` / `Color` to +//! tiny-skia's and threads `global_alpha` + `clip_mask` through +//! every call. + +use tiny_skia::{ Paint, PathBuilder, Stroke, Transform }; + +use crate::types::{ Color, Corners, Rect }; + +use super::helpers::build_rounded_rect; +use super::SoftwareCanvas; + +impl SoftwareCanvas +{ + pub fn clear( &mut self ) + { + self.pixmap.fill( tiny_skia::Color::TRANSPARENT ); + } + + pub fn fill( &mut self, color: Color ) + { + if self.clip_mask.is_none() + { + self.pixmap.fill( color.to_tiny_skia() ); + return; + } + let w = self.pixmap.width() as f32; + let h = self.pixmap.height() as f32; + let Some( ts_rect ) = tiny_skia::Rect::from_ltrb( 0.0, 0.0, w, h ) else { return }; + let mut paint = Paint::default(); + paint.set_color( color.to_tiny_skia() ); + self.pixmap.fill_rect( ts_rect, &paint, Transform::identity(), self.clip_mask.as_ref() ); + } + + pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners ) + { + let pw = self.pixmap.width() as f32; + let ph = self.pixmap.height() as f32; + if rect.x + rect.width < 0.0 || rect.x > pw + || rect.y + rect.height < 0.0 || rect.y > ph { return; } + + let Some( ts_rect ) = rect.to_tiny_skia() else { return }; + let mut paint = Paint::default(); + let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ); + paint.set_color( adjusted_color.to_tiny_skia() ); + paint.anti_alias = true; + if !corners.is_zero() + { + if let Some( path ) = build_rounded_rect( ts_rect, corners ) + { + self.pixmap.fill_path( + &path, + &paint, + tiny_skia::FillRule::Winding, + Transform::identity(), + self.clip_mask.as_ref(), + ); + } + } else { + self.pixmap.fill_rect( ts_rect, &paint, Transform::identity(), self.clip_mask.as_ref() ); + } + } + + pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: Corners ) + { + let Some( ts_rect ) = rect.to_tiny_skia() else { return }; + let mut paint = Paint::default(); + let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ); + paint.set_color( adjusted_color.to_tiny_skia() ); + paint.anti_alias = true; + let mut stroke = Stroke::default(); + stroke.width = width; + if !corners.is_zero() + { + if let Some( path ) = build_rounded_rect( ts_rect, corners ) + { + self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() ); + } + } else { + let path = PathBuilder::from_rect( ts_rect ); + self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() ); + } + } + + pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 ) + { + let mut pb = PathBuilder::new(); + pb.move_to( x0, y0 ); + pb.line_to( x1, y1 ); + let Some( path ) = pb.finish() else { return }; + let mut paint = Paint::default(); + let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ); + paint.set_color( adjusted_color.to_tiny_skia() ); + paint.anti_alias = true; + let mut stroke = Stroke::default(); + stroke.width = width; + self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() ); + } +} diff --git a/src/render/setup.rs b/src/render/setup.rs new file mode 100644 index 0000000..1cbb1a7 --- /dev/null +++ b/src/render/setup.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Construction + accessors for [`SoftwareCanvas`]: new / sub_canvas +//! / resize plus the font-registry installer and `blit`. + +use std::collections::HashMap; +use std::sync::{ Arc, OnceLock }; + +use fontdue::{ Font, FontSettings }; +use tiny_skia::{ Pixmap, PixmapPaint, Transform }; + +use crate::theme::{ FontRegistry, FontStyle }; + +use super::helpers::load_default_font_bytes; +use super::SoftwareCanvas; + +/// Process-wide cache of the default font face. Avoids re-reading + +/// re-parsing the file on every surface bring-up. Sora is small +/// (~50 KB) so the cost was minor in absolute terms — but a layer +/// shell that brings up a launcher overlay, a QS panel, a calendar +/// popup and a handful of toast surfaces would still pay the parse +/// cost a dozen times in a single session, all of which is wasted +/// work. +static DEFAULT_FONT: OnceLock> = OnceLock::new(); + +fn default_font() -> Arc +{ + Arc::clone( DEFAULT_FONT.get_or_init( || + { + let bytes = load_default_font_bytes(); + let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ) + .expect( "bad font" ); + Arc::new( font ) + } ) ) +} + +impl SoftwareCanvas +{ + /// Create a canvas of the given pixel dimensions, loading a system font. + /// + /// Fallback fonts (Noto Sans / CJK / Devanagari / …) are NOT loaded + /// here — they are owned by the crate-private system-fonts chain + /// and loaded lazily per codepoint. A canvas that only ever paints + /// Latin text will never touch those files; the first non-Latin + /// glyph triggers a single targeted load and the rest of the + /// process reuses the cached `Arc`. + pub fn new( width: u32, height: u32 ) -> Self + { + Self + { + pixmap: Pixmap::new( width, height ).expect( "pixmap" ), + font: default_font(), + font_registry: None, + dpi_scale: 1.0, + global_alpha: 1.0, + glyph_cache: HashMap::new(), + clip_mask: None, + clip_bounds: Vec::new(), + } + } + + /// Create a blank sub-canvas sharing the same font and DPI scale. + pub fn sub_canvas( &self, width: u32, height: u32 ) -> SoftwareCanvas + { + SoftwareCanvas + { + pixmap: Pixmap::new( width, height ).expect( "pixmap" ), + font: Arc::clone( &self.font ), + font_registry: self.font_registry.as_ref().map( Arc::clone ), + dpi_scale: self.dpi_scale, + global_alpha: self.global_alpha, + glyph_cache: HashMap::new(), + clip_mask: None, + clip_bounds: Vec::new(), + } + } + + /// Install a theme font registry so [`Self::font_for`] can + /// resolve family+weight+style triples declared by the theme's + /// `fonts` block. The default [`Self::font`] stays in place as a + /// fallback. + pub fn set_font_registry( &mut self, registry: Arc ) + { + self.font_registry = Some( registry ); + } + + /// Resolve a specific font from the theme registry, falling back + /// to the canvas' default [`Self::font`] when no registry is + /// installed or the triple cannot be satisfied. + pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc + { + self.font_registry + .as_ref() + .and_then( |r| r.resolve( family, weight, style ) ) + .unwrap_or_else( || Arc::clone( &self.font ) ) + } + + /// Pick the right font for `ch`. Tries the primary [`Self::font`] + /// first; on a miss, delegates to the crate-private system-fonts + /// fallback chain (lazy load of the relevant Noto pack). Falls + /// back to the primary (which paints a `.notdef` box) when no + /// installed fallback covers the codepoint. + pub fn font_for_char( &self, ch: char ) -> Arc + { + if self.font.lookup_glyph_index( ch ) != 0 + { + return Arc::clone( &self.font ); + } + crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) ) + } + + pub fn blit( &mut self, src: &SoftwareCanvas, dest_x: i32, dest_y: i32 ) + { + let paint = PixmapPaint::default(); + let t = Transform::from_translate( dest_x as f32, dest_y as f32 ); + self.pixmap.draw_pixmap( 0, 0, src.pixmap.as_ref(), &paint, t, self.clip_mask.as_ref() ); + } + + pub fn resize( &mut self, width: u32, height: u32 ) + { + self.pixmap = Pixmap::new( width, height ).expect( "pixmap" ); + } +} diff --git a/src/render/text.rs b/src/render/text.rs new file mode 100644 index 0000000..e519806 --- /dev/null +++ b/src/render/text.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Text rendering for [`SoftwareCanvas`]. fontdue rasterises each +//! glyph once into the persistent [`super::GlyphEntry`] cache; the +//! per-frame hot path just lays out positions and blends cached +//! bitmaps into the pixmap with the active clip mask + global alpha. + +use std::sync::Arc; + +use fontdue::Font; + +use crate::types::Color; + +use super::{ GlyphEntry, GlyphKey, SoftwareCanvas }; + +const GLYPH_CACHE_SOFT_CAP: usize = 8192; + +/// Stable identifier for an `Arc`: the address of the font's +/// allocation. Different reweights / families always live in +/// distinct allocations, so the address is enough to disambiguate +/// glyph cache entries. +fn font_id( font: &Arc ) -> usize +{ + Arc::as_ptr( font ) as usize +} + +impl SoftwareCanvas +{ + /// Per-glyph default font lookup. Routes through + /// [`Self::font_for_char`], which already consults the lazy + /// system-font fallback chain. Returns an owned [`Arc`] so + /// callers can hold the handle across `&mut self` borrows of the + /// glyph cache. + fn default_font_for_char( &self, ch: char ) -> ( usize, Arc ) + { + let font = self.font_for_char( ch ); + let id = Arc::as_ptr( &font ) as usize; + ( id, font ) + } + + pub ( super ) fn rasterize_cached( &mut self, ch: char, scaled: f32 ) -> &GlyphEntry + { + let ( id, font ) = self.default_font_for_char( ch ); + let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id }; + self.evict_if_full( &key ); + if !self.glyph_cache.contains_key( &key ) + { + let ( metrics, bitmap ) = font.rasterize( ch, scaled ); + self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } ); + } + self.glyph_cache.get( &key ).expect( "inserted above on miss" ) + } + + /// Pick the font to use for `ch` given a "preferred" override. + /// Falls through to the canvas default + Noto chain when the + /// preferred font does not own the glyph — so Sora Bold rendering + /// of CJK / Devanagari / etc. still works. + fn font_for_char_with_pref( &self, ch: char, pref: &Arc ) -> ( usize, Arc ) + { + if pref.lookup_glyph_index( ch ) != 0 + { + return ( font_id( pref ), Arc::clone( pref ) ); + } + self.default_font_for_char( ch ) + } + + fn rasterize_cached_with( &mut self, ch: char, scaled: f32, pref: &Arc ) -> &GlyphEntry + { + let ( id, font ) = self.font_for_char_with_pref( ch, pref ); + let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id }; + self.evict_if_full( &key ); + if !self.glyph_cache.contains_key( &key ) + { + let ( metrics, bitmap ) = font.rasterize( ch, scaled ); + self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } ); + } + self.glyph_cache.get( &key ).expect( "inserted above on miss" ) + } + + fn evict_if_full( &mut self, key: &GlyphKey ) + { + if !self.glyph_cache.contains_key( key ) + && self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP + { + let drop_n = self.glyph_cache.len() / 2; + let victims: Vec<_> = self.glyph_cache.keys().copied().take( drop_n ).collect(); + for k in victims + { + self.glyph_cache.remove( &k ); + } + } + } + + pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color ) + { + self.draw_text_inner( text, x, y, size, color, None ); + } + + /// Draw `text` using the explicitly supplied font instead of the + /// canvas default + fallback chain. + pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc ) + { + self.draw_text_inner( text, x, y, size, color, Some( font ) ); + } + + fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc> ) + { + let scaled = size * self.dpi_scale; + let line_h = scaled * 1.5; + let ph = self.pixmap.height() as f32; + let pw = self.pixmap.width() as f32; + if y + line_h < 0.0 || y - line_h > ph { return; } + if x > pw { return; } + if self.has_clip() && !self.strip_intersects_clip( y - line_h, y + 0.5 * line_h ) + { + return; + } + + let mut layout: Vec<( GlyphKey, f32 )> = Vec::with_capacity( text.chars().count() ); + { + let mut cursor_x = x; + for ch in text.chars() + { + let ( id, advance ) = match font + { + Some( f ) => + { + let id = self.font_for_char_with_pref( ch, f ).0; + let advance = self.rasterize_cached_with( ch, scaled, f ).metrics.advance_width; + ( id, advance ) + } + None => + { + let id = self.default_font_for_char( ch ).0; + let advance = self.rasterize_cached( ch, scaled ).metrics.advance_width; + ( id, advance ) + } + }; + layout.push( ( GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id }, cursor_x ) ); + cursor_x += advance; + } + } + + let w = self.pixmap.width() as i32; + let h = self.pixmap.height() as i32; + let cr = (color.r * 255.0) as u8; + let cg = (color.g * 255.0) as u8; + let cb = (color.b * 255.0) as u8; + let color_a = color.a * self.global_alpha; + + let pixels = self.pixmap.data_mut(); + let cache = &self.glyph_cache; + let mask_data = self.clip_mask.as_ref().map( |m| ( m.data(), m.width() as i32 ) ); + + for ( key, cursor_x ) in layout + { + let entry = cache.get( &key ).expect( "warmed above" ); + let metrics = &entry.metrics; + let bitmap = &entry.bitmap; + for ( i, &alpha ) in bitmap.iter().enumerate() + { + if alpha == 0 { continue; } + let px = cursor_x as i32 + metrics.xmin + (i % metrics.width) as i32; + let py = y as i32 + - metrics.ymin as i32 + - metrics.height as i32 + + 1 + + (i / metrics.width) as i32; + if px < 0 || py < 0 || px >= w || py >= h { continue; } + if let Some( ( md, mw ) ) = mask_data + { + if md[ ( py * mw + px ) as usize ] == 0 { continue; } + } + let idx = (py as usize * w as usize + px as usize) * 4; + let a = (alpha as f32 / 255.0) * color_a; + let inv = 1.0 - a; + pixels[idx] = (cr as f32 * a + pixels[idx] as f32 * inv) as u8; + pixels[idx + 1] = (cg as f32 * a + pixels[idx + 1] as f32 * inv) as u8; + pixels[idx + 2] = (cb as f32 * a + pixels[idx + 2] as f32 * inv) as u8; + let a_dst = pixels[idx + 3] as f32 / 255.0; + pixels[idx + 3] = ( ( a + a_dst * inv ) * 255.0 ) as u8; + } + } + } + + pub fn measure_text( &self, text: &str, size: f32 ) -> f32 + { + text.chars().map( |ch| + { + self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width + } ).sum() + } + + pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc ) -> f32 + { + text.chars().map( |ch| + { + let ( _, picked ) = self.font_for_char_with_pref( ch, font ); + picked.metrics( ch, size * self.dpi_scale ).advance_width + } ).sum() + } +} diff --git a/src/secure_mem.rs b/src/secure_mem.rs new file mode 100644 index 0000000..af51942 --- /dev/null +++ b/src/secure_mem.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Defensive primitives for credential handling. +//! +//! [`secure_zero`] overwrites a byte slice with zeros using volatile stores +//! so the optimiser cannot elide the wipe even when it can prove the buffer +//! is no longer read. It is the building block the `Drop` impls of secure +//! widgets (currently [`crate::widget::text_edit::TextEdit`] when +//! `secure( true )` is set) use to scrub credential text before the heap is +//! returned to the allocator. +//! +//! This is the minimal stand-in for the well-known `zeroize` crate: ltk is +//! a UI toolkit, the only call site is text-input wiping, and the cost of +//! pulling another dependency is not justified. + +use core::ptr; +use core::sync::atomic::{ compiler_fence, Ordering }; + +/// Overwrite `buf` with zeros. The writes go through `write_volatile` so +/// the compiler treats them as observable side effects — the elision pass +/// cannot drop them even when the buffer is about to be freed. +/// +/// A `compiler_fence(SeqCst)` after the loop pins the wipe to "before any +/// later memory operation", so a subsequent `Drop` that hands the +/// underlying allocation back to the allocator cannot be reordered above +/// the zero stores. +pub( crate ) fn secure_zero( buf: &mut [u8] ) +{ + for b in buf.iter_mut() + { + // SAFETY: writing a primitive byte through a unique mutable + // reference; volatile reflects the intent that the store has an + // observer beyond ordinary Rust semantics (the ex-credential). + unsafe { ptr::write_volatile( b, 0u8 ); } + } + compiler_fence( Ordering::SeqCst ); +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn empty_slice_is_a_noop() + { + let mut buf: [u8; 0] = []; + secure_zero( &mut buf ); + // Nothing to assert beyond "did not panic" — exercised so the + // fence + zero-iter loop compiles for the empty case. + } + + #[ test ] + fn fills_every_byte_with_zero() + { + let mut buf = [ 0xAAu8; 64 ]; + secure_zero( &mut buf ); + assert!( buf.iter().all( |&b| b == 0 ) ); + } + + #[ test ] + fn wipes_a_credential_string_in_place() + { + let mut password = String::from( "hunter2" ); + // SAFETY: as_mut_vec lets us reach the underlying byte buffer. + // We only write zeros, leaving an empty / NUL-filled UTF-8 byte + // sequence which is still valid UTF-8 (NUL is U+0000). + let bytes = unsafe { password.as_mut_vec() }; + secure_zero( bytes ); + assert!( bytes.iter().all( |&b| b == 0 ) ); + // After the wipe the String is technically all NULs, not empty; + // the consumer drops it immediately so the heap allocation is + // returned to the allocator already overwritten. + assert_eq!( password.len(), 7 ); + assert!( password.bytes().all( |b| b == 0 ) ); + } +} diff --git a/src/system_fonts.rs b/src/system_fonts.rs new file mode 100644 index 0000000..89f20c4 --- /dev/null +++ b/src/system_fonts.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Lazy per-glyph fallback font resolution. +//! +//! The default font ([`crate::theme::fallback::FALLBACK_FONT`], Sora) +//! covers Latin and a portion of extended Latin; everything outside +//! that band — Cyrillic, Devanagari, Arabic, Hebrew, Thai, the CJK +//! ideographic block — relies on a chain of system Noto fonts loaded +//! on demand: a fallback file is read off disk the first time some +//! glyph asks for it and the resulting `Arc` is cached +//! process-wide. +//! +//! Each `(path, face)` entry in [`FALLBACK_FONT_CANDIDATES`] gets its +//! own `OnceLock>>` slot. `Some(font)` means the +//! file was read and parsed successfully; `None` means it's missing +//! or unparseable, locked in for the rest of the process so we don't +//! re-`stat` the same path on every glyph miss. The `OnceLock` +//! handles concurrent first-loads correctly: at most one thread runs +//! the closure for a given slot, the rest wait for the result. +//! +//! Renderers (`SoftwareCanvas` and `GlesCanvas`) consult this module +//! through [`lookup`] in their `font_for_char` paths. + +use std::sync::{ Arc, OnceLock }; + +use fontdue::{ Font, FontSettings }; + +/// Per-script fallback font path with the TrueType-collection face +/// index fontdue should load (most files are single-face → 0; CJK +/// `.ttc` archives carry many faces, see the SC face on the canonical +/// Adobe-built `NotoSansCJK-Regular.ttc`). +struct FallbackFontSpec +{ + path: &'static str, + face: u32, +} + +/// Ordered fallback chain consulted on a glyph miss. Order matters: +/// the first slot whose font owns a non-zero glyph index for the +/// codepoint wins, so the broadest families come first (Noto Sans +/// covers Cyrillic, Greek, extended Latin) and the script-specific +/// + CJK packs trail. DejaVu is the last resort — most distros carry +/// it under one path or another. +const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] = +&[ + // Noto Sans — Cyrillic, Greek, extended Latin. + FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", face: 0 }, + FallbackFontSpec { path: "/usr/share/fonts/noto/NotoSans-Regular.ttf", face: 0 }, + + // Devanagari (Hindi). + FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf", face: 0 }, + FallbackFontSpec { path: "/usr/share/fonts/noto/NotoSansDevanagari-Regular.ttf", face: 0 }, + + // Arabic, Hebrew, Thai — common scripts cheap to keep. + FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", face: 0 }, + FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", face: 0 }, + FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", face: 0 }, + + // CJK packs ship as `.ttc`. The collection layout on the canonical + // Adobe-built `NotoSansCJK-Regular.ttc` is 0=JP, 1=KR, 2=SC, 3=TC, + // 4=HK; we want CJK shared ideographs available in any locale, so + // any of the faces is fine — pick SC (face 2) as the broadest + // baseline. ~30 MB on disk; under the lazy loader this only fires + // when a user-visible string actually contains a CJK codepoint. + FallbackFontSpec { path: "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", face: 2 }, + FallbackFontSpec { path: "/usr/share/fonts/opentype/noto-cjk/NotoSansCJK-Regular.ttc", face: 2 }, + FallbackFontSpec { path: "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", face: 2 }, + + // Last resort — DejaVu has broad-but-shallow coverage of most + // scripts and ships almost everywhere. + FallbackFontSpec { path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", face: 0 }, +]; + +/// Per-slot lazy state. `Vec` length matches +/// [`FALLBACK_FONT_CANDIDATES`]; each `OnceLock` resolves +/// independently so the act of looking up a Devanagari codepoint +/// doesn't drag the CJK pack into memory. `None` inside a resolved +/// slot means the file was missing or fontdue rejected it — a sticky +/// negative result so subsequent misses skip the slot in O(1). +fn slots() -> &'static [ OnceLock>> ] +{ + static SLOTS: OnceLock>>>> = OnceLock::new(); + SLOTS.get_or_init( || + { + ( 0..FALLBACK_FONT_CANDIDATES.len() ) + .map( |_| OnceLock::new() ) + .collect() + } ) +} + +/// Try to load and parse the fallback font at slot `idx`. Each +/// `OnceLock` wraps `Option>` so a missing or malformed +/// file is recorded as `None` and never re-attempted. +fn slot_font( idx: usize ) -> Option> +{ + let slot = &slots()[ idx ]; + slot.get_or_init( || + { + let spec = &FALLBACK_FONT_CANDIDATES[ idx ]; + let bytes = std::fs::read( spec.path ).ok()?; + let opts = FontSettings { collection_index: spec.face, ..FontSettings::default() }; + Font::from_bytes( bytes.as_slice(), opts ).ok().map( Arc::new ) + } ) + .as_ref() + .map( Arc::clone ) +} + +/// Find the first fallback font that has a non-zero glyph index for +/// `ch`, loading it from disk on the first hit and caching the +/// `Arc` for the rest of the process. Returns `None` if no +/// installed fallback covers the codepoint — the caller then paints +/// the primary font's `.notdef` rather than dropping the glyph. +/// +/// Side effect: walking the chain may load and cache a slot even if +/// it doesn't end up covering `ch` (`lookup_glyph_index` reads the +/// `cmap` table, which requires the font to be parsed). That's +/// acceptable — the slot is cached on the first encounter regardless, +/// and most coverage gaps in early slots are the small Noto Sans +/// scripts (Devanagari, Arabic, …) whose total weight is a fraction +/// of the CJK pack everyone was paying for unconditionally. +pub fn lookup( ch: char ) -> Option> +{ + for idx in 0..FALLBACK_FONT_CANDIDATES.len() + { + let Some( font ) = slot_font( idx ) else { continue }; + if font.lookup_glyph_index( ch ) != 0 + { + return Some( font ); + } + } + None +} diff --git a/src/theme/document.rs b/src/theme/document.rs new file mode 100644 index 0000000..60ce249 --- /dev/null +++ b/src/theme/document.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The top-level theme document: metadata, fonts block and per-mode slots. +//! +//! This is the runtime representation of a fully parsed `theme.json`, +//! and the single source of truth for the active theme — installed via +//! [`super::set_active_document`] and read back via +//! [`super::active_document`]. +//! +//! # Modes +//! +//! `fonts` is shared between light and dark and only what actually differs +//! — slot tables, wallpaper paths, optional window-controls overrides — is +//! nested under `modes.light` / `modes.dark`. + +use std::collections::HashMap; +use std::path::{ Path, PathBuf }; + +use super::fonts::FontFamilyDef; +use super::slots::SlotStore; +use super::{ search_paths, LauncherSpec, ThemeError, WallpaperSpec, WindowControlsSpec }; + +// ─── Mode ──────────────────────────────────────────────────────────────────── + +/// Per-variant content: the slot table plus the surfaces that don't fit +/// cleanly in slots (wallpaper image, window-controls payload). +#[ derive( Debug, Clone ) ] +pub struct Mode +{ + /// Homescreen / shell wallpaper. Absent means "solid background, no + /// image" (the renderer falls back to whatever the theme's surface slot + /// resolves to for the viewport). + pub wallpaper: Option, + /// Lockscreen / greeter wallpaper. Distinct from `wallpaper` because the + /// lockscreen is typically quieter (often a darker crop). + pub lockscreen: Option, + /// Launcher surface styling. Aggregate (background + border_radius) + /// rather than a slot because the radius is a scalar that widgets treat + /// as theme-imposed geometry, not a design-system colour token. + pub launcher: Option, + /// Window-decoration controls payload. Kept out of the slot table + /// because it is a contract with an external app, not a + /// design-system token. + pub window_controls: Option, + /// The typed slot table for this mode. + pub slots: SlotStore, +} + +// ─── ThemeDocument ─────────────────────────────────────────────────────────── + +/// A fully parsed theme, as loaded from a `theme.json` on disk. +#[ derive( Debug, Clone ) ] +pub struct ThemeDocument +{ + /// Stable identifier used to look up the theme across search paths. + pub id: String, + /// Human-readable display name. + pub name: String, + /// Directory the document was loaded from. `None` for documents built + /// in-memory (e.g. test fixtures). + pub root: Option, + /// Font families declared in the document. Indexed by the id used by + /// [`super::FontRef::Named`]. The same registry is shared between modes. + pub fonts: HashMap, + /// Light-mode content. + pub light: Mode, + /// Dark-mode content. + pub dark: Mode, +} + +impl ThemeDocument +{ + /// Return the mode matching `mode`. + pub fn mode( &self, mode: super::ThemeMode ) -> &Mode + { + match mode + { + super::ThemeMode::Light => &self.light, + super::ThemeMode::Dark => &self.dark, + } + } + + /// Load a document from a directory containing a `theme.json`. + /// + /// Paths inside the document (wallpaper, lockscreen, font sources) are + /// resolved against `dir`. + pub fn load_from_dir( dir: &Path ) -> Result + { + super::schema::load_document_from_dir( dir ) + } + + /// Look up a document by id across the standard search paths. + /// + /// Order, highest priority first: + /// 1. `$LTK_THEMES_DIR//` (when the env var is set) + /// 2. `$XDG_DATA_HOME/ltk/themes//` (defaults to `~/.local/share/...`) + /// 3. `/usr/share/ltk/themes//` + pub fn find( id: &str ) -> Result + { + for base in search_paths() + { + let dir = base.join( id ); + if dir.join( "theme.json" ).is_file() + { + return Self::load_from_dir( &dir ); + } + } + Err( ThemeError::NotFound( id.to_string() ) ) + } +} diff --git a/src/theme/fallback/Sora-LICENSE.txt b/src/theme/fallback/Sora-LICENSE.txt new file mode 100644 index 0000000..b921683 --- /dev/null +++ b/src/theme/fallback/Sora-LICENSE.txt @@ -0,0 +1,134 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: sora +Upstream-Contact: The Sora Project Authors +Files-Excluded: docs fonts/* +Source: https://github.com/sora-xor/sora-font + +Files: * +Copyright: 2019-2020 The Sora Project Authors + 2020 Jonathan Barnbrook + 2020 Julián Moncada +License: OFL-1.1 + ----------------------------------------------------------- + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + ----------------------------------------------------------- + . + PREAMBLE + The goals of the Open Font License (OFL) are to stimulate worldwide + development of collaborative font projects, to support the font creation + efforts of academic and linguistic communities, and to provide a free and + open framework in which fonts may be shared and improved in partnership + with others. + . + The OFL allows the licensed fonts to be used, studied, modified and + redistributed freely as long as they are not sold by themselves. The + fonts, including any derivative works, can be bundled, embedded, + redistributed and/or sold with any software provided that any reserved + names are not used by derivative works. The fonts and derivatives, + however, cannot be released under any other type of license. The + requirement for fonts to remain under this license does not apply + to any document created using the fonts or their derivatives. + . + DEFINITIONS + "Font Software" refers to the set of files released by the Copyright + Holder(s) under this license and clearly marked as such. This may + include source files, build scripts and documentation. + . + "Reserved Font Name" refers to any names specified as such after the + copyright statement(s). + . + "Original Version" refers to the collection of Font Software components as + distributed by the Copyright Holder(s). + . + "Modified Version" refers to any derivative made by adding to, deleting, + or substituting -- in part or in whole -- any of the components of the + Original Version, by changing formats or by porting the Font Software to a + new environment. + . + "Author" refers to any designer, engineer, programmer, technical + writer or other person who contributed to the Font Software. + . + PERMISSION & CONDITIONS + Permission is hereby granted, free of charge, to any person obtaining + a copy of the Font Software, to use, study, copy, merge, embed, modify, + redistribute, and sell modified and unmodified copies of the Font + Software, subject to the following conditions: + . + 1) Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + . + 2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + . + 3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + . + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + . + 5) The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + . + TERMINATION + This license becomes null and void if any of the above conditions are + not met. + . + DISCLAIMER + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + OTHER DEALINGS IN THE FONT SOFTWARE. + +Files: sources/sora-stat-table.py +Copyright: 2020 Google Sans Authors +License: Apache-2.0 + Licensed under the Apache License, Version 2.0 (the "License"); you + may not use this file except in compliance with the License. You may + obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing + permissions and limitations under the License. + . + On Debian systems, the complete text of the Apache version 2.0 + license can be found in "/usr/share/common-licenses/Apache-2.0". + +Files: debian/* +Copyright: 2020-2024 Alex Myczko +License: GPL-2+ + This package is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + . + This package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + You should have received a copy of the GNU General Public License + along with this program. If not, see + . + On Debian systems, the complete text of the GNU General + Public License version 2 can be found in "/usr/share/common-licenses/GPL-2". diff --git a/src/theme/fallback/Sora-Regular.otf b/src/theme/fallback/Sora-Regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..fd7eeef6a2da59fabc0c34fc50d8d7088e76415b GIT binary patch literal 50796 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 literal 0 HcmV?d00001 diff --git a/src/theme/fallback/mod.rs b/src/theme/fallback/mod.rs new file mode 100644 index 0000000..e95e2cd --- /dev/null +++ b/src/theme/fallback/mod.rs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! "Last resort" assets used when the canonical `default` theme or the +//! system font search chain come up empty. Two pieces: +//! +//! * [`document`] builds a black/white [`ThemeDocument`] with the +//! eight canonical palette slots populated — no wallpaper, no +//! launcher spec, no decorated surface slots. Widgets that look up +//! surface or shadow slots fall through to their flat-colour branch; +//! the UI is usable but not decorated. +//! * [`FALLBACK_FONT`] bundles Sora Regular (~50 KB, SIL OFL 1.1) so +//! `Canvas::new` / `Canvas::new_gles` can always load a font even +//! on systems without `fonts-sora`, `fonts-liberation` or +//! `fonts-dejavu`. +//! +//! Both are activated by [`super::ensure_active`] when +//! `ThemeDocument::find("default")` fails. The crate stamps every +//! frame with a red banner so the user can't miss the +//! "install `ltk-theme-default`" signal. +//! +//! # Font licence +//! +//! `Sora-Regular.otf` is distributed under the SIL Open Font Licence +//! 1.1. The full licence text is in +//! [`Sora-LICENSE.txt`](./Sora-LICENSE.txt) next to this source +//! file. A binary-only redistribution must carry the OFL text +//! alongside — see the crate's `debian/copyright`. + +use std::collections::HashMap; + +use crate::types::Color; + +use super::slots::{ Metadata, Slot, SlotStore }; +use super::{ Mode, ThemeDocument }; + +/// Sora Regular, OFL 1.1. Loaded by `Canvas::new` / `Canvas::new_gles` +/// when no system font is found through the candidate chain. ~50 KB, +/// one weight, one style — enough to render the fallback banner and +/// any app that only asks for default typography. +pub( crate ) const FALLBACK_FONT: &[u8] = include_bytes!( "Sora-Regular.otf" ); + +/// Build the B/W fallback document. Eight canonical palette slots per +/// mode (`bg-page`, `surface`, `surface-alt`, `text-primary`, +/// `text-secondary`, `accent`, `divider`, `icon`), no wallpaper, no +/// launcher, no window_controls, no fonts registered (the default +/// canvas font is used for every widget). +/// +/// Light mode: pure white backgrounds, pure black text + accent. +/// Dark mode: pure black backgrounds, pure white text + accent. +/// `accent` doubles as brand colour and focus-ring colour; setting +/// it to black/white keeps the fallback visually consistent with the +/// rest of the palette and gives the focus ring maximum contrast. +pub( super ) fn document() -> ThemeDocument +{ + ThemeDocument + { + id: "fallback".to_string(), + name: "Fallback (B/W)".to_string(), + root: None, + fonts: HashMap::new(), + light: light_mode(), + dark: dark_mode(), + } +} + +fn mk_color_slot( c: Color ) -> Slot +{ + Slot::Color { value: c, meta: Metadata::default() } +} + +fn light_mode() -> Mode +{ + let mut slots = SlotStore::new(); + slots.insert( "bg-page", mk_color_slot( Color::WHITE ) ); + slots.insert( "surface", mk_color_slot( Color::WHITE ) ); + slots.insert( "surface-alt", mk_color_slot( Color::rgb( 0.95, 0.95, 0.95 ) ) ); + slots.insert( "text-primary", mk_color_slot( Color::BLACK ) ); + slots.insert( "text-secondary", mk_color_slot( Color::rgba( 0.0, 0.0, 0.0, 0.6 ) ) ); + slots.insert( "accent", mk_color_slot( Color::BLACK ) ); + slots.insert( "divider", mk_color_slot( Color::rgba( 0.0, 0.0, 0.0, 0.1 ) ) ); + slots.insert( "icon", mk_color_slot( Color::BLACK ) ); + Mode + { + wallpaper: None, + lockscreen: None, + launcher: None, + window_controls: None, + slots, + } +} + +fn dark_mode() -> Mode +{ + let mut slots = SlotStore::new(); + slots.insert( "bg-page", mk_color_slot( Color::BLACK ) ); + slots.insert( "surface", mk_color_slot( Color::BLACK ) ); + slots.insert( "surface-alt", mk_color_slot( Color::rgb( 0.1, 0.1, 0.1 ) ) ); + slots.insert( "text-primary", mk_color_slot( Color::WHITE ) ); + slots.insert( "text-secondary", mk_color_slot( Color::rgba( 1.0, 1.0, 1.0, 0.6 ) ) ); + slots.insert( "accent", mk_color_slot( Color::WHITE ) ); + slots.insert( "divider", mk_color_slot( Color::rgba( 1.0, 1.0, 1.0, 0.1 ) ) ); + slots.insert( "icon", mk_color_slot( Color::WHITE ) ); + Mode + { + wallpaper: None, + lockscreen: None, + launcher: None, + window_controls: None, + slots, + } +} diff --git a/src/theme/font_registry.rs b/src/theme/font_registry.rs new file mode 100644 index 0000000..ae78d70 --- /dev/null +++ b/src/theme/font_registry.rs @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Runtime font registry: families → (weight, style) → `fontdue::Font`. +//! +//! The registry is the live, loaded-in-memory counterpart of the theme's +//! `fonts` block ([`super::FontFamilyDef`]). It is built once at theme load +//! time by calling [`FontRegistry::from_families`], then handed to the +//! render backends as an `Arc` so they can resolve specific +//! sources on demand without re-reading TTFs from disk. +//! +//! # Resolution order +//! +//! [`FontRegistry::resolve`] looks up a family / weight / style triple with +//! the following precedence: +//! +//! 1. Exact `(family, weight, style)` match. +//! 2. Same family and style, weight closest to the request (absolute diff). +//! 3. Same family, any style, weight closest to the request. +//! 4. Walk the family's `fallbacks` chain, recursing into each fallback +//! family with the original weight/style. +//! +//! When nothing matches, [`FontRegistry::resolve`] returns `None`; callers +//! fall back to the canvas' system font. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use fontdue::{ Font, FontSettings }; + +use super::fonts::FontFamilyDef; +use super::text_style::FontStyle; + +// ─── Key ───────────────────────────────────────────────────────────────────── + +/// Composite key identifying a single font source within a family. +/// +/// `family` is the id under which the family was declared in the theme's +/// `fonts` block (e.g. `"sora"`), not its human-readable name. +#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ] +pub struct FontKey +{ + pub family: String, + pub weight: u16, + pub style: FontStyle, +} + +// ─── Errors ────────────────────────────────────────────────────────────────── + +/// Error raised when loading a font source fails. +#[ derive( Debug ) ] +pub enum FontLoadError +{ + /// The source file could not be read. + Io( PathBuf, std::io::Error ), + /// The source file was read but `fontdue` rejected its contents. + Parse( PathBuf, String ), +} + +impl std::fmt::Display for FontLoadError +{ + fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result + { + match self + { + FontLoadError::Io( p, e ) => write!( f, "reading font {}: {}", p.display(), e ), + FontLoadError::Parse( p, m ) => write!( f, "parsing font {}: {}", p.display(), m ), + } + } +} + +impl std::error::Error for FontLoadError {} + +// ─── Registry ──────────────────────────────────────────────────────────────── + +/// A loaded font registry: the theme's declared families materialised into +/// live `fontdue::Font` handles, indexed by family id / weight / style. +#[ derive( Debug, Default ) ] +pub struct FontRegistry +{ + by_key: HashMap>, + fallbacks: HashMap>, +} + +impl FontRegistry +{ + /// Create an empty registry. Use [`Self::insert`] / [`Self::set_fallbacks`] + /// to populate it, or [`Self::from_families`] to load a whole theme in + /// one go. + pub fn new() -> Self + { + Self { by_key: HashMap::new(), fallbacks: HashMap::new() } + } + + /// Register a single font source. + pub fn insert + ( + &mut self, + family: impl Into, + weight: u16, + style: FontStyle, + font: Arc, + ) + { + self.by_key.insert( FontKey { family: family.into(), weight, style }, font ); + } + + /// Set the fallback chain for a family. + pub fn set_fallbacks( &mut self, family: impl Into, chain: Vec ) + { + self.fallbacks.insert( family.into(), chain ); + } + + /// Number of loaded sources across all families. Useful in tests. + pub fn len( &self ) -> usize { self.by_key.len() } + + /// Whether the registry has no sources loaded. + pub fn is_empty( &self ) -> bool { self.by_key.is_empty() } + + /// Resolve a triple to a loaded [`Font`]. See the module docs for the + /// precedence order. + pub fn resolve( &self, family: &str, weight: u16, style: FontStyle ) -> Option> + { + // 1. Exact match. + let exact = FontKey { family: family.to_string(), weight, style }; + if let Some( f ) = self.by_key.get( &exact ) + { + return Some( Arc::clone( f ) ); + } + + // 2. Same family + style, closest weight. + let best_same_style = self.by_key.iter() + .filter( |( k, _ )| k.family == family && k.style == style ) + .min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() ); + if let Some( ( _, f ) ) = best_same_style + { + return Some( Arc::clone( f ) ); + } + + // 3. Same family, any style, closest weight. + let best_same_family = self.by_key.iter() + .filter( |( k, _ )| k.family == family ) + .min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() ); + if let Some( ( _, f ) ) = best_same_family + { + return Some( Arc::clone( f ) ); + } + + // 4. Walk fallback chain. + if let Some( chain ) = self.fallbacks.get( family ) + { + for fb in chain + { + if let Some( f ) = self.resolve( fb, weight, style ) + { + return Some( f ); + } + } + } + + None + } + + /// Load every source declared in `families` into the registry. + /// + /// `families` is keyed by family id (the string the theme JSON uses in + /// `fonts.` and that [`super::FontRef::Named`] references). The + /// family's `name` field is carried for human display only; lookups go + /// through the id. + pub fn from_families + ( + families: &HashMap, + ) -> Result + { + let mut reg = Self::new(); + for ( id, family ) in families + { + if !family.fallbacks.is_empty() + { + reg.set_fallbacks( id.clone(), family.fallbacks.clone() ); + } + for src in &family.sources + { + let bytes = std::fs::read( &src.path ) + .map_err( |e| FontLoadError::Io( src.path.clone(), e ) )?; + let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ) + .map_err( |e| FontLoadError::Parse( src.path.clone(), e.to_string() ) )?; + reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) ); + } + } + Ok( reg ) + } + + /// Like [`Self::from_families`] but tolerant: sources that fail to load + /// are logged via `eprintln!` and skipped instead of aborting the whole + /// registry build. Intended for the runtime path where a missing TTF + /// on a user's machine should degrade to "fall back to system font for + /// that weight" rather than crash the shell. + /// + /// Returns a registry that may be partial — callers cannot assume any + /// specific weight is loaded, only that what COULD be loaded is + /// loaded. Font resolution's fallback ladder (family → closest weight + /// → fallback chain → `Canvas::font`) handles the gaps gracefully. + pub fn from_families_lenient + ( + families: &HashMap, + ) -> Self + { + let mut reg = Self::new(); + for ( id, family ) in families + { + if !family.fallbacks.is_empty() + { + reg.set_fallbacks( id.clone(), family.fallbacks.clone() ); + } + for src in &family.sources + { + let bytes = match std::fs::read( &src.path ) + { + Ok( b ) => b, + Err( e ) => + { + eprintln! + ( + "[ltk] skipping font {} (weight {}, {:?}): {}", + src.path.display(), src.weight, src.style, e + ); + continue; + } + }; + let font = match Font::from_bytes( bytes.as_slice(), FontSettings::default() ) + { + Ok( f ) => f, + Err( e ) => + { + eprintln! + ( + "[ltk] skipping font {} (weight {}, {:?}): parse error: {}", + src.path.display(), src.weight, src.style, e + ); + continue; + } + }; + reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) ); + } + } + reg + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + /// Load a real TTF from the system via the same search chain the + /// software canvas uses. Only used by tests that need an actual + /// `Font` — skipped when no font is found (keeps CI green on images + /// without the usual system fonts). + fn system_font() -> Option> + { + let path = crate::render::helpers::find_font_opt()?; + let bytes = std::fs::read( path ).ok()?; + let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).ok()?; + Some( Arc::new( font ) ) + } + + #[ test ] + fn empty_registry_resolves_to_none() + { + let reg = FontRegistry::new(); + assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_none() ); + assert!( reg.is_empty() ); + } + + #[ test ] + fn exact_match_wins() + { + let Some( font ) = system_font() else { return; }; + let mut reg = FontRegistry::new(); + reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) ); + reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) ); + assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() ); + assert!( reg.resolve( "sora", 700, FontStyle::Normal ).is_some() ); + assert_eq!( reg.len(), 2 ); + } + + #[ test ] + fn closest_weight_is_picked_when_exact_missing() + { + let Some( font ) = system_font() else { return; }; + let mut reg = FontRegistry::new(); + reg.insert( "sora", 300, FontStyle::Normal, Arc::clone( &font ) ); + reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) ); + + // Ask for 400: closer to 300 than to 700. Both rounds 100 away or + // 300 away respectively; 300 wins. + assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() ); + + // Ask for 800: closer to 700. + assert!( reg.resolve( "sora", 800, FontStyle::Normal ).is_some() ); + } + + #[ test ] + fn fallback_chain_is_walked_when_family_unknown() + { + let Some( font ) = system_font() else { return; }; + let mut reg = FontRegistry::new(); + reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) ); + reg.set_fallbacks( "display", vec![ "sora".to_string() ] ); + + // `display` has no direct entries, but its fallback chain leads to + // `sora` which is loaded. + assert!( reg.resolve( "display", 400, FontStyle::Normal ).is_some() ); + } + + #[ test ] + fn unreachable_family_returns_none() + { + let Some( font ) = system_font() else { return; }; + let mut reg = FontRegistry::new(); + reg.insert( "sora", 400, FontStyle::Normal, font ); + assert!( reg.resolve( "roboto", 400, FontStyle::Normal ).is_none() ); + } + + #[ test ] + fn from_families_reports_io_error_for_missing_path() + { + use crate::theme::fonts::FontSource; + let mut fams = HashMap::new(); + fams.insert( "sora".to_string(), FontFamilyDef + { + name: "Sora".to_string(), + fallbacks: Vec::new(), + sources: vec! + [ + FontSource + { + weight: 400, + style: FontStyle::Normal, + path: "/this/path/does/not/exist.ttf".into(), + }, + ], + }); + match FontRegistry::from_families( &fams ) + { + Err( FontLoadError::Io( p, _ ) ) => + { + assert!( p.to_string_lossy().contains( "does/not/exist" ) ); + } + other => panic!( "expected Io error, got {:?}", other ), + } + } +} diff --git a/src/theme/fonts.rs b/src/theme/fonts.rs new file mode 100644 index 0000000..123f8d6 --- /dev/null +++ b/src/theme/fonts.rs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Font family definitions as declared by the theme document. +//! +//! This module only models the **declaration**: a family name, a set of +//! source files indexed by weight/style, and a fallback chain. Loading the +//! `.ttf` bytes into `fontdue` and resolving text styles against a live +//! registry is the runtime registry's job (see [`super::FontRegistry`]). + +use std::path::PathBuf; + +use super::text_style::FontStyle; + +// ─── FontFamilyDef ─────────────────────────────────────────────────────────── + +/// A font family as declared by the theme document. +/// +/// `sources` is a flat list indexed by weight + style, not a nested map, so +/// the JSON round-trips without ambiguity on ordering. +#[ derive( Debug, Clone, PartialEq ) ] +pub struct FontFamilyDef +{ + /// Human-readable family name (e.g. `"Sora"`). Shown in telemetry and + /// in any eventual font-picker UI. + pub name: String, + /// Fallback chain if the family or a given weight/style is missing. + /// Resolved in order: the first entry that the font registry can + /// satisfy is used. + pub fallbacks: Vec, + /// One source per weight/style the family ships. The runtime font + /// registry registers each source in `fontdue` at load time. + pub sources: Vec, +} + +// ─── FontSource ────────────────────────────────────────────────────────────── + +/// A single font file, specified by its numeric weight and style. +#[ derive( Debug, Clone, PartialEq, Eq ) ] +pub struct FontSource +{ + /// CSS numeric weight (100..=900). + pub weight: u16, + /// Italic vs upright. + pub style: FontStyle, + /// Path to the `.ttf` / `.otf` file. Resolved to absolute at load time + /// (relative paths are taken to be relative to the theme directory root). + pub path: PathBuf, +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn family_def_holds_multiple_weights_of_same_family() + { + let sora = FontFamilyDef + { + name: "Sora".to_string(), + fallbacks: vec![ "system-ui".to_string(), "sans-serif".to_string() ], + sources: vec! + [ + FontSource { weight: 300, style: FontStyle::Normal, path: "fonts/Sora-Light.ttf".into() }, + FontSource { weight: 400, style: FontStyle::Normal, path: "fonts/Sora-Regular.ttf".into() }, + FontSource { weight: 600, style: FontStyle::Normal, path: "fonts/Sora-SemiBold.ttf".into() }, + FontSource { weight: 700, style: FontStyle::Normal, path: "fonts/Sora-Bold.ttf".into() }, + ], + }; + assert_eq!( sora.sources.len(), 4 ); + assert_eq!( sora.sources[2].weight, 600 ); + assert_eq!( sora.fallbacks[0], "system-ui" ); + } +} diff --git a/src/theme/gradient_lut.rs b/src/theme/gradient_lut.rs new file mode 100644 index 0000000..76cb14b --- /dev/null +++ b/src/theme/gradient_lut.rs @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! CPU-side gradient sampling and LUT baking. +//! +//! The GPU gradient path in `gles_render` shaders samples a 1D lookup +//! texture baked on the CPU: for each gradient we precompute N equally +//! spaced samples across an extended `t` domain (so stops outside +//! `[0, 1]` are covered without extra shader logic), already colour-space +//! converted, and upload those N × 4 bytes as an RGBA8 texture. +//! +//! # Extrapolation +//! +//! Stops whose `position` falls outside `[0, 1]` are supported by +//! **linear extrapolation**: below the first stop we prolong the +//! `(first, second)` slope, above the last stop we prolong the +//! `(last-1, last)` slope. Values are not clamped. This is the +//! physically correct behaviour for CSS `linear-gradient` stops defined +//! with positions outside the visible range. +//! +//! # Colour spaces +//! +//! [`GradientSpace::Srgb`] interpolates raw sRGB channels (cheap, looks +//! muddy on saturated gradients). [`GradientSpace::LinearRgb`] — the +//! default — converts each stop to linear light, interpolates, and +//! converts the result back to sRGB. [`GradientSpace::Oklab`] is not yet +//! implemented and silently falls back to `LinearRgb`. + +use crate::types::Color; + +use super::paint::{ ColorStop, GradientSpace }; + +/// How many samples the LUT stores along the `t` axis. +pub const LUT_SAMPLES: usize = 512; + +/// Extended `t` domain the LUT covers. Wide enough to comfortably contain +/// the typical out-of-`[0, 1]` stops produced by design-tool exports. +pub const LUT_DOMAIN: ( f32, f32 ) = ( -2.0, 3.0 ); + +// ─── sRGB ↔ linear ─────────────────────────────────────────────────────────── + +/// Convert one sRGB gamma-encoded channel to linear light. +#[ inline ] +pub fn srgb_to_linear( x: f32 ) -> f32 +{ + if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf( 2.4 ) } +} + +/// Convert one linear-light channel to sRGB gamma-encoded. +#[ inline ] +pub fn linear_to_srgb( x: f32 ) -> f32 +{ + if x <= 0.0031308 { x * 12.92 } else { 1.055 * x.powf( 1.0 / 2.4 ) - 0.055 } +} + +// ─── Sampling ──────────────────────────────────────────────────────────────── + +/// Sample the stops at position `t` using the requested interpolation +/// space. Stops need not be sorted. `t` may fall outside `[0, 1]`. +pub fn sample_stops( stops: &[ColorStop], t: f32, space: GradientSpace ) -> Color +{ + if stops.is_empty() { return Color::TRANSPARENT; } + if stops.len() == 1 { return stops[0].color; } + + // Sort by position. We do this each call because gradients hold at + // most a handful of stops and the LUT builder only calls us N times + // per gradient build (not per pixel). + let mut sorted: Vec<&ColorStop> = stops.iter().collect(); + sorted.sort_by( |a, b| + a.position.partial_cmp( &b.position ).unwrap_or( std::cmp::Ordering::Equal ) + ); + + // Pick the bracketing pair. Below the first stop we extrapolate from + // `(first, second)`; above the last, from `(last-1, last)`. + let n = sorted.len(); + let ( a, b ) = if t <= sorted[0].position + { + ( sorted[0], sorted[1] ) + } + else if t >= sorted[n - 1].position + { + ( sorted[n - 2], sorted[n - 1] ) + } + else + { + let mut pair = ( sorted[0], sorted[1] ); + for win in sorted.windows( 2 ) + { + if t >= win[0].position && t <= win[1].position + { + pair = ( win[0], win[1] ); + break; + } + } + pair + }; + + let dt = b.position - a.position; + let u = if dt.abs() < 1e-6 { 0.0 } else { ( t - a.position ) / dt }; + + mix_colors( a.color, b.color, u, space ) +} + +/// Linear mix of two colours at parameter `u` (not clamped — the caller +/// has already chosen the right bracketing pair). +fn mix_colors( a: Color, b: Color, u: f32, space: GradientSpace ) -> Color +{ + let alpha = a.a + ( b.a - a.a ) * u; + match space + { + GradientSpace::Srgb => Color + { + r: a.r + ( b.r - a.r ) * u, + g: a.g + ( b.g - a.g ) * u, + b: a.b + ( b.b - a.b ) * u, + a: alpha, + }, + GradientSpace::LinearRgb => mix_linear( a, b, u, alpha ), + // TODO: proper Oklab mix. For now fall back to linear-light. + GradientSpace::Oklab => mix_linear( a, b, u, alpha ), + } +} + +fn mix_linear( a: Color, b: Color, u: f32, alpha: f32 ) -> Color +{ + let ar = srgb_to_linear( a.r ); + let ag = srgb_to_linear( a.g ); + let ab = srgb_to_linear( a.b ); + let br = srgb_to_linear( b.r ); + let bg = srgb_to_linear( b.g ); + let bb = srgb_to_linear( b.b ); + + let r = linear_to_srgb( ar + ( br - ar ) * u ); + let g = linear_to_srgb( ag + ( bg - ag ) * u ); + let b = linear_to_srgb( ab + ( bb - ab ) * u ); + Color { r, g, b, a: alpha } +} + +// ─── LUT baking ────────────────────────────────────────────────────────────── + +/// Build an RGBA8 LUT of `LUT_SAMPLES` equally spaced samples spanning +/// [`LUT_DOMAIN`]. The returned vector has `LUT_SAMPLES * 4` bytes in +/// straight-alpha, row-major. The GPU shader expects this layout and +/// premultiplies at sample time. +pub fn build_lut_bytes( stops: &[ColorStop], space: GradientSpace ) -> Vec +{ + let ( t0, t1 ) = LUT_DOMAIN; + let n = LUT_SAMPLES; + let mut out = Vec::with_capacity( n * 4 ); + for i in 0..n + { + let t = t0 + ( t1 - t0 ) * ( i as f32 / ( n - 1 ) as f32 ); + let c = sample_stops( stops, t, space ); + out.push( ( c.r.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 ); + out.push( ( c.g.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 ); + out.push( ( c.b.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 ); + out.push( ( c.a.clamp( 0.0, 1.0 ) * 255.0 + 0.5 ) as u8 ); + } + out +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + fn approx( a: f32, b: f32 ) -> bool { ( a - b ).abs() < 2e-2 } + + #[ test ] + fn srgb_linear_roundtrip_is_stable() + { + for &v in &[ 0.0, 0.04, 0.1, 0.25, 0.5, 0.75, 1.0 ] + { + let back = linear_to_srgb( srgb_to_linear( v ) ); + assert!( ( v - back ).abs() < 1e-5, "roundtrip {} -> {}", v, back ); + } + } + + #[ test ] + fn sample_at_exact_stop_returns_that_stop() + { + let stops = vec! + [ + ColorStop { position: 0.0, color: Color::WHITE }, + ColorStop { position: 1.0, color: Color::BLACK }, + ]; + let a = sample_stops( &stops, 0.0, GradientSpace::Srgb ); + let b = sample_stops( &stops, 1.0, GradientSpace::Srgb ); + assert_eq!( a, Color::WHITE ); + assert_eq!( b, Color::BLACK ); + } + + #[ test ] + fn sample_midpoint_srgb_is_halfway() + { + let stops = vec! + [ + ColorStop { position: 0.0, color: Color::rgb( 0.0, 0.0, 0.0 ) }, + ColorStop { position: 1.0, color: Color::rgb( 1.0, 1.0, 1.0 ) }, + ]; + let m = sample_stops( &stops, 0.5, GradientSpace::Srgb ); + assert!( approx( m.r, 0.5 ) && approx( m.g, 0.5 ) && approx( m.b, 0.5 ) ); + } + + #[ test ] + fn sample_midpoint_linear_rgb_is_brighter_than_srgb() + { + // Classic demonstration that linear-light midpoint is brighter + // than the naive sRGB midpoint when interpolating 0 → 1. + let stops = vec! + [ + ColorStop { position: 0.0, color: Color::rgb( 0.0, 0.0, 0.0 ) }, + ColorStop { position: 1.0, color: Color::rgb( 1.0, 1.0, 1.0 ) }, + ]; + let m_srgb = sample_stops( &stops, 0.5, GradientSpace::Srgb ); + let m_linear = sample_stops( &stops, 0.5, GradientSpace::LinearRgb ); + assert!( m_linear.r > m_srgb.r, "linear {:?} should be brighter than srgb {:?}", m_linear, m_srgb ); + } + + #[ test ] + fn extrapolation_below_first_stop_continues_slope() + { + // stops at 0.0 (white) and 1.0 (black). Extrapolating to -1.0 + // along the same slope lands above 1.0 — we do NOT clamp; the + // LUT baker will clip when converting to u8. + let stops = vec! + [ + ColorStop { position: 0.0, color: Color::WHITE }, + ColorStop { position: 1.0, color: Color::BLACK }, + ]; + let c = sample_stops( &stops, -1.0, GradientSpace::Srgb ); + // Slope is (-1, -1, -1) per unit of t; at t=-1 we get rgb=(2,2,2). + assert!( c.r > 1.0, "extrapolation should exceed 1.0 before clamp: {}", c.r ); + } + + #[ test ] + fn extrapolation_above_last_stop_continues_slope() + { + let stops = vec! + [ + ColorStop { position: 0.0, color: Color::rgb( 0.2, 0.2, 0.2 ) }, + ColorStop { position: 1.0, color: Color::rgb( 0.8, 0.8, 0.8 ) }, + ]; + let c = sample_stops( &stops, 2.0, GradientSpace::Srgb ); + // Slope (0.6, 0.6, 0.6) per unit. At t=2.0, rgb=(1.4, …) — exceeds 1.0. + assert!( c.r > 1.0, "extrapolation should exceed 1.0: {}", c.r ); + } + + #[ test ] + fn alpha_mixes_linearly_across_spaces() + { + let stops = vec! + [ + ColorStop { position: 0.0, color: Color::rgba( 1.0, 0.0, 0.0, 1.0 ) }, + ColorStop { position: 1.0, color: Color::rgba( 1.0, 0.0, 0.0, 0.0 ) }, + ]; + for space in [ GradientSpace::Srgb, GradientSpace::LinearRgb, GradientSpace::Oklab ] + { + let m = sample_stops( &stops, 0.5, space ); + assert!( approx( m.a, 0.5 ), "alpha should mix linearly in {:?}: got {}", space, m.a ); + } + } + + #[ test ] + fn unsorted_stops_are_handled() + { + let stops = vec! + [ + ColorStop { position: 1.0, color: Color::BLACK }, + ColorStop { position: 0.0, color: Color::WHITE }, + ]; + let a = sample_stops( &stops, 0.0, GradientSpace::Srgb ); + let b = sample_stops( &stops, 1.0, GradientSpace::Srgb ); + assert_eq!( a, Color::WHITE ); + assert_eq!( b, Color::BLACK ); + } + + #[ test ] + fn build_lut_has_expected_length() + { + let stops = vec! + [ + ColorStop { position: 0.0, color: Color::WHITE }, + ColorStop { position: 1.0, color: Color::BLACK }, + ]; + let bytes = build_lut_bytes( &stops, GradientSpace::LinearRgb ); + assert_eq!( bytes.len(), LUT_SAMPLES * 4 ); + } + + #[ test ] + fn build_lut_clips_extrapolation_to_u8_range() + { + let stops = vec! + [ + ColorStop { position: 0.0, color: Color::WHITE }, + ColorStop { position: 1.0, color: Color::BLACK }, + ]; + let bytes = build_lut_bytes( &stops, GradientSpace::Srgb ); + // All bytes must be in [0, 255] — no panics from out-of-range casts. + for b in &bytes { let _ = *b; } + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs new file mode 100644 index 0000000..a495cd0 --- /dev/null +++ b/src/theme/mod.rs @@ -0,0 +1,1103 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Theming for ltk-based applications. +//! +//! A *theme* is a directory on disk holding a `theme.json` and any +//! supporting assets (wallpapers, fonts, …). The JSON declares `light` +//! and `dark` modes — each with its own slot table plus wallpaper, +//! lockscreen, launcher and window-controls blocks — plus a shared +//! `fonts` registry. The shell picks which mode is active via +//! [`ThemeMode`]. +//! +//! # On-disk layout +//! +//! ```text +//! /usr/share/ltk/themes// (system overlay, lower priority) +//! ~/.local/share/ltk/themes// (user overlay, higher priority) +//! theme.json +//! background-light.png +//! background-dark.png +//! fonts/ +//! Sora-Regular.ttf +//! ``` +//! +//! Paths inside `theme.json` are interpreted relative to the theme's +//! directory and are eagerly resolved to absolute paths at load time. +//! +//! # Process-wide active state +//! +//! The active theme is published process-wide so widgets can consult their +//! colours without every caller threading the palette through their own +//! state. Use [`set_active_document`] / [`set_active_mode`] to change it, +//! and [`active_document`] / [`active_mode`] / [`active_theme_id`] to read +//! it back. Per-slot shorthand accessors ([`color`], [`paint()`], [`surface()`], +//! [`palette`], …) cover the common patterns without going through the +//! full document. +//! +//! There is **no in-code fallback**: if `ensure_active` cannot locate the +//! `default` theme in any search path, the process aborts with a message +//! pointing at the `ltk-theme-default` Debian package (it `Provides: +//! ltk-theme`) or at the `LTK_THEMES_DIR` environment variable for +//! development installations. + +use std::collections::HashMap; +use std::path::{ Path, PathBuf }; +use std::sync::{ Arc, Mutex, RwLock }; + +use serde::{ Deserialize, Serialize }; + +use crate::types::Color; + +// ─── Submodules ────────────────────────────────────────────────────────────── +// +// Paint / Shadow / Surface / TextStyle are the building blocks of the +// slot-typed theme schema; the SlotStore (`slots`) indexes them by id; +// the JSON loader (`schema`) parses the on-disk shape. `document` holds +// the top-level `ThemeDocument` and per-mode `Mode` types. `fonts` + +// `font_registry` cover the typed font references and their +// runtime-loaded counterparts. `gradient_lut` is the small CPU helper +// the GPU gradient shaders consume. + +pub mod paint; +pub mod shadow; +pub mod surface; +pub mod text_style; + +pub mod fonts; +pub mod font_registry; +pub mod gradient_lut; +pub mod slots; +pub mod document; +pub ( crate ) mod schema; +pub ( crate ) mod fallback; + +pub use paint::{ ColorStop, GradientSpace, LinearGradient, Paint, RadialGradient }; +pub use shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef }; +pub use surface::Surface; +pub use text_style::{ FontRef, FontStyle, LineHeight, TextDecoration, TextStyle, TextTransform }; + +pub use fonts::{ FontFamilyDef, FontSource }; +pub use font_registry::{ FontKey, FontLoadError, FontRegistry }; +pub use slots::{ Metadata, Slot, SlotStore }; +pub use document::{ Mode, ThemeDocument }; + +// ─── Palette ───────────────────────────────────────────────────────────────── + +/// Semantic colour tokens shared by every widget. +#[ derive( Debug, Clone, Copy ) ] +pub struct Palette +{ + /// Page / wallpaper background when no image is present. + pub bg: Color, + /// Floating surface over the wallpaper (cards, panels, dock) — usually translucent. + pub surface: Color, + /// Elevated surface (hovered card, pressed toggle, inner pill). + pub surface_alt: Color, + /// Primary foreground text. + pub text_primary: Color, + /// Subdued text (date strip, helper text, disabled labels). + pub text_secondary: Color, + /// Brand accent used for active toggles, sliders, focus rings. + pub accent: Color, + /// Thin separators and low-contrast borders. + pub divider: Color, + /// Tint for symbolic icons (wifi / battery / search / etc). + pub icon: Color, + /// Foreground colour for destructive / error states — text in + /// "delete" buttons, error helper rows under invalid inputs, + /// the border of an errored pill. + pub danger: Color, + /// Soft fill behind error states. Pairs with `danger` as + /// foreground; light enough that body text remains legible on + /// top. + pub danger_bg: Color, +} + +// ─── Wallpaper / launcher ──────────────────────────────────────────────────── + +/// How a wallpaper image is fitted to its surface. +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ] +#[ serde( rename_all = "kebab-case" ) ] +pub enum WallpaperFit +{ + /// Scale uniformly to fill the surface, cropping the overflow. + Cover, + /// Scale uniformly to fit inside the surface, letterboxing if needed. + Contain, + /// Scale non-uniformly to exactly match the surface. + Stretch, + /// Place at original size centered on the surface. + Center, + /// Repeat the image at original size to cover the surface. + Tile, +} + +impl Default for WallpaperFit +{ + fn default() -> Self { WallpaperFit::Cover } +} + +/// Wallpaper backing for a single theme variant. +#[ derive( Debug, Clone ) ] +pub struct WallpaperSpec +{ + /// Absolute path to the image, or `None` for the built-in fallback that + /// just paints [`Palette::bg`]. + pub path: Option, + pub fit: WallpaperFit, +} + +/// Styling for the application launcher surface. +#[ derive( Debug, Clone, Copy ) ] +pub struct LauncherSpec +{ + /// Background fill of the launcher panel. + pub background: Color, + /// Outer corner radius of the launcher panel, in CSS pixels. + pub border_radius: f32, +} + +/// Styling for compositor / window-decoration controls. +#[ derive( Debug, Clone, Copy ) ] +pub struct WindowControlsSpec +{ + /// Symbol colour for minimize / maximize / restore / close glyphs. + pub icon: Color, + /// Button background while hovered. + pub hover_bg: Color, + /// Button background while pressed. + pub pressed_bg: Color, + /// Close-button background while hovered. + pub close_hover_bg: Color, + /// Close-button symbol colour while hovered. + pub close_icon: Color, + /// Keyboard focus ring colour. + pub focus_ring: Color, +} + +// ─── Palette projection ────────────────────────────────────────────────────── + +impl Palette +{ + /// Project a [`SlotStore`] onto the eight canonical palette fields. + /// Slot ids are the ones declared in the default theme JSON + /// (`bg-page`, `surface`, `surface-alt`, `text-primary`, + /// `text-secondary`, `accent`, `divider`, `icon`). Missing slots + /// fall back to a documented sensible default so downstream widgets + /// never see uninitialised colours. Used by [`palette`] and + /// [`window_controls`]. + pub fn from_slots( slots: &SlotStore ) -> Self + { + Self + { + bg: slots.color( "bg-page" ).unwrap_or( Color::WHITE ), + surface: slots.color( "surface" ).unwrap_or( Color::WHITE ), + surface_alt: slots.color( "surface-alt" ).unwrap_or( Color::WHITE ), + text_primary: slots.color( "text-primary" ).unwrap_or( Color::BLACK ), + text_secondary: slots.color( "text-secondary" ).unwrap_or( Color::rgba( 0.0, 0.0, 0.0, 0.6 ) ), + accent: slots.color( "accent" ).unwrap_or( Color::hex( 0x00, 0xCE, 0xB1 ) ), + divider: slots.color( "divider" ).unwrap_or( Color::rgba( 0.0, 0.0, 0.0, 0.08 ) ), + icon: slots.color( "icon" ).unwrap_or( Color::BLACK ), + // Conservative defaults: a rich red for fg, a tinted + // pink wash for bg. Themes can override via the + // `danger` / `danger-bg` slot ids. + danger: slots.color( "danger" ).unwrap_or( Color::hex( 0xA8, 0x00, 0x10 ) ), + danger_bg: slots.color( "danger-bg" ).unwrap_or( Color::rgba( 1.0, 0.85, 0.88, 1.0 ) ), + } + } +} + +// ─── Search paths ──────────────────────────────────────────────────────────── + +/// Returns the ordered list of directories under which theme families are +/// looked up, highest-priority first. +/// +/// `LTK_THEMES_DIR` (when set) takes absolute precedence — useful during +/// development to point the shells at an in-tree `themes/` directory without +/// having to install or symlink anything. +pub fn search_paths() -> Vec +{ + let mut out = Vec::new(); + if let Some( dev ) = std::env::var_os( "LTK_THEMES_DIR" ) + { + out.push( PathBuf::from( dev ) ); + } + if let Some( home ) = std::env::var_os( "XDG_DATA_HOME" ) + { + out.push( PathBuf::from( home ).join( "ltk/themes" ) ); + } + else if let Some( home ) = std::env::var_os( "HOME" ) + { + out.push( PathBuf::from( home ).join( ".local/share/ltk/themes" ) ); + } + out.push( PathBuf::from( "/usr/share/ltk/themes" ) ); + out +} + +// ─── Mode and preference ───────────────────────────────────────────────────── + +/// Which of the two variants a theme family resolves to. +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ] +#[ serde( rename_all = "kebab-case" ) ] +pub enum ThemeMode +{ + Light, + Dark, +} + +impl Default for ThemeMode +{ + fn default() -> Self { ThemeMode::Light } +} + +impl ThemeMode +{ + /// Auto-select light or dark from the local hour of day. + /// + /// Dark is used between 19:00 and 06:59 local time, light otherwise. + /// `hour` must be in `0..=23`. + pub const fn from_hour( hour: u32 ) -> Self + { + if hour >= 19 || hour < 7 { ThemeMode::Dark } else { ThemeMode::Light } + } +} + +/// Shell-side persisted preference. `Auto` is resolved to a [`ThemeMode`] +/// before reaching ltk — the toolkit itself only knows about Light and Dark. +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize ) ] +#[ serde( rename_all = "kebab-case" ) ] +pub enum ThemePreference +{ + Light, + Dark, + Auto, +} + +impl Default for ThemePreference +{ + fn default() -> Self { ThemePreference::Auto } +} + +impl ThemePreference +{ + /// Resolve to a concrete mode given the current local hour `[0, 23]`. + pub const fn resolve( self, hour: u32 ) -> ThemeMode + { + match self + { + ThemePreference::Light => ThemeMode::Light, + ThemePreference::Dark => ThemeMode::Dark, + ThemePreference::Auto => ThemeMode::from_hour( hour ), + } + } +} + +// ─── Active state ──────────────────────────────────────────────────────────── + +#[ derive( Clone ) ] +struct ActiveState +{ + /// Currently loaded theme document. The single source of truth — + /// every consumer-facing accessor in this module reads through it + /// against the active [`ThemeMode`]. + document: Arc, + mode: ThemeMode, + /// `true` when the active document was produced by + /// [`fallback::document`] because `ThemeDocument::find("default")` + /// failed. The draw path stamps every frame with a red banner in + /// this state so the user sees the missing-theme signal without + /// the process having to abort. + is_fallback: bool, +} + +// `RwLock::new` and `Option::None` are both const, so this needs no lazy init. +static ACTIVE: RwLock> = RwLock::new( None ); + +/// Read the active state, loading the `default` theme from disk on +/// first access. If the `default` theme cannot be found in any search +/// path, the crate falls back to an in-memory B/W +/// [`fallback::document`], marks the state with `is_fallback = true` +/// (so the draw path can paint the warning banner), and logs a line +/// to stderr pointing at the install instructions. The process never +/// panics on first-access — making ltk embeddable inside programs +/// that want to handle missing theme gracefully. +fn ensure_active() -> ActiveState +{ + { + let guard = ACTIVE.read().expect( "theme: ACTIVE poisoned" ); + if let Some( s ) = guard.as_ref() + { + return s.clone(); + } + } + let mut guard = ACTIVE.write().expect( "theme: ACTIVE poisoned" ); + if guard.is_none() + { + let ( doc, is_fallback ) = match ThemeDocument::find( "default" ) + { + Ok( d ) => ( d, false ), + Err( e ) => + { + eprintln! + ( + "[ltk] default theme not found ({e}); using embedded B/W \ + fallback. Install the `ltk-theme-default` Debian package \ + (Provides: ltk-theme) or set `LTK_THEMES_DIR` to a \ + directory containing `default/theme.json` to get the \ + real theme back." + ); + ( fallback::document(), true ) + } + }; + *guard = Some( ActiveState + { + document: Arc::new( doc ), + mode: ThemeMode::Light, + is_fallback, + }); + } + guard.as_ref().expect( "just installed" ).clone() +} + +/// Install `doc` as the active theme. The current mode is preserved +/// (defaulting to [`ThemeMode::Light`] if nothing was set yet). Also +/// clears the fallback flag — an explicit install supersedes the +/// embedded B/W document and the warning banner stops painting from +/// the next frame on. +pub fn set_active_document( doc: ThemeDocument ) +{ + let mut guard = ACTIVE.write().expect( "theme: ACTIVE poisoned" ); + let mode = guard.as_ref().map( |s| s.mode ).unwrap_or( ThemeMode::Light ); + *guard = Some( ActiveState + { + document: Arc::new( doc ), + mode, + is_fallback: false, + }); + // Drop any rasterised icons cached against the previous theme's + // paths. Cache keys embed absolute paths so old entries are never + // *wrong*, just dead memory; clearing keeps the working set small. + if let Ok( mut cache ) = SVG_CACHE.lock() + { + if let Some( ref mut map ) = *cache { map.clear(); } + } +} + +/// Switch the active variant. The document is left untouched — if nothing +/// has been loaded yet, the default theme is loaded first. +pub fn set_active_mode( mode: ThemeMode ) +{ + // Ensure the default theme is loaded before mutating the mode so we + // never publish an `ActiveState` with a missing document. + let _ = ensure_active(); + let mut guard = ACTIVE.write().expect( "theme: ACTIVE poisoned" ); + if let Some( s ) = guard.as_mut() { s.mode = mode; } +} + +/// The id of the active theme. +pub fn active_theme_id() -> String +{ + ensure_active().document.id.clone() +} + +/// The active variant (light or dark). +pub fn active_mode() -> ThemeMode +{ + ensure_active().mode +} + +/// The currently loaded theme document. Use this for slot-typed lookups +/// when the per-slot helpers ([`color`], [`surface()`], …) are not +/// expressive enough — e.g. iterating `mode.slots.entries`. +pub fn active_document() -> Arc +{ + ensure_active().document +} + +/// `true` when the active theme was produced by the embedded B/W +/// fallback because `ThemeDocument::find("default")` failed at +/// first-access. Flipped back to `false` the moment a consumer calls +/// [`set_active_document`] with any document (even another call to +/// the same fallback — the point is "this state came from an +/// explicit install, not from the missing-theme code path"). +/// +/// The draw layer reads this to decide whether to stamp each surface +/// with the red "install `ltk-theme-default`" banner; apps can read +/// it too, e.g. to log a diagnostic or surface a first-run +/// installation helper. +pub fn is_fallback_active() -> bool +{ + ensure_active().is_fallback +} + +// ─── Slot-typed shorthands ─────────────────────────────────────────────────── +// +// Widgets speak in terms of slot ids. The plumbing for "which document is +// active" and "which mode" is captured here so widget code stays +// free of that plumbing. + +/// Resolve a colour slot in the active mode. `None` when the slot is +/// missing, empty, or not a `Slot::Color` (use [`paint()`] if a gradient +/// should also be acceptable). +pub fn color( id: &str ) -> Option +{ + let state = ensure_active(); + state.document.mode( state.mode ).slots.color( id ) +} + +/// Like [`color`] but returns `fallback` when the slot is missing. The +/// typical pattern for widgets that must always paint something. +pub fn color_or( id: &str, fallback: Color ) -> Color +{ + color( id ).unwrap_or( fallback ) +} + +/// Resolve a paint slot (solid or gradient) in the active mode. A plain +/// `Slot::Color` is promoted to `Paint::Solid`. `None` for missing or +/// non-paintable slots (e.g. a text-style). +pub fn paint( id: &str ) -> Option +{ + let state = ensure_active(); + state.document.mode( state.mode ).slots.paint( id ) +} + +/// Resolve an elevation (outer-shadow stack) slot in the active mode. The +/// returned `Vec` is a freshly cloned copy so call sites can hold it +/// across frames without borrowing the global state. +pub fn shadows( id: &str ) -> Option> +{ + let state = ensure_active(); + state.document.mode( state.mode ).slots.shadows( id ).map( |s| s.to_vec() ) +} + +/// Resolve a surface slot in the active mode. A colour or paint slot +/// promotes to a surface with that fill and no decorations; a full +/// surface slot returns unchanged. +pub fn surface( id: &str ) -> Option +{ + let state = ensure_active(); + state.document.mode( state.mode ).slots.surface( id ) +} + +/// Resolve a surface slot together with its outer shadow stack. +/// +/// [`Surface::shadows`] may be `ShadowsRef::Named(id)`, pointing at a +/// separate `Slot::Shadows` entry in the same mode. `canvas.fill_surface` +/// expects the stack resolved as a plain `&[Shadow]`, so this helper does +/// the second lookup in one place: widgets call `resolve_surface(id)` and +/// get back the surface plus a `Vec` ready to hand to the canvas. +/// +/// Returns `None` only when the surface slot itself is absent. A missing +/// named shadow ref degrades silently to an empty stack — the surface +/// still paints its fill and inset decorations, just without the outer +/// glow. +pub fn resolve_surface( id: &str ) -> Option<( Surface, Vec )> +{ + let state = ensure_active(); + let mode = state.document.mode( state.mode ); + let surface = mode.slots.surface( id )?; + let shadows = match &surface.shadows + { + Some( ShadowsRef::Named( sid ) ) => mode.slots.shadows( sid ).map( |s| s.to_vec() ).unwrap_or_default(), + Some( ShadowsRef::Inline( v ) ) => v.clone(), + None => Vec::new(), + }; + Some( ( surface, shadows ) ) +} + +/// Resolve a typography slot in the active mode. Cloned so the result +/// can outlive the global-state borrow. +pub fn text_style( id: &str ) -> Option +{ + let state = ensure_active(); + state.document.mode( state.mode ).slots.text_style( id ).cloned() +} + +/// The active mode's window-controls payload, derived from slots if the +/// document did not declare an explicit block. +pub fn window_controls() -> WindowControlsSpec +{ + let state = ensure_active(); + let mode = state.document.mode( state.mode ); + if let Some( wc ) = mode.window_controls { return wc; } + default_window_controls( Palette::from_slots( &mode.slots ) ) +} + +/// The eight canonical palette slots of the active mode projected as a +/// [`Palette`] struct. This is a one-call shortcut equivalent to +/// `Palette::from_slots(&active_document().mode(active_mode()).slots)`, +/// covering the common case where a widget needs `text_primary` / +/// `surface` / `accent` / etc. without picking specific slot ids. +pub fn palette() -> Palette +{ + let state = ensure_active(); + Palette::from_slots( &state.document.mode( state.mode ).slots ) +} + +/// The active mode's homescreen / shell wallpaper. Prefers an explicit +/// declaration in `theme.json`; when absent, falls back to the +/// convention path `branding/{mode}/wallpaper.svg` via [`branding_asset`] +/// (with mode → opposite-mode → no-mode fallback). Returns `None` when +/// neither source resolves to an existing file. +/// +/// Always returns the SVG. Use [`branding_image( "wallpaper", sw, sh +/// )`](branding_image) when the surface size is known to prefer a +/// pre-rendered raster variant under `branding/{mode}/wallpaper/`. +pub fn wallpaper() -> Option +{ + let state = ensure_active(); + if let Some( spec ) = state.document.mode( state.mode ).wallpaper.clone() + { + return Some( spec ); + } + branding_asset( "wallpaper", "svg" ) + .map( |path| WallpaperSpec { path: Some( path ), fit: WallpaperFit::Cover } ) +} + +/// The active mode's lockscreen / greeter wallpaper. Same resolution +/// strategy as [`wallpaper`]: explicit `theme.json` declaration first, +/// otherwise `branding/{mode}/lockscreen.svg` via [`branding_asset`]. +/// Use [`branding_image( "lockscreen", sw, sh )`](branding_image) for +/// the size-aware raster lookup. +pub fn lockscreen() -> Option +{ + let state = ensure_active(); + if let Some( spec ) = state.document.mode( state.mode ).lockscreen.clone() + { + return Some( spec ); + } + branding_asset( "lockscreen", "svg" ) + .map( |path| WallpaperSpec { path: Some( path ), fit: WallpaperFit::Cover } ) +} + +/// Path to the named app icon SVG inside the active theme's `icons/apps/` +/// directory. `name` is the bare stem (e.g. `"firefox"`, `"calculator"`) +/// without the `.svg` extension. Returns `None` when the active document +/// has no on-disk root or the file does not exist. +pub fn app_icon( name: &str ) -> Option +{ + let state = ensure_active(); + let root = state.document.root.as_ref()?; + let path = root.join( "icons/apps" ).join( format!( "{}.svg", name ) ); + if path.is_file() { Some( path ) } else { None } +} + +/// Path to `app-default.svg` inside the active theme's icon directory. +/// Returns `None` when the document has no on-disk root or the file is +/// absent. +pub fn app_default_icon() -> Option +{ + let state = ensure_active(); + let root = state.document.root.as_ref()?; + let path = root.join( "icons/app-default.svg" ); + if path.is_file() { Some( path ) } else { None } +} + +/// Path to the launcher-logo SVG for the currently active mode. +/// Resolves through [`branding_asset`] using the convention +/// `branding/{mode}/launcher.svg`, with the standard mode → +/// opposite-mode → no-mode fallback chain. +pub fn launcher_icon() -> Option +{ + branding_asset( "launcher", "svg" ) +} + +/// Path to the brand logo SVG for the currently active mode. +/// Convention: `branding/{mode}/logo/logo.svg`. The "main" logo — +/// usually the wordmark variant a shell would put in an "About" +/// dialog or a splash. Pair with [`logo_square`] / [`logo_horizontal`] +/// when the surface dictates a different aspect ratio. +/// +/// Resolves through [`branding_asset`] with the same three-step +/// fallback (active mode → opposite mode → no-mode), so a theme that +/// only ships one mode still produces a usable path. +pub fn logo() -> Option +{ + branding_asset( "logo/logo", "svg" ) +} + +/// Path to the square (1:1) brand logo SVG for the currently active +/// mode. Convention: `branding/{mode}/logo/square.svg`. Use when the +/// surface is roughly square — app icons, login avatars, splash +/// screens, lockscreen brand badges. Same fallback chain as [`logo`]. +pub fn logo_square() -> Option +{ + branding_asset( "logo/square", "svg" ) +} + +/// Path to the horizontal (wordmark) brand logo SVG for the currently +/// active mode. Convention: `branding/{mode}/logo/horizontal.svg`. +/// Use when there is meaningful horizontal space — header bars, menu +/// bars, "About" dialogs, sign-in screens. Same fallback chain as +/// [`logo`]. +pub fn logo_horizontal() -> Option +{ + branding_asset( "logo/horizontal", "svg" ) +} + +/// Resolve a branding asset (launcher logo, wallpaper, lockscreen, …) +/// against the active theme's `branding/` tree. Tries three candidate +/// paths in order and returns the first one that exists on disk: +/// +/// 1. `branding/{active_mode}/{name}.{ext}` — preferred variant. +/// 2. `branding/{opposite_mode}/{name}.{ext}` — graceful degradation +/// when the theme only ships one mode of the asset. +/// 3. `branding/{name}.{ext}` — mode-agnostic asset, for themes that +/// do not bother with light/dark variants. +/// +/// Returns `None` when none of the candidates exist or the active +/// document has no on-disk root. +pub fn branding_asset( name: &str, ext: &str ) -> Option +{ + let state = ensure_active(); + let root = state.document.root.as_ref()?; + let branding = root.join( "branding" ); + let filename = format!( "{name}.{ext}" ); + let ( pref_dir, alt_dir ) = match state.mode + { + ThemeMode::Dark => ( "dark", "light" ), + ThemeMode::Light => ( "light", "dark" ), + }; + let preferred = branding.join( pref_dir ).join( &filename ); + if preferred.is_file() { return Some( preferred ); } + let alternate = branding.join( alt_dir ).join( &filename ); + if alternate.is_file() { return Some( alternate ); } + let modeless = branding.join( &filename ); + if modeless.is_file() { return Some( modeless ); } + None +} + +/// Resolve a raster variant of a branded asset for the given surface +/// dimensions. Looks under `branding/{mode}/{name}/` (with the +/// standard mode → opposite-mode → no-mode fallback chain on the +/// *directory*), parses each filename as `WIDTHxHEIGHT.` where +/// `` is `webp`, `png`, `jpg`, or `jpeg`. Returns the best match +/// in the *first existing* directory: +/// +/// - If one or more entries cover the surface (`W ≥ sw && H ≥ sh`), +/// returns the smallest such by area — the smallest raster that +/// fits without upscaling. +/// - Otherwise returns the largest entry available — better to +/// upscale a fast-decoding raster than to fall back to the +/// comparatively expensive SVG rasterisation. +/// +/// Ties on area are broken by `(width, height)` lexicographic order +/// for determinism. +/// +/// `(sw, sh)` of `(0, 0)` means "give me the smallest available +/// raster" — every entry trivially covers a zero-sized surface, so +/// the smallest by area wins. Useful at startup before the +/// surface-configure event has reported the real dimensions. +/// +/// Returns `None` only when no directory in the fallback chain +/// contains any parseable raster file. +/// +/// Note: only the *first existing* mode-directory in the chain is +/// considered. If `branding/{active_mode}/{name}/` has any raster, +/// the loader uses it; it does not cross over to the opposite-mode +/// directory. Cross-mode fallback happens at the SVG layer through +/// [`branding_asset`], where a colour-wrong raster would be more +/// jarring than a colour-correct vector. +pub fn branding_raster( name: &str, sw: u32, sh: u32 ) -> Option +{ + let state = ensure_active(); + let root = state.document.root.as_ref()?; + let branding = root.join( "branding" ); + let ( pref_dir, alt_dir ) = match state.mode + { + ThemeMode::Dark => ( "dark", "light" ), + ThemeMode::Light => ( "light", "dark" ), + }; + + let candidates = [ + branding.join( pref_dir ).join( name ), + branding.join( alt_dir ).join( name ), + branding.join( name ), + ]; + for dir in &candidates + { + if !dir.is_dir() { continue; } + if let Some( best ) = pick_best_raster( dir, sw, sh ) + { + return Some( best ); + } + } + None +} + +/// Resolve a branded image asset, preferring a raster variant (WebP / +/// PNG / JPEG) over the canonical SVG. Tries +/// [`branding_raster(name, sw, sh)`] first; on `None` (no raster files +/// at all under `branding/{mode}/{name}/`) falls back to +/// [`branding_asset(name, "svg")`]. +/// +/// Pass `(0, 0)` to get the smallest available raster — useful at +/// startup before the surface size is known. +pub fn branding_image( name: &str, sw: u32, sh: u32 ) -> Option +{ + branding_raster( name, sw, sh ) + .or_else( || branding_asset( name, "svg" ) ) +} + +/// Walk `dir` for files named `WIDTHxHEIGHT.` (case-insensitive +/// `x`, recognised raster extensions: webp, png, jpg, jpeg). Returns +/// the smallest entry whose dimensions cover `( sw, sh )`; if none +/// cover, returns the largest entry available (better to upscale a +/// fast raster than fall through to SVG rasterisation). Ties are +/// broken by `( width, height )` lexicographic order. `None` only +/// when the directory holds no parseable raster files. +fn pick_best_raster( dir: &Path, sw: u32, sh: u32 ) -> Option +{ + let entries = std::fs::read_dir( dir ).ok()?; + let mut covering: Option<( u32, u32, PathBuf )> = None; + let mut fallback: Option<( u32, u32, PathBuf )> = None; + for entry in entries.flatten() + { + let path = entry.path(); + let Some( stem ) = path.file_stem().and_then( |s| s.to_str() ) else { continue }; + let ext = path.extension().and_then( |e| e.to_str() ).unwrap_or( "" ); + if !matches!( ext.to_ascii_lowercase().as_str(), "webp" | "png" | "jpg" | "jpeg" ) + { + continue; + } + let Some( ( w_str, h_str ) ) = stem.split_once( |c: char| c == 'x' || c == 'X' ) else { continue }; + let ( Ok( w ), Ok( h ) ) = ( w_str.parse::(), h_str.parse::() ) else { continue }; + let new_area = ( w as u64 ) * ( h as u64 ); + if w >= sw && h >= sh + { + let take = match &covering + { + None => true, + Some( ( bw, bh, _ ) ) => + { + let cur_area = ( *bw as u64 ) * ( *bh as u64 ); + new_area < cur_area || ( new_area == cur_area && ( w, h ) < ( *bw, *bh ) ) + } + }; + if take { covering = Some( ( w, h, path ) ); } + } + else + { + let take = match &fallback + { + None => true, + Some( ( bw, bh, _ ) ) => + { + let cur_area = ( *bw as u64 ) * ( *bh as u64 ); + new_area > cur_area || ( new_area == cur_area && ( w, h ) > ( *bw, *bh ) ) + } + }; + if take { fallback = Some( ( w, h, path ) ); } + } + } + covering.or( fallback ).map( |( _, _, p )| p ) +} + +/// Build a live [`FontRegistry`] from the active document's `fonts` +/// block, loading each declared source from disk. Sources that fail to +/// read or parse are skipped with a warning on stderr — the registry +/// degrades gracefully so one missing TTF does not take down the rest +/// of the family. +/// +/// Returns `None` when the active document declares no families at +/// all (in which case the caller keeps the canvas' system-font +/// fallback). Callers should hand the returned registry to the +/// canvas via `Canvas::set_font_registry`; the draw loop already +/// does this at canvas creation time. +pub fn build_font_registry() -> Option +{ + let doc = ensure_active().document; + if doc.fonts.is_empty() { return None; } + Some( FontRegistry::from_families_lenient( &doc.fonts ) ) +} + +// ─── Typography ────────────────────────────────────────────────────────────── + +/// Typography scale used by the default theme. +/// +/// Designed around the **Sora** typeface (Google Fonts). If Sora is not +/// installed on the system, ltk falls back to Liberation Sans / DejaVu Sans; +/// glyph metrics will differ slightly but the scale still reads correctly. +pub mod typography +{ + pub const H0: f32 = 50.0; + pub const H1: f32 = 34.0; + pub const H2: f32 = 24.0; + pub const H3: f32 = 20.0; + pub const BODY: f32 = 16.0; + pub const BODY_S: f32 = 14.0; + pub const BODY_XS: f32 = 12.0; + + /// Interlineado (line-height) multiplier recommended by the kit. Apply as + /// `size * LINE_HEIGHT` when laying out multi-line text blocks. + pub const LINE_HEIGHT: f32 = 1.5; +} + +// ─── Symbolic icon tinting ─────────────────────────────────────────────────── + +/// Re-tint a symbolic RGBA icon: replace every pixel's RGB with `tint` while +/// keeping the source alpha (weighted by `tint.a`). +/// +/// Input `rgba` must be straight-alpha RGBA8 with 4 bytes per pixel. +/// Returns a freshly allocated `Vec` of the same length. +/// +/// Useful for flattening Papirus / freedesktop icons to a single theme colour +/// so they stay legible against both light and dark backgrounds. +pub fn tint_symbolic( rgba: &[u8], tint: Color ) -> Vec +{ + let r = (tint.r.clamp( 0.0, 1.0 ) * 255.0) as u8; + let g = (tint.g.clamp( 0.0, 1.0 ) * 255.0) as u8; + let b = (tint.b.clamp( 0.0, 1.0 ) * 255.0) as u8; + let ta = tint.a.clamp( 0.0, 1.0 ); + + let mut out = Vec::with_capacity( rgba.len() ); + for px in rgba.chunks_exact( 4 ) + { + let a = (px[3] as f32 / 255.0) * ta; + out.extend_from_slice( &[ r, g, b, (a * 255.0) as u8 ] ); + } + out +} + +// ─── SVG rasterisation ─────────────────────────────────────────────────────── + +/// Process-wide cache of rasterised theme icons, keyed by (absolute path on +/// disk, target longest-edge size in physical pixels). Entries are produced +/// by [`icon_rgba`] and never invalidated — the key embeds the absolute path +/// and the icon files are read-only on disk, so a `set_active_document` +/// switch produces fresh keys rather than serving stale data. +static SVG_CACHE: Mutex>, u32, u32 )>>> + = Mutex::new( None ); + +/// Decode a UTF-8 SVG document into a premultiplied RGBA8 pixmap of +/// the requested longest-edge `size` in physical pixels. The shorter +/// edge is scaled proportionally so the icon's aspect ratio is +/// preserved; the returned `(width, height)` reflect the final +/// pixmap. +/// +/// Returns `None` for malformed SVG input or when the size is too +/// small (≤ 0 px on the longest edge). +/// +/// The implementation uses [`resvg`] (which bundles `usvg` and +/// `tiny-skia`) so the rasteriser is the same as the rest of ltk's +/// software-canvas path. Use [`icon_rgba`] when you want +/// path-resolution + caching against the active theme tree. +pub fn decode_svg_bytes( svg_bytes: &[u8], size: u32 ) -> Option<( Arc>, u32, u32 )> +{ + if size == 0 { return None; } + // SVGs that declare `width="100%"` without an explicit pixel size + // fall back to usvg's `default_size` (100×100). Match it to the + // requested size so percentage-only documents rasterise at the + // dimensions the caller asked for instead of a tiny default. + let mut opts = resvg::usvg::Options::default(); + if let Some( ds ) = resvg::usvg::Size::from_wh( size as f32, size as f32 ) + { + opts.default_size = ds; + } + let tree = resvg::usvg::Tree::from_data( svg_bytes, &opts ).ok()?; + let svg_size = tree.size(); + let longest = svg_size.width().max( svg_size.height() ); + if longest <= 0.0 { return None; } + let scale = size as f32 / longest; + let w = ( svg_size.width() * scale ).ceil() as u32; + let h = ( svg_size.height() * scale ).ceil() as u32; + let mut pixmap = resvg::tiny_skia::Pixmap::new( w.max( 1 ), h.max( 1 ) )?; + let transform = resvg::tiny_skia::Transform::from_scale( scale, scale ); + resvg::render( &tree, transform, &mut pixmap.as_mut() ); + Some( ( Arc::new( pixmap.take() ), w, h ) ) +} + +/// Resolve a theme-relative icon name to an absolute path inside the +/// active theme's `icons/catalogue/` tree. +/// +/// `name` is the slash-separated path **without** the `.svg` +/// extension (e.g. `"general/right-simple"`, +/// `"system/wifi-signal-full"`). The lookup tries the +/// `catalogue/filled/.svg` variant first and falls back to +/// `catalogue/line/.svg`; returns `None` when neither file +/// exists or the active document has no on-disk root. +pub fn icon_path( name: &str ) -> Option +{ + let state = ensure_active(); + let root = state.document.root.as_ref()?; + let filled = root.join( "icons/catalogue/filled" ).join( format!( "{name}.svg" ) ); + if filled.is_file() { return Some( filled ); } + let line = root.join( "icons/catalogue/line" ).join( format!( "{name}.svg" ) ); + if line.is_file() { return Some( line ); } + None +} + +/// Rasterise a theme icon to a premultiplied RGBA8 pixmap. +/// +/// Combines [`icon_path`] (path resolution against the active theme) +/// with [`decode_svg_bytes`] (SVG → RGBA), and caches the result by +/// `(absolute path, size)` so a widget redrawn every frame pays the +/// rasterisation cost only on first access. +/// +/// Returns `None` when the icon cannot be located, the file cannot be +/// read, or the SVG is malformed. +pub fn icon_rgba( name: &str, size: u32 ) -> Option<( Arc>, u32, u32 )> +{ + let path = icon_path( name )?; + let key = ( path.clone(), size ); + + // Fast path: cache hit. + { + let mut guard = SVG_CACHE.lock().ok()?; + let cache = guard.get_or_insert_with( HashMap::new ); + if let Some( v ) = cache.get( &key ) + { + return Some( ( Arc::clone( &v.0 ), v.1, v.2 ) ); + } + } + + // Slow path: read + decode + populate cache. + let bytes = std::fs::read( &path ).ok()?; + let result = decode_svg_bytes( &bytes, size )?; + if let Ok( mut guard ) = SVG_CACHE.lock() + { + let cache = guard.get_or_insert_with( HashMap::new ); + cache.insert( key, ( Arc::clone( &result.0 ), result.1, result.2 ) ); + } + Some( result ) +} + +// ─── Shared helpers ────────────────────────────────────────────────────────── + +/// Derive a sensible [`WindowControlsSpec`] from a [`Palette`] when the +/// theme document omits the `window_controls` block. Also used by the +/// JSON loader in `schema.rs` when individual overrides are absent. +pub ( super ) fn default_window_controls( palette: Palette ) -> WindowControlsSpec +{ + WindowControlsSpec + { + icon: palette.icon, + hover_bg: palette.surface_alt, + pressed_bg: palette.divider, + close_hover_bg: Color::rgba( 0.92, 0.18, 0.18, 0.90 ), + close_icon: Color::WHITE, + focus_ring: palette.accent, + } +} + +// ─── Errors ────────────────────────────────────────────────────────────────── + +#[ derive( Debug ) ] +pub enum ThemeError +{ + Io( PathBuf, std::io::Error ), + ParseJson( PathBuf, serde_json::Error ), + NotFound( String ), + InvalidColor( String ), + UnknownColorRef( String ), +} + +impl std::fmt::Display for ThemeError +{ + fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result + { + match self + { + ThemeError::Io( p, e ) => write!( f, "reading {}: {}", p.display(), e ), + ThemeError::ParseJson( p, e ) => write!( f, "parsing {}: {}", p.display(), e ), + ThemeError::NotFound( id ) => write!( f, "theme `{}` not found in any search path", id ), + ThemeError::InvalidColor( s ) => write!( f, "invalid colour `{}` (expected #RRGGBB, #RRGGBBAA or rgb[a](…))", s ), + ThemeError::UnknownColorRef( s ) => write!( f, "unknown reference `@{}` (not defined in top-level `colors`, `gradients` or `inset_stacks`)", s ), + } + } +} + +impl std::error::Error for ThemeError {} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn tint_preserves_source_alpha() + { + let src = [ 255, 0, 0, 255, 0, 255, 0, 128 ]; + let out = tint_symbolic( &src, Color::hex( 0x0B, 0x1B, 0x38 ) ); + assert_eq!( out[0..3], [ 0x0B, 0x1B, 0x38 ] ); + assert_eq!( out[3], 255 ); + assert_eq!( out[4..7], [ 0x0B, 0x1B, 0x38 ] ); + assert_eq!( out[7], 128 ); + } + + #[ test ] + fn tint_respects_tint_alpha() + { + let src = [ 255, 255, 255, 255 ]; + let out = tint_symbolic( &src, Color { r: 1.0, g: 1.0, b: 1.0, a: 0.5 } ); + assert_eq!( out[3], 127 ); + } + + #[ test ] + fn decode_svg_bytes_returns_pixmap_for_minimal_svg() + { + // 16×16 red square. Double-pound raw byte string so the `#` + // inside `fill="#ff0000"` does not close the literal. + let svg = br##""##; + let ( rgba, w, h ) = decode_svg_bytes( svg, 16 ).expect( "decode" ); + assert_eq!( w, 16 ); + assert_eq!( h, 16 ); + assert_eq!( rgba.len(), ( 16 * 16 * 4 ) as usize ); + // Centre pixel should be opaque red (premultiplied → R=255 A=255). + let centre = ( ( 8 * 16 + 8 ) * 4 ) as usize; + assert!( rgba[ centre + 0 ] > 200, "red channel" ); + assert!( rgba[ centre + 1 ] < 50, "green channel" ); + assert!( rgba[ centre + 2 ] < 50, "blue channel" ); + assert_eq!( rgba[ centre + 3 ], 255, "alpha" ); + } + + #[ test ] + fn decode_svg_bytes_scales_to_requested_size() + { + // 16×16 source rasterised at 32 px → output is 32×32. + let svg = br##""##; + let ( _, w, h ) = decode_svg_bytes( svg, 32 ).expect( "decode" ); + assert_eq!( w, 32 ); + assert_eq!( h, 32 ); + } + + #[ test ] + fn decode_svg_bytes_rejects_garbage() + { + assert!( decode_svg_bytes( b"not valid svg", 32 ).is_none() ); + assert!( decode_svg_bytes( b"", 0 ).is_none() ); // size 0 + } + + #[ test ] + fn preference_resolves_auto_by_hour() + { + assert_eq!( ThemePreference::Auto.resolve( 3 ), ThemeMode::Dark ); + assert_eq!( ThemePreference::Auto.resolve( 12 ), ThemeMode::Light ); + assert_eq!( ThemePreference::Auto.resolve( 22 ), ThemeMode::Dark ); + assert_eq!( ThemePreference::Light.resolve( 22 ), ThemeMode::Light ); + } + + #[ test ] + fn palette_from_slots_uses_canonical_ids() + { + let mut store = slots::SlotStore::new(); + store.insert + ( + "bg-page", + Slot::Color { value: Color::hex( 0x01, 0x02, 0x03 ), meta: Metadata::default() }, + ); + store.insert + ( + "text-primary", + Slot::Color { value: Color::hex( 0xF0, 0xF1, 0xF2 ), meta: Metadata::default() }, + ); + let p = Palette::from_slots( &store ); + assert_eq!( p.bg, Color::hex( 0x01, 0x02, 0x03 ) ); + assert_eq!( p.text_primary, Color::hex( 0xF0, 0xF1, 0xF2 ) ); + // Missing slots fall back to documented sensible defaults. + assert_eq!( p.icon, Color::BLACK ); + } +} diff --git a/src/theme/paint.rs b/src/theme/paint.rs new file mode 100644 index 0000000..745ffb1 --- /dev/null +++ b/src/theme/paint.rs @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Paint primitives: the "what do we fill a shape with" side of theming. +//! +//! A [`Paint`] is either a flat [`Color`], a [`LinearGradient`] or a +//! [`RadialGradient`]. Gradients carry their colour stops, a direction (angle +//! for linear, center+radius for radial) and the [`GradientSpace`] in which +//! stops are interpolated. Sampling happens downstream in the renderer; these +//! types are pure data and have no rendering logic of their own. +//! +//! # Stop positions +//! +//! Stops are represented as fractions (`0.0..=1.0` for the caller's mental +//! model) but the [`ColorStop::position`] field accepts **values outside +//! that range**. This is intentional: design-tool exports often emit +//! gradients whose stops fall outside `[0, 1]`, meaning the visible region +//! of the shape only covers a middle slice of the interpolation. The +//! renderer is expected to extrapolate linearly, not clamp. +//! +//! # Colour space +//! +//! Picking the right interpolation space matters for saturated gradients: +//! interpolating `#04D9FE → #8A38F5` in sRGB produces a muddy grey in the +//! middle, while Oklab keeps the chroma. The space is resolved at theme-load +//! time (stops converted once), not per pixel. + +use crate::types::Color; + +// ─── Gradient space ────────────────────────────────────────────────────────── + +/// Colour space in which a gradient's stops are interpolated. +/// +/// The default is [`GradientSpace::LinearRgb`]: cheap, physically correct, and +/// a clear win over naive sRGB interpolation. [`GradientSpace::Oklab`] is kept +/// as an opt-in for brand gradients with high-chroma endpoints where even +/// linear-light shows an undesirable darkening in the mid-point. [`GradientSpace::Srgb`] +/// exists primarily to reproduce designs that were authored against it +/// byte-for-byte. +#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +pub enum GradientSpace +{ + /// Interpolate directly in sRGB gamma-encoded space. Cheap, but midpoints + /// of saturated gradients look muddy. + Srgb, + /// Interpolate in linear-light RGB. Default. Physically correct and fast. + LinearRgb, + /// Interpolate in the Oklab perceptual colour space. Best for saturated + /// brand gradients; slightly more expensive. + Oklab, +} + +impl Default for GradientSpace +{ + fn default() -> Self { GradientSpace::LinearRgb } +} + +// ─── Colour stops ──────────────────────────────────────────────────────────── + +/// One stop of a gradient: a `position` along the gradient axis (for linear) +/// or along the radius (for radial), plus the [`Color`] at that point. +/// +/// `position` is a fraction but **may fall outside `[0.0, 1.0]`**. See the +/// module-level note on extrapolation. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub struct ColorStop +{ + /// Position along the gradient. `0.0` is the start, `1.0` is the end; + /// values outside this range are allowed and will be extrapolated. + pub position: f32, + /// Colour at this position. + pub color: Color, +} + +// ─── Linear gradient ───────────────────────────────────────────────────────── + +/// A straight gradient swept along a vector set by an angle. +/// +/// The angle convention follows CSS `linear-gradient`: `0deg` points from the +/// bottom edge towards the top, `90deg` from left to right, `180deg` from top +/// to bottom, and so on. +#[ derive( Debug, Clone, PartialEq ) ] +pub struct LinearGradient +{ + /// Direction of the gradient, in degrees (CSS convention). + pub angle_deg: f32, + /// Stops along the axis, in source order. The renderer does not require + /// them to be sorted by position — it sorts internally — but keeping them + /// in increasing order is conventional and makes diffs readable. + pub stops: Vec, + /// Space in which stops are interpolated. See [`GradientSpace`]. + pub space: GradientSpace, +} + +// ─── Radial gradient ───────────────────────────────────────────────────────── + +/// A gradient radiating from a centre point out to a radius. +/// +/// `center` and `radius` are expressed as **fractions of the bounding box** +/// (not pixels) so the gradient scales with the widget. `center: [0.5, 0.5]` +/// and `radius: 0.5` describes a circle inscribed in a square widget. +#[ derive( Debug, Clone, PartialEq ) ] +pub struct RadialGradient +{ + /// Centre of the gradient in box-relative coordinates, `[0.0, 1.0]`. + pub center: [f32; 2], + /// Radius of the gradient in box-relative units. `0.5` reaches the edge + /// of a square box, `1.0` reaches the corner diagonally. + pub radius: f32, + /// Stops along the radius, in source order. + pub stops: Vec, + /// Space in which stops are interpolated. See [`GradientSpace`]. + pub space: GradientSpace, +} + +// ─── Paint ─────────────────────────────────────────────────────────────────── + +/// How a shape is filled: a flat colour or one of the gradient variants. +/// +/// Theming consumers rarely construct [`Paint`] directly: a slot of kind +/// `color` is promoted to [`Paint::Solid`] automatically when the widget asks +/// for a paint. Only slots that actually declare a gradient in the theme JSON +/// round-trip through [`Paint::Linear`] / [`Paint::Radial`]. +#[ derive( Debug, Clone, PartialEq ) ] +pub enum Paint +{ + /// A uniform fill with a single [`Color`]. + Solid( Color ), + /// A linear gradient sweep. + Linear( LinearGradient ), + /// A radial gradient sweep. + Radial( RadialGradient ), +} + +impl Paint +{ + /// Convenience constructor for the common solid case. + pub fn solid( color: Color ) -> Self + { + Paint::Solid( color ) + } +} + +impl From for Paint +{ + fn from( c: Color ) -> Self { Paint::Solid( c ) } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn stops_positions_outside_unit_range_are_accepted() + { + // Construction must succeed; the renderer will extrapolate. + let grad = LinearGradient + { + angle_deg: 152.77, + stops: vec! + [ + ColorStop { position: -1.1654, color: Color::hex( 0xFF, 0x93, 0xA9 ) }, + ColorStop { position: 1.2332, color: Color::WHITE }, + ], + space: GradientSpace::LinearRgb, + }; + assert_eq!( grad.stops.len(), 2 ); + assert!( grad.stops[0].position < 0.0 ); + assert!( grad.stops[1].position > 1.0 ); + } + + #[ test ] + fn default_gradient_space_is_linear_rgb() + { + assert_eq!( GradientSpace::default(), GradientSpace::LinearRgb ); + } + + #[ test ] + fn paint_promotes_from_color() + { + let c = Color::hex( 0x04, 0xD9, 0xFE ); + let p: Paint = c.into(); + assert_eq!( p, Paint::Solid( c ) ); + } + + #[ test ] + fn radial_gradient_uses_box_relative_coordinates() + { + // Documenting the contract: center + radius are fractions, not pixels. + let g = RadialGradient + { + center: [ 0.5, 0.5 ], + radius: 0.5, + stops: vec! + [ + ColorStop { position: 0.0, color: Color::WHITE }, + ColorStop { position: 1.0, color: Color::TRANSPARENT }, + ], + space: GradientSpace::LinearRgb, + }; + assert_eq!( g.center, [ 0.5, 0.5 ] ); + assert_eq!( g.radius, 0.5 ); + } +} diff --git a/src/theme/schema.rs b/src/theme/schema.rs new file mode 100644 index 0000000..46618cd --- /dev/null +++ b/src/theme/schema.rs @@ -0,0 +1,1449 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! JSON schema for the theme document: raw deserialisation types, colour +//! (de)serialiser, and conversion to the public runtime types. +//! +//! This module is the single place where impedance between "JSON as designers +//! and tools write it" and "the Rust types widgets consume" lives. Every +//! public type in `theme::paint`, `theme::shadow`, `theme::surface`, +//! `theme::text_style` and `theme::slots` gets a private `Raw*` counterpart +//! here with the minimum serde annotations to match the JSON shape, and a +//! `From for *` conversion that builds the public value. +//! +//! Keeping the derives off the public types means the public API stays +//! free of serde dependencies and the JSON format can evolve (add fields, +//! accept aliases, reject unknown keys) without touching consumer code. + +use std::collections::HashMap; +use std::path::{ Path, PathBuf }; + +use serde::{ Deserialize, Deserializer, Serialize, Serializer }; +use serde::de::Error as DeError; + +use crate::types::Color; + +use super::document::{ Mode, ThemeDocument }; +use super::fonts::{ FontFamilyDef, FontSource }; +use super::paint::{ ColorStop, GradientSpace, LinearGradient, Paint, RadialGradient }; +use super::shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef }; +use super::slots::{ Metadata, Slot, SlotStore }; +use super::surface::Surface; +use super::text_style:: +{ + FontRef, FontStyle, LineHeight, TextDecoration, TextStyle, TextTransform, +}; +use super:: +{ + default_window_controls, LauncherSpec, ThemeError, ThemeMode, WallpaperFit, + WallpaperSpec, WindowControlsSpec, +}; + +// ─── Color serialiser ──────────────────────────────────────────────────────── + +/// Encode / decode [`Color`] as a hex or `rgba(…)` string. +/// +/// Accepted input forms: +/// +/// * `#RRGGBB` or `RRGGBB` — opaque, 8 bits per channel. +/// * `#RRGGBBAA` or `RRGGBBAA` — with straight alpha, 8 bits per channel. +/// * `rgba(R, G, B, A)` — R/G/B as integers `0..=255` (or floats), A as a +/// float in `0.0..=1.0`. Whitespace around commas is tolerated. +/// * `rgb(R, G, B)` — same as above with implicit A = 1.0. +/// +/// Output form (serialisation): `#RRGGBB` when alpha is 1.0, `#RRGGBBAA` +/// otherwise. +pub mod color_serde +{ + use super::*; + + /// Parse one of the accepted color syntaxes into a [`Color`]. + pub fn parse( s: &str ) -> Result + { + let t = s.trim(); + if t.starts_with( "rgba(" ) || t.starts_with( "rgb(" ) + { + parse_functional( t ) + } + else + { + parse_hex( t ) + } + } + + fn parse_hex( s: &str ) -> Result + { + let h = s.trim_start_matches( '#' ); + let ( r, g, b, a ) = match h.len() + { + 6 => + { + let r = u8::from_str_radix( &h[0..2], 16 ).map_err( |_| bad( s ) )?; + let g = u8::from_str_radix( &h[2..4], 16 ).map_err( |_| bad( s ) )?; + let b = u8::from_str_radix( &h[4..6], 16 ).map_err( |_| bad( s ) )?; + ( r, g, b, 0xFF ) + } + 8 => + { + let r = u8::from_str_radix( &h[0..2], 16 ).map_err( |_| bad( s ) )?; + let g = u8::from_str_radix( &h[2..4], 16 ).map_err( |_| bad( s ) )?; + let b = u8::from_str_radix( &h[4..6], 16 ).map_err( |_| bad( s ) )?; + let a = u8::from_str_radix( &h[6..8], 16 ).map_err( |_| bad( s ) )?; + ( r, g, b, a ) + } + _ => return Err( bad( s ) ), + }; + Ok( Color + { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: a as f32 / 255.0, + }) + } + + fn parse_functional( s: &str ) -> Result + { + let ( with_alpha, inner ) = if let Some( rest ) = s.strip_prefix( "rgba(" ) + { + ( true, rest.strip_suffix( ')' ).ok_or_else( || bad( s ) )? ) + } + else if let Some( rest ) = s.strip_prefix( "rgb(" ) + { + ( false, rest.strip_suffix( ')' ).ok_or_else( || bad( s ) )? ) + } + else + { + return Err( bad( s ) ); + }; + let parts: Vec<&str> = inner.split( ',' ).map( str::trim ).collect(); + let expected = if with_alpha { 4 } else { 3 }; + if parts.len() != expected { return Err( bad( s ) ); } + + let r = parse_channel( parts[0] ).ok_or_else( || bad( s ) )?; + let g = parse_channel( parts[1] ).ok_or_else( || bad( s ) )?; + let b = parse_channel( parts[2] ).ok_or_else( || bad( s ) )?; + let a = if with_alpha + { + parts[3].parse::().map_err( |_| bad( s ) )?.clamp( 0.0, 1.0 ) + } + else + { + 1.0 + }; + + Ok( Color { r: r / 255.0, g: g / 255.0, b: b / 255.0, a }) + } + + fn parse_channel( s: &str ) -> Option + { + // Integer 0..=255 or float 0..=255. + if let Ok( n ) = s.parse::() + { + if n <= 255 { return Some( n as f32 ); } + return None; + } + if let Ok( f ) = s.parse::() + { + if ( 0.0..=255.0 ).contains( &f ) { return Some( f ); } + } + None + } + + fn bad( s: &str ) -> String + { + format! + ( + "invalid colour `{}` (expected `#RRGGBB`, `#RRGGBBAA` or `rgb[a](…)`)", + s + ) + } + + /// Canonical string form: `#RRGGBB` when opaque, `#RRGGBBAA` otherwise. + pub fn format( c: Color ) -> String + { + let r = (c.r.clamp( 0.0, 1.0 ) * 255.0).round() as u8; + let g = (c.g.clamp( 0.0, 1.0 ) * 255.0).round() as u8; + let b = (c.b.clamp( 0.0, 1.0 ) * 255.0).round() as u8; + let a = (c.a.clamp( 0.0, 1.0 ) * 255.0).round() as u8; + if a == 0xFF + { + format!( "#{:02X}{:02X}{:02X}", r, g, b ) + } + else + { + format!( "#{:02X}{:02X}{:02X}{:02X}", r, g, b, a ) + } + } + + pub fn serialize( c: &Color, ser: S ) -> Result + where S: Serializer + { + ser.serialize_str( &format( *c ) ) + } + + pub fn deserialize<'de, D>( de: D ) -> Result + where D: Deserializer<'de> + { + let s = String::deserialize( de )?; + parse( &s ).map_err( D::Error::custom ) + } +} + +/// Expose the colour parser so error messages and other loaders can share +/// the same syntax understanding. +pub fn parse_color_str( s: &str ) -> Result +{ + color_serde::parse( s ) +} + +// ─── Enum mirrors with defaults ────────────────────────────────────────────── + +#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] +#[ serde( rename_all = "kebab-case" ) ] +enum RawGradientSpace +{ + Srgb, + LinearRgb, + Oklab, +} + +impl Default for RawGradientSpace { fn default() -> Self { RawGradientSpace::LinearRgb } } + +impl From for GradientSpace +{ + fn from( r: RawGradientSpace ) -> Self + { + match r + { + RawGradientSpace::Srgb => GradientSpace::Srgb, + RawGradientSpace::LinearRgb => GradientSpace::LinearRgb, + RawGradientSpace::Oklab => GradientSpace::Oklab, + } + } +} + +#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] +#[ serde( rename_all = "kebab-case" ) ] +enum RawBlendMode +{ + Normal, + PlusLighter, + Overlay, + Multiply, + Screen, +} + +impl Default for RawBlendMode { fn default() -> Self { RawBlendMode::Normal } } + +impl From for BlendMode +{ + fn from( r: RawBlendMode ) -> Self + { + match r + { + RawBlendMode::Normal => BlendMode::Normal, + RawBlendMode::PlusLighter => BlendMode::PlusLighter, + RawBlendMode::Overlay => BlendMode::Overlay, + RawBlendMode::Multiply => BlendMode::Multiply, + RawBlendMode::Screen => BlendMode::Screen, + } + } +} + +#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] +#[ serde( rename_all = "kebab-case" ) ] +enum RawFontStyle { Normal, Italic } + +impl Default for RawFontStyle { fn default() -> Self { RawFontStyle::Normal } } + +impl From for FontStyle +{ + fn from( r: RawFontStyle ) -> Self + { + match r + { + RawFontStyle::Normal => FontStyle::Normal, + RawFontStyle::Italic => FontStyle::Italic, + } + } +} + +#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] +#[ serde( rename_all = "kebab-case" ) ] +enum RawTextTransform { None, Uppercase, Lowercase, Capitalize } + +impl Default for RawTextTransform { fn default() -> Self { RawTextTransform::None } } + +impl From for TextTransform +{ + fn from( r: RawTextTransform ) -> Self + { + match r + { + RawTextTransform::None => TextTransform::None, + RawTextTransform::Uppercase => TextTransform::Uppercase, + RawTextTransform::Lowercase => TextTransform::Lowercase, + RawTextTransform::Capitalize => TextTransform::Capitalize, + } + } +} + +#[ derive( Debug, Clone, Copy, Serialize, Deserialize ) ] +#[ serde( rename_all = "kebab-case" ) ] +enum RawTextDecoration { None, Underline, Strikethrough } + +impl Default for RawTextDecoration { fn default() -> Self { RawTextDecoration::None } } + +impl From for TextDecoration +{ + fn from( r: RawTextDecoration ) -> Self + { + match r + { + RawTextDecoration::None => TextDecoration::None, + RawTextDecoration::Underline => TextDecoration::Underline, + RawTextDecoration::Strikethrough => TextDecoration::Strikethrough, + } + } +} + +// ─── Metadata ──────────────────────────────────────────────────────────────── + +#[ derive( Debug, Clone, Default, Serialize, Deserialize ) ] +struct RawMetadata +{ + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + semantic: Option, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + fluent: Option, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + usage: Option, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + note: Option, +} + +impl From for Metadata +{ + fn from( r: RawMetadata ) -> Self + { + Metadata { semantic: r.semantic, fluent: r.fluent, usage: r.usage, note: r.note } + } +} + +// ─── Gradient stops and gradients ──────────────────────────────────────────── + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawColorStop +{ + #[ serde( alias = "position" ) ] + pos: f32, + #[ serde( with = "color_serde" ) ] + color: Color, +} + +impl From for ColorStop +{ + fn from( r: RawColorStop ) -> Self { ColorStop { position: r.pos, color: r.color } } +} + +// ─── Paint (for `surface.fill`) ────────────────────────────────────────────── + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( tag = "type", rename_all = "kebab-case", deny_unknown_fields ) ] +enum RawPaint +{ + Solid + { + #[ serde( with = "color_serde" ) ] + color: Color, + }, + Linear + { + angle_deg: f32, + #[ serde( default ) ] + space: RawGradientSpace, + stops: Vec, + }, + Radial + { + center: [f32; 2], + radius: f32, + #[ serde( default ) ] + space: RawGradientSpace, + stops: Vec, + }, +} + +impl From for Paint +{ + fn from( r: RawPaint ) -> Self + { + match r + { + RawPaint::Solid { color } => Paint::Solid( color ), + RawPaint::Linear { angle_deg, space, stops } => Paint::Linear( LinearGradient + { + angle_deg, + space: space.into(), + stops: collect_capped_stops( stops ), + }), + RawPaint::Radial { center, radius, space, stops } => Paint::Radial( RadialGradient + { + center, + radius, + space: space.into(), + stops: collect_capped_stops( stops ), + }), + } + } +} + +/// Soft cap on the number of color stops a gradient may carry. Realistic +/// designs use 2 – 6 stops, the shipped theme tops out at 4, and +/// 64 leaves comfortable headroom for hand-tuned tonemap palettes. The +/// cap exists to bound CPU + memory under a hostile theme document — a +/// 200 000-stop linear gradient would otherwise build a 6 MB +/// `Vec` per slot at parse time. Exceeding the cap truncates +/// the tail and logs once via stderr. +const MAX_GRADIENT_STOPS: usize = 64; + +fn collect_capped_stops( raw: Vec ) -> Vec +{ + if raw.len() > MAX_GRADIENT_STOPS + { + eprintln!( + "[ltk theme] gradient declares {} stops, truncating to {}", + raw.len(), MAX_GRADIENT_STOPS, + ); + raw.into_iter().take( MAX_GRADIENT_STOPS ).map( Into::into ).collect() + } + else + { + raw.into_iter().map( Into::into ).collect() + } +} + +// ─── Shadows ───────────────────────────────────────────────────────────────── + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawShadow +{ + offset: [f32; 2], + blur: f32, + #[ serde( default ) ] + spread: f32, + #[ serde( with = "color_serde" ) ] + color: Color, + #[ serde( default ) ] + blend: RawBlendMode, +} + +impl From for Shadow +{ + fn from( r: RawShadow ) -> Self + { + Shadow { offset: r.offset, blur: r.blur, spread: r.spread, color: r.color, blend: r.blend.into() } + } +} + +impl From for InsetShadow +{ + fn from( r: RawShadow ) -> Self + { + InsetShadow { offset: r.offset, blur: r.blur, spread: r.spread, color: r.color, blend: r.blend.into() } + } +} + +/// Reference to a shadow stack inside a surface: either a string id +/// pointing at a `shadows` slot, or a literal list. +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( untagged ) ] +enum RawShadowsRef +{ + Named( String ), + Inline( Vec ), +} + +impl From for ShadowsRef +{ + fn from( r: RawShadowsRef ) -> Self + { + match r + { + RawShadowsRef::Named( id ) => ShadowsRef::Named( id ), + RawShadowsRef::Inline( items ) => ShadowsRef::Inline( items.into_iter().map( Into::into ).collect() ), + } + } +} + +// ─── Surface ───────────────────────────────────────────────────────────────── + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawSurfaceBody +{ + fill: Box, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + shadows: Option, + #[ serde( default ) ] + inset_shadows: Vec, +} + +impl From for Surface +{ + fn from( r: RawSurfaceBody ) -> Self + { + Surface + { + fill: Paint::from( *r.fill ), + shadows: r.shadows.map( Into::into ), + inset_shadows: r.inset_shadows.into_iter().map( Into::into ).collect(), + } + } +} + +// ─── Text style ────────────────────────────────────────────────────────────── + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( untagged ) ] +enum RawLineHeight +{ + Px { px: f32 }, + Multiplier { mul: f32 }, +} + +impl From for LineHeight +{ + fn from( r: RawLineHeight ) -> Self + { + match r + { + RawLineHeight::Px { px } => LineHeight::Px( px ), + RawLineHeight::Multiplier { mul } => LineHeight::Multiplier( mul ), + } + } +} + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawFontRef +{ + r#ref: String, +} + +impl From for FontRef +{ + fn from( r: RawFontRef ) -> Self { FontRef::Named( r.r#ref ) } +} + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawTextStyleBody +{ + family: RawFontRef, + weight: u16, + #[ serde( default ) ] + style: RawFontStyle, + size: f32, + line_height: RawLineHeight, + #[ serde( default ) ] + letter_spacing: f32, + #[ serde( default ) ] + transform: RawTextTransform, + #[ serde( default ) ] + decoration: RawTextDecoration, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + color: Option, +} + +impl From for TextStyle +{ + fn from( r: RawTextStyleBody ) -> Self + { + TextStyle + { + family: r.family.into(), + weight: r.weight, + style: r.style.into(), + size: r.size, + line_height: r.line_height.into(), + letter_spacing: r.letter_spacing, + transform: r.transform.into(), + decoration: r.decoration.into(), + color: r.color, + } + } +} + +// ─── Slot ──────────────────────────────────────────────────────────────────── + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( tag = "type", rename_all = "kebab-case" ) ] +enum RawSlot +{ + Color + { + #[ serde( with = "color_serde" ) ] + value: Color, + #[ serde( default ) ] + meta: RawMetadata, + }, + Linear + { + angle_deg: f32, + #[ serde( default ) ] + space: RawGradientSpace, + stops: Vec, + #[ serde( default ) ] + meta: RawMetadata, + }, + Radial + { + center: [f32; 2], + radius: f32, + #[ serde( default ) ] + space: RawGradientSpace, + stops: Vec, + #[ serde( default ) ] + meta: RawMetadata, + }, + Shadows + { + shadows: Vec, + #[ serde( default ) ] + meta: RawMetadata, + }, + Surface + { + #[ serde( flatten ) ] + body: RawSurfaceBody, + #[ serde( default ) ] + meta: RawMetadata, + }, + Typography + { + #[ serde( flatten ) ] + body: RawTextStyleBody, + #[ serde( default ) ] + meta: RawMetadata, + }, +} + +impl From for Slot +{ + fn from( r: RawSlot ) -> Self + { + match r + { + RawSlot::Color { value, meta } => Slot::Color { value, meta: meta.into() }, + RawSlot::Linear { angle_deg, space, stops, meta } => Slot::Paint + { + value: Paint::Linear( LinearGradient + { + angle_deg, + space: space.into(), + stops: collect_capped_stops( stops ), + }), + meta: meta.into(), + }, + RawSlot::Radial { center, radius, space, stops, meta } => Slot::Paint + { + value: Paint::Radial( RadialGradient + { + center, + radius, + space: space.into(), + stops: collect_capped_stops( stops ), + }), + meta: meta.into(), + }, + RawSlot::Shadows { shadows, meta } => Slot::Shadows + { + value: shadows.into_iter().map( Into::into ).collect(), + meta: meta.into(), + }, + RawSlot::Surface { body, meta } => Slot::Surface + { + value: body.into(), + meta: meta.into(), + }, + RawSlot::Typography { body, meta } => Slot::TextStyle + { + value: body.into(), + meta: meta.into(), + }, + } + } +} + +// ─── Fonts block ───────────────────────────────────────────────────────────── + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawFontSource +{ + weight: u16, + #[ serde( default ) ] + style: RawFontStyle, + path: String, +} + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawFontFamily +{ + name: String, + #[ serde( default ) ] + fallbacks: Vec, + #[ serde( default ) ] + sources: Vec, +} + +fn family_from_raw( root: Option<&Path>, r: RawFontFamily ) -> FontFamilyDef +{ + FontFamilyDef + { + name: r.name, + fallbacks: r.fallbacks, + sources: r.sources.into_iter().map( |s| FontSource + { + weight: s.weight, + style: s.style.into(), + path: resolve_relative( root, &s.path ), + }).collect(), + } +} + +// ─── Wallpaper / window_controls ───────────────────────────────────────────── + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawWallpaper +{ + path: String, + #[ serde( default ) ] + fit: WallpaperFit, +} + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawLauncher +{ + background: String, + border_radius: f32, +} + +#[ derive( Debug, Clone, Serialize, Deserialize, Default ) ] +#[ serde( deny_unknown_fields ) ] +struct RawWindowControls +{ + #[ serde( default ) ] icon: Option, + #[ serde( default ) ] hover_bg: Option, + #[ serde( default ) ] pressed_bg: Option, + #[ serde( default ) ] close_hover_bg: Option, + #[ serde( default ) ] close_icon: Option, + #[ serde( default ) ] focus_ring: Option, +} + +// ─── Mode / Modes / Document ───────────────────────────────────────────────── + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawMode +{ + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + wallpaper: Option, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + lockscreen: Option, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + launcher: Option, + #[ serde( default, skip_serializing_if = "Option::is_none" ) ] + window_controls: Option, + #[ serde( default ) ] + slots: HashMap, +} + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawModes +{ + light: RawMode, + dark: RawMode, +} + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawThemeMeta +{ + id: String, + name: String, +} + +#[ derive( Debug, Clone, Serialize, Deserialize ) ] +#[ serde( deny_unknown_fields ) ] +struct RawThemeDocument +{ + theme: RawThemeMeta, + #[ serde( default ) ] + fonts: HashMap, + modes: RawModes, +} + +// ─── Conversion from raw to runtime types ──────────────────────────────────── + +fn wallpaper_from_raw( root: Option<&Path>, r: RawWallpaper ) -> WallpaperSpec +{ + WallpaperSpec { path: Some( resolve_relative( root, &r.path ) ), fit: r.fit } +} + +fn window_controls_from_raw +( + fallback_palette: Option<&super::Palette>, + _mode: ThemeMode, + raw: RawWindowControls, +) -> Result +{ + // When the mode has no palette to derive sensible defaults from, we fall + // back to neutral black-on-white defaults for the fields the author did + // not override. + let fallback = match fallback_palette + { + Some( p ) => default_window_controls( *p ), + None => WindowControlsSpec + { + icon: Color::BLACK, + hover_bg: Color::rgba( 0.0, 0.0, 0.0, 0.08 ), + pressed_bg: Color::rgba( 0.0, 0.0, 0.0, 0.12 ), + close_hover_bg: Color::rgba( 0.92, 0.18, 0.18, 0.90 ), + close_icon: Color::WHITE, + focus_ring: Color::hex( 0x04, 0xD9, 0xFE ), + }, + }; + Ok( WindowControlsSpec + { + icon: parse_opt( raw.icon.as_deref(), fallback.icon )?, + hover_bg: parse_opt( raw.hover_bg.as_deref(), fallback.hover_bg )?, + pressed_bg: parse_opt( raw.pressed_bg.as_deref(), fallback.pressed_bg )?, + close_hover_bg: parse_opt( raw.close_hover_bg.as_deref(), fallback.close_hover_bg )?, + close_icon: parse_opt( raw.close_icon.as_deref(), fallback.close_icon )?, + focus_ring: parse_opt( raw.focus_ring.as_deref(), fallback.focus_ring )?, + }) +} + +fn parse_opt( s: Option<&str>, fallback: Color ) -> Result +{ + match s + { + Some( v ) => parse_color_str( v ).map_err( ThemeError::InvalidColor ), + None => Ok( fallback ), + } +} + +fn mode_from_raw( root: Option<&Path>, r: RawMode ) -> Result +{ + let mut store = SlotStore::new(); + for ( id, raw ) in r.slots + { + store.insert( id, Slot::from( raw ) ); + } + let wallpaper = r.wallpaper.map( |w| wallpaper_from_raw( root, w ) ); + let lockscreen = r.lockscreen.map( |w| wallpaper_from_raw( root, w ) ); + let launcher = match r.launcher + { + Some( l ) => Some( LauncherSpec + { + background: parse_color_str( &l.background ).map_err( ThemeError::InvalidColor )?, + border_radius: l.border_radius, + }), + None => None, + }; + let window_controls = match r.window_controls + { + Some( raw ) => Some( window_controls_from_raw( None, ThemeMode::Light, raw )? ), + None => None, + }; + Ok( Mode { wallpaper, lockscreen, launcher, window_controls, slots: store } ) +} + +// ─── Public entry points ───────────────────────────────────────────────────── + +/// Parse a theme document from its JSON source text. +/// +/// `root` is the directory the document was read from, used to resolve +/// relative paths (wallpaper images, font files) to absolute ones. Pass +/// `None` when parsing in-memory fixtures from tests. +/// +/// Performs the colour-reference resolution pass before structural +/// deserialisation: any string of the form `@name` or `@name/AA` (where +/// `AA` is a two-digit hex alpha override) is rewritten to its literal +/// hex form by looking `name` up in the top-level `colors` object. The +/// rewritten JSON is then deserialised into [`RawThemeDocument`] and +/// converted to the runtime types as before. +pub fn parse_document_json( text: &str, root: Option<&Path> ) + -> Result +{ + let mut value: serde_json::Value = serde_json::from_str( text ).map_err( |e| + ThemeError::ParseJson( root.map( Path::to_path_buf ).unwrap_or_default(), e ) + )?; + let colors = extract_colors_map( &value )?; + let gradients = extract_gradients_map( &value )?; + let inset_stacks = extract_inset_stacks_map( &value )?; + // Gradients live in objects (`{ "type": "linear", … }`) and inset stacks + // live in arrays (`[ { offset, blur, … }, … ]`); the resolver does not + // care about the shape, so the two sections share a single internal + // lookup. Names must therefore be unique across both — a collision is + // rejected up front rather than letting `@foo` resolve ambiguously. + let mut tokens = gradients; + for ( k, v ) in inset_stacks + { + if tokens.contains_key( &k ) + { + return Err( ThemeError::InvalidColor( format!( + "name `{}` is defined in both `gradients` and `inset_stacks`", k + ))); + } + tokens.insert( k, v ); + } + // Pre-resolve `@color` references that live inside token bodies so + // subsequent substitutions are flat clones with no recursion. Tokens + // cannot reference each other, so we resolve against an empty token + // map here. + let empty_tokens = HashMap::new(); + for ( _, t ) in tokens.iter_mut() + { + resolve_refs( t, &colors, &empty_tokens )?; + } + // Drop the top-level palette sections before resolving the rest, so the + // walk does not waste cycles on entries that are about to be discarded + // and `RawThemeDocument`'s `deny_unknown_fields` does not reject them. + if let serde_json::Value::Object( ref mut map ) = value + { + map.remove( "colors" ); + map.remove( "gradients" ); + map.remove( "inset_stacks" ); + } + resolve_refs( &mut value, &colors, &tokens )?; + + let raw: RawThemeDocument = serde_json::from_value( value ).map_err( |e| + ThemeError::ParseJson( root.map( Path::to_path_buf ).unwrap_or_default(), e ) + )?; + let fonts = raw.fonts + .into_iter() + .map( |( k, v )| ( k, family_from_raw( root, v ) ) ) + .collect(); + let light = mode_from_raw( root, raw.modes.light )?; + let dark = mode_from_raw( root, raw.modes.dark )?; + Ok( ThemeDocument + { + id: raw.theme.id, + name: raw.theme.name, + root: root.map( Path::to_path_buf ), + fonts, + light, + dark, + }) +} + +/// Read the top-level `colors` object from the raw JSON value and validate +/// each entry is a literal hex string. Returns an empty map if no `colors` +/// section is present. +fn extract_colors_map( value: &serde_json::Value ) + -> Result, ThemeError> +{ + let mut out = HashMap::new(); + let raw = match value.get( "colors" ) + { + Some( c ) => c, + None => return Ok( out ), + }; + let map = raw.as_object().ok_or_else( || + ThemeError::InvalidColor( "`colors` must be an object".to_string() ) + )?; + for ( k, v ) in map + { + let s = v.as_str().ok_or_else( || + ThemeError::InvalidColor( format!( "`colors.{}` must be a hex string", k ) ) + )?; + // References inside `colors` itself are not supported — every entry + // must be a literal hex value the rest of the document can point at. + let h = s.trim_start_matches( '#' ); + let valid = ( h.len() == 6 || h.len() == 8 ) && h.chars().all( |c| c.is_ascii_hexdigit() ); + if !valid + { + return Err( ThemeError::InvalidColor( + format!( "`colors.{}` = `{}` (expected #RRGGBB or #RRGGBBAA)", k, s ) + )); + } + out.insert( k.clone(), s.to_string() ); + } + Ok( out ) +} + +/// Read the top-level `gradients` object from the raw JSON value. Each +/// entry must be a paint object (`{ "type": "linear", "angle_deg": …, +/// "stops": [ … ] }`); shape correctness beyond "is an object" is left +/// for downstream serde deserialisation to enforce when the gradient is +/// substituted into a paint position. +fn extract_gradients_map( value: &serde_json::Value ) + -> Result, ThemeError> +{ + extract_token_map( value, "gradients", "paint object", serde_json::Value::is_object ) +} + +/// Read the top-level `inset_stacks` object from the raw JSON value. Each +/// entry must be an array of inset-shadow definitions — substituted +/// wholesale into an `inset_shadows` field on a surface. +fn extract_inset_stacks_map( value: &serde_json::Value ) + -> Result, ThemeError> +{ + extract_token_map( value, "inset_stacks", "array of inset-shadow definitions", serde_json::Value::is_array ) +} + +/// Generic helper for the `gradients` / `inset_stacks` extractors above: +/// reads a top-level object, validates each entry against `valid`, and +/// returns the entries cloned into a `HashMap`. Returns an empty map if +/// `section` is absent. +fn extract_token_map( + value: &serde_json::Value, + section: &str, + expected_kind: &str, + valid: impl Fn( &serde_json::Value ) -> bool, +) -> Result, ThemeError> +{ + let mut out = HashMap::new(); + let raw = match value.get( section ) + { + Some( c ) => c, + None => return Ok( out ), + }; + let map = raw.as_object().ok_or_else( || + ThemeError::InvalidColor( format!( "`{}` must be an object", section ) ) + )?; + for ( k, v ) in map + { + if !valid( v ) + { + return Err( ThemeError::InvalidColor( + format!( "`{}.{}` must be {}", section, k, expected_kind ) + )); + } + out.insert( k.clone(), v.clone() ); + } + Ok( out ) +} + +/// Walk `value` and replace `@name` / `@name/AA` references with their +/// resolved form. A reference whose name appears in `tokens` (gradient +/// objects or inset-shadow arrays) is substituted by the cloned token +/// value; a reference whose name appears in `colors` is substituted by +/// its hex literal. After a substitution the new node is recursed into +/// so a gradient that uses `@cyan-soft` in its stops or an inset stack +/// that uses `@glass-hi` is fully expanded. +fn resolve_refs( + value: &mut serde_json::Value, + colors: &HashMap, + tokens: &HashMap, +) -> Result<(), ThemeError> +{ + // Step 1: if this node is a `@ref` string, resolve and overwrite. + let replacement = match value + { + serde_json::Value::String( s ) => match s.strip_prefix( '@' ) + { + Some( rest ) => Some( resolve_one_ref( rest, colors, tokens )? ), + None => None, + }, + _ => None, + }; + if let Some( new_value ) = replacement + { + *value = new_value; + // The replacement may itself contain references (typically a + // gradient with `@color` stops). Recurse into the new node. + resolve_refs( value, colors, tokens )?; + return Ok( () ); + } + // Step 2: walk children of objects / arrays. + match value + { + serde_json::Value::Object( map ) => + { + for ( _, v ) in map.iter_mut() + { + resolve_refs( v, colors, tokens )?; + } + } + serde_json::Value::Array( arr ) => + { + for v in arr.iter_mut() + { + resolve_refs( v, colors, tokens )?; + } + } + _ => {} + } + Ok( () ) +} + +/// Resolve a single `@name[/AA]` reference. Looks up `tokens` first +/// (gradient or inset-stack), then `colors`. The `/AA` alpha override +/// only applies to colour references; using it on a non-colour token is +/// rejected. +fn resolve_one_ref( + rest: &str, + colors: &HashMap, + tokens: &HashMap, +) -> Result +{ + let ( name, alpha_hex ) = match rest.split_once( '/' ) + { + Some( ( n, a ) ) => ( n, Some( a ) ), + None => ( rest, None ), + }; + if let Some( tok ) = tokens.get( name ) + { + if let Some( a ) = alpha_hex + { + return Err( ThemeError::InvalidColor( format!( + "@{} is a paint/inset token — alpha override `/{}` is not applicable", name, a + ))); + } + return Ok( tok.clone() ); + } + let base = colors.get( name ) + .ok_or_else( || ThemeError::UnknownColorRef( name.to_string() ) )?; + let h = base.trim_start_matches( '#' ); + // `extract_colors_map` already validated the base is 6 or 8 hex digits. + let rgb = &h[0..6]; + let s = match alpha_hex + { + Some( a ) => + { + if a.len() != 2 || u8::from_str_radix( a, 16 ).is_err() + { + return Err( ThemeError::InvalidColor( + format!( "@{}/{} (alpha must be two hex digits)", name, a ) + )); + } + format!( "#{}{}", rgb.to_uppercase(), a.to_uppercase() ) + } + None => format!( "#{}", h.to_uppercase() ), + }; + Ok( serde_json::Value::String( s ) ) +} + +/// Load a theme document from a directory containing a `theme.json`. +pub fn load_document_from_dir( dir: &Path ) -> Result +{ + let json_path = dir.join( "theme.json" ); + let text = std::fs::read_to_string( &json_path ) + .map_err( |e| ThemeError::Io( json_path.clone(), e ) )?; + parse_document_json( &text, Some( dir ) ) +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +fn resolve_relative( root: Option<&Path>, rel: &str ) -> PathBuf +{ + let p = Path::new( rel ); + if p.is_absolute() { return p.to_path_buf(); } + match root + { + Some( r ) => r.join( p ), + None => p.to_path_buf(), + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn color_parser_accepts_hex_and_functional() + { + assert_eq!( color_serde::parse( "#04D9FE" ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) ); + assert_eq!( color_serde::parse( "04D9FE" ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) ); + let with_a = color_serde::parse( "#FFFFFF80" ).unwrap(); + assert!( ( with_a.a - 128.0 / 255.0 ).abs() < 1e-6 ); + let f = color_serde::parse( "rgba(255, 147, 169, 0.8)" ).unwrap(); + assert_eq!( f.r, 1.0 ); + assert!( ( f.g - 147.0 / 255.0 ).abs() < 1e-6 ); + assert!( ( f.a - 0.8 ).abs() < 1e-6 ); + let three = color_serde::parse( "rgb(0, 0, 0)" ).unwrap(); + assert_eq!( three, Color::BLACK ); + } + + #[ test ] + fn color_parser_rejects_bad_input() + { + assert!( color_serde::parse( "" ).is_err() ); + assert!( color_serde::parse( "#FFF" ).is_err() ); + assert!( color_serde::parse( "rgba(1,2,3)" ).is_err() ); + assert!( color_serde::parse( "rgba(1,2,3,4,5)" ).is_err() ); + assert!( color_serde::parse( "rgba(300, 0, 0, 1)" ).is_err() ); + } + + #[ test ] + fn color_formatter_is_canonical() + { + assert_eq!( color_serde::format( Color::hex( 0x04, 0xD9, 0xFE ) ), "#04D9FE" ); + let semi = Color { r: 1.0, g: 1.0, b: 1.0, a: 0.5 }; + assert_eq!( color_serde::format( semi ), "#FFFFFF80" ); + } + + #[ test ] + fn slot_color_parses_with_meta() + { + let json = r##" + { + "type": "color", + "value": "#04D9FE", + "meta": { "semantic": "primary/500" } + } + "##; + let raw: RawSlot = serde_json::from_str( json ).unwrap(); + let slot = Slot::from( raw ); + match slot + { + Slot::Color { value, meta } => + { + assert_eq!( value, Color::hex( 0x04, 0xD9, 0xFE ) ); + assert_eq!( meta.semantic.as_deref(), Some( "primary/500" ) ); + } + other => panic!( "expected color slot, got {:?}", other ), + } + } + + #[ test ] + fn gradient_under_cap_is_passed_through_verbatim() + { + let raw_stops: Vec = ( 0..16 ) + .map( |i| serde_json::from_str( + &format!( r##"{{ "pos": {}, "color": "#FFFFFFFF" }}"##, i as f32 / 15.0 ), + ).unwrap() ) + .collect(); + let result = collect_capped_stops( raw_stops ); + assert_eq!( result.len(), 16, "stops below the soft cap pass through unchanged" ); + } + + #[ test ] + fn gradient_at_exact_cap_is_passed_through() + { + let raw_stops: Vec = ( 0..MAX_GRADIENT_STOPS ) + .map( |i| serde_json::from_str( + &format!( r##"{{ "pos": {}, "color": "#FFFFFFFF" }}"##, i as f32 / 63.0 ), + ).unwrap() ) + .collect(); + let result = collect_capped_stops( raw_stops ); + assert_eq!( result.len(), MAX_GRADIENT_STOPS ); + } + + #[ test ] + fn gradient_over_cap_is_truncated_to_cap() + { + let raw_stops: Vec = ( 0..200 ) + .map( |i| serde_json::from_str( + &format!( r##"{{ "pos": {}, "color": "#000000FF" }}"##, i as f32 ), + ).unwrap() ) + .collect(); + let result = collect_capped_stops( raw_stops ); + assert_eq!( result.len(), MAX_GRADIENT_STOPS, "tail past the cap must be dropped" ); + } + + #[ test ] + fn slot_linear_gradient_accepts_extrapolated_stops() + { + let json = r##" + { + "type": "linear", + "angle_deg": 152.77, + "space": "linear-rgb", + "stops": + [ + { "pos": -1.1654, "color": "rgba(255, 147, 169, 0.8)" }, + { "pos": 1.2332, "color": "rgba(255, 255, 255, 0.8)" } + ] + } + "##; + let raw: RawSlot = serde_json::from_str( json ).unwrap(); + match Slot::from( raw ) + { + Slot::Paint { value: Paint::Linear( grad ), .. } => + { + assert_eq!( grad.stops.len(), 2 ); + assert!( grad.stops[0].position < 0.0 ); + assert!( grad.stops[1].position > 1.0 ); + assert_eq!( grad.space, GradientSpace::LinearRgb ); + } + _ => panic!( "expected linear paint" ), + } + } + + #[ test ] + fn slot_shadows_parses_stack() + { + let json = r##" + { + "type": "shadows", + "shadows": + [ + { "offset": [0, 4], "blur": 10, "color": "#00000014" }, + { "offset": [0, 1], "blur": 4, "color": "#0000000A" } + ] + } + "##; + let raw: RawSlot = serde_json::from_str( json ).unwrap(); + match Slot::from( raw ) + { + Slot::Shadows { value, .. } => + { + assert_eq!( value.len(), 2 ); + assert_eq!( value[0].offset, [ 0.0, 4.0 ] ); + assert_eq!( value[0].blur, 10.0 ); + assert_eq!( value[0].blend, BlendMode::Normal ); + } + _ => panic!( "expected shadows slot" ), + } + } + + #[ test ] + fn slot_surface_reproduces_glass_accent() + { + let json = r##" + { + "type": "surface", + "fill": { "type": "solid", "color": "#04D9FE" }, + "shadows": "shadows-glass", + "inset_shadows": + [ + { "offset": [-3.6, -3.6], "blur": 13.5, "color": "#555555", "blend": "plus-lighter" }, + { "offset": [ 1.8, 1.8], "blur": 1.8, "color": "#555555", "blend": "plus-lighter" }, + { "offset": [ 0.45, 0.45], "blur": 0.9, "color": "#000000", "blend": "overlay" }, + { "offset": [ 1.8, 1.8], "blur": 7.2, "color": "#00000026", "blend": "normal" } + ], + "backdrop": { "blur_px": 22.5 } + } + "##; + let raw: RawSlot = serde_json::from_str( json ).unwrap(); + match Slot::from( raw ) + { + Slot::Surface { value, .. } => + { + assert!( matches!( value.fill, Paint::Solid( _ ) ) ); + assert_eq!( value.inset_shadows.len(), 4 ); + assert_eq!( value.inset_shadows[0].blend, BlendMode::PlusLighter ); + assert_eq!( value.inset_shadows[2].blend, BlendMode::Overlay ); + match value.shadows.as_ref().unwrap() + { + ShadowsRef::Named( id ) => assert_eq!( id, "shadows-glass" ), + other => panic!( "expected named shadow ref, got {:?}", other ), + } + } + _ => panic!( "expected surface slot" ), + } + } + + #[ test ] + fn slot_typography_parses_body_m() + { + let json = r##" + { + "type": "typography", + "family": { "ref": "sora" }, + "weight": 400, + "size": 16, + "line_height": { "px": 24 }, + "letter_spacing": 0, + "meta": { "semantic": "body/m" } + } + "##; + let raw: RawSlot = serde_json::from_str( json ).unwrap(); + match Slot::from( raw ) + { + Slot::TextStyle { value, meta } => + { + assert_eq!( value.weight, 400 ); + assert_eq!( value.size, 16.0 ); + assert_eq!( value.line_height, LineHeight::Px( 24.0 ) ); + assert_eq!( meta.semantic.as_deref(), Some( "body/m" ) ); + } + _ => panic!( "expected typography slot" ), + } + } + + #[ test ] + fn document_parses_minimal_two_modes() + { + let json = r##" + { + "theme": { "id": "demo", "name": "Demo" }, + "fonts": + { + "sora": + { + "name": "Sora", + "fallbacks": ["system-ui", "sans-serif"], + "sources": + [ + { "weight": 400, "path": "fonts/Sora-Regular.ttf" } + ] + } + }, + "modes": + { + "light": + { + "slots": + { + "primary-500": { "type": "color", "value": "#04D9FE" }, + "body-m": { "type": "typography", "family": { "ref": "sora" }, "weight": 400, "size": 16, "line_height": { "px": 24 } } + } + }, + "dark": + { + "slots": + { + "primary-500": { "type": "color", "value": "#04D9FE" } + } + } + } + } + "##; + let doc = parse_document_json( json, None ).unwrap(); + assert_eq!( doc.id, "demo" ); + assert_eq!( doc.name, "Demo" ); + assert_eq!( doc.fonts.len(), 1 ); + assert_eq!( doc.fonts["sora"].sources[0].weight, 400 ); + assert_eq!( doc.light.slots.len(), 2 ); + assert_eq!( doc.dark.slots.len(), 1 ); + assert!( doc.light.slots.color( "primary-500" ).is_some() ); + assert!( doc.light.slots.text_style( "body-m" ).is_some() ); + } + + #[ test ] + fn unknown_fields_in_stop_are_rejected() + { + // `deny_unknown_fields` doesn't cooperate well with + // `#[ serde( flatten ) ]`, so the slot-level check would be unreliable + // on variants that flatten a body struct. The safety net lives on + // concrete nested types like `RawColorStop`, `RawShadow`, and + // `RawWallpaper`, which reject typos outright. + let bad = r##" + { + "type": "linear", + "angle_deg": 90, + "stops": + [ + { "pos": 0, "color": "#FFFFFF", "extraneous": true } + ] + } + "##; + assert!( serde_json::from_str::( bad ).is_err() ); + } +} diff --git a/src/theme/shadow.rs b/src/theme/shadow.rs new file mode 100644 index 0000000..b404640 --- /dev/null +++ b/src/theme/shadow.rs @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Shadow primitives: outer drop shadows, inner inset shadows, and the +//! blend modes used to composite them. +//! +//! # Units +//! +//! [`Shadow::blur`] and [`InsetShadow::blur`] store the **CSS blur radius**, +//! not the SVG `stdDeviation`. The relationship is `blur = 2 × stdDeviation`, +//! which is what browsers compute for `box-shadow: … blur …`. The shader +//! integrates against `sigma`, so it applies `sigma = blur / 2` internally +//! (see [`Shadow::sigma`]). +//! +//! # Order +//! +//! A theme's `shadows` list is stored **back-to-front**, mirroring SVG's +//! `feBlend` stacking order. The first entry is painted first (lowest layer), +//! the last entry is painted last (topmost). This is the inverse of CSS +//! `box-shadow` string order. Documented here so the renderer loop (`for +//! shadow in shadows { ... }`) produces the visually correct result without +//! reversing. + +use crate::types::Color; + +// ─── Blend modes ───────────────────────────────────────────────────────────── + +/// How a shadow composites against the layers below it. +/// +/// All modes assume **premultiplied** colour and alpha. The GPU pipeline is +/// expected to be premul-correct; the software pipeline must premultiply +/// before applying these formulas. +#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +pub enum BlendMode +{ + /// Standard `src-over`: `result = src + dst × (1 − src.a)`. + Normal, + /// CSS `plus-lighter`: `result = min(1, src + dst)`, channel-wise on + /// premultiplied values. Adds light; never darkens. + PlusLighter, + /// Overlay (multiply on dark base, screen on light base). Preserves the + /// base's luminance while pushing local contrast. + Overlay, + /// Multiplicative blend: `result = src × dst`. Only darkens. + Multiply, + /// Screen blend: `result = 1 − (1 − src) × (1 − dst)`. Only lightens. + Screen, +} + +impl Default for BlendMode +{ + fn default() -> Self { BlendMode::Normal } +} + +// ─── Outer shadow ──────────────────────────────────────────────────────────── + +/// An outer drop shadow cast by a shape. +/// +/// Modelled after CSS `box-shadow`: an offset, a blur radius, an optional +/// spread that dilates (positive) or erodes (negative) the silhouette before +/// blurring, a colour, and a blend mode. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub struct Shadow +{ + /// `[dx, dy]` offset in CSS pixels. Positive `dy` is downward. + pub offset: [f32; 2], + /// CSS blur radius in pixels (2 × SVG `stdDeviation`). + pub blur: f32, + /// Spread in CSS pixels. Positive values dilate the silhouette before + /// blurring; negative values erode it. Usually `0.0`. + pub spread: f32, + /// Shadow colour, including alpha. + pub color: Color, + /// Compositing mode. Most drop shadows use [`BlendMode::Normal`]. + pub blend: BlendMode, +} + +impl Shadow +{ + /// Gaussian sigma derived from the CSS blur radius. + /// + /// The shader integrates Gaussian kernels against `sigma`, but the public + /// field stores the CSS blur radius for parity with CSS `box-shadow`. + /// The relationship is `sigma = blur / 2`. + pub fn sigma( &self ) -> f32 { self.blur * 0.5 } +} + +// ─── Inner shadow ──────────────────────────────────────────────────────────── + +/// An inset shadow: a shadow painted **inside** the shape's silhouette, as +/// opposed to the outer drop shadow cast behind it. +/// +/// Structurally identical to [`Shadow`]; kept as a separate type so the +/// renderer and theme JSON can't accidentally treat an inset as an outer +/// (and vice versa) — the dispatch is at the type level. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub struct InsetShadow +{ + /// `[dx, dy]` offset in CSS pixels. Positive `dy` is downward. + pub offset: [f32; 2], + /// CSS blur radius in pixels (2 × SVG `stdDeviation`). + pub blur: f32, + /// Spread in CSS pixels. + pub spread: f32, + /// Shadow colour, including alpha. + pub color: Color, + /// Compositing mode against the layers below. Insets routinely use + /// non-`Normal` modes (`PlusLighter` for highlights, `Overlay` for rim). + pub blend: BlendMode, +} + +impl InsetShadow +{ + /// Gaussian sigma derived from the CSS blur radius. See [`Shadow::sigma`]. + pub fn sigma( &self ) -> f32 { self.blur * 0.5 } +} + +// ─── Shadow reference ──────────────────────────────────────────────────────── + +/// How a [`crate::theme::Surface`] refers to its outer shadow stack: either +/// by name (reused across several surfaces — the common case for elevation +/// tokens) or inline (one-off, uncommon). +#[ derive( Debug, Clone, PartialEq ) ] +pub enum ShadowsRef +{ + /// Reference to another slot in the theme, by id. + Named( String ), + /// The shadow list, carried inline. Used when the surface is exotic + /// enough that the stack isn't worth a dedicated slot. + Inline( Vec ), +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn sigma_is_half_of_css_blur() + { + // CSS blur 4 → SVG stdDev 2. The shader needs sigma == stdDev == 2. + let s = Shadow + { + offset: [ 0.0, 2.0 ], + blur: 4.0, + spread: 0.0, + color: Color::rgba( 0.0, 0.0, 0.0, 0.04 ), + blend: BlendMode::Normal, + }; + assert_eq!( s.sigma(), 2.0 ); + } + + #[ test ] + fn inset_sigma_follows_same_convention() + { + let i = InsetShadow + { + offset: [ -3.6, -3.6 ], + blur: 13.5, + spread: 0.0, + color: Color::hex( 0x55, 0x55, 0x55 ), + blend: BlendMode::PlusLighter, + }; + assert!( ( i.sigma() - 6.75 ).abs() < 1e-6 ); + } + + #[ test ] + fn default_blend_mode_is_normal() + { + assert_eq!( BlendMode::default(), BlendMode::Normal ); + } + + #[ test ] + fn shadows_ref_distinguishes_named_and_inline() + { + let n = ShadowsRef::Named( "shadows-2".to_string() ); + let i = ShadowsRef::Inline( vec! + [ + Shadow + { + offset: [ 0.0, 4.0 ], + blur: 10.0, + spread: 0.0, + color: Color::rgba( 0.0, 0.0, 0.0, 0.08 ), + blend: BlendMode::Normal, + }, + ]); + match n { ShadowsRef::Named( ref s ) => assert_eq!( s, "shadows-2" ), _ => panic!() } + match i { ShadowsRef::Inline( v ) => assert_eq!( v.len(), 1 ), _ => panic!() } + } +} diff --git a/src/theme/slots.rs b/src/theme/slots.rs new file mode 100644 index 0000000..a99eab1 --- /dev/null +++ b/src/theme/slots.rs @@ -0,0 +1,345 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The slot-typed lookup table that backs the new theme API. +//! +//! A [`Slot`] is a typed entry in the theme document: it is always either a +//! [`Color`], a [`Paint`] (that is a gradient — solid colours live in the +//! `Color` variant), an outer [`Shadow`] stack, a composite [`Surface`] or +//! a [`TextStyle`]. Widgets resolve them through [`SlotStore`]. +//! +//! # Promotion +//! +//! The store offers five accessors (`color`, `paint`, `shadows`, `surface`, +//! `text_style`). They promote automatically wherever it makes sense: asking +//! for a paint against a colour slot returns `Paint::Solid(c)`; asking for a +//! surface against a colour returns a `Surface` with `fill: Solid(c)` and +//! empty decorations; asking for a surface against a paint returns a +//! `Surface` wrapping that paint. Stricter requests that cannot be satisfied +//! (asking for a `TextStyle` against a colour, for example) return `None`. + +use std::collections::HashMap; + +use crate::types::Color; + +use super::paint::Paint; +use super::shadow::Shadow; +use super::surface::Surface; +use super::text_style::TextStyle; + +// ─── Metadata ──────────────────────────────────────────────────────────────── + +/// Optional free-form annotations a theme author can attach to any slot. The +/// runtime ignores these — inspection tools (and human readers of the JSON) +/// use them. +/// +/// All fields are optional and default to `None`. Missing fields in the JSON +/// do not raise an error. +#[ derive( Debug, Clone, Default, PartialEq, Eq ) ] +pub struct Metadata +{ + /// Canonical name of the token in the design system + /// (e.g. `"primary/500"`). + pub semantic: Option, + /// Equivalent name in another system (e.g. `"NeutralColors.white"` for + /// Fluent). Useful when migrating from or cross-referencing other kits. + pub fluent: Option, + /// Human-readable guidance on where to use this slot. + pub usage: Option, + /// Free-form note, typically for quirks worth flagging in the JSON. + pub note: Option, +} + +// ─── Slot ──────────────────────────────────────────────────────────────────── + +/// One typed entry of the theme. +#[ derive( Debug, Clone, PartialEq ) ] +pub enum Slot +{ + /// A single opaque or translucent colour. + Color { value: Color, meta: Metadata }, + /// A gradient (linear or radial). Solid colours are kept in + /// [`Slot::Color`] — they do not round-trip through this variant. + Paint { value: Paint, meta: Metadata }, + /// An ordered stack of outer shadows, typically an elevation level. + Shadows { value: Vec, meta: Metadata }, + /// A composite surface: fill, outer shadows (ref or inline), inset + /// shadows and an optional backdrop. + Surface { value: Surface, meta: Metadata }, + /// A resolved text style (family, weight, size, line-height, …). + TextStyle { value: TextStyle, meta: Metadata }, +} + +impl Slot +{ + /// Annotations attached to the slot, if any. + pub fn metadata( &self ) -> &Metadata + { + match self + { + Slot::Color { meta, .. } => meta, + Slot::Paint { meta, .. } => meta, + Slot::Shadows { meta, .. } => meta, + Slot::Surface { meta, .. } => meta, + Slot::TextStyle { meta, .. } => meta, + } + } + + /// Short tag identifying the slot's kind. Useful in error messages. + pub fn kind_tag( &self ) -> &'static str + { + match self + { + Slot::Color { .. } => "color", + Slot::Paint { .. } => "paint", + Slot::Shadows { .. } => "shadows", + Slot::Surface { .. } => "surface", + Slot::TextStyle { .. } => "typography", + } + } +} + +// ─── Store ─────────────────────────────────────────────────────────────────── + +/// Lookup table indexed by slot id. +/// +/// The store is immutable from a consumer's standpoint: themes are built +/// once at load time and handed to the renderer. All accessors return +/// references bound to the store's lifetime. +#[ derive( Debug, Clone, Default ) ] +pub struct SlotStore +{ + entries: HashMap, +} + +impl SlotStore +{ + /// Build an empty store. Used by tests and by the JSON loader. + pub fn new() -> Self + { + Self { entries: HashMap::new() } + } + + /// Insert a slot under `id`. Returns the previous slot under that id if + /// there was one. Used by the JSON loader; not typically called from + /// widget code. + pub fn insert( &mut self, id: impl Into, slot: Slot ) -> Option + { + self.entries.insert( id.into(), slot ) + } + + /// The raw entry, if present. + pub fn get( &self, id: &str ) -> Option<&Slot> + { + self.entries.get( id ) + } + + /// Number of slots in the store. + pub fn len( &self ) -> usize { self.entries.len() } + + /// Whether the store is empty. + pub fn is_empty( &self ) -> bool { self.entries.is_empty() } + + // ─── Typed accessors ───────────────────────────────────────────────── + + /// Resolve `id` to a [`Color`]. Only `Slot::Color` matches — gradients + /// do not promote down to a single colour. + pub fn color( &self, id: &str ) -> Option + { + match self.entries.get( id )? + { + Slot::Color { value, .. } => Some( *value ), + _ => None, + } + } + + /// Resolve `id` to a [`Paint`]. A colour slot promotes to + /// [`Paint::Solid`]; a paint slot returns directly. + pub fn paint( &self, id: &str ) -> Option + { + match self.entries.get( id )? + { + Slot::Color { value, .. } => Some( Paint::Solid( *value ) ), + Slot::Paint { value, .. } => Some( value.clone() ), + _ => None, + } + } + + /// Resolve `id` to an outer-shadow stack. Only `Slot::Shadows` matches. + pub fn shadows( &self, id: &str ) -> Option<&[Shadow]> + { + match self.entries.get( id )? + { + Slot::Shadows { value, .. } => Some( value.as_slice() ), + _ => None, + } + } + + /// Resolve `id` to a [`Surface`]. A colour slot promotes to a surface + /// with a solid fill and no decorations; a paint slot promotes to a + /// surface with that paint as fill and no decorations; a surface slot + /// returns directly. + pub fn surface( &self, id: &str ) -> Option + { + match self.entries.get( id )? + { + Slot::Color { value, .. } => Some( Surface::from_paint( Paint::Solid( *value ) ) ), + Slot::Paint { value, .. } => Some( Surface::from_paint( value.clone() ) ), + Slot::Surface { value, .. } => Some( value.clone() ), + _ => None, + } + } + + /// Resolve `id` to a [`TextStyle`]. Only `Slot::TextStyle` matches — + /// typography does not promote from anything else. + pub fn text_style( &self, id: &str ) -> Option<&TextStyle> + { + match self.entries.get( id )? + { + Slot::TextStyle { value, .. } => Some( value ), + _ => None, + } + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::theme::paint::{ ColorStop, GradientSpace, LinearGradient }; + use crate::theme::shadow::{ BlendMode }; + use crate::theme::text_style::{ FontRef, LineHeight }; + + fn sample_color_slot() -> ( &'static str, Slot ) + { + ( + "primary-500", + Slot::Color + { + value: Color::hex( 0x04, 0xD9, 0xFE ), + meta: Metadata { semantic: Some( "primary/500".to_string() ), ..Metadata::default() }, + }, + ) + } + + fn sample_gradient_slot() -> ( &'static str, Slot ) + { + ( + "gradient-error-light", + Slot::Paint + { + value: Paint::Linear( LinearGradient + { + angle_deg: 152.77, + stops: vec! + [ + ColorStop { position: -1.1654, color: Color::hex( 0xFF, 0x93, 0xA9 ) }, + ColorStop { position: 1.2332, color: Color::WHITE }, + ], + space: GradientSpace::LinearRgb, + }), + meta: Metadata::default(), + }, + ) + } + + #[ test ] + fn color_slot_promotes_to_paint_and_surface() + { + let mut store = SlotStore::new(); + let ( id, slot ) = sample_color_slot(); + store.insert( id, slot ); + + assert!( store.color( id ).is_some() ); + assert_eq!( store.color( id ).unwrap(), Color::hex( 0x04, 0xD9, 0xFE ) ); + assert!( matches!( store.paint( id ).unwrap(), Paint::Solid( _ ) ) ); + let surface = store.surface( id ).unwrap(); + assert!( matches!( surface.fill, Paint::Solid( _ ) ) ); + assert!( surface.inset_shadows.is_empty() ); + + } + + #[ test ] + fn paint_slot_does_not_downgrade_to_color() + { + let mut store = SlotStore::new(); + let ( id, slot ) = sample_gradient_slot(); + store.insert( id, slot ); + + assert!( store.color( id ).is_none(), "gradient must not answer as color" ); + assert!( matches!( store.paint( id ).unwrap(), Paint::Linear( _ ) ) ); + assert!( matches!( store.surface( id ).unwrap().fill, Paint::Linear( _ ) ) ); + } + + #[ test ] + fn shadows_and_text_style_are_strict() + { + let mut store = SlotStore::new(); + let ( c_id, c_slot ) = sample_color_slot(); + store.insert( c_id, c_slot ); + + // Color does not promote into shadows or text_style. + assert!( store.shadows( c_id ).is_none() ); + assert!( store.text_style( c_id ).is_none() ); + + // A text-style slot answers as such. + store.insert + ( + "body-m", + Slot::TextStyle + { + value: TextStyle::new + ( + FontRef::Named( "sora".to_string() ), + 400, + 16.0, + LineHeight::Px( 24.0 ), + ), + meta: Metadata::default(), + }, + ); + let ts = store.text_style( "body-m" ).unwrap(); + assert_eq!( ts.size, 16.0 ); + } + + #[ test ] + fn missing_id_returns_none_everywhere() + { + let store = SlotStore::new(); + assert!( store.color ( "nope" ).is_none() ); + assert!( store.paint ( "nope" ).is_none() ); + assert!( store.shadows ( "nope" ).is_none() ); + assert!( store.surface ( "nope" ).is_none() ); + assert!( store.text_style( "nope" ).is_none() ); + } + + #[ test ] + fn metadata_accessor_is_uniform_across_variants() + { + let ( id, slot ) = sample_color_slot(); + assert_eq!( slot.metadata().semantic.as_deref(), Some( "primary/500" ) ); + assert_eq!( slot.kind_tag(), "color" ); + + let shadow_slot = Slot::Shadows + { + value: vec! + [ + Shadow + { + offset: [ 0.0, 2.0 ], + blur: 4.0, + spread: 0.0, + color: Color::rgba( 0.0, 0.0, 0.0, 0.04 ), + blend: BlendMode::Normal, + }, + ], + meta: Metadata { note: Some( "elevation 1 - topmost layer".to_string() ), ..Default::default() }, + }; + assert_eq!( shadow_slot.kind_tag(), "shadows" ); + assert!( shadow_slot.metadata().note.is_some() ); + + let _ = id; + } +} diff --git a/src/theme/surface.rs b/src/theme/surface.rs new file mode 100644 index 0000000..61a36a6 --- /dev/null +++ b/src/theme/surface.rs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Composite surfaces: fill + outer shadows + inset shadows. +//! +//! A [`Surface`] is the richest kind of theme slot. It packages together a +//! fill (possibly a gradient), an elevation stack, and inset shadows with +//! their own blend modes. Widgets that only need a flat colour or a plain +//! gradient do not construct [`Surface`] values directly; slot promotion +//! (in `theme::slots`) hands them a [`Surface`] with empty decorations when +//! they ask for one against a simpler slot. + +use super::paint::Paint; +use super::shadow::{ InsetShadow, ShadowsRef }; +use crate::types::Color; + +// ─── Surface ───────────────────────────────────────────────────────────────── + +/// A composite theme surface: fill, elevation, insets. +/// +/// All decorations are optional. A surface with `fill: Paint::Solid(...)`, +/// no shadows and no insets behaves exactly like a flat-colour fill, which +/// is why the promotion path from a `color` slot to a [`Surface`] is trivial. +#[ derive( Debug, Clone, PartialEq ) ] +pub struct Surface +{ + /// What the surface is filled with. + pub fill: Paint, + /// Outer shadow stack. When `Some`, can reference another slot by name + /// or inline the full list. + pub shadows: Option, + /// Inset shadows, in back-to-front order. Each entry carries its own + /// [`crate::theme::BlendMode`]. + pub inset_shadows: Vec, +} + +impl Surface +{ + /// Build a surface from just a [`Paint`], with no shadows or insets. + pub fn from_paint( paint: Paint ) -> Self + { + Self + { + fill: paint, + shadows: None, + inset_shadows: Vec::new(), + } + } +} + +impl From for Surface +{ + fn from( p: Paint ) -> Self { Surface::from_paint( p ) } +} + +impl From for Surface +{ + fn from( c: Color ) -> Self { Surface::from_paint( Paint::Solid( c ) ) } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::types::Color; + use crate::theme::shadow::{ BlendMode, InsetShadow, Shadow, ShadowsRef }; + + #[ test ] + fn surface_promotes_from_color() + { + let s: Surface = Color::hex( 0x04, 0xD9, 0xFE ).into(); + assert_eq!( s.fill, Paint::Solid( Color::hex( 0x04, 0xD9, 0xFE ) ) ); + assert!( s.shadows.is_none() ); + assert!( s.inset_shadows.is_empty() ); + } + + #[ test ] + fn glass_accent_shape_composes() + { + let s = Surface + { + fill: Paint::Solid( Color::hex( 0x04, 0xD9, 0xFE ) ), + shadows: Some( ShadowsRef::Named( "shadows-glass".to_string() ) ), + inset_shadows: vec! + [ + InsetShadow { offset: [ -3.6, -3.6 ], blur: 13.5, spread: 0.0, color: Color::hex( 0x55, 0x55, 0x55 ), blend: BlendMode::PlusLighter }, + InsetShadow { offset: [ 1.8, 1.8 ], blur: 1.8, spread: 0.0, color: Color::hex( 0x55, 0x55, 0x55 ), blend: BlendMode::PlusLighter }, + InsetShadow { offset: [ 0.45, 0.45 ], blur: 0.9, spread: 0.0, color: Color::BLACK, blend: BlendMode::Overlay }, + InsetShadow { offset: [ 1.8, 1.8 ], blur: 7.2, spread: 0.0, color: Color::rgba( 0.0, 0.0, 0.0, 0.15 ), blend: BlendMode::Normal }, + ], + }; + assert_eq!( s.inset_shadows.len(), 4 ); + assert_eq!( s.inset_shadows[0].blend, BlendMode::PlusLighter ); + assert_eq!( s.inset_shadows[2].blend, BlendMode::Overlay ); + + let _ = Shadow + { + offset: [ 0.0, 0.0 ], + blur: 9.0, + spread: 0.0, + color: Color::rgba( 33.0 / 255.0, 33.0 / 255.0, 33.0 / 255.0, 0.25 ), + blend: BlendMode::Normal, + }; + } +} diff --git a/src/theme/text_style.rs b/src/theme/text_style.rs new file mode 100644 index 0000000..6ddc241 --- /dev/null +++ b/src/theme/text_style.rs @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Typography tokens: a resolved text style (family + weight + size + …) as +//! the theme knows it. +//! +//! The struct is deliberately named [`TextStyle`] rather than `Typography` +//! to avoid colliding with the [`super::typography`] constants module that +//! ships alongside it. +//! +//! # Resolution +//! +//! A [`TextStyle`] carries a [`FontRef`] (by name) and optionally a slot +//! reference for its default colour. Both are resolved lazily by the theme's +//! slot table: the string IDs live here, the actual font bytes and +//! [`crate::types::Color`] value come from the font registry and the slot +//! store respectively. This keeps the struct copy-cheap and JSON-friendly. + +// ─── Font reference ────────────────────────────────────────────────────────── + +/// Reference to a font family registered in the theme's `fonts` block. +/// +/// A family name like `"sora"` maps to a [`super::FontFamilyDef`] that +/// lists the actual `.ttf` paths for each weight/style. Keeping the +/// reference by name lets multiple text styles share a family without +/// duplicating the source list. +#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ] +pub enum FontRef +{ + /// Reference by family id, looked up in the theme's `fonts` block. + Named( String ), +} + +// ─── Style axes ────────────────────────────────────────────────────────────── + +/// Italic vs upright. +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ] +pub enum FontStyle +{ + Normal, + Italic, +} + +impl Default for FontStyle +{ + fn default() -> Self { FontStyle::Normal } +} + +/// Case transform applied at render time. +#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +pub enum TextTransform +{ + /// Render the string unchanged. + None, + /// Uppercase every character. + Uppercase, + /// Lowercase every character. + Lowercase, + /// Uppercase the first letter of each word, leave the rest untouched. + Capitalize, +} + +impl Default for TextTransform +{ + fn default() -> Self { TextTransform::None } +} + +/// Underline / strikethrough decoration. +#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +pub enum TextDecoration +{ + None, + Underline, + Strikethrough, +} + +impl Default for TextDecoration +{ + fn default() -> Self { TextDecoration::None } +} + +// ─── Line height ───────────────────────────────────────────────────────────── + +/// How the vertical advance between lines is specified. +/// +/// Both forms are supported because design-tool exports usually emit absolute +/// pixel heights, while hand-authored themes more often prefer relative +/// line-heights that scale with the font size. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub enum LineHeight +{ + /// Absolute pixel box height for the line. + Px( f32 ), + /// Multiplier of the font size. `1.5` means line-height is 1.5× the size. + Multiplier( f32 ), +} + +impl LineHeight +{ + /// Resolve to an absolute pixel height given the font size. + pub fn resolve( self, font_size: f32 ) -> f32 + { + match self + { + LineHeight::Px( h ) => h, + LineHeight::Multiplier( m ) => font_size * m, + } + } +} + +// ─── TextStyle ─────────────────────────────────────────────────────────────── + +/// A resolved text style: family, weight, size, line-height and the visual +/// modifiers that go with them. +/// +/// `color` is a slot id (string) rather than a resolved [`crate::types::Color`] +/// because the slot store is what knows how to resolve names to values — and +/// widgets can override the colour with [`.color()`] at the call-site, which +/// always wins over the style's default. +#[ derive( Debug, Clone, PartialEq ) ] +pub struct TextStyle +{ + /// Font family, looked up in the theme's `fonts` block. + pub family: FontRef, + /// Weight as a CSS numeric value (100..=900). + pub weight: u16, + /// Italic vs upright. + pub style: FontStyle, + /// Font size in CSS pixels. + pub size: f32, + /// Line height. + pub line_height: LineHeight, + /// Letter spacing in `em` (fraction of the font size). `0.0` by default. + pub letter_spacing: f32, + /// Case transform applied before shaping. + pub transform: TextTransform, + /// Underline / strikethrough. + pub decoration: TextDecoration, + /// Default colour slot id. `None` means "inherit from the widget's own + /// colour setting". When `Some`, widgets that don't override with + /// [`.color()`] get this. + pub color: Option, +} + +impl TextStyle +{ + /// Convenience constructor that fills the non-essential fields with + /// sensible defaults (upright, no transform, no decoration, no default + /// colour, no letter spacing). + pub fn new( family: FontRef, weight: u16, size: f32, line_height: LineHeight ) -> Self + { + Self + { + family, + weight, + style: FontStyle::Normal, + size, + line_height, + letter_spacing: 0.0, + transform: TextTransform::None, + decoration: TextDecoration::None, + color: None, + } + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn new_constructor_sets_sensible_defaults() + { + let s = TextStyle::new + ( + FontRef::Named( "sora".to_string() ), + 400, + 16.0, + LineHeight::Px( 24.0 ), + ); + assert_eq!( s.weight, 400 ); + assert_eq!( s.size, 16.0 ); + assert_eq!( s.line_height.resolve( 16.0 ), 24.0 ); + assert_eq!( s.transform, TextTransform::None ); + assert_eq!( s.letter_spacing, 0.0 ); + assert!( s.color.is_none() ); + } + + #[ test ] + fn caption_l_upcases_at_render_time() + { + let s = TextStyle + { + family: FontRef::Named( "sora".to_string() ), + weight: 400, + style: FontStyle::Normal, + size: 16.0, + line_height: LineHeight::Px( 20.0 ), + letter_spacing: 0.0, + transform: TextTransform::Uppercase, + decoration: TextDecoration::None, + color: None, + }; + assert_eq!( s.transform, TextTransform::Uppercase ); + } + + #[ test ] + fn line_height_multiplier_scales_with_size() + { + let lh = LineHeight::Multiplier( 1.5 ); + assert_eq!( lh.resolve( 16.0 ), 24.0 ); + assert_eq!( lh.resolve( 12.0 ), 18.0 ); + } + + #[ test ] + fn defaults_are_normal_and_none() + { + assert_eq!( FontStyle::default(), FontStyle::Normal ); + assert_eq!( TextTransform::default(), TextTransform::None ); + assert_eq!( TextDecoration::default(), TextDecoration::None ); + } +} diff --git a/src/tree.rs b/src/tree.rs new file mode 100755 index 0000000..1ae896e --- /dev/null +++ b/src/tree.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::Point; +use crate::widget::{ LaidOutWidget, WidgetHandlers }; + +/// Hit test `pos` against the laid-out focusable widgets. Returns the +/// `flat_idx` of the topmost widget under the point, or `None` if the point +/// hits nothing focusable. Topmost-wins because layout pushes parents before +/// children — the reverse iteration order makes the visually-on-top widget +/// (drawn last) hit-tested first. +pub fn find_widget_at( + widget_rects: &[ LaidOutWidget ], + pos: Point, +) -> Option +{ + for w in widget_rects.iter().rev() + { + if w.rect.contains( pos ) { return Some( w.flat_idx ); } + } + None +} + +/// O(N) lookup of a single widget by `flat_idx`. N is the number of +/// *focusable leaves*, not the size of the [`crate::widget::Element`] tree. +/// If the hot path ever needs O(1), swap the per-surface `widget_rects` +/// slice for a `Vec` + a `HashMap` index. +pub fn find_widget<'a, Msg: Clone>( + widget_rects: &'a [ LaidOutWidget ], + flat_idx: usize, +) -> Option<&'a LaidOutWidget> +{ + widget_rects.iter().find( |w| w.flat_idx == flat_idx ) +} + +/// Convenience wrapper around [`find_widget`] that returns just the handler +/// snapshot — what most input dispatch sites actually want. +pub fn find_handlers<'a, Msg: Clone>( + widget_rects: &'a [ LaidOutWidget ], + flat_idx: usize, +) -> Option<&'a WidgetHandlers> +{ + find_widget( widget_rects, flat_idx ).map( |w| &w.handlers ) +} + +/// Compute the next focusable widget for Tab / Shift+Tab navigation. +/// Returns the `flat_idx` of the next keyboard-focusable entry after the one +/// matching `current` (or wrapping around). `reverse` flips the direction +/// (Shift+Tab). Returns `None` when no entry has +/// [`LaidOutWidget::keyboard_focusable`] set — typical for a surface that only +/// hosts hit-testable chrome (e.g. a row of `WindowButton`s). +/// +/// When `current` is `None` (no widget currently focused) the result is the +/// first or last keyboard-focusable widget depending on direction — matching +/// how desktop toolkits behave when Tab is pressed in an unfocused window. +/// +/// Non-focusable interactive entries (chrome such as `WindowButton`) live in +/// the same `widget_rects` slice so pointer/touch hit testing finds them, but +/// are skipped here so they don't steal Tab focus from window content. +pub fn next_focusable_index( + widget_rects: &[ LaidOutWidget ], + current: Option, + reverse: bool, +) -> Option +{ + // Project the slice down to just the keyboard-focusable entries. Tab + // cycles over this projection only — pointer-only chrome sits in + // `widget_rects` for hit testing but never receives keyboard focus. + let focusables: Vec = widget_rects + .iter() + .filter( |w| w.keyboard_focusable ) + .map( |w| w.flat_idx ) + .collect(); + let n = focusables.len(); + if n == 0 { return None; } + + let current_pos = current.and_then( |fi| focusables.iter().position( |&i| i == fi ) ); + + let next = if reverse + { + current_pos + .map( |pos| focusables[ ( pos + n - 1 ) % n ] ) + .unwrap_or( focusables[ n - 1 ] ) + } else { + current_pos + .map( |pos| focusables[ ( pos + 1 ) % n ] ) + .unwrap_or( focusables[ 0 ] ) + }; + Some( next ) +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..5475a84 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Geometry and primitive value types used across the public API. +//! +//! These are the cheap, copy-friendly types that flow through every +//! widget builder, layout method and runtime hook: +//! +//! - [`Color`] — RGBA in `[0.0, 1.0]` floats; `Color::WHITE`, +//! `Color::BLACK`, `Color::TRANSPARENT` constants and a `Color::hex(r, g, b)` +//! constructor for byte literals. +//! - [`Rect`] — axis-aligned `(x, y, width, height)`; the universal +//! layout / hit-test currency. +//! - [`Point`] — a 2D point used by hit testing and gesture progress. +//! - [`Size`] — a `(width, height)` pair without an origin. +//! - [`Corners`] — per-corner radius for the +//! [`Container`](crate::container()) widget and any other rounded +//! surface; coerces from `f32` for the uniform case. +//! - [`WidgetId`] — a stable `&'static str` identifier for focus +//! management, paired with [`crate::App::take_focus_request`]. +//! +//! Every type is `Copy` (or `Clone`) so passing them by value is the +//! default. The crate root re-exports them all (`ltk::Color`, +//! `ltk::Rect`, …) so application code rarely needs the `ltk::types::` +//! prefix. + +/// An RGBA color with floating-point channels in the range `[0.0, 1.0]`. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub struct Color +{ + /// Red channel `[0.0, 1.0]`. + pub r: f32, + /// Green channel `[0.0, 1.0]`. + pub g: f32, + /// Blue channel `[0.0, 1.0]`. + pub b: f32, + /// Alpha channel — `0.0` is fully transparent, `1.0` is fully opaque. + pub a: f32, +} + +impl Color +{ + /// Fully opaque white. + pub const WHITE: Self = Self { r: 1., g: 1., b: 1., a: 1. }; + /// Fully opaque black. + pub const BLACK: Self = Self { r: 0., g: 0., b: 0., a: 1. }; + /// Fully transparent black. + pub const TRANSPARENT: Self = Self { r: 0., g: 0., b: 0., a: 0. }; + + /// Create an opaque color from 8-bit `r`, `g`, `b` components. + pub const fn hex( r: u8, g: u8, b: u8 ) -> Self + { + Self { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0, a: 1.0 } + } + + /// Create an opaque color from float `r`, `g`, `b` components in `[0.0, 1.0]`. + pub fn rgb( r: f32, g: f32, b: f32 ) -> Self + { + Self { r, g, b, a: 1. } + } + + /// Create a color from float `r`, `g`, `b`, `a` components in `[0.0, 1.0]`. + pub fn rgba( r: f32, g: f32, b: f32, a: f32 ) -> Self + { + Self { r, g, b, a } + } + + /// Convert to a [`tiny_skia::Color`] for rendering. + pub fn to_tiny_skia( self ) -> tiny_skia::Color + { + tiny_skia::Color::from_rgba( self.r, self.g, self.b, self.a ) + .unwrap_or( tiny_skia::Color::BLACK ) + } +} + +/// A 2-D point in screen coordinates (pixels, top-left origin). +#[ derive( Debug, Clone, Copy, PartialEq, Default ) ] +pub struct Point +{ + /// Horizontal position in pixels. + pub x: f32, + /// Vertical position in pixels. + pub y: f32, +} + +/// A width/height pair in pixels. +#[ derive( Debug, Clone, Copy, PartialEq, Default ) ] +pub struct Size +{ + /// Width in pixels. + pub width: f32, + /// Height in pixels. + pub height: f32, +} + +/// An axis-aligned rectangle in screen coordinates. +#[ derive( Debug, Clone, Copy, PartialEq, Default ) ] +pub struct Rect +{ + /// Left edge in pixels. + pub x: f32, + /// Top edge in pixels. + pub y: f32, + /// Width in pixels. + pub width: f32, + /// Height in pixels. + pub height: f32, +} + +impl Rect +{ + /// Returns `true` if `p` lies inside or on the boundary of this rect. + pub fn contains( &self, p: Point ) -> bool + { + p.x >= self.x + && p.x <= self.x + self.width + && p.y >= self.y + && p.y <= self.y + self.height + } + + /// Returns a new rect grown by `amount` pixels on every side. + pub fn expand( &self, amount: f32 ) -> Self + { + Self + { + x: self.x - amount, + y: self.y - amount, + width: self.width + amount * 2.0, + height: self.height + amount * 2.0, + } + } + + /// Convert to [`tiny_skia::Rect`], returning `None` if dimensions are non-positive. + pub fn to_tiny_skia( &self ) -> Option + { + tiny_skia::Rect::from_xywh( self.x, self.y, self.width, self.height ) + } +} + +/// Per-corner radii for a rounded rect, ordered top-left → top-right → +/// bottom-right → bottom-left (clockwise from top-left, matching CSS +/// `border-radius`'s long form). All four values are independent +/// pixel radii — set any subset to `0.0` for a square corner, or use +/// the [`top`](Self::top), [`bottom`](Self::bottom), +/// [`left`](Self::left), [`right`](Self::right) shortcuts for the +/// common asymmetric cases. +/// +/// The renderer caps each corner against the inscribed-circle limit +/// `min(width, height) / 2`, mirroring tiny-skia / browser behaviour: +/// passing absurdly large values is a "make this side a pill" idiom +/// rather than an error. +/// +/// `f32` and `(f32, f32, f32, f32)` both convert via [`From`] so any +/// API taking `impl Into` accepts a uniform radius literal +/// (`.radius( 16.0 )`), an explicit set (`.radius( ( 16.0, 16.0, +/// 0.0, 0.0 ) )`), or a constructed value (`.radius( Corners::top( +/// 16.0 ) )`) interchangeably. +#[ derive( Debug, Clone, Copy, PartialEq, Default ) ] +pub struct Corners +{ + /// Top-left corner radius in pixels. + pub tl: f32, + /// Top-right corner radius in pixels. + pub tr: f32, + /// Bottom-right corner radius in pixels. + pub br: f32, + /// Bottom-left corner radius in pixels. + pub bl: f32, +} + +impl Corners +{ + /// All four corners square (radius `0`). + pub const ZERO: Self = Self { tl: 0.0, tr: 0.0, br: 0.0, bl: 0.0 }; + + /// Uniform radius on every corner — equivalent to `r.into()` and + /// the most common construction. + pub const fn all( r: f32 ) -> Self + { + Self { tl: r, tr: r, br: r, bl: r } + } + + /// Rounded top corners, square bottom corners. Matches the CSS + /// shorthand `border-radius: r r 0 0` and the typical "card sits + /// flush against the bottom of the screen" pattern (docks, + /// bottom-anchored modals). + pub const fn top( r: f32 ) -> Self + { + Self { tl: r, tr: r, br: 0.0, bl: 0.0 } + } + + /// Rounded bottom corners, square top corners. Mirror of + /// [`top`](Self::top) for top-anchored chrome. + pub const fn bottom( r: f32 ) -> Self + { + Self { tl: 0.0, tr: 0.0, br: r, bl: r } + } + + /// Rounded left corners, square right corners. + pub const fn left( r: f32 ) -> Self + { + Self { tl: r, tr: 0.0, br: 0.0, bl: r } + } + + /// Rounded right corners, square left corners. + pub const fn right( r: f32 ) -> Self + { + Self { tl: 0.0, tr: r, br: r, bl: 0.0 } + } + + /// `true` when every corner is `<= 0` — the renderer can take + /// the fast straight-rect path. + pub fn is_zero( &self ) -> bool + { + self.tl <= 0.0 && self.tr <= 0.0 && self.br <= 0.0 && self.bl <= 0.0 + } + + /// `true` when every corner has the same radius. Used by the + /// software path to fall back to the single-radius cubic builder + /// when the asymmetric path would produce an identical curve. + pub fn is_uniform( &self ) -> bool + { + self.tl == self.tr && self.tr == self.br && self.br == self.bl + } + + /// The largest of the four radii. Useful for sizing the shader + /// quad's anti-alias pad — the worst-case AA band has to cover + /// the steepest curve. + pub fn max( &self ) -> f32 + { + self.tl.max( self.tr ).max( self.br ).max( self.bl ) + } + + /// Cap every corner to `min(width, height) / 2`, the inscribed- + /// circle limit a rounded box can't exceed without degenerating. + /// Mirrors the clamp the GLES shader applies internally; software + /// path callers use it before building the path so the cubic + /// control points stay inside the rect. + pub fn clamp_to_size( &self, width: f32, height: f32 ) -> Self + { + let cap = ( width.min( height ) * 0.5 ).max( 0.0 ); + Self + { + tl: self.tl.min( cap ).max( 0.0 ), + tr: self.tr.min( cap ).max( 0.0 ), + br: self.br.min( cap ).max( 0.0 ), + bl: self.bl.min( cap ).max( 0.0 ), + } + } + + /// Pack as `[ tl, tr, br, bl ]` for `glUniform4fv`. Order + /// matches the `vec4 u_radii` convention every fragment shader + /// in `gles_render::shaders` reads. + pub fn to_uniform( &self ) -> [ f32; 4 ] + { + [ self.tl, self.tr, self.br, self.bl ] + } +} + +impl From for Corners +{ + fn from( r: f32 ) -> Self { Self::all( r ) } +} + +impl From<( f32, f32, f32, f32 )> for Corners +{ + /// Tuple form, ordered `( tl, tr, br, bl )` — matches CSS shorthand. + fn from( t: ( f32, f32, f32, f32 ) ) -> Self + { + Self { tl: t.0, tr: t.1, br: t.2, bl: t.3 } + } +} + +/// A stable widget identifier used for focus management. +/// +/// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request +/// focus via [`App::take_focus_request`](crate::app::App::take_focus_request). +#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +pub struct WidgetId( pub &'static str ); + +/// Pointer cursor shape, sent to the compositor via +/// `wp_cursor_shape_v1` when the pointer enters a widget that +/// declares one. Mirrors `cursor_icon::CursorIcon` 1:1 so the +/// runtime can convert losslessly. Compositors that do not advertise +/// `wp_cursor_shape_v1` ignore these — the user sees their default +/// system cursor. +#[ derive( Debug, Clone, Copy, PartialEq, Eq, Hash ) ] +pub enum CursorShape +{ + Default, + ContextMenu, + Help, + /// "Hand" — clickable buttons, links. + Pointer, + /// "Spinning wheel" — work in progress, you can still interact. + Progress, + /// "Hourglass" — UI is busy and unresponsive. + Wait, + Cell, + Crosshair, + /// I-beam — text input fields. + Text, + VerticalText, + Alias, + Copy, + Move, + NoDrop, + NotAllowed, + /// Open hand — draggable but not yet dragging. + Grab, + /// Closed hand — currently dragging. + Grabbing, + EResize, + NResize, + NeResize, + NwResize, + SResize, + SeResize, + SwResize, + WResize, + EwResize, + NsResize, + NeswResize, + NwseResize, + ColResize, + RowResize, + AllScroll, + ZoomIn, + ZoomOut, +} + +impl Default for CursorShape +{ + fn default() -> Self { CursorShape::Default } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + // ── Color ───────────────────────────────────────────────────────────────── + + #[ test ] + fn color_hex_sets_rgb_and_full_alpha() + { + let c = Color::hex( 0xFF, 0x00, 0x80 ); + assert!( ( c.r - 1.0 ).abs() < 1e-3 ); + assert!( ( c.g - 0.0 ).abs() < 1e-6 ); + assert!( ( c.b - 0x80 as f32 / 255.0 ).abs() < 1e-3 ); + assert_eq!( c.a, 1.0 ); + } + + #[ test ] + fn color_rgba_stores_all_channels() + { + let c = Color::rgba( 0.1, 0.2, 0.3, 0.4 ); + assert!( ( c.r - 0.1 ).abs() < 1e-6 ); + assert!( ( c.g - 0.2 ).abs() < 1e-6 ); + assert!( ( c.b - 0.3 ).abs() < 1e-6 ); + assert!( ( c.a - 0.4 ).abs() < 1e-6 ); + } + + #[ test ] + fn color_white_constant_is_all_ones() + { + let c = Color::WHITE; + assert_eq!( c.r, 1. ); + assert_eq!( c.g, 1. ); + assert_eq!( c.b, 1. ); + assert_eq!( c.a, 1. ); + } + + #[ test ] + fn color_transparent_has_zero_alpha() + { + assert_eq!( Color::TRANSPARENT.a, 0. ); + } + + #[ test ] + fn color_rgb_sets_full_alpha() + { + let c = Color::rgb( 0.5, 0.5, 0.5 ); + assert_eq!( c.a, 1.0 ); + } + + // ── Rect ────────────────────────────────────────────────────────────────── + + #[ test ] + fn rect_contains_interior_point() + { + let r = Rect { x: 10., y: 20., width: 100., height: 50. }; + assert!( r.contains( Point { x: 60., y: 45. } ) ); + } + + #[ test ] + fn rect_contains_boundary_points() + { + let r = Rect { x: 0., y: 0., width: 100., height: 100. }; + assert!( r.contains( Point { x: 0., y: 0. } ) ); + assert!( r.contains( Point { x: 100., y: 100. } ) ); + } + + #[ test ] + fn rect_does_not_contain_exterior_points() + { + let r = Rect { x: 10., y: 20., width: 100., height: 50. }; + assert!( !r.contains( Point { x: 5., y: 45. } ) ); + assert!( !r.contains( Point { x: 60., y: 5. } ) ); + assert!( !r.contains( Point { x: 200., y: 45. } ) ); + assert!( !r.contains( Point { x: 60., y: 80. } ) ); + } + + #[ test ] + fn rect_expand_grows_in_all_directions() + { + let r = Rect { x: 10., y: 10., width: 80., height: 40. }; + let e = r.expand( 5. ); + assert_eq!( e.x, 5. ); + assert_eq!( e.y, 5. ); + assert_eq!( e.width, 90. ); + assert_eq!( e.height, 50. ); + } + + #[ test ] + fn rect_expand_zero_is_identity() + { + let r = Rect { x: 1., y: 2., width: 3., height: 4. }; + let e = r.expand( 0. ); + assert_eq!( r, e ); + } +} diff --git a/src/wallpaper.rs b/src/wallpaper.rs new file mode 100644 index 0000000..b2ea373 --- /dev/null +++ b/src/wallpaper.rs @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Orientation-aware wallpaper helper. +//! +//! Theme assets only ship a single landscape image per variant; portrait +//! surfaces (phones held vertically, the lock screen on a tablet, …) take a +//! left-aligned crop of that image down to the surface aspect ratio. The crop +//! result is cached so panning around different windows on the same display +//! does not redecode anything. +//! +//! Construct with [`WallpaperBundle::from_path`] (or one of the +//! `…_or_fallback` variants) at startup, then call +//! [`WallpaperBundle::for_size`] from each `view()` to get the variant +//! appropriate for the current surface. + +use std::path::Path; +use std::sync::{ Arc, Mutex }; + +/// Decoded RGBA image: (pixel buffer, width, height). +pub type ImageData = ( Arc>, u32, u32 ); + +/// A wallpaper that adapts to landscape vs portrait surfaces. +/// +/// Holds a single full-resolution landscape image and lazily produces +/// left-cropped portrait variants on demand, caching the most recently +/// requested crop dimensions. +pub struct WallpaperBundle +{ + landscape: ImageData, + cache: Mutex>, +} + +impl WallpaperBundle +{ + /// Wrap an already-decoded landscape image. + pub fn from_decoded( landscape: ImageData ) -> Self + { + Self + { + landscape, + cache: Mutex::new( None ), + } + } + + /// Decode `bytes` (PNG/JPEG) as the landscape image. + pub fn from_bytes( bytes: &[u8] ) -> Result + { + Ok( Self::from_decoded( decode_bytes( bytes )? ) ) + } + + /// Load the landscape image from `path`. + pub fn from_path( path: &Path ) -> Result + { + Ok( Self::from_decoded( decode_path( path )? ) ) + } + + /// Try to load the landscape image from `path`; on failure fall back to + /// decoding `bundled_fallback` (typically a `include_bytes!` asset shipped + /// inside the consumer binary). Errors loading the path are reported on + /// stderr. + pub fn from_path_or_bytes( path: Option<&Path>, bundled_fallback: &[u8] ) -> Self + { + if let Some( p ) = path + { + match decode_path( p ) + { + Ok( img ) => return Self::from_decoded( img ), + Err( e ) => eprintln!( + "ltk: failed to load wallpaper {}: {} — falling back to bundled", + p.display(), e, + ), + } + } + Self::from_bytes( bundled_fallback ).expect( "bundled wallpaper must decode" ) + } + + /// Try to load from `path`; on failure produce a 1×1 solid-colour bundle + /// using `(r, g, b)` instead of requiring embedded PNG bytes. Reports path + /// load errors on stderr. + pub fn from_path_or_solid( path: Option<&Path>, r: u8, g: u8, b: u8 ) -> Self + { + if let Some( p ) = path + { + match decode_path( p ) + { + Ok( img ) => return Self::from_decoded( img ), + Err( e ) => eprintln!( + "ltk: failed to load wallpaper {}: {} — falling back to solid colour", + p.display(), e, + ), + } + } + let rgba = Arc::new( vec![ r, g, b, 255u8 ] ); + Self::from_decoded( ( rgba, 1, 1 ) ) + } + + /// The original landscape image. Cheap clone — only bumps the inner `Arc`. + pub fn landscape( &self ) -> ImageData + { + ( Arc::clone( &self.landscape.0 ), self.landscape.1, self.landscape.2 ) + } + + /// Return the wallpaper variant appropriate for a surface of `(sw, sh)`. + /// + /// - Landscape surface (`sw >= sh`): returns the full landscape image. + /// - Portrait surface (`sw < sh`): returns a left-cropped variant whose + /// aspect ratio matches `(sw, sh)`. The crop is computed once per unique + /// `(sw, sh)` pair and reused on subsequent calls. + /// + /// `(0, 0)` is treated as landscape (returns the original image) so that + /// callers can use this before the surface has been sized. + pub fn for_size( &self, sw: u32, sh: u32 ) -> ImageData + { + if sw == 0 || sh == 0 || sw >= sh + { + return self.landscape(); + } + + let key = ( sw, sh ); + let mut guard = self.cache.lock().expect( "wallpaper cache poisoned" ); + if let Some( ( cached_key, data ) ) = guard.as_ref() + { + if *cached_key == key + { + return ( Arc::clone( &data.0 ), data.1, data.2 ); + } + } + + let cropped = crop_left_to_aspect( &self.landscape, sw, sh ); + let result = ( Arc::clone( &cropped.0 ), cropped.1, cropped.2 ); + *guard = Some( ( key, cropped ) ); + result + } +} + +fn decode_bytes( bytes: &[u8] ) -> Result +{ + use image::GenericImageView as _; + let img = image::load_from_memory( bytes )?; + let ( w, h ) = img.dimensions(); + Ok( ( Arc::new( img.to_rgba8().into_raw() ), w, h ) ) +} + +fn decode_path( path: &Path ) -> Result +{ + use image::GenericImageView as _; + let img = image::open( path )?; + let ( w, h ) = img.dimensions(); + Ok( ( Arc::new( img.to_rgba8().into_raw() ), w, h ) ) +} + +/// Take the left-most slice of `src` whose width matches the `(target_w, target_h)` +/// aspect ratio. If `src` is already narrower than the target, it is returned +/// unchanged (callers will scale it up — letterboxing avoidance is the +/// renderer's job, not this helper's). +fn crop_left_to_aspect( src: &ImageData, target_w: u32, target_h: u32 ) -> ImageData +{ + let ( ref rgba, sw, sh ) = *src; + if sw == 0 || sh == 0 || target_w == 0 || target_h == 0 + { + return ( Arc::clone( rgba ), sw, sh ); + } + let target_aspect = target_w as f32 / target_h as f32; + let new_w_f = ( sh as f32 * target_aspect ).round(); + let new_w = ( new_w_f as u32 ).clamp( 1, sw ); + if new_w >= sw + { + return ( Arc::clone( rgba ), sw, sh ); + } + + let mut out = Vec::with_capacity( ( new_w as usize ) * ( sh as usize ) * 4 ); + let row_bytes_src = ( sw as usize ) * 4; + let row_bytes_new = ( new_w as usize ) * 4; + for y in 0..( sh as usize ) + { + let start = y * row_bytes_src; + out.extend_from_slice( &rgba[ start .. start + row_bytes_new ] ); + } + ( Arc::new( out ), new_w, sh ) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + /// Build a tiny gradient image where each pixel's red channel encodes its + /// x coordinate. Lets us assert that a left-crop really starts at column 0. + fn xgrad( w: u32, h: u32 ) -> ImageData + { + let mut buf = Vec::with_capacity( ( w * h * 4 ) as usize ); + for _y in 0..h + { + for x in 0..w + { + buf.extend_from_slice( &[ x as u8, 0, 0, 255 ] ); + } + } + ( Arc::new( buf ), w, h ) + } + + #[ test ] + fn landscape_surface_returns_full_image() + { + let bundle = WallpaperBundle::from_decoded( xgrad( 16, 8 ) ); + let ( _, w, h ) = bundle.for_size( 32, 16 ); + assert_eq!( ( w, h ), ( 16, 8 ) ); + } + + #[ test ] + fn portrait_surface_left_crops() + { + // 100x10 source, portrait surface 9:16 → crop width = round(10 * 9/16) = 6. + let bundle = WallpaperBundle::from_decoded( xgrad( 100, 10 ) ); + let ( bytes, w, h ) = bundle.for_size( 9, 16 ); + assert_eq!( h, 10 ); + assert_eq!( w, 6 ); + // First pixel of the cropped image must come from column 0 of the source. + assert_eq!( bytes[0], 0 ); + // Last pixel of the first row must come from column (w-1) of the crop = 5. + let last = ( ( w as usize - 1 ) * 4 ) as usize; + assert_eq!( bytes[ last ], 5 ); + } + + #[ test ] + fn portrait_crop_is_cached() + { + let bundle = WallpaperBundle::from_decoded( xgrad( 100, 10 ) ); + let a = bundle.for_size( 9, 16 ); + let b = bundle.for_size( 9, 16 ); + // Same Arc pointer ⇒ second call hit the cache. + assert!( Arc::ptr_eq( &a.0, &b.0 ) ); + } + + #[ test ] + fn portrait_narrower_than_target_returns_source() + { + // Source already 1:10 (very tall) — taller than any portrait surface, + // so the helper returns it unchanged. + let bundle = WallpaperBundle::from_decoded( xgrad( 1, 10 ) ); + let ( _, w, h ) = bundle.for_size( 9, 16 ); + assert_eq!( ( w, h ), ( 1, 10 ) ); + } + + #[ test ] + fn zero_size_returns_landscape() + { + let bundle = WallpaperBundle::from_decoded( xgrad( 4, 4 ) ); + let ( _, w, h ) = bundle.for_size( 0, 0 ); + assert_eq!( ( w, h ), ( 4, 4 ) ); + } +} diff --git a/src/widget/anchored_overlay.rs b/src/widget/anchored_overlay.rs new file mode 100644 index 0000000..937ac33 --- /dev/null +++ b/src/widget/anchored_overlay.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Position a child element below the on-screen rect of another widget, +//! looked up from the *previous* frame's [`LaidOutWidget`] snapshot. +//! +//! The classic use case is a combo / dropdown popup that should appear +//! flush below its trigger. The trigger's rect is not known in `view()` +//! time — it's assigned during the layout pass — so the popup widget +//! cannot place itself there directly. `AnchoredOverlay` solves this +//! with a one-frame-old anchor: the trigger carries a stable +//! [`WidgetId`], the popup wraps in `AnchoredOverlay` referencing the +//! same id, and at draw time the wrapper looks up the trigger's rect +//! from the runtime's persisted `widget_rects` (frame N − 1) and +//! overrides the rect that its parent gave it. +//! +//! When the anchor is not found (first frame after open, missing id, +//! widget went off-screen) the wrapper falls back to drawing the child +//! in the parent-supplied rect — which, for a typical Stack-overlay +//! root in a `view()`, means the full surface, giving a sane modal +//! fallback until the next frame fixes the position. + +use crate::types::{ Rect, WidgetId }; + +use super::Element; + +/// A wrapper that re-positions its child relative to an anchor widget +/// found in the previous frame's layout snapshot. +pub struct AnchoredOverlay +{ + /// The element to draw at the anchored position. + pub child: Box>, + /// Stable identifier of the widget whose rect provides the anchor. + pub anchor_id: WidgetId, + /// Vertical pixel gap between the bottom of the anchor and the top + /// of the child. + pub gap: f32, +} + +impl AnchoredOverlay +{ + /// Wrap `child` so it draws anchored below the widget that carries + /// `anchor_id` in its `.id( … )` builder. `gap` is the vertical + /// space (logical pixels) between the anchor's bottom edge and the + /// child's top edge. + pub fn new( child: impl Into>, anchor_id: WidgetId, gap: f32 ) -> Self + { + Self + { + child: Box::new( child.into() ), + anchor_id, + gap, + } + } + + /// Compute the draw rect for the child, given the anchor rect (when + /// available) and the parent-supplied fallback. + /// + /// Anchor available → place the child flush below the anchor with + /// `gap` spacing, preserving the anchor's width. + /// Anchor missing → return the parent rect verbatim, so the child + /// renders modal-style as a fallback. + pub fn resolve_rect( anchor: Option, gap: f32, fallback: Rect ) -> Rect + { + match anchor + { + Some( a ) => Rect + { + x: a.x, + y: a.y + a.height + gap, + width: a.width, + height: fallback.height, + }, + None => fallback, + } + } + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> AnchoredOverlay + where + U: Clone + 'static, + Msg: 'static, + { + AnchoredOverlay + { + child: Box::new( self.child.map_arc( f ) ), + anchor_id: self.anchor_id, + gap: self.gap, + } + } +} + +impl From> for Element +{ + fn from( a: AnchoredOverlay ) -> Self + { + Element::AnchoredOverlay( a ) + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn resolve_rect_uses_anchor_when_present() + { + let anchor = Rect { x: 100.0, y: 50.0, width: 200.0, height: 40.0 }; + let fallback = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 }; + let r = AnchoredOverlay::<()>::resolve_rect( Some( anchor ), 8.0, fallback ); + assert_eq!( r.x, 100.0 ); + assert_eq!( r.y, 98.0 ); // anchor.y + height + gap + assert_eq!( r.width, 200.0 ); // anchor width + assert_eq!( r.height, 600.0 ); // fallback height + } + + #[ test ] + fn resolve_rect_falls_back_when_anchor_missing() + { + let fallback = Rect { x: 5.0, y: 6.0, width: 7.0, height: 8.0 }; + let r = AnchoredOverlay::<()>::resolve_rect( None, 8.0, fallback ); + assert_eq!( r, fallback ); + } +} diff --git a/src/widget/button.rs b/src/widget/button.rs new file mode 100644 index 0000000..15b91a1 --- /dev/null +++ b/src/widget/button.rs @@ -0,0 +1,553 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use std::sync::Arc; +use crate::types::{ Color, Rect, WidgetId }; +use crate::render::Canvas; +use super::Element; + +// Theme colors driven by the process-wide palette (see `ltk::theme`). +// Non-colour geometry (radius, font size, focus width, etc.) is static — only +// palette tokens respond to light/dark mode. +mod theme +{ + use crate::types::Color; + + /// Primary button background — brand accent. + pub fn p_default_bg() -> Color { crate::theme::palette().accent } + /// Primary button border — a darker tone of the accent so the pill + /// reads as a discrete affordance over surfaces that share the + /// background hue. Computed by darkening the linear-RGB components + /// of the accent rather than carrying yet another palette slot. + pub fn p_default_border() -> Color + { + let a = crate::theme::palette().accent; + Color { r: a.r * 0.55, g: a.g * 0.55, b: a.b * 0.55, a: a.a } + } + /// Primary button label colour. Falls back to the theme's + /// `text_primary`; themes whose accent does not pair well with + /// their text-primary token can override the palette so this + /// reads cleanly. + pub fn p_default_text() -> Color { crate::theme::palette().text_primary } + /// Disabled primary background — uses the theme's divider token, + /// the lowest-contrast neutral available across modes. + pub fn p_disabled_bg() -> Color { crate::theme::palette().divider } + /// Disabled primary / secondary / tertiary text — uses the + /// theme's secondary text token. + pub fn p_disabled_text() -> Color { crate::theme::palette().text_secondary } + /// Secondary button default background — matches the surface_alt surface. + pub fn s_bg() -> Color { crate::theme::palette().surface_alt } + /// Secondary button border. + pub fn s_border() -> Color { crate::theme::palette().text_primary } + /// Disabled secondary background — slightly lighter than the + /// disabled primary so the two states stay visually distinct. + pub fn s_disabled_bg() -> Color { crate::theme::palette().surface_alt } + /// Disabled secondary border — uses the divider token. + pub fn s_disabled_border() -> Color { crate::theme::palette().divider } + /// Tertiary button text colour. + pub fn t_text() -> Color { crate::theme::palette().text_primary } + /// Keyboard focus ring / hover circle. + pub fn focus_color() -> Color { crate::theme::palette().accent } + + pub const S_BORDER_W: f32 = 2.0; + pub const P_BORDER_W: f32 = 1.0; + pub const FOCUS_W: f32 = 3.0; + pub const HEIGHT: f32 = 48.0; + pub const RADIUS: f32 = 100.0; + pub const FONT_SIZE: f32 = 16.0; + pub const PAD_H: f32 = 24.0; +} + +/// Visual style of a text button. +#[ derive( Clone, Default ) ] +pub enum ButtonVariant +{ + /// Filled with the brand color — use for the primary call-to-action. + #[ default ] + Primary, + /// White background with a dark border — use for secondary actions. + Secondary, + /// Text-only, no background — use for low-emphasis actions. + Tertiary, +} + +/// Internal content of a button — either a text label or a PNG icon. +pub enum ButtonContent +{ + /// A text label rendered with the theme font. + Text( String ), + /// An RGBA image used as the button face (Arc avoids per-frame cloning). + Icon { rgba: Arc>, img_w: u32, img_h: u32 }, +} + +/// A pressable button widget. +/// +/// Create text buttons with [`button()`](crate::button()) and icon buttons with +/// [`icon_button()`](crate::icon_button()). Buttons that step a value +/// (date / time pickers, numeric spinners) can opt into press-and- +/// hold repeat via [`Self::repeating`] — the runtime then re-fires +/// `on_press` while the button is held, at the keyboard's repeat +/// cadence. +pub struct Button +{ + /// The visual content of this button. + pub content: ButtonContent, + /// Message emitted when the button is pressed, or `None` if disabled. + pub on_press: Option, + /// Message emitted when the user holds the button for + /// [`App::long_press_duration`](crate::app::App::long_press_duration) + /// without moving past the tolerance, OR when the user right-clicks + /// with the mouse. `None` leaves the button without a context-menu + /// equivalent. The fire does NOT by itself put the gesture into + /// drag mode — that is governed by [`Self::on_drag_start`]. + pub on_long_press: Option, + /// Drag-arm message. Fires when the press transitions into a drag: + /// touch on hold-timer expiry (in addition to `on_long_press`), + /// mouse on motion past the drag-promotion threshold (without + /// firing the menu). Independent of `on_long_press` so a button + /// can open a menu without becoming draggable, or be draggable + /// without showing a menu. + pub on_drag_start: Option, + /// Visual variant controlling colors and borders. + pub variant: ButtonVariant, + /// Width and height in pixels for icon buttons. Defaults to `48.0`. + pub icon_size: f32, + /// Optional stable identifier for focus management. + pub id: Option, + /// Whether this button participates in keyboard focus (Tab). Default: `true`. + pub focusable: bool, + /// Override the pointer cursor shape on hover. `None` falls back + /// to the `Pointer` (hand) default for clickable widgets. + pub cursor: Option, + /// When `true`, holding the button down auto-fires the + /// `on_press` message: one immediate fire on press, then an + /// initial delay (≈ 500 ms — same as the keyboard) followed by + /// repeats every ~120 ms (≈ 8 Hz, deliberately slower than the + /// keyboard's 30 Hz so a stepper does not whip past the + /// target). The runtime cancels the timer on release, on touch + /// cancel, and on long-press promotion. Default `false` — most + /// buttons fire on tap only. + pub repeating: bool, +} + +impl Button +{ + /// Create a text button with the given label. + pub fn new( label: String ) -> Self + { + Self + { + content: ButtonContent::Text( label ), + on_press: None, + on_long_press: None, + on_drag_start: None, + variant: ButtonVariant::Primary, + icon_size: theme::HEIGHT, + id: None, + focusable: true, + cursor: None, + repeating: false, + } + } + + /// Override the pointer cursor shape shown on hover. + pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self + { + self.cursor = Some( shape ); + self + } + + /// Create an icon button from a shared RGBA buffer. + /// + /// `img_w` and `img_h` must match the dimensions of `rgba`. + pub fn new_icon( rgba: Arc>, img_w: u32, img_h: u32 ) -> Self + { + Self + { + content: ButtonContent::Icon { rgba, img_w, img_h }, + on_press: None, + on_long_press: None, + on_drag_start: None, + variant: ButtonVariant::Tertiary, + icon_size: theme::HEIGHT, + id: None, + focusable: true, + cursor: None, + repeating: false, + } + } + + /// Set the message emitted when the button is pressed. + pub fn on_press( mut self, msg: Msg ) -> Self + { + self.on_press = Some( msg ); + self + } + + /// Optionally set the message — `None` leaves the button disabled. + pub fn on_press_maybe( mut self, msg: Option ) -> Self + { + self.on_press = msg; + self + } + + /// Auto-fire `on_press` while the button is held down. The + /// runtime fires once on press, then re-fires after the + /// keyboard's repeat *delay* (≈ 500 ms) and at a fixed ~120 ms + /// (≈ 8 Hz) interval afterwards — slow enough to release on + /// the value the user wants, fast enough to ramp. Each tick + /// re-reads `on_press` from the live widget tree, so a + /// stepper-style button whose message is `"go to value + 1"` + /// keeps stepping correctly as the value updates. + /// + /// Mutually compatible with `on_long_press` only in spirit — + /// once the long-press message fires the gesture machine + /// transitions to drag mode and the repeat timer is cancelled + /// regardless of `repeating`. Default `false`. + pub fn repeating( mut self, on: bool ) -> Self + { + self.repeating = on; + self + } + + /// Attach a long-press message. Fires when the press has been held + /// stationary for [`App::long_press_duration`](crate::app::App::long_press_duration), + /// or when the user right-clicks with the mouse. By itself this does + /// NOT put the gesture into drag mode — that is governed by + /// [`Self::on_drag_start`]. The regular `on_press` is suppressed + /// only when the press has been promoted to a drag (drag-arm fired). + pub fn on_long_press( mut self, msg: Msg ) -> Self + { + self.on_long_press = Some( msg ); + self + } + + /// Attach a drag-arm message. Fires when the press transitions into + /// drag mode — touch on hold-timer expiry (alongside `on_long_press`), + /// mouse on motion past the drag-promotion threshold (without firing + /// `on_long_press`). Independent of the menu so a button can be + /// draggable without showing a menu, or open a menu without becoming + /// draggable. + pub fn on_drag_start( mut self, msg: Msg ) -> Self + { + self.on_drag_start = Some( msg ); + self + } + + /// Control whether this button receives keyboard focus (Tab navigation). + /// Set to `false` for purely decorative or status-indicator buttons. + pub fn focusable( mut self, yes: bool ) -> Self + { + self.focusable = yes; + self + } + + /// Set the visual variant. + pub fn variant( mut self, v: ButtonVariant ) -> Self + { + self.variant = v; + self + } + + /// Set the display size (width = height) for icon buttons in pixels. + pub fn icon_size( mut self, size: f32 ) -> Self + { + self.icon_size = size; + self + } + + /// Assign a stable identifier for focus management. + pub fn id( mut self, id: WidgetId ) -> Self + { + self.id = Some( id ); + self + } + + /// Bounding box of everything the button can paint at `rect`, across every + /// interaction state. This is the sum of: icon-button hover/press circle + /// (radius `rect.min_dim / 2 + 8`), focus ring (grows `FOCUS_W + 1` beyond + /// that), stroke half-width (`FOCUS_W / 2`), plus ~1 px of antialiasing + /// bleed. Text buttons only have the focus ring. + /// + /// The partial-redraw path uses this to know how much canvas area to + /// invalidate when the button transitions in/out of a state. + pub fn paint_bounds( &self, rect: crate::types::Rect ) -> crate::types::Rect + { + let stroke_bleed = theme::FOCUS_W * 0.5 + 1.0; + match &self.content + { + ButtonContent::Icon { .. } => + { + // The circle grows 8 px beyond the icon rect, the focus ring grows + // `FOCUS_W + 1` beyond the circle. + let circle_pad = 8.0_f32; + let ring_pad = theme::FOCUS_W + 1.0; + rect.expand( circle_pad + ring_pad + stroke_bleed ) + } + ButtonContent::Text( _ ) => match self.variant + { + ButtonVariant::Primary | ButtonVariant::Secondary => + { + rect.expand( theme::FOCUS_W + 2.0 + stroke_bleed ) + } + ButtonVariant::Tertiary => rect.expand( 2.0 + stroke_bleed ), + }, + } + } + + /// Return the preferred `(width, height)` given available `max_width`. + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) + { + match &self.content + { + ButtonContent::Text( label ) => + { + let text_w = canvas.measure_text( label, theme::FONT_SIZE ); + let w = (text_w + theme::PAD_H * 2.0).min( max_width ); + ( w, theme::HEIGHT ) + } + ButtonContent::Icon { .. } => + { + let s = self.icon_size.min( max_width ); + ( s, s ) + } + } + } + + /// Draw the button into `canvas` at `rect`. + /// + /// `focused` draws a keyboard-focus ring; `hovered` and `pressed` apply + /// pointer/touch state overlays (icon buttons only). + pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool, hovered: bool, pressed: bool ) + { + match &self.content + { + ButtonContent::Text( label ) => + { + self.draw_text_button( canvas, rect, focused, label ); + } + ButtonContent::Icon { rgba, img_w, img_h } => + { + self.draw_icon_button( canvas, rect, focused, hovered, pressed, rgba, *img_w, *img_h ); + } + } + } + + fn draw_text_button( &self, canvas: &mut Canvas, rect: Rect, focused: bool, label: &str ) + { + let is_disabled = self.on_press.is_none(); + let text_y = rect.y + (rect.height + theme::FONT_SIZE) / 2.0 - 2.0; + + match self.variant + { + ButtonVariant::Primary => + { + let bg = if is_disabled { theme::p_disabled_bg() } else { theme::p_default_bg() }; + let text_c = if is_disabled { theme::p_disabled_text() } else { theme::p_default_text() }; + let border_c = theme::p_default_border(); + canvas.fill_rect( rect, bg, theme::RADIUS ); + if !is_disabled + { + canvas.stroke_rect( rect, border_c, theme::P_BORDER_W, theme::RADIUS ); + } + if focused + { + let ring = rect.expand( theme::FOCUS_W + 2.0 ); + canvas.stroke_rect( + ring, + theme::focus_color(), + theme::FOCUS_W, + theme::RADIUS + theme::FOCUS_W + 2.0, + ); + } + let text_w = canvas.measure_text( label, theme::FONT_SIZE ); + canvas.draw_text( + label, + rect.x + (rect.width - text_w) / 2.0, + text_y, + theme::FONT_SIZE, + text_c, + ); + } + ButtonVariant::Secondary => + { + let bg = if is_disabled { theme::s_disabled_bg() } else { theme::s_bg() }; + let text_c = if is_disabled { theme::p_disabled_text() } else { theme::t_text() }; + let border_c = if is_disabled { theme::s_disabled_border() } else { theme::s_border() }; + canvas.fill_rect( rect, bg, theme::RADIUS ); + canvas.stroke_rect( rect, border_c, theme::S_BORDER_W, theme::RADIUS ); + if focused + { + let ring = rect.expand( theme::FOCUS_W + 2.0 ); + canvas.stroke_rect( + ring, + theme::focus_color(), + theme::FOCUS_W, + theme::RADIUS + theme::FOCUS_W + 2.0, + ); + } + let text_w = canvas.measure_text( label, theme::FONT_SIZE ); + canvas.draw_text( + label, + rect.x + (rect.width - text_w) / 2.0, + text_y, + theme::FONT_SIZE, + text_c, + ); + } + ButtonVariant::Tertiary => + { + let text_c = if is_disabled { theme::p_disabled_text() } else { theme::t_text() }; + if focused + { + let ring = rect.expand( 2.0 ); + canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS ); + } + let text_w = canvas.measure_text( label, theme::FONT_SIZE ); + canvas.draw_text( + label, + rect.x + (rect.width - text_w) / 2.0, + text_y, + theme::FONT_SIZE, + text_c, + ); + } + } + } + + fn draw_icon_button( + &self, + canvas: &mut Canvas, + rect: Rect, + focused: bool, + hovered: bool, + pressed: bool, + rgba: &[u8], + img_w: u32, + img_h: u32, + ) + { + // Semi-transparent circular overlay behind the icon for hover / press feedback + let circle_pad = 8.0_f32; + let r = rect.width.min( rect.height ) / 2.0 + circle_pad; + let cx = rect.x + rect.width / 2.0; + let cy = rect.y + rect.height / 2.0; + let circle = Rect + { + x: cx - r, + y: cy - r, + width: r * 2.0, + height: r * 2.0, + }; + // Hover / press feedback is the theme's primary text colour + // at low alpha — works as a "lighten" in light mode (where + // text_primary tends to be dark and the underlying icon is + // dark) and as a subtle wash in dark mode without baking in + // a fixed white. + let fp = crate::theme::palette().text_primary; + if pressed + { + canvas.fill_rect( circle, Color::rgba( fp.r, fp.g, fp.b, 0.18 ), r ); + } else if hovered { + canvas.fill_rect( circle, Color::rgba( fp.r, fp.g, fp.b, 0.10 ), r ); + } + if focused + { + let ring = circle.expand( theme::FOCUS_W + 1.0 ); + canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, r + theme::FOCUS_W + 1.0 ); + } + canvas.draw_image_data( rgba, img_w, img_h, rect, 1.0 ); + } + + /// Wrap this button in an [`Element`]. + pub fn into_element( self ) -> Element + { + Element::Button( self ) + } + + /// Re-tag this button's three message slots through `f`. Called by + /// [`Element::map`] while walking a sub-tree. + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> Button + where + U: Clone + 'static, + Msg: 'static, + { + Button + { + content: self.content, + on_press: self.on_press.map( |m| ( *f )( m ) ), + on_long_press: self.on_long_press.map( |m| ( *f )( m ) ), + on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ), + variant: self.variant, + icon_size: self.icon_size, + id: self.id, + focusable: self.focusable, + cursor: self.cursor, + repeating: self.repeating, + } + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn text_button_focusable_by_default() + { + let b = Button::<()>::new( "ok".into() ); + assert!( b.focusable ); + } + + #[ test ] + fn icon_button_focusable_by_default() + { + let b = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 ); + assert!( b.focusable ); + } + + #[ test ] + fn focusable_builder_disables_focus() + { + let b = Button::<()>::new( "ok".into() ).focusable( false ); + assert!( !b.focusable ); + } + + #[ test ] + fn focusable_builder_re_enables_focus() + { + let b = Button::<()>::new( "ok".into() ).focusable( false ).focusable( true ); + assert!( b.focusable ); + } + + #[ test ] + fn on_press_none_by_default() + { + let b = Button::<()>::new( "ok".into() ); + assert!( b.on_press.is_none() ); + } + + #[ test ] + fn on_press_maybe_none_leaves_disabled() + { + let b = Button::<()>::new( "ok".into() ).on_press_maybe( None ); + assert!( b.on_press.is_none() ); + } + + #[ test ] + fn repeating_off_by_default() + { + let b = Button::<()>::new( "ok".into() ); + assert!( !b.repeating ); + } + + #[ test ] + fn repeating_builder_sets_flag() + { + let b = Button::<()>::new( "ok".into() ).repeating( true ); + assert!( b.repeating ); + let b = b.repeating( false ); + assert!( !b.repeating ); + } +} diff --git a/src/widget/checkbox.rs b/src/widget/checkbox.rs new file mode 100644 index 0000000..c3ccb53 --- /dev/null +++ b/src/widget/checkbox.rs @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::{ Rect, WidgetId }; +use crate::render::Canvas; +use super::Element; + +mod theme +{ + use crate::types::Color; + pub fn box_border() -> Color { crate::theme::palette().divider } + pub fn box_checked() -> Color { crate::theme::palette().accent } + /// Tick mark — uses the page-background colour so it reads as + /// the inverse of the accent fill regardless of mode. + pub fn check_color() -> Color { crate::theme::palette().bg } + pub fn focus_color() -> Color { crate::theme::palette().accent } + pub fn label_color() -> Color { crate::theme::palette().text_primary } + pub const BOX_SIZE: f32 = 24.0; + pub const RADIUS: f32 = 4.0; + pub const BORDER_W: f32 = 2.0; + pub const GAP: f32 = 12.0; + pub const HEIGHT: f32 = 48.0; + pub const FOCUS_W: f32 = 3.0; + pub const CHECK_W: f32 = 2.5; + pub const FONT_SIZE: f32 = 16.0; +} + +/// A two-state opt-in control with a square box and a check glyph. +/// +/// Use for individual binary choices inside a form (terms acceptance, +/// multi-select lists, "remember me"). The widget is stateless — the +/// application owns `checked` and rebuilds the checkbox from current state +/// on every frame. +/// +/// ```rust,no_run +/// # use ltk::{ checkbox, Checkbox }; +/// # #[ derive( Clone ) ] enum Msg { ToggleTerms } +/// # struct App { accept_terms: bool } +/// # impl App { fn _ex( &self ) -> Checkbox { +/// // In view(): +/// checkbox( self.accept_terms ) +/// .label( "I accept the terms" ) +/// .on_toggle( Msg::ToggleTerms ) +/// # }} +/// ``` +/// +/// See also [`Toggle`](super::toggle::Toggle) for prominent on / off +/// switches (settings panels, system toggles) and +/// [`Radio`](super::radio::Radio) for mutually-exclusive selection in a +/// group. +pub struct Checkbox +{ + /// Current checked state. Drawn from this field every frame; the + /// runtime never mutates it. + pub checked: bool, + /// Message emitted on activation. `None` leaves the checkbox inert. + pub on_toggle: Option, + /// Optional label drawn to the right of the box. + pub label: Option, + /// Optional stable identifier for focus management. + pub id: Option, +} + +impl Checkbox +{ + /// Create a checkbox in the given state, with no label and no + /// callback. Wire activation through [`Self::on_toggle`] before adding + /// it to a widget tree. + pub fn new( checked: bool ) -> Self + { + Self { checked, on_toggle: None, label: None, id: None } + } + + /// Set the message emitted when the checkbox is activated. The + /// application's `update` is responsible for flipping `checked` in + /// response. + pub fn on_toggle( mut self, msg: Msg ) -> Self + { + self.on_toggle = Some( msg ); + self + } + + /// Set a text label rendered to the right of the box. The checkbox's + /// preferred width grows to fit `box_size + gap + label_width`, + /// clamped to `max_width`. + pub fn label( mut self, label: impl Into ) -> Self + { + self.label = Some( label.into() ); + self + } + + /// Assign a stable identifier for focus management. + pub fn id( mut self, id: WidgetId ) -> Self + { + self.id = Some( id ); + self + } + + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) + { + let w = if let Some( ref label ) = self.label + { + let text_w = canvas.measure_text( label, theme::FONT_SIZE ); + ( theme::BOX_SIZE + theme::GAP + text_w ).min( max_width ) + } else { + theme::BOX_SIZE.min( max_width ) + }; + ( w, theme::HEIGHT ) + } + + /// Focus ring on the box extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px` beyond + /// the box edge (which sits flush with the widget's left edge). + pub fn paint_bounds( &self, rect: Rect ) -> Rect + { + rect.expand( theme::FOCUS_W + 2.0 + theme::FOCUS_W * 0.5 + 1.0 ) + } + + pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool ) + { + let box_y = rect.y + ( rect.height - theme::BOX_SIZE ) / 2.0; + let box_rect = Rect + { + x: rect.x, + y: box_y, + width: theme::BOX_SIZE, + height: theme::BOX_SIZE, + }; + + if self.checked + { + canvas.fill_rect( box_rect, theme::box_checked(), theme::RADIUS ); + let cx = rect.x + theme::BOX_SIZE / 2.0; + let cy = box_y + theme::BOX_SIZE / 2.0; + let s = theme::BOX_SIZE * 0.3; + canvas.draw_line( cx - s, cy, cx - s * 0.3, cy + s * 0.7, theme::check_color(), theme::CHECK_W ); + canvas.draw_line( cx - s * 0.3, cy + s * 0.7, cx + s, cy - s * 0.5, theme::check_color(), theme::CHECK_W ); + } else { + canvas.stroke_rect( box_rect, theme::box_border(), theme::BORDER_W, theme::RADIUS ); + } + + if focused + { + let ring = box_rect.expand( theme::FOCUS_W + 2.0 ); + canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS + theme::FOCUS_W + 2.0 ); + } + + if let Some( ref label ) = self.label + { + let text_x = rect.x + theme::BOX_SIZE + theme::GAP; + let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0; + canvas.draw_text( label, text_x, text_y, theme::FONT_SIZE, theme::label_color() ); + } + } + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> Checkbox + where + U: Clone + 'static, + Msg: 'static, + { + Checkbox + { + checked: self.checked, + on_toggle: self.on_toggle.map( |m| ( *f )( m ) ), + label: self.label, + id: self.id, + } + } +} + +/// Create a [`Checkbox`] in the given state. +/// +/// Shorthand for [`Checkbox::new`]. Wire activation with +/// [`Checkbox::on_toggle`] and add a label with [`Checkbox::label`]: +/// +/// ```rust,no_run +/// # use ltk::{ checkbox, Checkbox }; +/// # #[ derive( Clone ) ] enum Msg { ToggleAccept } +/// # struct App { accept: bool } +/// # impl App { fn _ex( &self ) -> Checkbox { +/// checkbox( self.accept ) +/// .label( "I accept the terms" ) +/// .on_toggle( Msg::ToggleAccept ) +/// # }} +/// ``` +pub fn checkbox( checked: bool ) -> Checkbox +{ + Checkbox::new( checked ) +} + +impl From> for Element +{ + fn from( c: Checkbox ) -> Self + { + Element::Checkbox( c ) + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn checkbox_default_state() + { + let c = checkbox::<()>( true ); + assert!( c.checked ); + assert!( c.on_toggle.is_none() ); + } + + #[test] + fn checkbox_unchecked() + { + let c = checkbox::<()>( false ); + assert!( !c.checked ); + } +} diff --git a/src/widget/color_picker.rs b/src/widget/color_picker.rs new file mode 100644 index 0000000..8685340 --- /dev/null +++ b/src/widget/color_picker.rs @@ -0,0 +1,545 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! ColorPicker — RGBA sliders + hex input + preview swatch + a +//! continuous hue strip for picking arbitrary colours. +//! +//! Stateless — the application owns the current [`Color`] and updates +//! it from [`ColorPicker::on_change`]. Four sliders cover R / G / B +//! and (when [`ColorPicker::show_alpha`]) A; a hex input lets the user +//! type or paste `#RRGGBB` / `#RRGGBBAA`; a hue slider with a rainbow +//! track lets the user grab any pure hue at full saturation / value +//! in one drag, and the RGB sliders then fine-tune it. +//! +//! ```rust,no_run +//! # use ltk::{ color_picker, Color, ColorPicker }; +//! # #[ derive( Clone ) ] enum Msg { AccentChanged( Color ) } +//! # struct App { accent: Color } +//! # impl App { fn _ex( &self ) -> ColorPicker { +//! color_picker( self.accent ) +//! .show_alpha( false ) +//! .on_change( Msg::AccentChanged ) +//! # }} +//! ``` + +use std::sync::Arc; + +use crate::types::Color; +use crate::layout::column::column; +use crate::layout::row::row; +use crate::layout::spacer::spacer; +use crate::theme::{ ColorStop, GradientSpace, LinearGradient, Paint }; + +use super::Element; + +mod theme +{ + use crate::types::Color; + pub fn surface_alt() -> Color { crate::theme::palette().surface_alt } + pub fn divider() -> Color { crate::theme::palette().divider } + pub fn text_muted() -> Color { crate::theme::palette().text_secondary } + pub const PADDING: f32 = 16.0; + pub const RADIUS: f32 = 16.0; + pub const SWATCH_SZ: f32 = 40.0; + pub const LABEL_FS: f32 = 12.0; + pub const SPACING: f32 = 8.0; +} + +/// Format a [`Color`] as `#RRGGBB` (or `#RRGGBBAA` when `with_alpha` +/// is true and the colour is not fully opaque). Bytes are clamped to +/// `0..=255`. +pub fn color_to_hex( c: Color, with_alpha: bool ) -> String +{ + let to_byte = | f: f32 | ( f.clamp( 0.0, 1.0 ) * 255.0 ).round() as u8; + let r = to_byte( c.r ); + let g = to_byte( c.g ); + let b = to_byte( c.b ); + let a = to_byte( c.a ); + if with_alpha && a != 255 + { + format!( "#{:02X}{:02X}{:02X}{:02X}", r, g, b, a ) + } else { + format!( "#{:02X}{:02X}{:02X}", r, g, b ) + } +} + +/// Parse a hex colour string (`"#RGB"` / `"#RGBA"` / `"#RRGGBB"` / +/// `"#RRGGBBAA"`, with or without the leading `#`, case-insensitive) +/// into a [`Color`]. Returns `None` for malformed input. +pub fn parse_hex( s: &str ) -> Option +{ + let s = s.trim(); + let s = s.strip_prefix( '#' ).unwrap_or( s ); + let parse_byte = | hi: char, lo: char | -> Option + { + let h = hi.to_digit( 16 )?; + let l = lo.to_digit( 16 )?; + Some( ( ( h << 4 ) | l ) as u8 ) + }; + let chars: Vec = s.chars().collect(); + let to_color = | r: u8, g: u8, b: u8, a: u8 | Color::rgba( + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + a as f32 / 255.0, + ); + match chars.len() + { + 3 => + { + let r = parse_byte( chars[0], chars[0] )?; + let g = parse_byte( chars[1], chars[1] )?; + let b = parse_byte( chars[2], chars[2] )?; + Some( to_color( r, g, b, 255 ) ) + } + 4 => + { + let r = parse_byte( chars[0], chars[0] )?; + let g = parse_byte( chars[1], chars[1] )?; + let b = parse_byte( chars[2], chars[2] )?; + let a = parse_byte( chars[3], chars[3] )?; + Some( to_color( r, g, b, a ) ) + } + 6 => + { + let r = parse_byte( chars[0], chars[1] )?; + let g = parse_byte( chars[2], chars[3] )?; + let b = parse_byte( chars[4], chars[5] )?; + Some( to_color( r, g, b, 255 ) ) + } + 8 => + { + let r = parse_byte( chars[0], chars[1] )?; + let g = parse_byte( chars[2], chars[3] )?; + let b = parse_byte( chars[4], chars[5] )?; + let a = parse_byte( chars[6], chars[7] )?; + Some( to_color( r, g, b, a ) ) + } + _ => None, + } +} + +/// RGBA colour selector with sliders, hex input, preview swatch and +/// a continuous hue strip. +pub struct ColorPicker +{ + pub value: Color, + pub on_change: Option Msg>>, + /// When `true` the alpha slider is shown and the hex input + /// accepts `#RRGGBBAA`. Default: `false` — most "pick a theme + /// colour" flows are opaque. + pub show_alpha: bool, +} + +impl ColorPicker +{ + pub fn new( value: Color ) -> Self + { + Self + { + value, + on_change: None, + show_alpha: false, + } + } + + pub fn on_change( mut self, f: impl Fn( Color ) -> Msg + 'static ) -> Self + { + self.on_change = Some( Arc::new( f ) ); + self + } + + pub fn show_alpha( mut self, on: bool ) -> Self + { + self.show_alpha = on; + self + } + + /// Build the `Element` tree representing this color picker. + pub fn build( self ) -> Element + { + use super::{ container, text, text_edit }; + use super::slider::slider; + + let value = self.value; + let on_chg = self.on_change.clone(); + let show_alpha = self.show_alpha; + + // Preview swatch. + let swatch: Element = container::( spacer() ) + .background( value ) + .border( theme::divider(), 1.0 ) + .radius( 12.0 ) + .padding( 0.0 ) + .into(); + let mut preview_row = row::().spacing( theme::SPACING ).push( + container::( swatch ) + .padding( 0.0 ) + .radius( 12.0 ), + ); + // We size the swatch via an explicit container holding the + // preview rect — the parent row will negotiate the space. + let _ = theme::SWATCH_SZ; + + // Hex input — the `on_change` parses the typed string and + // only fires the picker's callback on a successful parse, so + // in-progress typing does not blank the preview every key. + let hex_value = color_to_hex( value, show_alpha ); + let mut hex_edit = text_edit::( "#RRGGBB", hex_value ); + if let Some( ref cb ) = on_chg + { + let cb = cb.clone(); + hex_edit = hex_edit.on_change( move |s| + { + match parse_hex( &s ) + { + Some( c ) => cb( c ), + None => cb( value ), // hold the previous value + } + } ); + } + preview_row = preview_row.push( hex_edit ); + + // One slider per channel. Each slider's on_change rebuilds + // the colour by replacing only its channel — the others + // snapshot at view-build time, which is correct because + // every change goes through `on_change` and re-renders. + let chan_slider = | label: &str, current: f32, build_color: Arc Color> | -> Element + { + let mut s = slider::( current ); + if let Some( ref cb ) = on_chg + { + let cb_outer = cb.clone(); + s = s.on_change( move |v| + { + let c = build_color( v ); + cb_outer( c ) + } ); + } + column::().spacing( 4.0 ) + .push( text( label ).size( theme::LABEL_FS ).color( theme::text_muted() ) ) + .push( s ) + .into() + }; + + let r_build: Arc Color> = Arc::new( move |v| Color::rgba( v, value.g, value.b, value.a ) ); + let g_build: Arc Color> = Arc::new( move |v| Color::rgba( value.r, v, value.b, value.a ) ); + let b_build: Arc Color> = Arc::new( move |v| Color::rgba( value.r, value.g, v, value.a ) ); + let a_build: Arc Color> = Arc::new( move |v| Color::rgba( value.r, value.g, value.b, v ) ); + + let mut sliders = column::().spacing( theme::SPACING ) + .push( chan_slider( "R", value.r, r_build ) ) + .push( chan_slider( "G", value.g, g_build ) ) + .push( chan_slider( "B", value.b, b_build ) ); + if show_alpha + { + sliders = sliders.push( chan_slider( "A", value.a, a_build ) ); + } + + // Hue strip: a slider whose track is a multi-stop rainbow + // gradient. Position 0 maps to red, position 1 to a hue + // just *short* of the full wheel (see `HUE_RANGE` below) + // so that dragging to the right edge does not snap the + // thumb back to the left on the next render. Moving the + // thumb fires `on_change` with the picked hue at full + // saturation / value, preserving the current alpha — RGB + // sliders fine-tune brightness afterwards. + // + // `HUE_RANGE = 359.0` instead of 360 closes the + // round-trip: at position 1.0 we pick hue 359°, the colour + // is almost-red (one degree off pure red), `rgb_to_hue` + // returns ~359°, and `position = 359 / 359 = 1.0` lands + // back where the user was. Mapping to 360° instead would + // snap to hue 0 (pure red, same as position 0) and the + // slider thumb would teleport to the left — the wheel + // closes on itself, but a linear slider cannot represent + // both endpoints. The 1° colour gap between the two ends + // is imperceptible. + // + // Software backend: `fill_paint_rect` falls back to the + // gradient's first stop (a flat red), so the slider still + // works functionally; only the rainbow visual is missing + // off the GLES path. Acceptable trade-off given the + // software path is the fallback for compositors without GL. + const HUE_RANGE: f32 = 359.0; + let current_hue = rgb_to_hue( value.r, value.g, value.b ); + let hue_alpha = value.a; + let mut hue_slider = slider::( ( current_hue / HUE_RANGE ).clamp( 0.0, 1.0 ) ) + .track_paint( rainbow_gradient() ); + if let Some( ref cb ) = on_chg + { + let cb = cb.clone(); + hue_slider = hue_slider.on_change( move |v| + { + let ( r, g, b ) = hue_to_rgb( v.clamp( 0.0, 1.0 ) * HUE_RANGE ); + cb( Color::rgba( r, g, b, hue_alpha ) ) + } ); + } + let hue_row: Element = column::().spacing( 4.0 ) + .push( text( "Hue" ).size( theme::LABEL_FS ).color( theme::text_muted() ) ) + .push( hue_slider ) + .into(); + + let body = column::().spacing( theme::SPACING * 2.0 ) + .push( preview_row ) + .push( sliders ) + .push( hue_row ); + + container::( body ) + .background( theme::surface_alt() ) + .padding( theme::PADDING ) + .radius( theme::RADIUS ) + .into() + } +} + +/// Build the rainbow [`Paint::Linear`] used as the hue strip's track. +/// Seven stops across the wheel with a final repeat of red so the +/// gradient closes cleanly. CSS angle convention: `90deg` sweeps +/// left-to-right, matching the slider's value axis. +fn rainbow_gradient() -> Paint +{ + let stop = | pos: f32, c: Color | ColorStop { position: pos, color: c }; + Paint::Linear( LinearGradient + { + angle_deg: 90.0, + stops: vec! + [ + stop( 0.000, Color::rgba( 1.0, 0.0, 0.0, 1.0 ) ), // red + stop( 1.0 / 6.0, Color::rgba( 1.0, 1.0, 0.0, 1.0 ) ), // yellow + stop( 2.0 / 6.0, Color::rgba( 0.0, 1.0, 0.0, 1.0 ) ), // green + stop( 3.0 / 6.0, Color::rgba( 0.0, 1.0, 1.0, 1.0 ) ), // cyan + stop( 4.0 / 6.0, Color::rgba( 0.0, 0.0, 1.0, 1.0 ) ), // blue + stop( 5.0 / 6.0, Color::rgba( 1.0, 0.0, 1.0, 1.0 ) ), // magenta + stop( 1.000, Color::rgba( 1.0, 0.0, 0.0, 1.0 ) ), // red (closes the wheel) + ], + space: GradientSpace::Srgb, + } ) +} + +/// Compute the HSV hue (in degrees, `0..360`) of an RGB triple. Returns +/// `0.0` for greys (where hue is undefined) so the slider reads at a +/// stable position when the user dials saturation down to zero via the +/// RGB sliders. +pub fn rgb_to_hue( r: f32, g: f32, b: f32 ) -> f32 +{ + let max = r.max( g ).max( b ); + let min = r.min( g ).min( b ); + let delta = max - min; + if delta <= f32::EPSILON { return 0.0; } + let h = if max == r + { + 60.0 * ( ( ( g - b ) / delta ).rem_euclid( 6.0 ) ) + } + else if max == g + { + 60.0 * ( ( b - r ) / delta + 2.0 ) + } + else + { + 60.0 * ( ( r - g ) / delta + 4.0 ) + }; + if h < 0.0 { h + 360.0 } else { h } +} + +/// Build an RGB triple at full saturation and value from a hue in +/// degrees (`0..360`). Returns each channel in `0.0..=1.0`. +pub fn hue_to_rgb( hue_deg: f32 ) -> ( f32, f32, f32 ) +{ + let h = hue_deg.rem_euclid( 360.0 ) / 60.0; + let c = 1.0_f32; + let x = c * ( 1.0 - ( ( h.rem_euclid( 2.0 ) ) - 1.0 ).abs() ); + let ( r, g, b ) = match h as u32 + { + 0 => ( c, x, 0.0 ), + 1 => ( x, c, 0.0 ), + 2 => ( 0.0, c, x ), + 3 => ( 0.0, x, c ), + 4 => ( x, 0.0, c ), + _ => ( c, 0.0, x ), + }; + ( r, g, b ) +} + +impl From> for Element +{ + fn from( c: ColorPicker ) -> Self { c.build() } +} + +/// Create a [`ColorPicker`] starting from the given colour. +/// +/// ```rust,no_run +/// # use ltk::{ color_picker, Color, ColorPicker }; +/// # #[ derive( Clone ) ] enum Msg { AccentChanged( Color ) } +/// # struct App { accent: Color } +/// # impl App { fn _ex( &self ) -> ColorPicker { +/// color_picker( self.accent ) +/// .show_alpha( true ) +/// .on_change( Msg::AccentChanged ) +/// # }} +/// ``` +pub fn color_picker( value: Color ) -> ColorPicker +{ + ColorPicker::new( value ) +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + // ── hex serialization ───────────────────────────────────────────────────── + + #[ test ] + fn color_to_hex_uppercase_six_digits_for_opaque() + { + let c = Color::rgba( 1.0, 0.0, 0.5, 1.0 ); + assert_eq!( color_to_hex( c, false ), "#FF0080" ); + assert_eq!( color_to_hex( c, true ), "#FF0080" ); // alpha=255 stripped + } + + #[ test ] + fn color_to_hex_emits_eight_digits_when_alpha_kept_and_present() + { + let c = Color::rgba( 1.0, 0.0, 0.5, 0.5 ); + // 0.5 × 255 = 127.5 → rounds to 128 = 0x80. + assert_eq!( color_to_hex( c, true ), "#FF008080" ); + } + + #[ test ] + fn color_to_hex_clamps_out_of_range() + { + let c = Color::rgba( 1.5, -0.1, 0.0, 1.0 ); + assert_eq!( color_to_hex( c, false ), "#FF0000" ); + } + + // ── hex parsing ─────────────────────────────────────────────────────────── + + #[ test ] + fn parse_hex_six_digits() + { + let c = parse_hex( "#FF0080" ).expect( "ok" ); + assert!( ( c.r - 1.0 ).abs() < 1e-3 ); + assert_eq!( c.g, 0.0 ); + assert!( ( c.b - 0.502 ).abs() < 1e-2 ); + assert_eq!( c.a, 1.0 ); + } + + #[ test ] + fn parse_hex_three_digit_shorthand() + { + let c = parse_hex( "#F08" ).expect( "ok" ); + // "F" → 0xFF. "0" → 0x00. "8" → 0x88. + assert_eq!( ( c.r * 255.0 ).round() as u8, 0xFF ); + assert_eq!( ( c.g * 255.0 ).round() as u8, 0x00 ); + assert_eq!( ( c.b * 255.0 ).round() as u8, 0x88 ); + } + + #[ test ] + fn parse_hex_eight_digit_includes_alpha() + { + let c = parse_hex( "#FF008080" ).expect( "ok" ); + assert!( ( c.a * 255.0 ).round() as u8 == 0x80 ); + } + + #[ test ] + fn parse_hex_optional_leading_hash() + { + assert!( parse_hex( "FF0080" ).is_some() ); + assert!( parse_hex( "#FF0080" ).is_some() ); + } + + #[ test ] + fn parse_hex_case_insensitive() + { + let upper = parse_hex( "#A1B2C3" ).unwrap(); + let lower = parse_hex( "#a1b2c3" ).unwrap(); + assert_eq!( upper, lower ); + } + + #[ test ] + fn parse_hex_rejects_garbage() + { + assert!( parse_hex( "" ).is_none() ); + assert!( parse_hex( "#" ).is_none() ); + assert!( parse_hex( "#XYZ" ).is_none() ); + // 5 / 7 / 9 digits — not one of the valid widths (3/4/6/8). + assert!( parse_hex( "#12345" ).is_none() ); + assert!( parse_hex( "#1234567" ).is_none() ); + // Non-hex character inside a valid-length string. + assert!( parse_hex( "#GG0000" ).is_none() ); + } + + #[ test ] + fn parse_hex_round_trips_through_color_to_hex() + { + let original = Color::rgba( 0.2, 0.4, 0.6, 1.0 ); + let s = color_to_hex( original, false ); + let parsed = parse_hex( &s ).expect( "ok" ); + // Allow ±1/255 quantisation slack. + assert!( ( parsed.r - original.r ).abs() < 1.0 / 255.0 + 1e-6 ); + assert!( ( parsed.g - original.g ).abs() < 1.0 / 255.0 + 1e-6 ); + assert!( ( parsed.b - original.b ).abs() < 1.0 / 255.0 + 1e-6 ); + } + + // ── builders ────────────────────────────────────────────────────────────── + + #[ derive( Clone, Debug, PartialEq ) ] + enum Msg { Pick( Color ) } + + #[ test ] + fn defaults() + { + let p: ColorPicker = color_picker( Color::WHITE ); + assert_eq!( p.value, Color::WHITE ); + assert!( !p.show_alpha ); + assert!( p.on_change.is_none() ); + } + + #[ test ] + fn build_does_not_panic_minimal() + { + let _: Element = color_picker::( Color::WHITE ).build(); + } + + #[ test ] + fn build_does_not_panic_with_alpha_and_callback() + { + let _: Element = color_picker::( Color::rgba( 0.2, 0.4, 0.6, 0.8 ) ) + .show_alpha( true ) + .on_change( Msg::Pick ) + .build(); + } + + // ── Hue arithmetic ──────────────────────────────────────────────────────── + + #[ test ] + fn rgb_to_hue_pure_primaries() + { + assert!( ( rgb_to_hue( 1.0, 0.0, 0.0 ) - 0.0 ).abs() < 1e-3 ); + assert!( ( rgb_to_hue( 1.0, 1.0, 0.0 ) - 60.0 ).abs() < 1e-3 ); + assert!( ( rgb_to_hue( 0.0, 1.0, 0.0 ) - 120.0 ).abs() < 1e-3 ); + assert!( ( rgb_to_hue( 0.0, 1.0, 1.0 ) - 180.0 ).abs() < 1e-3 ); + assert!( ( rgb_to_hue( 0.0, 0.0, 1.0 ) - 240.0 ).abs() < 1e-3 ); + assert!( ( rgb_to_hue( 1.0, 0.0, 1.0 ) - 300.0 ).abs() < 1e-3 ); + } + + #[ test ] + fn rgb_to_hue_grey_returns_zero() + { + assert_eq!( rgb_to_hue( 0.5, 0.5, 0.5 ), 0.0 ); + assert_eq!( rgb_to_hue( 0.0, 0.0, 0.0 ), 0.0 ); + } + + #[ test ] + fn hue_to_rgb_round_trips_at_primaries() + { + for deg in [ 0.0, 60.0, 120.0, 180.0, 240.0, 300.0 ] + { + let ( r, g, b ) = hue_to_rgb( deg ); + let h = rgb_to_hue( r, g, b ); + let diff = ( ( h - deg + 540.0 ) % 360.0 - 180.0 ).abs(); + assert!( diff < 1e-3, "hue {deg} degrees did not round-trip (got {h})" ); + } + } +} diff --git a/src/widget/combo.rs b/src/widget/combo.rs new file mode 100644 index 0000000..8ada41b --- /dev/null +++ b/src/widget/combo.rs @@ -0,0 +1,977 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Combo / select / dropdown widget. +//! +//! Editable, searchable, multi-select, scrollable. Built as a pure +//! composition over existing widgets ([`text_edit`](super::text_edit), +//! [`list_item`](super::list_item), [`pressable`](super::pressable), +//! [`scroll`](super::scroll), [`viewport`](super::viewport), +//! [`container`](super::container), [`stack`](crate::layout::stack)), +//! so the runtime needs no special layout / dispatch path **and the +//! widget works in plain `xdg-shell` application windows** — the popup +//! is a `Stack` overlay layered into the same surface as the trigger, +//! not a separate Wayland surface. +//! +//! ## Mental model +//! +//! A `Combo` is a *projection* over a [`ComboState`] that the +//! application owns and mutates from `update()`. The widget itself is +//! stateless: every frame the app rebuilds it from current state and +//! consumes the messages the user produces. +//! +//! Two pieces flow into the app's view tree: +//! +//! 1. The **trigger** (returned by [`Combo::trigger`]) lives wherever the +//! app puts a normal widget: a column, a row, inside a container. It +//! paints the labelled pill, the optional chips for multi-select +//! selections, the query text edit, the down-arrow toggle, and the +//! helper / error rows. +//! 2. The **popup** (returned by [`Combo::popup`]) is `None` when the +//! combo is closed and `Some(Element)` when open. The application +//! layers it on top of the rest of the view via [`stack`](crate::stack). +//! The returned element already contains a full-surface dismiss +//! layer behind the panel so a tap outside fires +//! [`Combo::on_dismiss`]. +//! +//! ## Wiring +//! +//! ```rust,no_run +//! # #[ derive( Clone ) ] enum Msg { +//! # FruitQuery( String ), FruitToggle, +//! # FruitSelect( usize ), FruitUnselect( usize ), FruitDismiss, +//! # } +//! use ltk::{ App, Combo, ComboState, Element, combo, column, stack }; +//! +//! struct AppState +//! { +//! fruits: ComboState, +//! } +//! +//! impl AppState +//! { +//! fn build_combo( &self ) -> Combo +//! { +//! combo( self.fruits.clone(), [ "Apple", "Banana", "Cherry", "Date" ] +//! .iter().map( |s| s.to_string() ).collect() ) +//! .label( "Fruits" ) +//! .placeholder( "Pick one or more…" ) +//! .multi_select( true ) +//! .searchable( true ) +//! .on_query_change( Msg::FruitQuery ) +//! .on_toggle_open( Msg::FruitToggle ) +//! .on_select_idx( Msg::FruitSelect ) +//! .on_unselect_idx( Msg::FruitUnselect ) +//! .on_dismiss( Msg::FruitDismiss ) +//! } +//! } +//! +//! impl App for AppState +//! { +//! type Message = Msg; +//! fn view( &self ) -> Element +//! { +//! let combo = self.build_combo(); +//! let mut s = stack::().push( column().push( combo.trigger() ) ); +//! if let Some( p ) = combo.popup() { s = s.push( p ); } +//! s.into() +//! } +//! # fn update( &mut self, _msg: Msg ) {} +//! } +//! ``` +//! +//! `update()` then handles the messages: append to / remove from +//! `fruits.selected` on `FruitSelect` / `FruitUnselect`, flip +//! `fruits.is_open` on `FruitToggle` / `FruitDismiss`, and copy the new +//! query string into `fruits.query` on `FruitQuery`. +//! +//! ## Dismiss behaviour +//! +//! The runtime fires [`Combo::on_dismiss`] in three situations: the +//! compositor sends `xdg_popup.popup_done`; the user taps the main +//! surface outside the trigger pill while the popup is mapped; or the +//! user presses Escape. The app's only job is to flip `is_open` to +//! `false` in `update()` — the runtime is idempotent if the message +//! arrives more than once for the same open / close cycle. See +//! [`crate::app::OverlaySpec::on_dismiss`] for the full contract. + +use std::sync::Arc; +use std::collections::hash_map::DefaultHasher; +use std::hash::{ Hash, Hasher }; + +use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec }; +use crate::types::WidgetId; + +use super::Element; + +/// Stable identifier of the trigger pill used as the popup's anchor. +/// Read by [`crate::widget::anchored_overlay::AnchoredOverlay`] in the +/// draw pass to position the popup flush below the trigger. A single +/// constant is enough as long as only one combo is open at a time — +/// the popup of a closed combo contributes nothing to the view tree, +/// so two combos with the same anchor id coexist fine when they don't +/// open simultaneously. Apps that need overlapping open combos should +/// switch to [`Combo::anchor_id`] with distinct ids. +const COMBO_ANCHOR_DEFAULT: WidgetId = WidgetId( "ltk-combo-trigger" ); + +/// Application-owned state for a [`Combo`]. +/// +/// Lives on the app struct and is mutated from `update()`. The widget +/// reads it once per frame to render the trigger and popup; nothing +/// inside ltk keeps a copy. +#[ derive( Debug, Clone, Default ) ] +pub struct ComboState +{ + /// Current text in the trigger's search field. Drives item filtering. + /// Empty string means "no filter — show every item". + pub query: String, + /// `true` when the popup is visible. + pub is_open: bool, + /// Indices into the `items` slice currently selected. In single-select + /// mode this is empty or has a single entry; in multi-select mode it + /// can hold any subset. + pub selected: Vec, +} + +impl ComboState +{ + /// Empty state: no query, popup closed, nothing selected. + pub fn new() -> Self { Self::default() } + + /// Convenience: toggle `is_open`. + pub fn toggle_open( &mut self ) { self.is_open = !self.is_open; } + + /// Convenience: add `idx` to `selected` if not present. + pub fn select( &mut self, idx: usize ) + { + if !self.selected.contains( &idx ) { self.selected.push( idx ); } + } + + /// Convenience: remove `idx` from `selected` if present. + pub fn unselect( &mut self, idx: usize ) + { + self.selected.retain( |&i| i != idx ); + } +} + +/// A combo / select / dropdown widget. +/// +/// See the module-level documentation for the full wiring pattern. +/// Build via [`combo`] and configure with the chained builders. +pub struct Combo +{ + state: ComboState, + items: Vec, + label: Option, + description: Option, + placeholder: Option, + helper: Option, + error: Option, + disabled: bool, + multi_select: bool, + searchable: bool, + max_chips_visible: usize, + anchor_id: WidgetId, + popup_gap: f32, + popup_width: f32, + popup_max_height: f32, + on_query_change: Option Msg>>, + on_toggle_open: Option, + on_select_idx: Option Msg>>, + on_unselect_idx: Option Msg>>, + on_dismiss: Option, +} + +impl Combo +{ + /// Construct a combo over `items` driven by `state`. Both arguments + /// are taken by value because the widget tree is rebuilt every + /// frame; clone the app's state slice into here. + pub fn new( state: ComboState, items: Vec ) -> Self + { + Self + { + state, + items, + label: None, + description: None, + placeholder: None, + helper: None, + error: None, + disabled: false, + multi_select: false, + searchable: false, + max_chips_visible: 4, + anchor_id: COMBO_ANCHOR_DEFAULT, + popup_gap: 4.0, + popup_width: 320.0, + popup_max_height: 280.0, + on_query_change: None, + on_toggle_open: None, + on_select_idx: None, + on_unselect_idx: None, + on_dismiss: None, + } + } + + /// Bold label drawn above the trigger. + pub fn label( mut self, s: impl Into ) -> Self + { + self.label = Some( s.into() ); + self + } + + /// Optional descriptive paragraph drawn below the label. + pub fn description( mut self, s: impl Into ) -> Self + { + self.description = Some( s.into() ); + self + } + + /// Placeholder for the trigger's text edit when the query is empty. + pub fn placeholder( mut self, s: impl Into ) -> Self + { + self.placeholder = Some( s.into() ); + self + } + + /// Helper / informative text below the trigger. Hidden when an + /// error message is set. + pub fn helper( mut self, s: impl Into ) -> Self + { + self.helper = Some( s.into() ); + self + } + + /// Error message below the trigger. Replaces the helper text and + /// applies the destructive theme tokens to the trigger surface. + pub fn error( mut self, s: impl Into ) -> Self + { + self.error = Some( s.into() ); + self + } + + /// Render in the disabled style. Activations / selections still emit + /// messages — the consumer is responsible for ignoring them in + /// `update()`. + pub fn disabled( mut self, yes: bool ) -> Self + { + self.disabled = yes; + self + } + + /// Allow multiple items to be selected at once. Selected items are + /// rendered as chips above the trigger. + pub fn multi_select( mut self, yes: bool ) -> Self + { + self.multi_select = yes; + self + } + + /// Make the trigger an editable text field that filters the popup + /// list as the user types. Without `searchable`, the trigger is a + /// pressable button that displays the current selection. + pub fn searchable( mut self, yes: bool ) -> Self + { + self.searchable = yes; + self + } + + /// Cap on the number of selection chips drawn above the trigger + /// before falling back to a "+N more" indicator. Default `4`. + pub fn max_chips_visible( mut self, n: usize ) -> Self + { + self.max_chips_visible = n; + self + } + + /// Stable identifier of the trigger pill. The popup looks the rect + /// of this widget up in the previous frame's layout snapshot to + /// place itself flush below the trigger. Apps with several combos + /// that may open simultaneously must give each a distinct id. + pub fn anchor_id( mut self, id: WidgetId ) -> Self + { + self.anchor_id = id; + self + } + + /// Vertical gap (logical pixels) between the bottom of the trigger + /// and the top of the popup panel. Default `4`. + pub fn popup_gap( mut self, px: f32 ) -> Self + { + self.popup_gap = px.max( 0.0 ); + self + } + + /// Width of the popup in logical pixels. Default `320`. + pub fn popup_width( mut self, px: f32 ) -> Self + { + self.popup_width = px.max( 80.0 ); + self + } + + /// Maximum height of the popup before it scrolls internally. Default + /// `280`. The popup never grows past this height; it shrinks to fit + /// the filtered item list when shorter. + pub fn popup_max_height( mut self, px: f32 ) -> Self + { + self.popup_max_height = px.max( 80.0 ); + self + } + + /// Callback fired with the new query string on every keystroke + /// inside the trigger's search field. Required when `searchable( true )`. + pub fn on_query_change( mut self, f: impl Fn( String ) -> Msg + 'static ) -> Self + { + self.on_query_change = Some( Arc::new( f ) ); + self + } + + /// Message emitted when the user activates the trigger to toggle the + /// popup open / closed (tap on the trigger pill or press the + /// down-arrow icon button). + pub fn on_toggle_open( mut self, msg: Msg ) -> Self + { + self.on_toggle_open = Some( msg ); + self + } + + /// Callback fired with the index of the item the user selects from + /// the popup. The application's `update()` should add that index to + /// `state.selected` (multi-select) or replace it (single-select). + pub fn on_select_idx( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self + { + self.on_select_idx = Some( Arc::new( f ) ); + self + } + + /// Callback fired with the index of a chip the user dismisses + /// (tapping its `×`). Multi-select only. + pub fn on_unselect_idx( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self + { + self.on_unselect_idx = Some( Arc::new( f ) ); + self + } + + /// Message emitted when the user taps outside the popup. Typically + /// flips `state.is_open` back to `false` in `update()`. + pub fn on_dismiss( mut self, msg: Msg ) -> Self + { + self.on_dismiss = Some( msg ); + self + } + + // ── filtering / item lookup ────────────────────────────────────────── + + /// Return the indices of items that match the current query (case + /// insensitive `contains` match). Empty query passes every item. + pub fn filtered_indices( &self ) -> Vec + { + let q = self.state.query.to_lowercase(); + if q.is_empty() + { + return ( 0..self.items.len() ).collect(); + } + self.items.iter().enumerate() + .filter( |( _, item )| item.to_lowercase().contains( &q ) ) + .map( |( i, _ )| i ) + .collect() + } + + fn first_selected_label( &self ) -> Option<&str> + { + self.state.selected.first() + .and_then( |&i| self.items.get( i ) ) + .map( |s| s.as_str() ) + } +} + +// Methods that build widget trees. Split into a `'static` impl block so +// `From>` and the constructor functions don't fight over +// generic bounds. +impl Combo +{ + /// Build the trigger: label / description / chips (multi-select) / + /// pill with text-edit + arrow / helper or error row. Returns an + /// [`Element`] ready to drop into [`App::view`](crate::App::view). + pub fn trigger( &self ) -> Element + { + use super::{ container, text, text_edit }; + use super::flex::flex; + use super::pressable::pressable; + use crate::layout::column::column; + use crate::layout::row::row; + use crate::layout::spacer::spacer; + + let palette = crate::theme::palette(); + + let mut col = column::().padding( 0.0 ).spacing( 8.0 ).align_center_x( false ); + + // Label. + if let Some( ref s ) = self.label + { + let c = if self.disabled { palette.text_secondary } else { palette.text_primary }; + col = col.push( text( s.clone() ).size( 16.0 ).color( c ) ); + } + + // Descriptive text. + if let Some( ref s ) = self.description + { + col = col.push( text( s.clone() ).size( 16.0 ).color( palette.text_secondary ) ); + } + + // Chip strip (multi-select with at least one selection). + if self.multi_select && !self.state.selected.is_empty() + { + const CHIP_X_PX: u32 = 14; + let chip_x = crate::theme::icon_rgba( "window/close", CHIP_X_PX ) + .map( | ( rgba, w, h ) | + { + let tinted = std::sync::Arc::new( + crate::theme::tint_symbolic( &rgba, palette.text_primary ), + ); + ( tinted, w, h ) + } ); + let mut chips = row::().padding( 0.0 ).spacing( 6.0 ); + let visible = self.state.selected.iter().take( self.max_chips_visible ); + for &idx in visible + { + if let Some( label ) = self.items.get( idx ).cloned() + { + let label_color = palette.text_primary; + let mut chip_row = row::().padding( 0.0 ).spacing( 10.0 ) + .push( text( label ).size( 14.0 ).color( label_color ) ); + if let Some( cb ) = self.on_unselect_idx.as_ref() + { + let msg = cb( idx ); + if let Some( ( ref rgba, w, h ) ) = chip_x + { + let btn = crate::widget::icon_button::( std::sync::Arc::clone( rgba ), w, h ) + .icon_size( CHIP_X_PX as f32 ) + .on_press( msg ); + chip_row = chip_row.push( btn ); + } + } + let chip: Element = container::( chip_row ) + .background( palette.surface_alt ) + .padding_h( 10.0 ) + .padding_v( 4.0 ) + .radius( 16.0 ) + .into(); + chips = chips.push( chip ); + } + } + let extra = self.state.selected.len().saturating_sub( self.max_chips_visible ); + if extra > 0 + { + chips = chips.push( + text( format!( "+{extra}" ) ).size( 14.0 ).color( palette.text_secondary ), + ); + } + col = col.push( chips ); + } + + // Trigger pill: text-edit (if searchable) + arrow toggle. + let trigger_inner: Element = { + let mut r = row::().padding( 0.0 ).spacing( 8.0 ); + + if self.searchable + { + let placeholder = self.placeholder.clone().unwrap_or_default(); + let value = self.state.query.clone(); + let mut te = text_edit::( placeholder, value ); + if let Some( cb ) = self.on_query_change.as_ref() + { + let cb = Arc::clone( cb ); + te = te.on_change( move |s| cb( s ) ); + } + // Wrap in `flex` so the text edit consumes the leftover + // width inside the row instead of claiming `max_width` for + // itself and pushing the down-arrow off-screen. + r = r.push( flex( te ) ); + } + else + { + let display = self.first_selected_label() + .map( |s| s.to_string() ) + .or_else( || self.placeholder.clone() ) + .unwrap_or_default(); + let c = if self.first_selected_label().is_some() + { + palette.text_primary + } + else + { + palette.text_secondary + }; + r = r.push( text( display ).size( 16.0 ).color( c ) ); + // Push the down-arrow to the right edge. + r = r.push( spacer() ); + } + + const CHEVRON_PX: u32 = 18; + let icon_name = if self.state.is_open + { + "general/up-simple" + } else { + "general/down-simple" + }; + if let Some( ( rgba, w, h ) ) = crate::theme::icon_rgba( icon_name, CHEVRON_PX ) + { + let tinted = std::sync::Arc::new( + crate::theme::tint_symbolic( &rgba, palette.text_primary ), + ); + let toggle_button = crate::widget::icon_button::( tinted, w, h ) + .icon_size( CHEVRON_PX as f32 ); + if let Some( ref msg ) = self.on_toggle_open + { + r = r.push( toggle_button.on_press( msg.clone() ) ); + } + else + { + r = r.push( toggle_button ); + } + } + r.into() + }; + + let ( pill_bg, pill_border ) = if self.error.is_some() + { + ( palette.danger_bg, palette.danger ) + } + else + { + ( palette.surface_alt, palette.divider ) + }; + let pill: Element = container::( trigger_inner ) + .background( pill_bg ) + .border( pill_border, 1.0 ) + .padding_h( 16.0 ) + .padding_v( 12.0 ) + .radius( 32.0 ) + .into(); + + // Always wrap the pill in a pressable so the popup toggles when + // the user taps anywhere on the pill chrome. Inner interactive + // widgets (text_edit, button) keep priority because the layout + // pass pushes the pressable's hit rect *before* recursing into + // the children — `find_widget_at` iterates the rect list in + // reverse, so deeper widgets win on overlap. Without this + // fallback, clicks that hit the gap between the text edit and + // the chevron button (or the rounded corners outside any child + // rect) silently land on nothing. + // + // The pressable also carries the anchor id so the popup can + // look the trigger pill's rect up at draw time and place + // itself flush below. + let pill: Element = { + let p = pressable::( pill ).id( self.anchor_id ); + let p = if let Some( ref msg ) = self.on_toggle_open + { + p.on_press( msg.clone() ) + } else { p }; + p.into() + }; + + col = col.push( pill ); + + // Helper / error row. + if let Some( ref err ) = self.error + { + col = col.push( text( err.clone() ).size( 14.0 ).color( palette.danger ) ); + } + else if let Some( ref h ) = self.helper + { + col = col.push( text( h.clone() ).size( 14.0 ).color( palette.text_secondary ) ); + } + + col.into() + } + + /// Build the popup as a [`Stack`](crate::Stack)-overlayable element. + /// Returns `None` when the combo is closed. + /// + /// The returned element is meant to be layered **on top of the rest + /// of the application's view tree** in the same surface — typically + /// by wrapping the app's `view()` output in a [`stack`](crate::stack) + /// and pushing this element when it is `Some`. It already contains: + /// + /// 1. A full-surface dismiss layer behind the panel — a transparent + /// [`pressable`](super::pressable::Pressable) that fires + /// [`Combo::on_dismiss`] when the user taps anywhere outside the + /// panel. + /// 2. The panel itself, centred horizontally and anchored 80 px from + /// the top of the available rect, with the filtered item list + /// inside a scrolling viewport bounded by [`Combo::popup_width`] + /// and [`Combo::popup_max_height`]. + /// + /// The popup centres modal-style — it does not anchor to the trigger + /// position because the trigger's screen-space rect is not known at + /// `view()` time. For a trigger-anchored popup, drive the popup's + /// position from a runtime hint the application stores after the + /// first hit-test. + pub fn popup( &self ) -> Option> + { + if !self.state.is_open { return None; } + + use super::container; + use super::list_item::list_item; + use super::pressable::pressable; + use super::scroll::scroll; + use super::text; + use super::viewport::viewport; + use crate::layout::column::column; + use crate::layout::spacer::spacer; + use crate::layout::stack::{ stack, HAlign, VAlign }; + + let palette = crate::theme::palette(); + + // Build the filtered list of items. The list_item widget paints + // its own hover / pressed surfaces; `selected( true )` overrides + // both with the dark surface + white text variant the design + // system specifies for the picked option. + let mut list = column::().padding( 4.0 ).spacing( 0.0 ).align_center_x( false ); + let filtered = self.filtered_indices(); + for idx in &filtered + { + let label = self.items[ *idx ].clone(); + let is_selected = self.state.selected.contains( idx ); + let mut li = list_item::( label ).selected( is_selected ); + if let Some( cb ) = self.on_select_idx.as_ref() + { + li = li.on_press( cb( *idx ) ); + } + list = list.push( li ); + } + + // Empty-list hint when no item matches the query. + if filtered.is_empty() + { + list = list.push( + text( "No matches" ).size( 14.0 ).color( palette.text_secondary ), + ); + } + + // Wrap the list in a viewport that pins the popup's max height + // and lets the inner scroll do its thing when the content + // overflows. + let scroller: Element = scroll::( list ).into(); + let bounded: Element = viewport::( scroller ) + .width( self.popup_width ) + .height( self.popup_max_height ) + .into(); + + let panel: Element = container::( bounded ) + .background( palette.surface_alt ) + .border( palette.divider, 1.0 ) + .padding( 8.0 ) + .radius( 32.0 ) + .into(); + + // Build the popup as a Stack with two layers: + // - layer 0 (Fill, Fill): full-surface dismiss layer. Built + // as `pressable( spacer() )` because Spacer reports zero + // intrinsic size and Stack's `Fill` alignment grows it to + // the full Stack rect (which the user installs at the root + // of `view()`, so it spans the whole surface). + // - layer 1 (Start, Top): the actual panel. The outer + // `AnchoredOverlay` below relocates this layer to flush + // below the trigger; without an anchor (first frame after + // open) the layer renders at the surface's top-left, which + // is acceptable as a one-frame artefact. + let mut s = stack::(); + if let Some( ref dismiss ) = self.on_dismiss + { + let backdrop: Element = pressable::( spacer() ) + .on_press( dismiss.clone() ) + .into(); + s = s.push( backdrop ); + } + + // The dismiss layer wants the FULL surface; the panel wants the + // anchored rect under the trigger. They cannot share a single + // `AnchoredOverlay` — wrapping the Stack root would relocate + // both layers and break the dismiss layer's full-coverage + // requirement. + // + // Solution: keep the dismiss layer at root level, and wrap + // only the panel in `AnchoredOverlay` so it (alone) reads + // the trigger's anchor. + let anchored_panel: Element = super::anchored_overlay::AnchoredOverlay::new( + panel, + self.anchor_id, + self.popup_gap, + ).into(); + s = s.push_aligned( anchored_panel, HAlign::Start, VAlign::Top ); + + Some( s.into() ) + } + + /// Build the [`OverlaySpec`] that the application should return from + /// [`App::overlays`](crate::App::overlays) when this combo is open. + /// + /// Returns `None` when the combo is closed. + /// + /// Unlike [`Combo::popup`], the overlay is rendered as a real Wayland + /// **xdg-popup** child of the application's main window — it can extend + /// outside the parent surface (the canonical select / dropdown + /// behaviour) and is positioned by the compositor relative to the + /// trigger pill rect from the previous frame's layout. The + /// [`OverlayId`] is derived from [`Combo::anchor_id`] so two combos + /// with distinct anchor ids get distinct overlay ids automatically. + /// + /// The spec's `view` is the panel itself (background, border, padding, + /// rounded corners and the bounded scrolling item list). No extra + /// dismiss layer is needed: tap-outside dismissal is handled by the + /// usual ltk mechanism — wire [`App::on_tap`](crate::App::on_tap) to + /// close the combo, or rely on the spec's `on_dismiss` which fires + /// when the compositor sends `popup_done`. + pub fn overlay( &self ) -> Option> + { + if !self.state.is_open { return None; } + + use super::container; + use super::list_item::list_item; + use super::scroll::scroll; + use super::text; + use super::viewport::viewport; + use crate::layout::column::column; + + let palette = crate::theme::palette(); + + let mut list = column::().padding( 4.0 ).spacing( 0.0 ).align_center_x( false ); + let filtered = self.filtered_indices(); + for idx in &filtered + { + let label = self.items[ *idx ].clone(); + let is_selected = self.state.selected.contains( idx ); + let mut li = list_item::( label ).selected( is_selected ); + if let Some( cb ) = self.on_select_idx.as_ref() + { + li = li.on_press( cb( *idx ) ); + } + list = list.push( li ); + } + if filtered.is_empty() + { + list = list.push( + text( "No matches" ).size( 14.0 ).color( palette.text_secondary ), + ); + } + + let scroller: Element = scroll::( list ).into(); + let bounded: Element = viewport::( scroller ) + .width( self.popup_width ) + .height( self.popup_max_height ) + .into(); + + let panel: Element = container::( bounded ) + .background( palette.surface_alt ) + .border( palette.divider, 1.0 ) + .padding( 8.0 ) + .radius( 32.0 ) + .into(); + + // Derive the OverlayId from the anchor_id's static-str so apps + // don't have to manually pick non-colliding ids for each combo. + let mut hasher = DefaultHasher::new(); + self.anchor_id.0.hash( &mut hasher ); + let overlay_id = OverlayId( hasher.finish() as u32 ); + + Some( OverlaySpec + { + id: overlay_id, + // `layer` / `anchor` / `exclusive_zone` / `keyboard_exclusive` + // are ignored on the xdg-popup path; the values below are the + // neutral defaults a layer-shell fallback would expect. + layer: Layer::Overlay, + anchor: Anchor::ALL, + // `size.0 == 0` asks the runtime to size the popup to the + // trigger pill width (the canonical select / dropdown + // behaviour). Height stays capped at `popup_max_height`. + size: ( 0, self.popup_max_height as u32 ), + exclusive_zone: 0, + keyboard_exclusive: false, + input_region: None, + view: panel, + on_dismiss: self.on_dismiss.clone(), + anchor_widget_id: Some( self.anchor_id ), + } ) + } +} + +/// Create a [`Combo`] over `items` driven by `state`. +pub fn combo( state: ComboState, items: Vec ) -> Combo +{ + Combo::new( state, items ) +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + fn st() -> ComboState { ComboState::new() } + + #[ test ] + fn defaults_match_documented_values() + { + let c: Combo<()> = combo( st(), vec![] ); + assert!( c.label.is_none() ); + assert!( !c.disabled ); + assert!( !c.multi_select ); + assert!( !c.searchable ); + assert_eq!( c.max_chips_visible, 4 ); + assert_eq!( c.popup_width, 320.0 ); + assert_eq!( c.popup_max_height, 280.0 ); + } + + #[ test ] + fn builders_round_trip_through_struct_fields() + { + let c: Combo<()> = combo( st(), vec![] ) + .label( "Fruit" ) + .description( "Choose" ) + .placeholder( "Pick…" ) + .helper( "Type to search" ) + .disabled( true ) + .multi_select( true ) + .searchable( true ) + .popup_width( 400.0 ) + .popup_max_height( 320.0 ) + .max_chips_visible( 6 ); + + assert_eq!( c.label.as_deref(), Some( "Fruit" ) ); + assert_eq!( c.description.as_deref(), Some( "Choose" ) ); + assert_eq!( c.placeholder.as_deref(), Some( "Pick…" ) ); + assert_eq!( c.helper.as_deref(), Some( "Type to search" ) ); + assert!( c.disabled ); + assert!( c.multi_select ); + assert!( c.searchable ); + assert_eq!( c.popup_width, 400.0 ); + assert_eq!( c.popup_max_height, 320.0 ); + assert_eq!( c.max_chips_visible, 6 ); + } + + #[ test ] + fn popup_width_is_clamped_to_eighty_minimum() + { + let c: Combo<()> = combo( st(), vec![] ).popup_width( 10.0 ); + assert_eq!( c.popup_width, 80.0 ); + } + + #[ test ] + fn popup_max_height_is_clamped_to_eighty_minimum() + { + let c: Combo<()> = combo( st(), vec![] ).popup_max_height( 10.0 ); + assert_eq!( c.popup_max_height, 80.0 ); + } + + #[ test ] + fn empty_query_returns_every_index() + { + let items = vec![ "Apple".into(), "Banana".into(), "Cherry".into() ]; + let c: Combo<()> = combo( st(), items ); + assert_eq!( c.filtered_indices(), vec![ 0, 1, 2 ] ); + } + + #[ test ] + fn case_insensitive_contains_filter() + { + let items = vec![ "Apple".into(), "Banana".into(), "Avocado".into(), "Cherry".into() ]; + let mut state = st(); + state.query = "a".into(); + let c: Combo<()> = combo( state, items ); + // "Apple", "Banana", "Avocado" all contain `a` in some case; + // "Cherry" does not. + assert_eq!( c.filtered_indices(), vec![ 0, 1, 2 ] ); + } + + #[ test ] + fn filter_match_is_case_insensitive_for_query_uppercase() + { + let items = vec![ "apple".into(), "BANANA".into(), "Cherry".into() ]; + let mut state = st(); + state.query = "RY".into(); + let c: Combo<()> = combo( state, items ); + assert_eq!( c.filtered_indices(), vec![ 2 ] ); + } + + #[ test ] + fn filter_with_no_matches_returns_empty() + { + let items = vec![ "Apple".into(), "Banana".into() ]; + let mut state = st(); + state.query = "zzz".into(); + let c: Combo<()> = combo( state, items ); + assert!( c.filtered_indices().is_empty() ); + } + + #[ test ] + fn closed_state_yields_no_popup() + { + let c: Combo<()> = combo( st(), vec![ "x".into() ] ); + assert!( c.popup().is_none() ); + } + + #[ test ] + fn open_state_yields_popup_element() + { + let mut state = st(); + state.is_open = true; + let c: Combo<()> = combo( state, vec![ "x".into() ] ); + assert!( c.popup().is_some(), "open combo must produce a popup element" ); + } + + #[ test ] + fn combo_state_helpers_select_and_unselect() + { + let mut s = ComboState::new(); + s.select( 1 ); + s.select( 3 ); + s.select( 1 ); // duplicate should be a no-op + assert_eq!( s.selected, vec![ 1, 3 ] ); + + s.unselect( 1 ); + assert_eq!( s.selected, vec![ 3 ] ); + + s.unselect( 99 ); // missing index is a no-op + assert_eq!( s.selected, vec![ 3 ] ); + } + + #[ test ] + fn combo_state_toggle_flips_open_flag() + { + let mut s = ComboState::new(); + assert!( !s.is_open ); + s.toggle_open(); + assert!( s.is_open ); + s.toggle_open(); + assert!( !s.is_open ); + } + + #[ test ] + fn first_selected_label_returns_none_when_empty() + { + let c: Combo<()> = combo( st(), vec![ "Apple".into() ] ); + assert!( c.first_selected_label().is_none() ); + } + + #[ test ] + fn first_selected_label_returns_first_selected_item() + { + let mut state = st(); + state.selected = vec![ 1 ]; + let c: Combo<()> = combo( state, vec![ "Apple".into(), "Banana".into() ] ); + assert_eq!( c.first_selected_label(), Some( "Banana" ) ); + } + + #[ test ] + fn first_selected_label_handles_stale_index() + { + let mut state = st(); + state.selected = vec![ 99 ]; // stale index — no panic + let c: Combo<()> = combo( state, vec![ "Apple".into() ] ); + assert!( c.first_selected_label().is_none() ); + } +} diff --git a/src/widget/container.rs b/src/widget/container.rs new file mode 100644 index 0000000..d376e6d --- /dev/null +++ b/src/widget/container.rs @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::{ Color, Corners }; +use crate::render::Canvas; +use super::Element; + +/// A transparent wrapper that adds a background color or a themed +/// surface and padding around any child [`Element`]. +/// +/// Does not consume a flat index — it is invisible to focus/hit-testing. +/// +/// Two background styles. [`Container::background`] paints a flat +/// colour rounded rect. [`Container::surface`] names a theme slot (a +/// `"type": "surface"` entry in the active `ThemeDocument`) which +/// resolves at paint time to a full Glass stack: gradient / solid +/// fill, outer drop shadow, inset shadows, backdrop blur. `surface` +/// takes precedence when both are set, and degrades to `background` +/// (or to no background at all, when neither is set) if the slot is +/// absent from the active theme — third-party themes that do not +/// ship the named surface still render the content, just without +/// the Glass chrome. +/// +/// ```rust,no_run +/// # use ltk::{ column, container, row, text, Color, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex( +/// # icon: Element, +/// # title: Element, +/// # subtitle: Element, +/// # ) -> ( Element, Element ) { +/// // Flat colour +/// let flat = container( text( "Hello" ) ) +/// .background( Color::rgb( 0.2, 0.2, 0.25 ) ) +/// .padding( 12.0 ); +/// +/// // Glass card backed by a named theme surface +/// let card = container( +/// row() +/// .push( icon ) +/// .push( column().push( title ).push( subtitle ) ) +/// ) +/// .surface( "surface-card" ) +/// .radius( 32.0 ) +/// .padding_h( 16.5 ) +/// .padding_v( 24.0 ); +/// # ( flat.into(), card.into() ) +/// # } +/// ``` +pub struct Container +{ + pub child: Box>, + pub background: Option, + /// Slot id of a themed surface (resolved via + /// [`crate::theme::resolve_surface`]). When set, takes precedence + /// over `background` and paints the full Glass stack instead of a + /// flat colour fill. + pub surface: Option, + /// Per-corner radii applied to every painted layer of the + /// container chrome — flat fill, themed surface (gradient + outer + /// shadows + insets + backdrop blur). Stored as [`Corners`] so + /// callers can pin the rounded shape to one or two corners (a + /// panel pinned to the screen bottom, a side panel pinned to the + /// left edge, …) without hitting the renderer with an offset + /// trick. + pub corners: Corners, + /// Padding on the top edge in logical px — gap between the + /// container's top boundary and its child. + pub pad_top: f32, + /// Padding on the right edge in logical px. + pub pad_right: f32, + /// Padding on the bottom edge in logical px. + pub pad_bottom: f32, + /// Padding on the left edge in logical px. + pub pad_left: f32, + pub opacity: f32, + /// Optional `( color, width_px )` border stroke painted around the + /// container's rounded rectangle, after the fill / surface and + /// before the child draws. `None` leaves the chrome flat. + pub border: Option<( Color, f32 )>, +} + +impl Container +{ + pub fn new( child: impl Into> ) -> Self + { + Self + { + child: Box::new( child.into() ), + background: None, + surface: None, + corners: Corners::ZERO, + pad_top: 0.0, + pad_right: 0.0, + pad_bottom: 0.0, + pad_left: 0.0, + opacity: 1.0, + border: None, + } + } + + /// Paint a rounded-rect stroke around the container with the given + /// colour and pixel width. Useful for input fields, popovers and + /// any chrome the design system specifies as outlined rather than + /// filled. + pub fn border( mut self, color: Color, width: f32 ) -> Self + { + self.border = Some( ( color, width.max( 0.0 ) ) ); + self + } + + /// Set the background fill color. Ignored at paint time if a + /// themed [`surface`](Self::surface) is set and resolves against + /// the active theme. + pub fn background( mut self, color: Color ) -> Self + { + self.background = Some( color ); + self + } + + /// Back the container with a themed surface slot. The slot id is + /// resolved against the active `ThemeDocument` at paint time via + /// [`crate::theme::resolve_surface`]; missing slots fall through + /// to [`background`](Self::background) or to no background at all. + /// + /// Slot ids are documented by the theme. The default theme ships + /// `surface-card` (generic Glass container) and the slider-specific + /// slots; downstream themes are free to add their own. + pub fn surface( mut self, slot: impl Into ) -> Self + { + self.surface = Some( slot.into() ); + self + } + + /// Set the corner radii for every painted layer of the container + /// chrome. Accepts a single `f32` (uniform radius — the common + /// case, equivalent to `Corners::all( r )`), a tuple `( tl, tr, + /// br, bl )` (CSS shorthand order), or any explicit + /// [`Corners`] value. + /// + /// ```rust,no_run + /// # use ltk::{ container, text, Corners, Element }; + /// # #[ derive( Clone ) ] enum Msg {} + /// # fn _ex() -> ( Element, Element, Element ) { + /// // Uniform 16 px on all corners (single-value form). + /// let a = container( text( "child" ) ).radius( 16.0 ); + /// + /// // Rounded top corners only — for a panel pinned flush against + /// // the bottom edge of the screen. + /// let b = container( text( "child" ) ).radius( Corners::top( 16.0 ) ); + /// + /// // Custom four-corner radii. + /// let c = container( text( "child" ) ).radius( ( 16.0, 16.0, 0.0, 0.0 ) ); + /// # ( a.into(), b.into(), c.into() ) + /// # } + /// ``` + pub fn radius( mut self, corners: impl Into ) -> Self + { + self.corners = corners.into(); + self + } + + /// Set uniform padding on all four sides — equivalent to setting + /// `padding_top`, `padding_right`, `padding_bottom`, and + /// `padding_left` to `p`. Asymmetric variants + /// ([`padding_top`](Self::padding_top), …) override individual + /// edges, so calling this first and then a per-edge setter is the + /// idiomatic way to express "uniform padding except for one + /// edge". + pub fn padding( mut self, p: f32 ) -> Self + { + self.pad_top = p; + self.pad_right = p; + self.pad_bottom = p; + self.pad_left = p; + self + } + + /// Set horizontal padding (left + right each). + pub fn padding_h( mut self, p: f32 ) -> Self + { + self.pad_left = p; + self.pad_right = p; + self + } + + /// Set vertical padding (top + bottom each). + pub fn padding_v( mut self, p: f32 ) -> Self + { + self.pad_top = p; + self.pad_bottom = p; + self + } + + /// Set the top edge padding only. Pairs with + /// [`padding_bottom`](Self::padding_bottom) for asymmetric + /// vertical insets. + pub fn padding_top( mut self, p: f32 ) -> Self + { + self.pad_top = p; + self + } + + /// Set the right edge padding only. + pub fn padding_right( mut self, p: f32 ) -> Self + { + self.pad_right = p; + self + } + + /// Set the bottom edge padding only. + pub fn padding_bottom( mut self, p: f32 ) -> Self + { + self.pad_bottom = p; + self + } + + /// Set the left edge padding only. + pub fn padding_left( mut self, p: f32 ) -> Self + { + self.pad_left = p; + self + } + + /// Set opacity for the entire container and its contents (0.0 = transparent, 1.0 = opaque). + pub fn opacity( mut self, alpha: f32 ) -> Self + { + self.opacity = alpha.clamp( 0.0, 1.0 ); + self + } + + /// Return the preferred `(width, height)` accounting for padding. + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) + { + let pad_x = self.pad_left + self.pad_right; + let pad_y = self.pad_top + self.pad_bottom; + let inner_w = ( max_width - pad_x ).max( 0.0 ); + let ( cw, ch ) = self.child.preferred_size( inner_w, canvas ); + ( cw + pad_x, ch + pad_y ) + } + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> Container + where + U: Clone + 'static, + Msg: 'static, + { + Container + { + child: Box::new( self.child.map_arc( f ) ), + background: self.background, + surface: self.surface, + corners: self.corners, + pad_top: self.pad_top, + pad_right: self.pad_right, + pad_bottom: self.pad_bottom, + pad_left: self.pad_left, + opacity: self.opacity, + border: self.border, + } + } +} + +/// Create a [`Container`] that wraps `child`. +pub fn container( child: impl Into> ) -> Container +{ + Container::new( child ) +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::layout::spacer::spacer; + + #[ test ] + fn default_no_background() + { + let c = container::<()>( spacer() ); + assert!( c.background.is_none() ); + } + + #[ test ] + fn padding_sets_all_four_sides() + { + let c = container::<()>( spacer() ).padding( 10.0 ); + assert_eq!( c.pad_top, 10.0 ); + assert_eq!( c.pad_right, 10.0 ); + assert_eq!( c.pad_bottom, 10.0 ); + assert_eq!( c.pad_left, 10.0 ); + } + + #[ test ] + fn padding_h_only_touches_left_and_right() + { + let c = container::<()>( spacer() ).padding_h( 8.0 ); + assert_eq!( c.pad_left, 8.0 ); + assert_eq!( c.pad_right, 8.0 ); + assert_eq!( c.pad_top, 0.0 ); + assert_eq!( c.pad_bottom, 0.0 ); + } + + #[ test ] + fn padding_v_only_touches_top_and_bottom() + { + let c = container::<()>( spacer() ).padding_v( 6.0 ); + assert_eq!( c.pad_top, 6.0 ); + assert_eq!( c.pad_bottom, 6.0 ); + assert_eq!( c.pad_left, 0.0 ); + assert_eq!( c.pad_right, 0.0 ); + } + + #[ test ] + fn per_edge_overrides_uniform_padding() + { + // Idiomatic "uniform with one override". + let c = container::<()>( spacer() ) + .padding( 12.0 ) + .padding_bottom( 22.0 ); + assert_eq!( c.pad_top, 12.0 ); + assert_eq!( c.pad_right, 12.0 ); + assert_eq!( c.pad_bottom, 22.0 ); + assert_eq!( c.pad_left, 12.0 ); + } + + #[ test ] + fn background_set() + { + use crate::types::Color; + let c = container::<()>( spacer() ).background( Color::BLACK ); + assert!( c.background.is_some() ); + } + + #[ test ] + fn radius_set_uniform() + { + let c = container::<()>( spacer() ).radius( 12.0 ); + assert_eq!( c.corners, Corners::all( 12.0 ) ); + } + + #[ test ] + fn radius_set_per_corner() + { + let c = container::<()>( spacer() ).radius( Corners::top( 16.0 ) ); + assert_eq!( c.corners.tl, 16.0 ); + assert_eq!( c.corners.tr, 16.0 ); + assert_eq!( c.corners.br, 0.0 ); + assert_eq!( c.corners.bl, 0.0 ); + } + + #[ test ] + fn radius_set_tuple() + { + let c = container::<()>( spacer() ).radius( ( 8.0, 4.0, 2.0, 1.0 ) ); + assert_eq!( c.corners.tl, 8.0 ); + assert_eq!( c.corners.tr, 4.0 ); + assert_eq!( c.corners.br, 2.0 ); + assert_eq!( c.corners.bl, 1.0 ); + } + + #[ test ] + fn default_no_surface() + { + let c = container::<()>( spacer() ); + assert!( c.surface.is_none() ); + } + + #[ test ] + fn surface_stores_slot_id() + { + let c = container::<()>( spacer() ).surface( "surface-card" ); + assert_eq!( c.surface.as_deref(), Some( "surface-card" ) ); + } +} + diff --git a/src/widget/date_picker.rs b/src/widget/date_picker.rs new file mode 100644 index 0000000..f52b187 --- /dev/null +++ b/src/widget/date_picker.rs @@ -0,0 +1,617 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! DatePicker — month-grid calendar widget. +//! +//! Stateless: the application owns the selected date and the +//! currently-visible month. Two callbacks bridge widget → app: +//! +//! - [`DatePicker::on_change`] fires when the user taps a day cell; +//! the message carries the picked [`Date`]. +//! - [`DatePicker::on_navigate`] fires when the user taps the +//! previous / next-month arrows; the message carries the new +//! `(year, month)` that the calendar should display. +//! +//! `on_navigate` is optional — when not wired, the navigation arrows +//! render disabled. The application typically stores both `date: +//! Date` and a `view: Date` (or `(view_year, view_month)`) in its +//! state and updates `view` from `on_navigate` to let the user scroll +//! through months without changing the selection. +//! +//! Date arithmetic (leap years, day-of-week, month wraparound) is +//! built in — no `chrono` / `time` dependency. Limited to the +//! Gregorian calendar from year 1 onwards (Zeller's congruence). +//! +//! ```rust,no_run +//! # use ltk::{ date_picker, Date, DatePicker }; +//! # #[ derive( Clone ) ] enum Msg { DateChanged( Date ), DateView( i32, u8 ) } +//! # struct App { date: Date, view_year: i32, view_month: u8, today: Date } +//! # impl App { fn _ex( &self ) -> DatePicker { +//! date_picker( self.date ) +//! .view( self.view_year, self.view_month ) +//! .today( self.today ) +//! .on_change( Msg::DateChanged ) +//! .on_navigate( |y, m| Msg::DateView( y, m ) ) +//! # }} +//! ``` + +use std::sync::Arc; + +use crate::layout::column::column; +use crate::layout::spacer::spacer; +use crate::layout::wrap_grid::grid; + +use super::Element; + +mod theme +{ + use crate::types::Color; + pub fn surface() -> Color { crate::theme::palette().surface } + pub fn surface_alt() -> Color { crate::theme::palette().surface_alt } + pub fn text() -> Color { crate::theme::palette().text_primary } + pub fn text_muted() -> Color { crate::theme::palette().text_secondary } + pub fn accent() -> Color { crate::theme::palette().accent } + pub const PADDING: f32 = 16.0; + pub const RADIUS: f32 = 16.0; + pub const HEADER_FS: f32 = 16.0; + pub const DOW_FS: f32 = 12.0; + pub const DAY_FS: f32 = 14.0; + pub const CELL_SIZE: f32 = 36.0; + pub const SPACING: f32 = 4.0; +} + +/// A calendar date in the proleptic Gregorian calendar. No time +/// component, no timezone. `month` is 1–12, `day` is 1–31 (further +/// constrained by [`days_in_month`]). +#[ derive( Clone, Copy, Debug, PartialEq, Eq, Hash ) ] +pub struct Date +{ + pub year: i32, + pub month: u8, + pub day: u8, +} + +impl Date +{ + /// Construct a [`Date`] without validating bounds. Callers that + /// might pass user-controlled data should run [`Self::is_valid`] + /// first. + pub const fn new( year: i32, month: u8, day: u8 ) -> Self + { + Self { year, month, day } + } + + /// `true` when `month` is in `1..=12` and `day` is a real day of + /// that month / year (29-Feb in leap years, etc.). + pub fn is_valid( self ) -> bool + { + ( 1..=12 ).contains( &self.month ) + && self.day >= 1 + && self.day <= days_in_month( self.year, self.month ) + } +} + +/// `true` when `year` is a leap year in the proleptic Gregorian +/// calendar. +pub fn is_leap_year( year: i32 ) -> bool +{ + ( year % 4 == 0 && year % 100 != 0 ) || year % 400 == 0 +} + +/// Number of days in `month` of `year` (1-indexed month). Returns 0 +/// for invalid month numbers. +pub fn days_in_month( year: i32, month: u8 ) -> u8 +{ + match month + { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => if is_leap_year( year ) { 29 } else { 28 }, + _ => 0, + } +} + +/// Day of the week for `(year, month, day)`. `0 = Sunday`, +/// `1 = Monday`, …, `6 = Saturday`. Uses Zeller's congruence; +/// undefined for years before AD 1. +pub fn day_of_week( year: i32, month: u8, day: u8 ) -> u8 +{ + let ( m, y ) = if month < 3 { ( month as i32 + 12, year - 1 ) } else { ( month as i32, year ) }; + let k = y.rem_euclid( 100 ); + let j = y.div_euclid( 100 ); + let h = ( day as i32 + ( 13 * ( m + 1 ) ) / 5 + k + k / 4 + j / 4 + 5 * j ).rem_euclid( 7 ); + // h: 0=Saturday, 1=Sunday, 2=Monday, ..., 6=Friday → re-map to 0=Sun..6=Sat + ( ( h + 6 ).rem_euclid( 7 ) ) as u8 +} + +/// Add `delta` months to `(year, month)` with wraparound. Negative +/// deltas walk backwards; year crosses are handled. +pub fn add_months( year: i32, month: u8, delta: i32 ) -> ( i32, u8 ) +{ + let total = year * 12 + ( month as i32 ) - 1 + delta; + let new_year = total.div_euclid( 12 ); + let new_month = ( total.rem_euclid( 12 ) + 1 ) as u8; + ( new_year, new_month ) +} + +/// Locale settings for the date picker. Month names and day-of-week +/// labels are pulled from the i18n registry via +/// [`rust_i18n::t!`] so changing the active locale (e.g. `set_locale("es")`) +/// flips the calendar without recreating the widget. The remaining piece is +/// the **first day of the week**, which varies independently of language +/// (US starts on Sunday, ES / FR / DE on Monday) — that one stays as a +/// builder field. +#[ derive( Clone, Copy, Debug ) ] +pub struct Locale +{ + /// First day of the week (0 = Sunday, 1 = Monday). Default 1 + /// (Monday) — the convention used across most of Europe. + pub first_dow: u8, +} + +impl Locale +{ + /// Locale starting the week on Monday. The default. + pub const MONDAY_FIRST: Self = Self { first_dow: 1 }; + /// Locale starting the week on Sunday — common in US English. + pub const SUNDAY_FIRST: Self = Self { first_dow: 0 }; +} + +impl Default for Locale +{ + fn default() -> Self { Self::MONDAY_FIRST } +} + +/// Translated month name (1-indexed: `month = 1` → January, …, +/// `month = 12` → December). Resolves through the i18n registry, so +/// `set_locale("es")` returns `"Enero"`, etc. +fn month_name( month: u8 ) -> String +{ + match month + { + 1 => rust_i18n::t!( "date_picker.month_1" ).to_string(), + 2 => rust_i18n::t!( "date_picker.month_2" ).to_string(), + 3 => rust_i18n::t!( "date_picker.month_3" ).to_string(), + 4 => rust_i18n::t!( "date_picker.month_4" ).to_string(), + 5 => rust_i18n::t!( "date_picker.month_5" ).to_string(), + 6 => rust_i18n::t!( "date_picker.month_6" ).to_string(), + 7 => rust_i18n::t!( "date_picker.month_7" ).to_string(), + 8 => rust_i18n::t!( "date_picker.month_8" ).to_string(), + 9 => rust_i18n::t!( "date_picker.month_9" ).to_string(), + 10 => rust_i18n::t!( "date_picker.month_10" ).to_string(), + 11 => rust_i18n::t!( "date_picker.month_11" ).to_string(), + 12 => rust_i18n::t!( "date_picker.month_12" ).to_string(), + _ => "?".to_string(), + } +} + +/// Translated single-letter day-of-week label keyed by `dow` where +/// `0 = Sunday`, …, `6 = Saturday`. The display order in the calendar +/// header is rotated by [`Locale::first_dow`]. +fn dow_short( dow: u8 ) -> String +{ + match dow + { + 0 => rust_i18n::t!( "date_picker.dow_short_0" ).to_string(), + 1 => rust_i18n::t!( "date_picker.dow_short_1" ).to_string(), + 2 => rust_i18n::t!( "date_picker.dow_short_2" ).to_string(), + 3 => rust_i18n::t!( "date_picker.dow_short_3" ).to_string(), + 4 => rust_i18n::t!( "date_picker.dow_short_4" ).to_string(), + 5 => rust_i18n::t!( "date_picker.dow_short_5" ).to_string(), + 6 => rust_i18n::t!( "date_picker.dow_short_6" ).to_string(), + _ => "?".to_string(), + } +} + +/// Calendar date selector. +pub struct DatePicker +{ + pub value: Date, + pub view_year: i32, + pub view_month: u8, + pub today: Option, + pub on_change: Option Msg>>, + pub on_navigate: Option Msg>>, + pub locale: Locale, +} + +impl DatePicker +{ + /// Create a date picker with the given selected date. The view + /// month defaults to the same month as `value`; override with + /// [`Self::view`] when the user is browsing without selecting. + pub fn new( value: Date ) -> Self + { + Self + { + value, + view_year: value.year, + view_month: value.month, + today: None, + on_change: None, + on_navigate: None, + locale: Locale::default(), + } + } + + /// Override the visible month. Call this from your view function + /// with whatever `(year, month)` your application state stores + /// for "the calendar's current page". + pub fn view( mut self, year: i32, month: u8 ) -> Self + { + self.view_year = year; + self.view_month = month; + self + } + + /// Mark a specific date as "today" — drawn with a subtle accent + /// ring even if it is not selected. + pub fn today( mut self, today: Date ) -> Self + { + self.today = Some( today ); + self + } + + /// Day-tap callback. Required for the picker to be interactive. + pub fn on_change( mut self, f: impl Fn( Date ) -> Msg + 'static ) -> Self + { + self.on_change = Some( Arc::new( f ) ); + self + } + + /// Arrow-tap callback. The runtime calls `f(new_year, new_month)` + /// when the user taps prev / next. Wire to your view-month state. + pub fn on_navigate( mut self, f: impl Fn( i32, u8 ) -> Msg + 'static ) -> Self + { + self.on_navigate = Some( Arc::new( f ) ); + self + } + + /// Override the locale (month / day-of-week names + week start). + pub fn locale( mut self, l: Locale ) -> Self + { + self.locale = l; + self + } + + /// Build the `Element` tree representing this date picker. + pub fn build( self ) -> Element + { + use super::{ button, container, icon_button, text }; + use super::pressable::pressable; + use super::button::ButtonVariant; + use crate::layout::stack::{ stack, HAlign, VAlign }; + + let view_y = self.view_year; + let view_m = self.view_month.clamp( 1, 12 ); + let today = self.today; + let value = self.value; + let on_chg = self.on_change.clone(); + let on_nav = self.on_navigate.clone(); + let first = self.locale.first_dow; + + // Header chevrons load from the active theme as SVG icons + // (`icons/catalogue/filled/multimedia/{previous,next}.svg`), + // tinted to the primary text colour so they read against + // either light or dark surfaces. Sized small so the buttons + // sit visually inside the grid's first / last column. + // Falls back to the matching Unicode glyph if the icon is + // missing from the theme. + const CHEVRON_PX: u32 = 18; + let nav_button = | name: &str, fallback: &str | -> super::button::Button + { + match crate::theme::icon_rgba( name, CHEVRON_PX ) + { + Some( ( rgba, w, h ) ) => + { + let tinted = std::sync::Arc::new( + crate::theme::tint_symbolic( &rgba, theme::text() ), + ); + icon_button::( tinted, w, h ).icon_size( CHEVRON_PX as f32 ) + } + None => button::( fallback ).variant( ButtonVariant::Tertiary ), + } + }; + + let title = format!( "{} {}", month_name( view_m ), view_y ); + let mut prev = nav_button( "multimedia/previous", "‹" ); + let mut next = nav_button( "multimedia/next", "›" ); + if let Some( ref nav ) = on_nav + { + let ( py, pm ) = add_months( view_y, view_m, -1 ); + let ( ny, nm ) = add_months( view_y, view_m, 1 ); + let nav_p = nav.clone(); + let nav_n = nav.clone(); + prev = prev.on_press( nav_p( py, pm ) ); + next = next.on_press( nav_n( ny, nm ) ); + } + + // Header: title centred horizontally; prev pinned to the left + // edge, next pinned to the right edge — both at the same + // padding offset as the calendar grid below, so they align + // vertically with the first and last day columns. Built as a + // `Stack` (the only layout that lets independent children + // sit at left / centre / right of the same rect) wrapped in a + // container that fixes the row height. + let header_height = ( CHEVRON_PX as f32 + 16.0 ).max( theme::HEADER_FS + 16.0 ); + let header_inner: Element = stack::() + .push_aligned( prev, HAlign::Start, VAlign::Center ) + .push_aligned( + text( title ).size( theme::HEADER_FS ).color( theme::text() ), + HAlign::Center, VAlign::Center, + ) + .push_aligned( next, HAlign::End, VAlign::Center ) + .into(); + let header: Element = container::( header_inner ) + .padding_v( ( header_height - CHEVRON_PX as f32 ) * 0.5 ) + .padding_h( 0.0 ) + .into(); + + // Day-of-week row in the locale's display order. Built as a + // 7-column `wrap_grid` so the columns share the same + // equal-width slots that the day-cell grid below will use, + // guaranteeing letter-and-number alignment frame to frame + // regardless of glyph width (`W` is wider than `M`, etc.). + let mut dow_row = grid::( 7 ).spacing( theme::SPACING ); + for slot in 0..7u8 + { + // Convert the column index (display order) back to a + // weekday number (0 = Sunday) honouring `first_dow`. + let dow = ( ( slot + first ) % 7 ) as u8; + let cell: Element = container::( + text( dow_short( dow ) ) + .size( theme::DOW_FS ) + .color( theme::text_muted() ) + .align_center(), + ) + .padding_h( 0.0 ) + .padding_v( 4.0 ) + .into(); + dow_row = dow_row.push( cell ); + } + + // Day grid: 7 columns × 6 rows of equal-width cells. Off-month + // slots stay empty so the calendar always reserves the same + // height; the `wrap_grid` lays out each cell in its own slot + // so vertical alignment with the DOW row above is automatic. + let dim = days_in_month( view_y, view_m ); + let first_dow = day_of_week( view_y, view_m, 1 ); + let leading_blanks = ( ( first_dow as i32 - first as i32 ).rem_euclid( 7 ) ) as u8; + let total_cells = 6 * 7; + + let mut day_grid = grid::( 7 ).spacing( theme::SPACING ); + for slot in 0..total_cells + { + let day_num = slot as i32 - leading_blanks as i32 + 1; + let cell: Element = if day_num < 1 || day_num > dim as i32 + { + container::( spacer() ).padding( 0.0 ).into() + } else { + let day = day_num as u8; + let date = Date::new( view_y, view_m, day ); + let is_selected = date == value; + let is_today = today == Some( date ); + + let label_color = if is_selected { theme::surface() } else { theme::text() }; + let bg = if is_selected + { + Some( theme::accent() ) + } else if is_today { + Some( theme::surface_alt() ) + } else { + None + }; + + let mut card = container::( + text( format!( "{}", day ) ) + .size( theme::DAY_FS ) + .color( label_color ) + .align_center(), + ) + .padding_h( 4.0 ) + .padding_v( 8.0 ) + .radius( theme::CELL_SIZE * 0.5 ); + if let Some( c ) = bg { card = card.background( c ); } + + if let Some( ref cb ) = on_chg + { + let m = cb( date ); + pressable( card ).on_press( m ).into() + } else { + card.into() + } + }; + day_grid = day_grid.push( cell ); + } + + container::( + column::() + .spacing( 0.0 ) + .push( header ) + // Extra breathing space between the month title and + // the day-of-week header so the row separation reads + // cleanly. Tuned by feedback — the previous shared + // `SPACING * 2.0` was too tight. + .push( spacer().height( theme::SPACING * 4.0 ) ) + .push( dow_row ) + .push( spacer().height( theme::SPACING * 1.5 ) ) + .push( day_grid ), + ) + .background( theme::surface_alt() ) + .padding( theme::PADDING ) + .radius( theme::RADIUS ) + .into() + } +} + +impl From> for Element +{ + fn from( d: DatePicker ) -> Self { d.build() } +} + +/// Create a [`DatePicker`] with the given selected date. +/// +/// ```rust,no_run +/// # use ltk::{ date_picker, Date, DatePicker }; +/// # #[ derive( Clone ) ] enum Msg { DateChanged( Date ), DateView( i32, u8 ) } +/// # struct App { date: Date, today: Date } +/// # impl App { fn _ex( &self ) -> DatePicker { +/// date_picker( self.date ) +/// .today( self.today ) +/// .on_change( Msg::DateChanged ) +/// .on_navigate( Msg::DateView ) +/// # }} +/// ``` +pub fn date_picker( value: Date ) -> DatePicker +{ + DatePicker::new( value ) +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + // ── leap years ──────────────────────────────────────────────────────────── + + #[ test ] + fn leap_year_rules() + { + assert!( is_leap_year( 2024 ) ); + assert!( is_leap_year( 2000 ) ); // div by 400 + assert!( !is_leap_year( 1900 ) ); // div by 100 but not 400 + assert!( !is_leap_year( 2023 ) ); + assert!( is_leap_year( -4 ) ); // proleptic + } + + #[ test ] + fn days_in_month_handles_leap_february() + { + assert_eq!( days_in_month( 2024, 2 ), 29 ); + assert_eq!( days_in_month( 2023, 2 ), 28 ); + assert_eq!( days_in_month( 2024, 4 ), 30 ); + assert_eq!( days_in_month( 2024, 7 ), 31 ); + } + + #[ test ] + fn days_in_month_zero_for_invalid_month() + { + assert_eq!( days_in_month( 2024, 0 ), 0 ); + assert_eq!( days_in_month( 2024, 13 ), 0 ); + } + + // ── day of week ─────────────────────────────────────────────────────────── + + #[ test ] + fn day_of_week_known_dates() + { + // 1970-01-01 was a Thursday → 4. + assert_eq!( day_of_week( 1970, 1, 1 ), 4 ); + // 2000-01-01 was a Saturday → 6. + assert_eq!( day_of_week( 2000, 1, 1 ), 6 ); + // 2024-02-29 was a Thursday → 4 (leap day). + assert_eq!( day_of_week( 2024, 2, 29 ), 4 ); + // 2026-05-02 (today, per user clock) was a Saturday → 6. + assert_eq!( day_of_week( 2026, 5, 2 ), 6 ); + } + + // ── month arithmetic ────────────────────────────────────────────────────── + + #[ test ] + fn add_months_within_year() + { + assert_eq!( add_months( 2024, 3, 1 ), ( 2024, 4 ) ); + assert_eq!( add_months( 2024, 3, -1 ), ( 2024, 2 ) ); + } + + #[ test ] + fn add_months_wraps_into_next_year() + { + assert_eq!( add_months( 2024, 12, 1 ), ( 2025, 1 ) ); + } + + #[ test ] + fn add_months_wraps_into_previous_year() + { + assert_eq!( add_months( 2024, 1, -1 ), ( 2023, 12 ) ); + } + + #[ test ] + fn add_months_handles_large_deltas() + { + assert_eq!( add_months( 2024, 1, 25 ), ( 2026, 2 ) ); + assert_eq!( add_months( 2024, 1, -25 ), ( 2021, 12 ) ); + } + + // ── Date validity ───────────────────────────────────────────────────────── + + #[ test ] + fn date_is_valid_on_realistic_inputs() + { + assert!( Date::new( 2024, 2, 29 ).is_valid() ); + assert!( !Date::new( 2023, 2, 29 ).is_valid() ); // not leap + assert!( !Date::new( 2024, 4, 31 ).is_valid() ); // april has 30 + assert!( !Date::new( 2024, 0, 1 ).is_valid() ); + assert!( !Date::new( 2024, 13, 1 ).is_valid() ); + assert!( !Date::new( 2024, 1, 0 ).is_valid() ); + } + + // ── builders ────────────────────────────────────────────────────────────── + + #[ derive( Clone, Debug, PartialEq, Eq ) ] + enum Msg + { + Pick( Date ), + Nav( i32, u8 ), + } + + #[ test ] + fn defaults_view_to_value_month() + { + let d: DatePicker = date_picker( Date::new( 2024, 7, 15 ) ); + assert_eq!( d.view_year, 2024 ); + assert_eq!( d.view_month, 7 ); + } + + #[ test ] + fn view_builder_overrides() + { + let d: DatePicker = date_picker( Date::new( 2024, 7, 15 ) ).view( 2025, 1 ); + assert_eq!( d.view_year, 2025 ); + assert_eq!( d.view_month, 1 ); + } + + #[ test ] + fn on_change_callback_invokes_with_date() + { + let d: DatePicker = date_picker( Date::new( 2024, 7, 15 ) ) + .on_change( Msg::Pick ); + let cb = d.on_change.as_ref().expect( "set" ); + assert_eq!( cb( Date::new( 2024, 7, 16 ) ), Msg::Pick( Date::new( 2024, 7, 16 ) ) ); + } + + #[ test ] + fn on_navigate_callback_invokes_with_year_month() + { + let d: DatePicker = date_picker( Date::new( 2024, 7, 15 ) ) + .on_navigate( Msg::Nav ); + let cb = d.on_navigate.as_ref().expect( "set" ); + assert_eq!( cb( 2025, 1 ), Msg::Nav( 2025, 1 ) ); + } + + #[ test ] + fn build_does_not_panic_on_minimal_config() + { + let _: Element = date_picker::( Date::new( 2024, 7, 15 ) ).build(); + } + + #[ test ] + fn build_does_not_panic_when_view_month_is_clamped() + { + // `view_month = 0` is invalid — build clamps to 1 instead of + // indexing month_names at -1. + let mut d: DatePicker = date_picker( Date::new( 2024, 7, 15 ) ); + d.view_month = 0; + let _: Element = d.build(); + } +} diff --git a/src/widget/dialog.rs b/src/widget/dialog.rs new file mode 100644 index 0000000..8736036 --- /dev/null +++ b/src/widget/dialog.rs @@ -0,0 +1,451 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Modal / non-modal centered dialog: a darkened scrim with a card +//! holding a title, an optional subtitle, an optional body, and a row +//! of action buttons. +//! +//! The widget is implemented as a thin builder over existing +//! primitives: at conversion time +//! ([`From> for Element`](Dialog#impl-From%3CDialog%3CMsg%3E%3E-for-Element%3CMsg%3E)) +//! it lowers itself to a [`Stack`](crate::layout::stack::Stack) of +//! +//! 1. a full-surface [`Pressable`](crate::widget::pressable::Pressable) +//! scrim — `swallow=true` so it absorbs every pointer event that +//! misses the card (so widgets behind the dialog cannot be clicked), +//! `on_escape=cancel_msg` so the keyboard ESC handler can find it, +//! and `on_press=dismiss_msg` only when the dialog is non-modal and +//! a `dismiss_on_scrim` was configured; +//! 2. a centered card backed by a flat opaque fill (`palette.surface` +//! with alpha forced to 1.0 — themed `surface-card` / similar +//! Glass surfaces ship as translucent for the rest of the toolkit, +//! but a confirmation dialog must read against any background, so +//! the dialog opts out of the Glass chrome). The card wraps a +//! *card-area* `Pressable( swallow=true )` so the body itself +//! silently absorbs taps and only clicks strictly outside the card +//! can dismiss the dialog. Inside the card sits a column with the +//! title (700-weight, wraps), the subtitle +//! (`text_secondary`, wraps), the user-supplied body, and a +//! right-aligned actions row. +//! +//! ## Example +//! +//! ```rust,no_run +//! # use ltk::{ button, dialog, ButtonVariant, Element }; +//! # #[ derive( Clone ) ] enum Msg { Cancel, Confirm } +//! # fn _ex() -> Element { +//! dialog() +//! .title( "Delete partition?" ) +//! .subtitle( "This will erase every file on /dev/sda2." ) +//! .cancel( Msg::Cancel ) +//! .action( button::( "Cancel" ) +//! .variant( ButtonVariant::Tertiary ) +//! .on_press( Msg::Cancel ) ) +//! .action( button::( "Delete" ) +//! .variant( ButtonVariant::Primary ) +//! .on_press( Msg::Confirm ) ) +//! .into() +//! # } +//! ``` +//! +//! ## Modality and dismissal +//! +//! `modal` is `true` by default — every pointer event outside the card +//! is silently swallowed and underlying widgets cannot be reached. Set +//! `modal( false )` to let pointer events pass through to the +//! application **except** that you can still wire a +//! [`Dialog::dismiss_on_scrim`] message that fires only when the user +//! taps strictly outside the card. `dismiss_on_scrim` is rejected at +//! build time for a modal dialog (the contract is "modality means no +//! escape", so an "escape via tap-outside" message is contradictory). +//! +//! `Esc` always fires the [`Dialog::cancel`] message — independent of +//! modality. Wire `cancel` to the same message your "Cancel" / +//! "Dismiss" action button uses so keyboard ESC matches the click +//! behaviour. + +use crate::layout::column::column; +use crate::layout::row::row; +use crate::layout::spacer::spacer; +use crate::layout::stack::stack; +use crate::types::{ Color, Corners }; + +use super::container::container; +use super::pressable::pressable; +use super::text; +use super::Element; + +/// Default scrim opacity over the underlying surface. +pub const SCRIM_ALPHA: f32 = 0.45; +/// Default card max-width (logical pixels). Override with +/// [`Dialog::max_width`]. +pub const DEFAULT_MAX_WIDTH: f32 = 480.0; +/// Default card corner radius. +pub const CARD_RADIUS: f32 = 16.0; +/// Default card padding (uniform). +pub const CARD_PADDING: f32 = 24.0; +/// Default vertical gap between the title, subtitle, body, and +/// actions row. +pub const SECTION_GAP: f32 = 12.0; +/// Default horizontal gap between action buttons. +pub const ACTION_GAP: f32 = 8.0; +/// Default title font size. +pub const TITLE_SIZE: f32 = 22.0; +/// Default title weight. +pub const TITLE_WEIGHT: u16 = 700; +/// Default subtitle font size. +pub const SUBTITLE_SIZE: f32 = 14.0; + +/// A centered confirmation dialog with optional title, subtitle, body +/// and action buttons. +pub struct Dialog +{ + pub title: Option, + pub subtitle: Option, + /// User-supplied body element rendered between the subtitle and + /// the action row. Use this for sliders, lists, spinners or any + /// other custom content. + pub body: Option>>, + pub actions: Vec>, + pub modal: bool, + /// Optional message dispatched when the user taps the scrim + /// (strictly outside the card). Always `None` when [`Self::modal`] + /// is `true`. Construction panics if both are set together. + pub dismiss_msg: Option, + /// Optional message dispatched when the user presses `Escape` + /// while the dialog is on screen. Wire this to the same message + /// your "Cancel" action button uses. + pub cancel_msg: Option, + pub max_width: f32, +} + +impl Default for Dialog +{ + fn default() -> Self + { + Self::new() + } +} + +impl Dialog +{ + /// Construct a default modal dialog with no title, subtitle, + /// body, or actions. Build it up with the chained setters. + pub fn new() -> Self + { + Self + { + title: None, + subtitle: None, + body: None, + actions: Vec::new(), + modal: true, + dismiss_msg: None, + cancel_msg: None, + max_width: DEFAULT_MAX_WIDTH, + } + } + + /// Set the dialog title. Wraps across multiple lines if it does + /// not fit on a single one. + pub fn title( mut self, t: impl Into ) -> Self + { + self.title = Some( t.into() ); + self + } + + /// Set the dialog subtitle. Wraps across multiple lines if it + /// does not fit on a single one. + pub fn subtitle( mut self, s: impl Into ) -> Self + { + self.subtitle = Some( s.into() ); + self + } + + /// Replace the dialog body with a custom element — slider, + /// progress indicator, list, anything. Rendered between the + /// subtitle and the action row. + pub fn body( mut self, e: impl Into> ) -> Self + { + self.body = Some( Box::new( e.into() ) ); + self + } + + /// Append an action element to the right-aligned action row. + /// Typically a [`Button`](crate::widget::button::Button); any + /// `Element` is accepted. + pub fn action( mut self, e: impl Into> ) -> Self + { + self.actions.push( e.into() ); + self + } + + /// Toggle modality. Default: `true` (every pointer event outside + /// the card is silently absorbed). + pub fn modal( mut self, on: bool ) -> Self + { + self.modal = on; + self + } + + /// Bind a message dispatched when the user taps the scrim + /// (outside the card). Only valid for non-modal dialogs; + /// construction panics if combined with `modal( true )`. + pub fn dismiss_on_scrim( mut self, msg: Msg ) -> Self + { + self.dismiss_msg = Some( msg ); + self + } + + /// Bind a message dispatched when the user presses `Escape`. + /// Wire the same message your "Cancel" / "Dismiss" action button + /// uses so keyboard and pointer behaviour match. + pub fn cancel( mut self, msg: Msg ) -> Self + { + self.cancel_msg = Some( msg ); + self + } + + /// Override the card's maximum width in logical pixels. Default + /// is `480.0`. + pub fn max_width( mut self, w: f32 ) -> Self + { + self.max_width = w; + self + } + +} + +impl From> for Element +{ + fn from( d: Dialog ) -> Element + { + assert!( + !( d.modal && d.dismiss_msg.is_some() ), + "dialog: dismiss_on_scrim is not valid when modal=true", + ); + + let palette = crate::theme::palette(); + + // 1. Inner card column: title, subtitle, body, actions. + let mut card_col = column::().spacing( SECTION_GAP ); + if let Some( title ) = d.title + { + card_col = card_col.push( + text( title ) + .size( TITLE_SIZE ) + .weight( TITLE_WEIGHT ) + .color( palette.text_primary ) + .wrap( true ), + ); + } + if let Some( subtitle ) = d.subtitle + { + card_col = card_col.push( + text( subtitle ) + .size( SUBTITLE_SIZE ) + .color( palette.text_secondary ) + .wrap( true ), + ); + } + if let Some( body ) = d.body + { + card_col = card_col.push( *body ); + } + if !d.actions.is_empty() + { + let mut actions_row = row::().spacing( ACTION_GAP ).push( spacer() ); + for a in d.actions + { + actions_row = actions_row.push( a ); + } + card_col = card_col.push( actions_row ); + } + + // 2. Card surface — flat opaque fill, no themed surface stack. + // Themed surfaces (`surface-card` / `surface-dialog`) ship as + // translucent Glass for the rest of the toolkit; for a + // confirmation dialog the body must read against any + // background, so we force `palette.surface` to full opacity + // and skip the Glass chrome. + let card_bg = Color { a: 1.0, ..palette.surface }; + let card = container::( card_col ) + .background( card_bg ) + .radius( Corners::all( CARD_RADIUS ) ) + .padding( CARD_PADDING ); + + // 3. Card-area swallow: a Pressable wrapping the card so + // taps on the body silently absorb without firing the + // scrim's `dismiss_msg`. Always armed (modal or not) — the + // card never dismisses. + let card_swallow = pressable::( card ).swallow( true ); + + // 4. Center the card on the screen. The outer column claims + // the full surface; `center_y` + `align_center_x` keep the + // card vertically and horizontally centered, and `max_width` + // caps it at `d.max_width` even on ultra-wide layouts. + let centered = column::() + .center_y( true ) + .align_center_x( true ) + .max_width( d.max_width ) + .push( card_swallow ); + + // 5. Scrim — a full-bleed Pressable with the dim layer + // rendered behind it. `swallow=true` means it always shows + // up in `widget_rects` (modal dialogs need this so missing + // hits do not fall through to the underlying app); when + // non-modal AND a `dismiss_msg` was configured, taps fire + // the message. The cancel-on-ESC binding lives here too. + let scrim_bg = container::( spacer() ) + .background( Color { r: 0.0, g: 0.0, b: 0.0, a: SCRIM_ALPHA } ) + .radius( Corners::ZERO ); + let mut scrim_press = pressable::( scrim_bg ).swallow( true ); + if let Some( msg ) = d.dismiss_msg + { + scrim_press = scrim_press.on_press( msg ); + } + if let Some( msg ) = d.cancel_msg + { + scrim_press = scrim_press.on_escape( msg ); + } + + // 6. Stack scrim + centered card. Layout walker pushes + // children in order, so the scrim's `flat_idx` < the card + // area's < each action button's; `iter().rev()` hit-testing + // therefore finds buttons first, then the card-area + // swallow, and finally the scrim outside the card. + stack::() + .push( scrim_press ) + .push( centered ) + .into() + } +} + +/// Construct a [`Dialog`]. See the type's documentation for the full +/// builder API and the lowering details. +pub fn dialog() -> Dialog +{ + Dialog::new() +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::layout::spacer::spacer; + + #[ derive( Clone, Debug, PartialEq, Eq ) ] + enum Msg { Cancel, Dismiss } + + #[ test ] + fn new_defaults_are_modal_with_no_content() + { + let d = Dialog::::new(); + assert!( d.modal ); + assert!( d.title.is_none() ); + assert!( d.subtitle.is_none() ); + assert!( d.body.is_none() ); + assert!( d.actions.is_empty() ); + assert!( d.dismiss_msg.is_none() ); + assert!( d.cancel_msg.is_none() ); + assert_eq!( d.max_width, DEFAULT_MAX_WIDTH ); + } + + #[ test ] + fn title_and_subtitle_builders_set_strings() + { + let d = Dialog::::new() + .title( "Hello" ) + .subtitle( "World" ); + assert_eq!( d.title.as_deref(), Some( "Hello" ) ); + assert_eq!( d.subtitle.as_deref(), Some( "World" ) ); + } + + #[ test ] + fn body_builder_replaces_existing_body() + { + let d = Dialog::::new() + .body( spacer() ) + .body( spacer() ); + assert!( d.body.is_some() ); + } + + #[ test ] + fn action_builder_appends_in_order() + { + let d = Dialog::::new() + .action( spacer() ) + .action( spacer() ) + .action( spacer() ); + assert_eq!( d.actions.len(), 3 ); + } + + #[ test ] + fn modal_builder_toggles_flag() + { + assert!( !Dialog::::new().modal( false ).modal ); + assert!( Dialog::::new().modal( true ).modal ); + } + + #[ test ] + fn dismiss_on_scrim_builder_records_message() + { + let d = Dialog::::new() + .modal( false ) + .dismiss_on_scrim( Msg::Dismiss ); + assert_eq!( d.dismiss_msg, Some( Msg::Dismiss ) ); + } + + #[ test ] + fn cancel_builder_records_escape_message() + { + let d = Dialog::::new().cancel( Msg::Cancel ); + assert_eq!( d.cancel_msg, Some( Msg::Cancel ) ); + } + + #[ test ] + fn max_width_builder_overrides_default() + { + let d = Dialog::::new().max_width( 720.0 ); + assert_eq!( d.max_width, 720.0 ); + } + + #[ test ] + #[ should_panic( expected = "dismiss_on_scrim is not valid when modal=true" ) ] + fn modal_with_dismiss_panics_at_lower() + { + // Construction allows the combination; only the lowering + // to `Element` enforces the contract — that is where the + // invariant matters because that is where input dispatch + // would have to honour two contradictory rules. + let d = Dialog::::new() + .modal( true ) + .dismiss_on_scrim( Msg::Dismiss ); + let _: Element = d.into(); + } + + #[ test ] + fn non_modal_with_dismiss_lowers_cleanly() + { + let d = Dialog::::new() + .modal( false ) + .dismiss_on_scrim( Msg::Dismiss ) + .cancel( Msg::Cancel ) + .title( "Title" ); + // Lowering must not panic. + let _: Element = d.into(); + } + + #[ test ] + fn modal_with_cancel_only_lowers_cleanly() + { + // modal=true + cancel(...) (no dismiss_on_scrim) is the + // canonical confirm-dialog shape. + let d = Dialog::::new() + .title( "Confirm" ) + .subtitle( "Are you sure?" ) + .cancel( Msg::Cancel ); + let _: Element = d.into(); + } + +} diff --git a/src/widget/external.rs b/src/widget/external.rs new file mode 100644 index 0000000..774e7fc --- /dev/null +++ b/src/widget/external.rs @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Widget that hosts content rendered by an external GL producer. +//! +//! Reserves layout space and, at draw time, samples a caller-provided GL +//! texture into the LTK canvas via [`Canvas::draw_external_texture`]. The +//! producer (a web engine, a video decoder, …) keeps ownership of the +//! texture and updates it on its own cadence; LTK only composites. +//! +//! # Source closure contract +//! +//! [`ExternalSource::Texture`] carries an +//! `Arc Option>` that LTK +//! invokes once per frame, with its GLES context current. The producer: +//! +//! 1. Reads `Rect` (in physical pixels of the host surface) — useful for +//! sizing the inner viewport (e.g. resizing a `WPEToplevel` to match) +//! and for translating input coordinates by `rect.origin`. +//! 2. May allocate / update GL textures against the supplied +//! `glow::Context`. +//! 3. May bind extension-imported EGLImages onto a persistent texture. +//! 4. Returns the `glow::Texture` to sample, or `None` to paint +//! transparent (e.g. while the first frame is still being produced). +//! +//! Returning `None` for one frame and `Some` for the next is fine; LTK +//! re-invokes on every redraw. +//! +//! # Use cases +//! +//! * `ltk-webkit` hosting a `WPEView`-rendered page. +//! * Video / media playback widgets that decode into a GL texture. +//! * Any embedding that already has a GLES producer and wants its output +//! in-line with the rest of the LTK widget tree. +//! +//! # Example +//! +//! ```rust,no_run +//! # use std::sync::{ Arc, Mutex }; +//! # use ltk::{ External, ExternalSource, Element }; +//! # #[ derive( Clone ) ] enum Msg {} +//! # fn _ex() -> Element { +//! let cached_texture: Arc>> = Arc::new( Mutex::new( None ) ); +//! let cached = Arc::clone( &cached_texture ); +//! External::new( +//! 800.0, 600.0, +//! ExternalSource::Texture( Arc::new( move | _gl, _rect | -> Option +//! { +//! *cached.lock().ok()? +//! } ) ), +//! ).into() +//! # } +//! ``` + +use std::sync::Arc; + +use crate::render::Canvas; +use crate::types::Rect; + +/// A widget that defers its pixels to an external GL texture producer. +/// +/// The widget itself is non-interactive and produces no messages. Wrap it +/// in a [`crate::widget::pressable::Pressable`] (or similar) when input +/// capture is needed. +pub struct External +{ + /// Reserved width in logical pixels. + pub width: f32, + /// Reserved height in logical pixels. + pub height: f32, + /// Source of the GL texture sampled at draw time. + pub source: ExternalSource, + /// Opacity multiplier in `[0.0, 1.0]`. + pub opacity: f32, +} + +/// Backends an [`External`] widget can pull pixels from. +/// +/// The only variant today is [`ExternalSource::Texture`], a closure +/// returning the current GL texture name. Future variants (DMA-BUF +/// imports, software pixmaps for the CPU backend, …) can be added +/// without changing call sites. +#[ derive( Clone ) ] +pub enum ExternalSource +{ + /// Closure invoked once per frame with LTK's [`glow::Context`] current + /// and the widget's laid-out rect (in physical pixels of the host + /// surface). The producer can allocate textures, bind extension- + /// imported EGLImages, etc., and returns the texture name to sample. + /// Returning `None` paints transparent — useful while the producer + /// is still warming up its first frame. The rect is exposed so + /// embedders can size their inner viewport to match LTK's actual + /// allocation (e.g. resize a WPEToplevel on layout change) and + /// translate input coordinates by `rect.origin`. + Texture( Arc Option + Send + Sync> ), +} + +impl External +{ + /// Build a new external-content widget reserving `width × height` + /// logical pixels. + pub fn new( width: f32, height: f32, source: ExternalSource ) -> Self + { + Self { width, height, source, opacity: 1.0 } + } + + /// Override the opacity multiplier. Default: `1.0`. + pub fn opacity( mut self, opacity: f32 ) -> Self + { + self.opacity = opacity.clamp( 0.0, 1.0 ); + self + } + + pub fn preferred_size( &self, _max_width: f32 ) -> ( f32, f32 ) + { + ( self.width, self.height ) + } + + pub fn draw( &self, canvas: &mut Canvas, rect: Rect ) + { + // SAFETY-ish: this is the only call site of the source closure; + // LTK's draw pass guarantees the GLES context is current and + // the canvas is bound to its FBO. The rect we pass is the + // widget's laid-out rect in physical pixels — the same + // coordinate space pointer events arrive in, so the producer + // can use `rect.origin` to translate input. + // External content only renders against the GLES backend — the + // software backend has no GL texture to sample. Skip silently. + let gl = match canvas + { + Canvas::Software( _ ) => return, + Canvas::Gles( c ) => c.gl.clone(), + }; + match &self.source + { + ExternalSource::Texture( get ) => + { + if let Some( tex ) = get( &gl, rect ) + { + canvas.draw_external_texture( tex, rect, self.opacity ); + } + } + } + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use std::sync::Arc; + + fn dummy_source() -> ExternalSource + { + ExternalSource::Texture( Arc::new( | _gl, _rect | None ) ) + } + + #[ test ] + fn preferred_size_returns_constructed_dimensions() + { + let w = External::new( 320.0, 200.0, dummy_source() ); + assert_eq!( w.preferred_size( 9999.0 ), ( 320.0, 200.0 ) ); + } + + #[ test ] + fn opacity_default_is_one() + { + let w = External::new( 1.0, 1.0, dummy_source() ); + assert_eq!( w.opacity, 1.0 ); + } + + #[ test ] + fn opacity_setter_clamps_to_unit_range() + { + let above = External::new( 1.0, 1.0, dummy_source() ).opacity( 1.7 ); + let below = External::new( 1.0, 1.0, dummy_source() ).opacity( -0.4 ); + let mid = External::new( 1.0, 1.0, dummy_source() ).opacity( 0.5 ); + assert_eq!( above.opacity, 1.0 ); + assert_eq!( below.opacity, 0.0 ); + assert_eq!( mid.opacity, 0.5 ); + } +} diff --git a/src/widget/flex.rs b/src/widget/flex.rs new file mode 100644 index 0000000..81cc08a --- /dev/null +++ b/src/widget/flex.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::render::Canvas; +use super::Element; + +/// Wraps an [`Element`] so that a [`Row`](crate::layout::row::Row) treats it +/// like a [`Spacer`](crate::layout::spacer::Spacer) for leftover-width +/// distribution, but draws the child inside the allocated rect. +/// +/// Use this to make a non-trivial child fill remaining width without resorting +/// to a hard-coded `max_width`. The row computes how much width is left after +/// the fixed-size siblings, splits it across all flex / spacer children +/// proportionally to their `weight`, and gives the flex its share. The wrapped +/// child sees that share as its layout rect — text inside it triggers its own +/// elide path on overflow, columns adjust their inner width, etc. +/// +/// ```rust,no_run +/// # use ltk::{ column, flex, row, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex( +/// # icon: Element, +/// # title: Element, +/// # subtitle: Element, +/// # ) -> Element { +/// row() +/// .push( icon ) +/// .push( flex( column().push( title ).push( subtitle ) ) ) +/// .into() +/// # } +/// ``` +/// +/// Currently only [`Row`](crate::layout::row::Row) honours flex distribution. +/// Inside a [`Column`](crate::layout::column::Column) a flex child behaves +/// like a regular zero-width child along the main axis — vertical flex is not +/// yet implemented. +pub struct Flex +{ + pub child: Box>, + pub weight: u32, +} + +impl Flex +{ + pub fn new( child: impl Into> ) -> Self + { + Self + { + child: Box::new( child.into() ), + weight: 1, + } + } + + /// Relative weight when sharing leftover width with other flex / spacer + /// children in the same row (default `1`). A flex with `weight = 2` + /// claims twice the space of a sibling with `weight = 1`. + pub fn weight( mut self, w: u32 ) -> Self + { + self.weight = w; + self + } + + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) + { + // Width contribution to the parent row is zero — the actual width + // comes from the flex distribution. Height comes from the child so + // the row's row-height calculation still picks us up. + let ( _, h ) = self.child.preferred_size( max_width, canvas ); + ( 0.0, h ) + } + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> Flex + where + U: Clone + 'static, + Msg: 'static, + { + Flex + { + child: Box::new( self.child.map_arc( f ) ), + weight: self.weight, + } + } +} + +pub fn flex( child: impl Into> ) -> Flex +{ + Flex::new( child ) +} + +impl From> for Element +{ + fn from( f: Flex ) -> Self + { + Element::Flex( f ) + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::layout::spacer::spacer; + use crate::render::Canvas; + + fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } + + #[ test ] + fn new_defaults_to_weight_one() + { + let f = Flex::<()>::new( spacer() ); + assert_eq!( f.weight, 1 ); + } + + #[ test ] + fn weight_builder_sets_relative_weight() + { + let f = Flex::<()>::new( spacer() ).weight( 5 ); + assert_eq!( f.weight, 5 ); + } + + #[ test ] + fn preferred_size_reports_zero_width_so_row_treats_it_as_filler() + { + let canvas = make_canvas(); + let f = Flex::<()>::new( spacer().width( 100.0 ).height( 40.0 ) ); + let ( w, _ ) = f.preferred_size( 600.0, &canvas ); + assert_eq!( w, 0.0, "flex must contribute zero width to the row's intrinsic-width sum" ); + } + + #[ test ] + fn preferred_size_passes_child_height_through() + { + let canvas = make_canvas(); + let f = Flex::<()>::new( spacer().width( 100.0 ).height( 40.0 ) ); + let ( _, h ) = f.preferred_size( 600.0, &canvas ); + assert_eq!( h, 40.0 ); + } +} diff --git a/src/widget/image.rs b/src/widget/image.rs new file mode 100644 index 0000000..deec22e --- /dev/null +++ b/src/widget/image.rs @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use std::sync::Arc; +use crate::types::Rect; +use crate::render::Canvas; + +/// A static image widget that renders RGBA pixel data. +/// +/// Images are scaled to fill their allocated rect. Alpha blending against the +/// background is handled automatically (straight → premultiplied conversion). +/// +/// The pixel buffer is shared via `Arc` so reusing the same image across +/// frames (e.g. a background decoded once at startup) is a cheap pointer +/// copy instead of a full `Vec` clone. +/// +/// ```rust,no_run +/// # use std::sync::Arc; +/// # use ltk::{ img_widget, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex( rgba_bytes: Arc>, width: u32, height: u32 ) -> Element { +/// img_widget( rgba_bytes, width, height ) +/// .opacity( 0.8 ) +/// .into() +/// # } +/// ``` +pub struct Image +{ + /// Raw RGBA pixel data (4 bytes per pixel, straight alpha). + pub rgba: Arc>, + /// Pixel width of the source image. + pub width: u32, + /// Pixel height of the source image. + pub height: u32, + /// When `true` the image scales to fill the available width (cover mode). + pub cover: bool, + /// Optional explicit display size in logical pixels. + pub display_size: Option<( f32, f32 )>, + /// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`. + pub opacity: f32, +} + +impl Image +{ + /// Create an image from a shared RGBA buffer. + /// + /// `width` and `height` must match the dimensions of `rgba`. + pub fn new( rgba: Arc>, width: u32, height: u32 ) -> Self + { + Self { rgba, width, height, cover: false, display_size: None, opacity: 1.0 } + } + + /// Load an image from a file path. Supports PNG, JPEG, and other formats + /// supported by the [`image`](https://crates.io/crates/image) crate. + pub fn from_path( path: &str ) -> Result> + { + let img = image::open( path )?.into_rgba8(); + let ( width, height ) = img.dimensions(); + Ok( Self { rgba: Arc::new( img.into_raw() ), width, height, cover: false, display_size: None, opacity: 1.0 } ) + } + + /// Scale the image to fill the available width, preserving aspect ratio (cover mode). + pub fn cover( mut self ) -> Self + { + self.cover = true; + self + } + + /// Set an explicit display size in logical pixels. + pub fn size( mut self, width: f32, height: f32 ) -> Self + { + self.display_size = Some( ( width.max( 0.0 ), height.max( 0.0 ) ) ); + self + } + + /// Set the opacity multiplier. Clamped to `[0.0, 1.0]`. + pub fn opacity( mut self, o: f32 ) -> Self + { + self.opacity = o.clamp( 0.0, 1.0 ); + self + } + + /// Return the preferred `(width, height)` given available `max_width`. + pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32) + { + if let Some( size ) = self.display_size + { + return size; + } + if self.cover + { + ( max_width, max_width * self.height as f32 / self.width as f32 ) + } else { + let scale = max_width / self.width as f32; + ( max_width, self.height as f32 * scale ) + } + } + + /// Draw the image into `canvas` at `rect`. + pub fn draw( &self, canvas: &mut Canvas, rect: Rect ) + { + canvas.draw_image_data( &self.rgba[..], self.width, self.height, rect, self.opacity ); + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + fn solid_rgba( w: u32, h: u32 ) -> Arc> + { + Arc::new( vec![ 255u8; ( w * h * 4 ) as usize ] ) + } + + // ── builders / defaults ─────────────────────────────────────────────────── + + #[ test ] + fn new_uses_documented_defaults() + { + let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ); + assert_eq!( img.width, 4 ); + assert_eq!( img.height, 4 ); + assert!( !img.cover ); + assert!( img.display_size.is_none() ); + assert_eq!( img.opacity, 1.0 ); + } + + #[ test ] + fn cover_builder_sets_cover_flag() + { + let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).cover(); + assert!( img.cover ); + } + + #[ test ] + fn size_builder_sets_explicit_display_size() + { + let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( 120.0, 80.0 ); + assert_eq!( img.display_size, Some( ( 120.0, 80.0 ) ) ); + } + + #[ test ] + fn size_builder_clamps_negative_dimensions_to_zero() + { + let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( -10.0, -20.0 ); + assert_eq!( img.display_size, Some( ( 0.0, 0.0 ) ) ); + } + + #[ test ] + fn opacity_clamps_to_unit_interval() + { + assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( -0.5 ).opacity, 0.0 ); + assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( 1.5 ).opacity, 1.0 ); + assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( 0.5 ).opacity, 0.5 ); + } + + // ── preferred_size ──────────────────────────────────────────────────────── + + #[ test ] + fn preferred_size_default_scales_height_to_match_width_aspect() + { + // 200×100 source image at max_width = 400 → scale = 2 → height 200. + let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ); + let ( w, h ) = img.preferred_size( 400.0 ); + assert_eq!( w, 400.0 ); + assert_eq!( h, 200.0 ); + } + + #[ test ] + fn preferred_size_with_explicit_size_wins_over_aspect_logic() + { + let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ).size( 50.0, 25.0 ); + assert_eq!( img.preferred_size( 1000.0 ), ( 50.0, 25.0 ) ); + } + + #[ test ] + fn preferred_size_cover_mode_keeps_aspect_ratio() + { + // 100×50 source in cover mode at max_width = 300 → height = 300 * (50/100) = 150. + let img = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover(); + let ( w, h ) = img.preferred_size( 300.0 ); + assert_eq!( w, 300.0 ); + assert_eq!( h, 150.0 ); + } + + #[ test ] + fn preferred_size_cover_and_default_match_for_uniform_scaling() + { + // When the source aspect matches the requested width, cover and the + // default fit-width path produce identical sizes — they only diverge + // when the source is taller than wide. + let normal = Image::new( solid_rgba( 100, 50 ), 100, 50 ); + let covered = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover(); + assert_eq!( normal.preferred_size( 200.0 ), covered.preferred_size( 200.0 ) ); + } + + // ── Arc sharing ─────────────────────────────────────────────────────────── + + #[ test ] + fn rgba_buffer_is_shared_via_arc_not_cloned_per_image() + { + let bytes = solid_rgba( 8, 8 ); + let strong_before = Arc::strong_count( &bytes ); + + let img = Image::new( bytes.clone(), 8, 8 ); + assert_eq!( Arc::strong_count( &bytes ), strong_before + 1 ); + + // Same buffer goes into a second image — strong count rises again. + let img2 = Image::new( bytes.clone(), 8, 8 ); + assert_eq!( Arc::strong_count( &bytes ), strong_before + 2 ); + + drop( img ); + drop( img2 ); + assert_eq!( Arc::strong_count( &bytes ), strong_before ); + } +} diff --git a/src/widget/list_item.rs b/src/widget/list_item.rs new file mode 100644 index 0000000..a8ef9e4 --- /dev/null +++ b/src/widget/list_item.rs @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::{ Rect, WidgetId }; +use crate::render::Canvas; +use super::Element; + +mod theme +{ + use crate::types::Color; + pub fn label_color() -> Color { crate::theme::palette().text_primary } + pub fn subtitle_color() -> Color { crate::theme::palette().text_secondary } + pub fn trailing_color() -> Color { crate::theme::palette().text_secondary } + /// Alpha-only overlay tuned for the active mode (white on dark, navy on light). + pub fn hover_bg() -> Color + { + let p = crate::theme::palette(); + Color { r: p.text_primary.r, g: p.text_primary.g, b: p.text_primary.b, a: 0.06 } + } + pub fn press_bg() -> Color + { + let p = crate::theme::palette(); + Color { r: p.text_primary.r, g: p.text_primary.g, b: p.text_primary.b, a: 0.12 } + } + /// Solid dark surface for the [`ListItem::selected`](super::ListItem::selected) state. + /// `palette.text_primary` happens to be the right colour for both + /// modes (navy on light, white on dark) so the white-on-dark contrast + /// pattern flips into a navy-on-white contrast in dark mode without + /// needing a separate slot. + pub fn selected_bg() -> Color { crate::theme::palette().text_primary } + pub fn focus_color() -> Color { crate::theme::palette().accent } + pub const LABEL_SIZE: f32 = 16.0; + pub const SUBTITLE_SIZE: f32 = 13.0; + pub const TRAILING_SIZE: f32 = 14.0; + pub const HEIGHT: f32 = 56.0; + pub const HEIGHT_SUB: f32 = 68.0; + pub const PAD_H: f32 = 16.0; + pub const RADIUS: f32 = 12.0; + pub const FOCUS_W: f32 = 2.0; +} + +/// A row inside a list with a primary label and optional subtitle / trailing +/// text. +/// +/// Use to build settings menus, navigation lists, contact rows or any other +/// vertically-stacked tappable content. The widget paints its own hover and +/// pressed surfaces and a rounded focus ring; wrap a column of `ListItem`s +/// inside a [`scroll`](crate::scroll) for scrollable lists. +/// +/// ```rust,no_run +/// # use ltk::{ column, list_item, scroll, Element }; +/// # #[ derive( Clone ) ] enum Msg { OpenWifi, OpenBluetooth, OpenDisplay } +/// # fn _ex() -> Element { +/// // In view(): +/// scroll( +/// column() +/// .push( list_item( "Wi-Fi" ).trailing( "Eduroam" ).on_press( Msg::OpenWifi ) ) +/// .push( list_item( "Bluetooth" ).subtitle( "AirPods Pro" ).on_press( Msg::OpenBluetooth ) ) +/// .push( list_item( "Display" ).trailing( "Light" ).on_press( Msg::OpenDisplay ) ), +/// ) +/// .into() +/// # } +/// ``` +pub struct ListItem +{ + /// Primary label (always visible, top-aligned when a subtitle is + /// present). + pub label: String, + /// Optional secondary line drawn below the label in muted colour. + /// Doubles the row height when set. + pub subtitle: Option, + /// Optional right-aligned text (current setting, badge count, "›" + /// disclosure). Drawn in muted colour. + pub trailing: Option, + /// Message emitted on tap. `None` keeps the item visible but inert. + pub on_press: Option, + /// Optional stable identifier for focus management. + pub id: Option, + /// `true` paints the row with the dark selected surface and white + /// text, regardless of hover / press state. Use to indicate the + /// active item in a list of choices (combo dropdown, segmented + /// picker, settings group with a single active value). + pub selected: bool, +} + +impl ListItem +{ + /// Create a list item with the given primary label, no subtitle, no + /// trailing text and no callback. + pub fn new( label: impl Into ) -> Self + { + Self + { + label: label.into(), + subtitle: None, + trailing: None, + on_press: None, + id: None, + selected: false, + } + } + + /// Mark this row as the currently-selected option in its list. + /// Selected rows paint with a dark surface and white text and + /// override hover / press visuals. + pub fn selected( mut self, yes: bool ) -> Self + { + self.selected = yes; + self + } + + /// Add a secondary line below the label. Doubles the row height to + /// fit both lines comfortably. + pub fn subtitle( mut self, s: impl Into ) -> Self + { + self.subtitle = Some( s.into() ); + self + } + + /// Add right-aligned text (settings value, badge, disclosure arrow). + pub fn trailing( mut self, s: impl Into ) -> Self + { + self.trailing = Some( s.into() ); + self + } + + /// Set the message emitted when the row is tapped. + pub fn on_press( mut self, msg: Msg ) -> Self + { + self.on_press = Some( msg ); + self + } + + /// Assign a stable identifier for focus management. + pub fn id( mut self, id: WidgetId ) -> Self + { + self.id = Some( id ); + self + } + + pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32) + { + let h = if self.subtitle.is_some() { theme::HEIGHT_SUB } else { theme::HEIGHT }; + ( max_width, h ) + } + + /// Focus stroke is centered on `rect`, so half the stroke width plus ~1 px + /// of antialiasing bleed sits outside. + pub fn paint_bounds( &self, rect: Rect ) -> Rect + { + rect.expand( theme::FOCUS_W * 0.5 + 1.0 ) + } + + pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool, hovered: bool, pressed: bool ) + { + // Selected wins over hover and press: a chosen item stays + // chosen even when the pointer is over it. + let ( label_color, subtitle_color, trailing_color ) = if self.selected + { + canvas.fill_rect( rect, theme::selected_bg(), theme::RADIUS ); + // Selected row is filled with `text_primary` colour; + // label / subtitle / trailing read as the inverse via + // `bg` so they stay legible. + let inverse = crate::theme::palette().bg; + ( inverse, inverse, inverse ) + } + else + { + if pressed + { + canvas.fill_rect( rect, theme::press_bg(), theme::RADIUS ); + } + else if hovered + { + canvas.fill_rect( rect, theme::hover_bg(), theme::RADIUS ); + } + ( theme::label_color(), theme::subtitle_color(), theme::trailing_color() ) + }; + + if focused + { + canvas.stroke_rect( rect, theme::focus_color(), theme::FOCUS_W, theme::RADIUS ); + } + + let has_sub = self.subtitle.is_some(); + let label_y = if has_sub + { + rect.y + rect.height * 0.35 + theme::LABEL_SIZE * 0.3 + } else { + rect.y + ( rect.height + theme::LABEL_SIZE ) / 2.0 - 2.0 + }; + + canvas.draw_text( &self.label, rect.x + theme::PAD_H, label_y, theme::LABEL_SIZE, label_color ); + + if let Some( ref sub ) = self.subtitle + { + let sub_y = rect.y + rect.height * 0.62 + theme::SUBTITLE_SIZE * 0.3; + canvas.draw_text( sub, rect.x + theme::PAD_H, sub_y, theme::SUBTITLE_SIZE, subtitle_color ); + } + + if let Some( ref trail ) = self.trailing + { + let tw = canvas.measure_text( trail, theme::TRAILING_SIZE ); + let tx = rect.x + rect.width - theme::PAD_H - tw; + let ty = rect.y + ( rect.height + theme::TRAILING_SIZE ) / 2.0 - 2.0; + canvas.draw_text( trail, tx, ty, theme::TRAILING_SIZE, trailing_color ); + } + } + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> ListItem + where + U: Clone + 'static, + Msg: 'static, + { + ListItem + { + label: self.label, + subtitle: self.subtitle, + trailing: self.trailing, + on_press: self.on_press.map( |m| ( *f )( m ) ), + id: self.id, + selected: self.selected, + } + } +} + +/// Create a [`ListItem`] with the given primary label. +/// +/// Add detail and behaviour through the chained builders: +/// +/// ```rust,no_run +/// # use ltk::{ list_item, ListItem }; +/// # #[ derive( Clone ) ] enum Msg { OpenDisplay } +/// # fn _ex() -> ListItem { +/// list_item( "Display" ) +/// .subtitle( "Resolution, brightness, night mode" ) +/// .trailing( "›" ) +/// .on_press( Msg::OpenDisplay ) +/// # } +/// ``` +pub fn list_item( label: impl Into ) -> ListItem +{ + ListItem::new( label ) +} + +impl From> for Element +{ + fn from( l: ListItem ) -> Self + { + Element::ListItem( l ) + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn list_item_default() + { + let l = list_item::<()>( "Test" ); + assert_eq!( l.label, "Test" ); + assert!( l.subtitle.is_none() ); + assert!( l.trailing.is_none() ); + assert!( l.on_press.is_none() ); + } +} diff --git a/src/widget/mod.rs b/src/widget/mod.rs new file mode 100755 index 0000000..b7ef607 --- /dev/null +++ b/src/widget/mod.rs @@ -0,0 +1,923 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Widgets — the interactive and decorative leaves of the [`Element`] tree. +//! +//! Each widget lives in its own submodule and is reached through the +//! crate-root re-exports (`button`, `text`, `text_edit`, `slider`, …) plus +//! the `img_widget` alias for [`image::Image`]. Construct one from its +//! free constructor function, configure it through builder-style methods, +//! and convert it into [`Element`] via `.into()` when pushing it +//! into a layout. +//! +//! ```rust,no_run +//! # use ltk::{ button, column, slider, text, Element }; +//! # #[ derive( Clone ) ] enum Msg { SetVolume( f32 ), Mute } +//! # struct App { volume: f32 } +//! # impl App { fn _ex( &self ) -> Element { +//! column() +//! .push( text( "Volume" ) ) +//! .push( slider( self.volume ).on_change( |v| Msg::SetVolume( v ) ) ) +//! .push( button( "Mute" ).on_press( Msg::Mute ) ) +//! .into() +//! # }} +//! ``` +//! +//! ## What lives here +//! +//! * **Buttons / activations**: [`button::Button`], +//! [`pressable::Pressable`], [`window_button::WindowButton`], +//! [`list_item::ListItem`]. +//! * **Stateful binary controls**: [`toggle::Toggle`], +//! [`checkbox::Checkbox`], [`radio::Radio`]. +//! * **Continuous controls**: [`slider::Slider`], [`vslider::VSlider`], +//! [`progress_bar::ProgressBar`]. +//! * **Text**: [`text::Text`], [`text_edit::TextEdit`]. +//! * **Images / decoration**: [`image::Image`], [`separator::Separator`], +//! [`container::Container`]. +//! * **Clipping wrappers**: [`scroll::Scroll`] (with gesture-driven +//! scrolling), [`viewport::Viewport`] (passive clip / fade), and +//! [`flex::Flex`] (treats a non-spacer child as a row filler). +//! * **Overlays**: [`dialog::Dialog`] (modal / non-modal centered +//! confirmation card with built-in scrim, ESC-to-cancel, and +//! tap-outside-to-dismiss for the non-modal variant). +//! +//! Layouts ([`column`](crate::column), [`row`](crate::row), +//! [`stack`](crate::stack), [`grid`](crate::grid), +//! [`spacer`](crate::spacer)) live in [`crate::layout`]; they share the +//! same [`Element`] tree but are kept separate to make the "what does +//! this paint" / "how is this arranged" distinction explicit. +//! +//! ## Per-leaf handler snapshot +//! +//! [`WidgetHandlers`] is the snapshot the layout pass takes of every +//! interactive widget so the input handlers can dispatch in O(1) without +//! re-walking the [`Element`] tree. It is `pub( crate )` plumbing for the +//! runtime; downstream apps usually never see it. The `test_support` +//! module re-exports it for integration tests that want to assert on the +//! handler shape. + +pub mod button; +pub mod container; +pub mod text_edit; +pub mod image; +pub mod text; +pub mod scroll; +pub mod viewport; +pub mod slider; +pub mod vslider; +pub mod toggle; +pub mod separator; +pub mod progress_bar; +pub mod checkbox; +pub mod radio; +pub mod list_item; +pub mod window_button; +pub mod pressable; +pub mod flex; +pub mod combo; +pub mod anchored_overlay; +pub mod spinner; +pub mod tab_bar; +pub mod toast; +pub mod tooltip; +pub mod notebook; +pub mod date_picker; +pub mod time_picker; +pub mod color_picker; +pub mod dialog; +pub mod external; +use std::sync::Arc; +use crate::types::{ Point, Rect, WidgetId }; +use crate::render::Canvas; + +/// Per-leaf interaction snapshot captured during layout. One variant per +/// interactive widget kind; the layout pass clones the relevant callbacks / +/// values from the [`Element`] tree into here so input handlers can dispatch +/// in O(1) without re-walking the tree. +/// +/// `None` is used for focusable widgets that emit no message (e.g. a disabled +/// button or a focusable container) — the entry still appears in +/// `widget_rects` for hit testing and focus traversal. +pub enum WidgetHandlers +{ + None, + Button + { + on_press: Option, + on_long_press: Option, + on_drag_start: Option, + /// Keyboard `Escape`-key message — the runtime scans every laid-out + /// `Button` snapshot in reverse and fires the first non-`None` + /// `on_escape` it finds before the default ESC fallthrough chain. + /// Currently sourced from + /// [`crate::widget::pressable::Pressable::on_escape`]; native + /// [`crate::widget::button::Button`] always sets this to `None`. + on_escape: Option, + repeating: bool, + }, + Toggle { on_toggle: Option }, + Checkbox { on_toggle: Option }, + Radio { on_select: Option }, + ListItem { on_press: Option }, + WindowButton { on_press: Option }, + TextEdit + { + value: String, + on_change: Option Msg>>, + on_submit: Option, + /// `true` when the source `TextEdit` was built with + /// `.secure( true )`. Propagates the wipe-on-drop behaviour to + /// every per-frame handler snapshot so credential text does not + /// linger across multiple cloned `WidgetHandlers` allocations. + secure: bool, + /// `true` when the source `TextEdit` was built with + /// `.multiline( true )`. The keyboard dispatch reads this so + /// pressing Enter inserts a `\n` instead of firing + /// [`Self::submit_msg`]. Mutually exclusive with `secure`. + multiline: bool, + /// Horizontal alignment snapshot — needed by the runtime's + /// hit-testing path so a click on a centred / right-aligned + /// field lands on the correct glyph. + align: text::TextAlign, + /// Font size snapshot — needed by the hit-testing path so + /// the runtime measures glyphs at the same size the renderer + /// drew them. Always the default `theme::FONT_SIZE` for + /// fields that do not call `.font_size( … )`. + font_size: f32, + /// `true` when the source field opted into select-all-on- + /// focus. The runtime reads this in `set_focus` to decide + /// whether the new selection should anchor at `0` (replace + /// on next keystroke) or at the cursor (insert). + select_on_focus: bool, + /// Snapshot of the + /// [`crate::widget::text_edit::TextEdit::password_toggle`] + /// callback. When `Some`, pointer / touch dispatch checks + /// the eye-icon hit zone (via + /// [`crate::widget::text_edit::password_toggle_hit_zone`]) + /// before falling through to cursor placement and fires + /// this message instead. + password_toggle_msg: Option, + }, + Slider + { + on_change: Option Msg>>, + axis: slider::SliderAxis, + }, +} + +impl Drop for WidgetHandlers +{ + /// Mirror the wipe-on-drop behaviour of [`text_edit::TextEdit`] for the + /// per-frame handler snapshots the runtime keeps. When the snapshot + /// was built from a secure text edit we scrub the value bytes here so + /// the heap allocation that backs the cloned `String` is overwritten + /// before it is returned to the allocator. Non-secure variants run an + /// inert path; the field-level drops that fire after this fn handle + /// the rest of the data. + fn drop( &mut self ) + { + if let WidgetHandlers::TextEdit { value, secure: true, .. } = self + { + // SAFETY: identical reasoning to `TextEdit::drop` — we only + // write zeros into the underlying byte buffer, leaving valid + // UTF-8 (NUL bytes) in place until the auto-drop frees it. + let bytes = unsafe { value.as_mut_vec() }; + crate::secure_mem::secure_zero( bytes ); + } + } +} + +impl Clone for WidgetHandlers +{ + fn clone( &self ) -> Self + { + match self + { + WidgetHandlers::None => WidgetHandlers::None, + WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape, repeating } => WidgetHandlers::Button + { + on_press: on_press.clone(), + on_long_press: on_long_press.clone(), + on_drag_start: on_drag_start.clone(), + on_escape: on_escape.clone(), + repeating: *repeating, + }, + WidgetHandlers::Toggle { on_toggle } => WidgetHandlers::Toggle { on_toggle: on_toggle.clone() }, + WidgetHandlers::Checkbox { on_toggle } => WidgetHandlers::Checkbox { on_toggle: on_toggle.clone() }, + WidgetHandlers::Radio { on_select } => WidgetHandlers::Radio { on_select: on_select.clone() }, + WidgetHandlers::ListItem { on_press } => WidgetHandlers::ListItem { on_press: on_press.clone() }, + WidgetHandlers::WindowButton { on_press } => WidgetHandlers::WindowButton { on_press: on_press.clone() }, + WidgetHandlers::TextEdit { value, on_change, on_submit, secure, multiline, align, font_size, select_on_focus, password_toggle_msg } => + { + WidgetHandlers::TextEdit + { + value: value.clone(), + on_change: on_change.clone(), + on_submit: on_submit.clone(), + secure: *secure, + multiline: *multiline, + align: *align, + font_size: *font_size, + select_on_focus: *select_on_focus, + password_toggle_msg: password_toggle_msg.clone(), + } + } + WidgetHandlers::Slider { on_change, axis } => + { + WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis } + } + } + } +} + +impl WidgetHandlers +{ + pub fn is_text_input( &self ) -> bool + { + matches!( self, WidgetHandlers::TextEdit { .. } ) + } + + /// `true` when this is a [`WidgetHandlers::TextEdit`] whose source + /// widget was built with `.multiline( true )`. The keyboard + /// dispatch reads this so pressing Enter inserts a `\n` instead of + /// submitting. + pub fn is_multiline_text_input( &self ) -> bool + { + matches!( self, WidgetHandlers::TextEdit { multiline: true, .. } ) + } + + pub fn is_slider( &self ) -> bool + { + matches!( self, WidgetHandlers::Slider { .. } ) + } + + /// `true` when this widget is a row inside a scrollable list that + /// keyboard arrow navigation should treat as a stepping point. + /// Currently restricted to [`ListItem`](crate::ListItem); buttons, + /// toggles, sliders, etc. are not stepped over by Arrow Up/Down so + /// they do not interfere with the row-by-row navigation pattern in + /// combo popups, settings menus and similar lists. + pub fn is_navigable_list_item( &self ) -> bool + { + matches!( self, WidgetHandlers::ListItem { .. } ) + } + + /// Convenience: extract the press / activation message for the variants + /// that have one (Button, Toggle, Checkbox, Radio, ListItem). Returns + /// `None` for sliders / text edits / `None` / disabled widgets. + pub fn press_msg( &self ) -> Option + { + match self + { + WidgetHandlers::Button { on_press, .. } => on_press.clone(), + WidgetHandlers::Toggle { on_toggle } => on_toggle.clone(), + WidgetHandlers::Checkbox { on_toggle } => on_toggle.clone(), + WidgetHandlers::Radio { on_select } => on_select.clone(), + WidgetHandlers::ListItem { on_press } => on_press.clone(), + WidgetHandlers::WindowButton { on_press } => on_press.clone(), + _ => None, + } + } + + /// Submit message (Enter on a focused TextEdit). `None` for every other + /// variant. + pub fn submit_msg( &self ) -> Option + { + match self + { + WidgetHandlers::TextEdit { on_submit, .. } => on_submit.clone(), + _ => None, + } + } + + /// Build the `on_change` message for a TextEdit given the new value. + pub fn text_change_msg( &self, new_value: &str ) -> Option + { + match self + { + WidgetHandlers::TextEdit { on_change: Some( f ), .. } => Some( f( new_value.to_string() ) ), + _ => None, + } + } + + /// Build the `on_change` message for a Slider given a value in `[0,1]`. + pub fn slider_change_msg( &self, value: f32 ) -> Option + { + match self + { + WidgetHandlers::Slider { on_change: Some( f ), .. } => Some( f( value ) ), + _ => None, + } + } + + /// Compute the `[0.0, 1.0]` value for the slider this handler belongs to, + /// given a pointer position inside its layout rect. Dispatches on the + /// stored [`slider::SliderAxis`] so the same call site in `input.rs` drives + /// both horizontal [`Slider`](slider::Slider) and vertical + /// [`VSlider`](vslider::VSlider). + /// + /// Returns `0.0` for non-slider variants — callers combine this with + /// [`Self::slider_change_msg`], which also gates on the variant, so the + /// zero is never consumed in practice. + pub fn slider_value_from_pos( &self, rect: Rect, pos: Point ) -> f32 + { + match self + { + WidgetHandlers::Slider { axis, .. } => + { + slider::value_from_pos_in_rect( rect, pos, *axis ) + } + _ => 0.0, + } + } + + /// `true` when this is a [`WidgetHandlers::Button`] whose source + /// widget opted into press-and-hold repeat. The runtime reads + /// this on press to decide whether to fire `press_msg` + /// immediately + arm a calloop repeat timer, and on release to + /// suppress the regular tap-on-release fire. + pub fn is_repeating( &self ) -> bool + { + matches!( self, WidgetHandlers::Button { repeating: true, .. } ) + } + + /// Long-press / right-click message for this widget, or `None` if + /// none configured. Currently only [`WidgetHandlers::Button`] + /// carries one. Firing this does not by itself put the press into + /// drag mode — see [`Self::drag_start_msg`] for that. + pub fn long_press_msg( &self ) -> Option + { + match self + { + WidgetHandlers::Button { on_long_press, .. } => on_long_press.clone(), + _ => None, + } + } + + /// Drag-arm message for this widget, or `None` if none configured. + /// Fired by the touch hold-timer alongside `long_press_msg`, and + /// by mouse left-button motion past the drag-promotion threshold + /// (without firing the menu). Promotes the gesture into drag mode. + pub fn drag_start_msg( &self ) -> Option + { + match self + { + WidgetHandlers::Button { on_drag_start, .. } => on_drag_start.clone(), + _ => None, + } + } + + /// Keyboard `Escape` message for this widget, or `None` if none + /// configured. Used by the keyboard ESC handler to scan + /// `widget_rects` for a [`crate::widget::dialog::Dialog`] (or other + /// `Pressable::on_escape`-bearing wrapper) and fire its cancel + /// message before the default ESC fallthrough chain. + pub fn escape_msg( &self ) -> Option + { + match self + { + WidgetHandlers::Button { on_escape, .. } => on_escape.clone(), + _ => None, + } + } + + /// Current text-edit value (for cursor placement on focus, backspace + /// rebuild, etc.). `None` for non-text-edit variants. + pub fn current_value( &self ) -> Option<&str> + { + match self + { + WidgetHandlers::TextEdit { value, .. } => Some( value.as_str() ), + _ => None, + } + } +} + +/// Result of laying out one *interactive* widget — i.e. a widget that should +/// receive pointer / touch hit-testing. Captures both the *hit rect* (where +/// input lands) and the *paint rect* (the bounding box of everything the +/// widget actually paints, including hover circles, focus rings, shadows…). +/// The paint rect is used by the partial-redraw path to know how much of the +/// canvas must be invalidated when a widget transitions in/out of a given +/// state — it is always `>= rect`. +/// +/// `handlers` carries the snapshot of the widget's callbacks/values at layout +/// time so input dispatch is O(1) instead of re-walking the [`Element`] tree. +/// +/// `keyboard_focusable` snapshots whether the widget should participate in the +/// Tab / Shift+Tab cycle. Most interactive widgets are also keyboard-focusable +/// (`Button`, `TextEdit`, `Slider`, …) but window-decoration chrome +/// (`WindowButton`) is interactive without taking keyboard focus by default — +/// matching the convention of macOS / GNOME / Windows title bars. +pub struct LaidOutWidget +{ + pub rect: Rect, + pub flat_idx: usize, + pub id: Option, + pub paint_rect: Rect, + pub handlers: WidgetHandlers, + pub keyboard_focusable: bool, + /// Cursor shape this widget wants to show on hover. Snapshotted + /// from [`Element::cursor_shape`] at layout time so the input + /// dispatch can pick the right shape without re-walking the + /// element tree. + pub cursor: crate::types::CursorShape, +} + +impl Clone for LaidOutWidget +{ + fn clone( &self ) -> Self + { + Self + { + rect: self.rect, + flat_idx: self.flat_idx, + id: self.id, + paint_rect: self.paint_rect, + handlers: self.handlers.clone(), + keyboard_focusable: self.keyboard_focusable, + cursor: self.cursor, + } + } +} + +pub enum Element +{ + Button( button::Button ), + Container( container::Container ), + TextEdit( text_edit::TextEdit ), + Image( image::Image ), + Column( crate::layout::column::Column ), + Row( crate::layout::row::Row ), + Stack( crate::layout::stack::Stack ), + Text( text::Text ), + Spacer( crate::layout::spacer::Spacer ), + Scroll( scroll::Scroll ), + Viewport( viewport::Viewport ), + WrapGrid( crate::layout::wrap_grid::WrapGrid ), + Slider( slider::Slider ), + VSlider( vslider::VSlider ), + Toggle( toggle::Toggle ), + Separator( separator::Separator ), + ProgressBar( progress_bar::ProgressBar ), + Checkbox( checkbox::Checkbox ), + Radio( radio::Radio ), + ListItem( list_item::ListItem ), + WindowButton( window_button::WindowButton ), + Pressable( pressable::Pressable ), + Flex( flex::Flex ), + AnchoredOverlay( anchored_overlay::AnchoredOverlay ), + Spinner( spinner::Spinner ), + External( external::External ), +} + +impl Element +{ + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) + { + match self + { + Element::Button( b ) => b.preferred_size( max_width, canvas ), + Element::TextEdit( t ) => t.preferred_size( max_width, canvas ), + Element::Image( i ) => i.preferred_size( max_width ), + Element::Column( c ) => c.preferred_size( max_width, canvas ), + Element::Row( r ) => r.preferred_size( max_width, canvas ), + Element::Stack( s ) => s.preferred_size( max_width, canvas ), + Element::Text( t ) => t.preferred_size( max_width, canvas ), + Element::Spacer( s ) => s.preferred_size(), + Element::Scroll( s ) => s.preferred_size( max_width, canvas ), + Element::Viewport( v ) => v.preferred_size( max_width, canvas ), + Element::WrapGrid( g ) => g.preferred_size( max_width, canvas ), + Element::Slider( s ) => s.preferred_size( max_width, canvas ), + Element::VSlider( s ) => s.preferred_size( max_width, canvas ), + Element::Container( c ) => c.preferred_size( max_width, canvas ), + Element::Toggle( t ) => t.preferred_size( max_width, canvas ), + Element::Separator( s ) => s.preferred_size( max_width ), + Element::ProgressBar( p ) => p.preferred_size( max_width ), + Element::Checkbox( c ) => c.preferred_size( max_width, canvas ), + Element::Radio( r ) => r.preferred_size( max_width, canvas ), + Element::ListItem( l ) => l.preferred_size( max_width, canvas ), + Element::WindowButton( b ) => b.preferred_size( max_width, canvas ), + Element::Pressable( p ) => p.preferred_size( max_width, canvas ), + Element::Flex( f ) => f.preferred_size( max_width, canvas ), + Element::AnchoredOverlay( a ) => a.child.preferred_size( max_width, canvas ), + Element::Spinner( s ) => s.preferred_size( max_width ), + Element::External( e ) => e.preferred_size( max_width ), + } + } + + pub fn draw( + &self, + canvas: &mut Canvas, + rect: Rect, + focused: bool, + hovered: bool, + pressed: bool, + cursor_pos: usize, + selection_anchor: usize, + ) + { + match self + { + Element::Button( b ) => b.draw( canvas, rect, focused, hovered, pressed ), + Element::TextEdit( t ) => t.draw( canvas, rect, focused, cursor_pos, selection_anchor ), + Element::Image( i ) => i.draw( canvas, rect ), + Element::Column( c ) => c.draw( canvas, rect, focused ), + Element::Row( r ) => r.draw( canvas, rect, focused ), + Element::Stack( s ) => s.draw( canvas, rect, focused ), + Element::Text( t ) => t.draw( canvas, rect, focused ), + Element::Spacer( _ ) => {} + Element::Scroll( _ ) => {} + Element::Viewport( _ ) => {} + Element::WrapGrid( _ ) => {} + Element::Slider( s ) => s.draw( canvas, rect, focused ), + Element::VSlider( s ) => s.draw( canvas, rect, focused ), + Element::Container( _ ) => {} + Element::Toggle( t ) => t.draw( canvas, rect, focused ), + Element::Separator( s ) => s.draw( canvas, rect ), + Element::ProgressBar( p ) => p.draw( canvas, rect ), + Element::Checkbox( c ) => c.draw( canvas, rect, focused ), + Element::Radio( r ) => r.draw( canvas, rect, focused ), + Element::ListItem( l ) => l.draw( canvas, rect, focused, hovered, pressed ), + Element::WindowButton( b ) => b.draw( canvas, rect, focused, hovered, pressed ), + Element::Pressable( _ ) => {} + Element::Flex( _ ) => {} + Element::AnchoredOverlay( _ ) => {} + Element::Spinner( s ) => s.draw( canvas, rect ), + Element::External( e ) => e.draw( canvas, rect ), + } + } + + pub fn hit_test( &self, rect: Rect, pos: Point ) -> bool + { + rect.contains( pos ) + } + + /// Bounding box of every pixel this widget may paint given `rect` as its + /// layout rect. Must enclose the `draw` output in every possible state + /// (hover, focus, press). Widgets that paint outside their layout rect + /// (hover halos, focus rings, drop shadows…) must override this; the + /// default returns `rect` unchanged. + /// + /// Used by the partial-redraw path to invalidate exactly the pixels that + /// might change when a widget transitions in/out of a state. + pub( crate ) fn paint_bounds( &self, rect: Rect ) -> Rect + { + match self + { + Element::Button( b ) => b.paint_bounds( rect ), + Element::Toggle( t ) => t.paint_bounds( rect ), + Element::Radio( r ) => r.paint_bounds( rect ), + Element::Checkbox( c ) => c.paint_bounds( rect ), + Element::TextEdit( e ) => e.paint_bounds( rect ), + Element::ListItem( l ) => l.paint_bounds( rect ), + Element::WindowButton( b ) => b.paint_bounds( rect ), + Element::Slider( s ) => s.paint_bounds( rect ), + Element::VSlider( s ) => s.paint_bounds( rect ), + _ => rect, + } + } + + /// Whether the widget should be included in the per-surface + /// `widget_rects` list — i.e. whether pointer / touch hit testing must be + /// able to land on it. This is the predicate that gates the layout pass's + /// push to `DrawCtx::widget_rects` in the draw pass. + /// + /// Defaults to [`Self::is_focusable`] for every widget that takes keyboard + /// focus (those are interactive by definition). Widgets that are + /// click/touch-only without taking keyboard focus — currently + /// [`Element::WindowButton`] — opt in here without opting in to the Tab + /// cycle. + pub fn is_interactive( &self ) -> bool + { + match self + { + // Always hit-testable regardless of `focusable`. Lets callers + // opt out of keyboard focus (no Tab target, no lingering focus + // ring after a press) while keeping clicks / taps working. + Element::Button( _ ) | Element::WindowButton( _ ) => true, + // Pressable wrappers participate in hit testing only when they + // carry a handler — a no-op pressable is invisible to input. + Element::Pressable( p ) => p.has_handler(), + _ => self.is_focusable(), + } + } + + /// Whether the widget participates in the Tab / Shift+Tab focus cycle. + /// Snapshotted onto [`LaidOutWidget::keyboard_focusable`] at layout time so + /// `next_focusable_index` can iterate without the [`Element`] tree. + /// + /// Hit-testable chrome that should *not* steal keyboard focus from window + /// content (e.g. [`Element::WindowButton`]) returns `false` here while + /// still returning `true` from [`Self::is_interactive`]. + pub fn is_focusable( &self ) -> bool + { + match self + { + Element::Button( b ) => b.focusable, + Element::TextEdit( _ ) | Element::Slider( _ ) | Element::VSlider( _ ) => true, + Element::Toggle( _ ) | Element::Checkbox( _ ) | Element::Radio( _ ) | Element::ListItem( _ ) => true, + Element::WindowButton( b ) => b.focusable, + _ => false, + } + } + + pub fn is_text_input( &self ) -> bool + { + matches!( self, Element::TextEdit( _ ) ) + } + + /// Cursor shape to display while the pointer is over this widget. + /// Per-widget defaults match desktop conventions: + /// + /// * Text inputs → `Text` (I-beam) + /// * Clickables (Button, Pressable, Toggle, Checkbox, Radio, + /// ListItem, WindowButton) → `Pointer` (hand) + /// * Anything else → `Default` + /// + /// Widgets that carry a per-instance override (set via the + /// `.cursor( shape )` builder) return the override; otherwise they + /// return their type-based default. The runtime snapshots this onto + /// [`LaidOutWidget::cursor`] at layout time and dispatches the + /// shape via `wp_cursor_shape_v1` on hover transitions. + pub fn cursor_shape( &self ) -> crate::types::CursorShape + { + use crate::types::CursorShape::*; + match self + { + Element::TextEdit( t ) => t.cursor.unwrap_or( Text ), + Element::Button( b ) => b.cursor.unwrap_or( Pointer ), + Element::Pressable( p ) => p.cursor.unwrap_or( Pointer ), + Element::Toggle( _ ) => Pointer, + Element::Checkbox( _ ) => Pointer, + Element::Radio( _ ) => Pointer, + Element::ListItem( _ ) => Pointer, + Element::WindowButton( _ ) => Pointer, + // Sliders read as buttons on hover (`Pointer`) and as + // `Grabbing` during a drag — the runtime swaps to + // `Grabbing` itself when `gesture.dragging_slider` is + // set, so the static value here is just the hover state. + Element::Slider( _ ) => Pointer, + Element::VSlider( _ ) => Pointer, + _ => Default, + } + } + + /// Snapshot the widget's callbacks/value into a [`WidgetHandlers`] for + /// O(1) dispatch later. Called once per focusable leaf during the layout + /// pass; the handlers are then stored alongside the rect in + /// [`LaidOutWidget`] so input handlers don't need the [`Element`] tree. + /// + /// Containers and layouts that delegate to children return + /// [`WidgetHandlers::None`] — only leaf widgets actually carry payload. + pub( crate ) fn handlers( &self ) -> WidgetHandlers + { + match self + { + Element::Button( b ) => WidgetHandlers::Button + { + on_press: b.on_press.clone(), + on_long_press: b.on_long_press.clone(), + on_drag_start: b.on_drag_start.clone(), + on_escape: None, + repeating: b.repeating, + }, + Element::Pressable( p ) => WidgetHandlers::Button + { + on_press: p.on_press.clone(), + on_long_press: p.on_long_press.clone(), + on_drag_start: p.on_drag_start.clone(), + on_escape: p.on_escape.clone(), + repeating: false, + }, + Element::Toggle( t ) => WidgetHandlers::Toggle { on_toggle: t.on_toggle.clone() }, + Element::Checkbox( c ) => WidgetHandlers::Checkbox { on_toggle: c.on_toggle.clone() }, + Element::Radio( r ) => WidgetHandlers::Radio { on_select: r.on_select.clone() }, + Element::ListItem( l ) => WidgetHandlers::ListItem { on_press: l.on_press.clone() }, + Element::WindowButton( b ) => WidgetHandlers::WindowButton { on_press: b.on_press.clone() }, + Element::TextEdit( t ) => + { + WidgetHandlers::TextEdit + { + value: t.value.clone(), + on_change: t.on_change.clone(), + on_submit: t.on_submit.clone(), + // `secure` here drives memory wipe-on-drop and + // the IME bypass — so any password field (with + // or without a toggle) opts in, regardless of + // the user's current visibility choice. + secure: t.secure || t.password_toggle.is_some(), + multiline: t.is_multiline(), + align: t.align, + font_size: t.font_size, + select_on_focus: t.select_on_focus, + password_toggle_msg: t.password_toggle.as_ref().map( |( _, m )| m.clone() ), + } + } + Element::Slider( s ) => + { + WidgetHandlers::Slider + { + on_change: s.on_change.clone(), + axis: slider::SliderAxis::Horizontal, + } + } + Element::VSlider( s ) => + { + WidgetHandlers::Slider + { + on_change: s.on_change.clone(), + axis: slider::SliderAxis::Vertical, + } + } + _ => WidgetHandlers::None, + } + } +} + +/// Type alias for the message-mapping closure shared across an +/// [`Element::map`] walk. Stored as `Arc` so every per-widget +/// `map_msg` can clone and re-share it without copying the closure body +/// — the same closure is invoked once per emitted message, regardless +/// of how many leaves the sub-tree has. +pub( crate ) type MapFn = Arc U>; + +impl Element +{ + /// Re-tag every message a sub-view emits. + /// + /// Walks the tree once and rewrites every per-leaf message store — + /// `Button::on_press`, `Slider::on_change`, the children of + /// `Column`/`Row`/`Stack`/`WrapGrid`, and so on — so the returned + /// `Element` no longer references `Msg`. The standard Elm / + /// `iced` shape: a sub-view defined as `fn view( …) -> Element` + /// can be embedded inside a parent that produces `Element` + /// by calling `.map( AppMsg::Sub )`. + /// + /// Cost: `O( leaves )` allocations for the closure-wrapping in the + /// `Arc` callbacks (`text_edit`, `slider`, `vslider`), + /// and the closure itself runs an extra indirect call per emitted + /// message — both per-`map`-layer. Trees built fresh every frame + /// (the typical case) absorb this in the same allocator pressure + /// `view()` already produces, so the overhead is in the noise. + /// + /// ```rust,no_run + /// # use ltk::{ button, text, Element }; + /// # #[ derive( Clone ) ] enum SubMsg { Save } + /// # #[ derive( Clone ) ] enum AppMsg { Sub( SubMsg ) } + /// # fn sub_view() -> Element { + /// # button( "Save" ).on_press( SubMsg::Save ).into() + /// # } + /// # fn _ex() -> Element { + /// sub_view().map( AppMsg::Sub ) + /// # } + /// ``` + pub fn map( self, f: F ) -> Element + where + U: Clone + 'static, + F: Fn( Msg ) -> U + 'static, + { + let f: MapFn = Arc::new( f ); + self.map_arc( &f ) + } + + pub( crate ) fn map_arc( self, f: &MapFn ) -> Element + where + U: Clone + 'static, + { + match self + { + Element::Button( b ) => Element::Button( b.map_msg( f ) ), + Element::Container( c ) => Element::Container( c.map_msg( f ) ), + Element::TextEdit( t ) => Element::TextEdit( t.map_msg( f ) ), + Element::Image( i ) => Element::Image( i ), + Element::Column( c ) => Element::Column( c.map_msg( f ) ), + Element::Row( r ) => Element::Row( r.map_msg( f ) ), + Element::Stack( s ) => Element::Stack( s.map_msg( f ) ), + Element::Text( t ) => Element::Text( t ), + Element::Spacer( s ) => Element::Spacer( s ), + Element::Scroll( s ) => Element::Scroll( s.map_msg( f ) ), + Element::Viewport( v ) => Element::Viewport( v.map_msg( f ) ), + Element::WrapGrid( g ) => Element::WrapGrid( g.map_msg( f ) ), + Element::Slider( s ) => Element::Slider( s.map_msg( f ) ), + Element::VSlider( s ) => Element::VSlider( s.map_msg( f ) ), + Element::Toggle( t ) => Element::Toggle( t.map_msg( f ) ), + Element::Separator( s ) => Element::Separator( s ), + Element::ProgressBar( p ) => Element::ProgressBar( p ), + Element::Checkbox( c ) => Element::Checkbox( c.map_msg( f ) ), + Element::Radio( r ) => Element::Radio( r.map_msg( f ) ), + Element::ListItem( l ) => Element::ListItem( l.map_msg( f ) ), + Element::WindowButton( b ) => Element::WindowButton( b.map_msg( f ) ), + Element::Pressable( p ) => Element::Pressable( p.map_msg( f ) ), + Element::Flex( fx ) => Element::Flex( fx.map_msg( f ) ), + Element::AnchoredOverlay( a ) => Element::AnchoredOverlay( a.map_msg( f ) ), + Element::Spinner( s ) => Element::Spinner( s ), + Element::External( e ) => Element::External( e ), + } + } +} + +impl From> for Element +{ + fn from( c: container::Container ) -> Self + { + Element::Container( c ) + } +} + +impl From> for Element +{ + fn from( b: button::Button ) -> Self + { + Element::Button( b ) + } +} + +impl From> for Element +{ + fn from( t: text_edit::TextEdit ) -> Self + { + Element::TextEdit( t ) + } +} + +impl From for Element +{ + fn from( i: image::Image ) -> Self + { + Element::Image( i ) + } +} + +impl From for Element +{ + fn from( e: external::External ) -> Self + { + Element::External( e ) + } +} + +impl From> for Element +{ + fn from( c: crate::layout::column::Column ) -> Self + { + Element::Column( c ) + } +} + +impl From> for Element +{ + fn from( r: crate::layout::row::Row ) -> Self + { + Element::Row( r ) + } +} + +impl From> for Element +{ + fn from( s: crate::layout::stack::Stack ) -> Self + { + Element::Stack( s ) + } +} + +pub fn button( label: impl Into ) -> button::Button +{ + button::Button::new( label.into() ) +} + +pub fn icon_button( rgba: Arc>, img_w: u32, img_h: u32 ) -> button::Button +{ + button::Button::new_icon( rgba, img_w, img_h ) +} + +pub fn text_edit( + placeholder: impl Into, + value: impl Into, +) -> text_edit::TextEdit +{ + text_edit::TextEdit::new( placeholder.into(), value.into() ) +} + +pub fn image( rgba: Arc>, width: u32, height: u32 ) -> image::Image +{ + image::Image::new( rgba, width, height ) +} + +pub fn text( content: impl Into ) -> text::Text +{ + text::Text::new( content ) +} + +pub fn container( child: impl Into> ) -> container::Container +{ + container::Container::new( child ) +} + +/// Build an [`external::External`] widget that hosts content rendered by +/// a caller-managed GL texture producer. +pub fn external( width: f32, height: f32, source: external::ExternalSource ) -> external::External +{ + external::External::new( width, height, source ) +} diff --git a/src/widget/notebook.rs b/src/widget/notebook.rs new file mode 100644 index 0000000..d4d28d2 --- /dev/null +++ b/src/widget/notebook.rs @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Notebook — paginated tabs with a content area. +//! +//! Different from `tab_bar` (which is just a segmented selector — the +//! application is responsible for rendering whatever content +//! corresponds to the active tab). A `Notebook` owns the +//! pages: each page bundles a label *and* its content, and only the +//! active page's content is laid out / drawn each frame. +//! +//! The widget itself is stateless: the application owns +//! `self.tab: usize` and updates it from the `on_select` callback. The +//! pages can be built fresh every frame (typical) or memoised on the +//! application side. +//! +//! ```rust,no_run +//! # use ltk::{ notebook, text, Element, Notebook }; +//! # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) } +//! # struct App { tab: usize } +//! # impl App { +//! # fn general_view( &self ) -> Element { text( "g" ).into() } +//! # fn network_view( &self ) -> Element { text( "n" ).into() } +//! # fn audio_view( &self ) -> Element { text( "a" ).into() } +//! # fn _ex( &self ) -> Notebook { +//! notebook() +//! .page( "General", self.general_view() ) +//! .page( "Network", self.network_view() ) +//! .page( "Audio", self.audio_view() ) +//! .selected( self.tab ) +//! .on_select( Msg::SelectTab ) +//! # } +//! # } +//! ``` + +use std::sync::Arc; + +use crate::layout::column::column; +use crate::layout::spacer::spacer; + +use super::Element; + +mod theme +{ + pub const SPACING: f32 = 12.0; +} + +/// One page of a [`Notebook`] — a label for the tab strip and the +/// element to show when this page is active. +pub struct NotebookPage +{ + pub label: String, + pub view: Element, +} + +/// Paginated tab container. +/// +/// Renders a `tab_bar` strip at the top followed by the +/// active page's content. Pages whose index is not [`Self::selected`] +/// are dropped before draw so they do not consume layout time — a +/// notebook with 50 pages costs the same to draw as one with 5. +pub struct Notebook +{ + pub pages: Vec>, + pub selected: usize, + pub on_select: Option Msg>>, +} + +impl Notebook +{ + /// Create an empty notebook with no pages and no selection. + pub fn new() -> Self + { + Self + { + pages: Vec::new(), + selected: 0, + on_select: None, + } + } + + /// Append a page. Returns `Self` for chaining. + pub fn page( + mut self, + label: impl Into, + view: impl Into>, + ) -> Self + { + self.pages.push( NotebookPage + { + label: label.into(), + view: view.into(), + } ); + self + } + + /// Index of the currently active page. Out-of-range values fall + /// back to page 0; a notebook with no pages renders an empty + /// container. + pub fn selected( mut self, idx: usize ) -> Self + { + self.selected = idx; + self + } + + /// Callback invoked with the index of the tab the user tapped. + pub fn on_select( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self + { + self.on_select = Some( Arc::new( f ) ); + self + } + + /// Build the `Element` tree representing this notebook. + pub fn build( self ) -> Element + { + use super::tab_bar::tabs; + + let labels: Vec = self.pages.iter().map( |p| p.label.clone() ).collect(); + let selected = if self.selected < self.pages.len() { self.selected } else { 0 }; + let n_pages = self.pages.len(); + + // Build the tab strip; wire on_select if the caller provided one. + let mut strip = tabs::( labels ).selected( selected ); + if let Some( cb ) = self.on_select.clone() + { + strip = strip.on_select( move |i| cb( i ) ); + } + + // Drain to extract the active page's view by index. + let mut pages = self.pages; + let active_view: Element = if pages.is_empty() + { + spacer().into() + } else { + let _ = n_pages; + pages.swap_remove( selected ) + .view + }; + + column() + .spacing( theme::SPACING ) + .push( strip ) + .push( active_view ) + .into() + } +} + +impl Default for Notebook +{ + fn default() -> Self { Self::new() } +} + +impl From> for Element +{ + fn from( n: Notebook ) -> Self { n.build() } +} + +/// Create an empty [`Notebook`]. +/// +/// ```rust,no_run +/// # use ltk::{ notebook, text, Element, Notebook }; +/// # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) } +/// # struct App { tab: usize } +/// # impl App { +/// # fn inbox_view( &self ) -> Element { text( "i" ).into() } +/// # fn sent_view( &self ) -> Element { text( "s" ).into() } +/// # fn _ex( &self ) -> Notebook { +/// notebook() +/// .page( "Inbox", self.inbox_view() ) +/// .page( "Sent", self.sent_view() ) +/// .selected( self.tab ) +/// .on_select( Msg::SelectTab ) +/// # } +/// # } +/// ``` +pub fn notebook() -> Notebook +{ + Notebook::new() +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::layout::spacer::spacer; + + #[ derive( Clone, Debug, PartialEq, Eq ) ] + enum Msg { Pick( usize ) } + + #[ test ] + fn defaults_have_no_pages() + { + let n: Notebook = notebook(); + assert_eq!( n.pages.len(), 0 ); + assert_eq!( n.selected, 0 ); + assert!( n.on_select.is_none() ); + } + + #[ test ] + fn page_appends_in_order() + { + let n: Notebook = notebook() + .page( "A", spacer() ) + .page( "B", spacer() ) + .page( "C", spacer() ); + assert_eq!( n.pages.len(), 3 ); + assert_eq!( n.pages[0].label, "A" ); + assert_eq!( n.pages[1].label, "B" ); + assert_eq!( n.pages[2].label, "C" ); + } + + #[ test ] + fn selected_builder_records_index() + { + let n: Notebook = notebook() + .page( "A", spacer() ) + .page( "B", spacer() ) + .selected( 1 ); + assert_eq!( n.selected, 1 ); + } + + #[ test ] + fn on_select_callback_is_invoked_for_index() + { + let n: Notebook = notebook() + .page( "A", spacer() ) + .on_select( Msg::Pick ); + let cb = n.on_select.as_ref().expect( "callback present" ); + assert_eq!( cb( 7 ), Msg::Pick( 7 ) ); + } + + #[ test ] + fn build_does_not_panic_on_empty_pages() + { + let _: Element = notebook().build(); + } + + #[ test ] + fn build_does_not_panic_on_out_of_range_selected() + { + // Out-of-range `selected` must fall back to page 0 instead of + // panicking with an index error in the swap_remove. + let n: Notebook = notebook() + .page( "A", spacer() ) + .page( "B", spacer() ) + .selected( 99 ); + let _: Element = n.build(); + } +} diff --git a/src/widget/pressable.rs b/src/widget/pressable.rs new file mode 100644 index 0000000..edc44da --- /dev/null +++ b/src/widget/pressable.rs @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::render::Canvas; +use crate::types::WidgetId; +use super::Element; + +/// Wraps any [`Element`] and emits a message on tap. Use when you want +/// click-to-emit on something richer than a [`Button`](super::button::Button) +/// — for example a [`Container`](super::container::Container) styled as a +/// card holding a row of icon + labels. +/// +/// The wrapper is invisible to drawing: it delegates `preferred_size` and +/// rendering to the child. It does record a hit rect covering its full +/// allocated rect so taps anywhere inside fire `on_press`. Inner widgets +/// that are themselves interactive (e.g. a button nested inside the +/// pressable) keep priority — the layout pass pushes the wrapper's hit +/// rect *before* recursing into the child, and hit testing iterates in +/// reverse, so deeper widgets win. +/// +/// No visual press feedback is applied — for state-driven appearance +/// changes use a [`Button`](super::button::Button) or compose with a +/// container that reacts to focus/press signals. +/// +/// ```rust,no_run +/// # use ltk::{ column, container, pressable, row, Element, Pressable }; +/// # #[ derive( Clone ) ] enum Msg { OpenWifiPicker } +/// # fn _ex( +/// # icon: Element, +/// # title: Element, +/// # subtitle: Element, +/// # ) -> Pressable { +/// pressable( +/// container( row() +/// .push( icon ) +/// .push( column().push( title ).push( subtitle ) ) ) +/// .surface( "surface-card" ) +/// .radius( 32.0 ) +/// .padding_h( 16.5 ) +/// .padding_v( 24.0 ), +/// ) +/// .on_press( Msg::OpenWifiPicker ) +/// # } +/// ``` +pub struct Pressable +{ + pub child: Box>, + pub on_press: Option, + pub on_long_press: Option, + /// Drag-arm message. Fired alongside `on_long_press` when the touch + /// hold timer elapses, AND fired by mouse left-button motion past + /// the drag-promotion threshold without waiting for the timer. The + /// caller uses this to arm any per-app drag state (in crustace, + /// `dragging_item`); a widget that opens a context menu but isn't + /// draggable leaves this `None`. + pub on_drag_start: Option, + /// Keyboard `Escape`-key message. The runtime scans every laid-out + /// pressable's snapshot and fires the topmost (highest `flat_idx`) + /// `on_escape` it finds before the default ESC fallthrough chain. + /// Used by [`crate::widget::dialog::Dialog`] to make `Esc` cancel a + /// modal dialog without each app having to wire a global keyboard + /// hook — but available to any composite that needs the same + /// semantics. + pub on_escape: Option, + /// Make the pressable hit-testable even when no callback is set. + /// A `swallow=true` pressable consumes pointer events at its hit + /// rect and emits no message — used by + /// [`crate::widget::dialog::Dialog`] for the modal scrim plus the + /// card-area swallow that prevents `dismiss_on_scrim` from firing + /// when the user clicks on the dialog body itself. Has no effect + /// when any handler is also set; in that case the handler determines + /// the message and `swallow` is implicit. + pub swallow: bool, + pub id: Option, + pub cursor: Option, +} + +impl Pressable +{ + pub fn new( child: impl Into> ) -> Self + { + Self + { + child: Box::new( child.into() ), + on_press: None, + on_long_press: None, + on_drag_start: None, + on_escape: None, + swallow: false, + id: None, + cursor: None, + } + } + + /// Override the pointer cursor shape shown on hover. + pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self + { + self.cursor = Some( shape ); + self + } + + pub fn on_press( mut self, msg: Msg ) -> Self + { + self.on_press = Some( msg ); + self + } + + pub fn on_long_press( mut self, msg: Msg ) -> Self + { + self.on_long_press = Some( msg ); + self + } + + /// Attach a drag-arm message. Fires when the press transitions into + /// drag mode — touch on hold-timer expiry, mouse on motion past the + /// drag-promotion threshold. Independent of `on_long_press` so a + /// widget can open a menu without becoming draggable, or be + /// draggable without opening a menu. + pub fn on_drag_start( mut self, msg: Msg ) -> Self + { + self.on_drag_start = Some( msg ); + self + } + + /// Bind a keyboard-`Escape` message to this pressable. See + /// [`Pressable::on_escape`] for the dispatch order semantics. + pub fn on_escape( mut self, msg: Msg ) -> Self + { + self.on_escape = Some( msg ); + self + } + + /// Make the pressable hit-testable even when no `on_press` / + /// `on_long_press` / `on_drag_start` is configured. See + /// [`Pressable::swallow`]. + pub fn swallow( mut self, on: bool ) -> Self + { + self.swallow = on; + self + } + + pub fn id( mut self, id: WidgetId ) -> Self + { + self.id = Some( id ); + self + } + + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) + { + self.child.preferred_size( max_width, canvas ) + } + + /// True when the wrapper participates in hit-testing — has at least + /// one pointer / keyboard handler set, or has been opted in to + /// silent swallow via [`Pressable::swallow`]. Used by the layout + /// pass to skip pushing a hit rect for a no-op pressable. + pub fn has_handler( &self ) -> bool + { + self.on_press.is_some() + || self.on_long_press.is_some() + || self.on_drag_start.is_some() + || self.on_escape.is_some() + || self.swallow + } + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> Pressable + where + U: Clone + 'static, + Msg: 'static, + { + Pressable + { + child: Box::new( self.child.map_arc( f ) ), + on_press: self.on_press.map( |m| ( *f )( m ) ), + on_long_press: self.on_long_press.map( |m| ( *f )( m ) ), + on_drag_start: self.on_drag_start.map( |m| ( *f )( m ) ), + on_escape: self.on_escape.map( |m| ( *f )( m ) ), + swallow: self.swallow, + id: self.id, + cursor: self.cursor, + } + } +} + +pub fn pressable( child: impl Into> ) -> Pressable +{ + Pressable::new( child ) +} + +impl From> for Element +{ + fn from( p: Pressable ) -> Self + { + Element::Pressable( p ) + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::layout::spacer::spacer; + use crate::render::Canvas; + use crate::types::WidgetId; + + #[ derive( Clone, Debug, PartialEq, Eq ) ] + enum Msg { Tap, Hold, Drag, Cancel } + + #[ test ] + fn new_defaults_have_no_handlers() + { + let p = Pressable::::new( spacer() ); + assert!( p.on_press.is_none() ); + assert!( p.on_long_press.is_none() ); + assert!( p.on_drag_start.is_none() ); + assert!( p.on_escape.is_none() ); + assert!( !p.swallow ); + assert!( p.id.is_none() ); + assert!( !p.has_handler() ); + } + + #[ test ] + fn on_press_builder_arms_tap_message() + { + let p = Pressable::new( spacer() ).on_press( Msg::Tap ); + assert_eq!( p.on_press, Some( Msg::Tap ) ); + assert!( p.has_handler() ); + } + + #[ test ] + fn on_long_press_builder_arms_long_press_message() + { + let p = Pressable::new( spacer() ).on_long_press( Msg::Hold ); + assert_eq!( p.on_long_press, Some( Msg::Hold ) ); + assert!( p.has_handler() ); + } + + #[ test ] + fn on_drag_start_builder_arms_drag_message() + { + let p = Pressable::new( spacer() ).on_drag_start( Msg::Drag ); + assert_eq!( p.on_drag_start, Some( Msg::Drag ) ); + assert!( p.has_handler() ); + } + + #[ test ] + fn has_handler_is_true_when_any_callback_is_set() + { + assert!( Pressable::::new( spacer() ).on_press( Msg::Tap ).has_handler() ); + assert!( Pressable::::new( spacer() ).on_long_press( Msg::Hold ).has_handler() ); + assert!( Pressable::::new( spacer() ).on_drag_start( Msg::Drag ).has_handler() ); + assert!( Pressable::::new( spacer() ) + .on_press( Msg::Tap ).on_long_press( Msg::Hold ).on_drag_start( Msg::Drag ).has_handler() ); + } + + #[ test ] + fn on_escape_builder_arms_escape_message() + { + let p = Pressable::new( spacer() ).on_escape( Msg::Cancel ); + assert_eq!( p.on_escape, Some( Msg::Cancel ) ); + assert!( p.has_handler() ); + } + + #[ test ] + fn swallow_builder_makes_pressable_hit_testable_without_callbacks() + { + let p = Pressable::::new( spacer() ).swallow( true ); + assert!( p.swallow ); + assert!( p.on_press.is_none() ); + assert!( p.has_handler() ); + } + + #[ test ] + fn swallow_off_with_no_callbacks_is_invisible_to_input() + { + let p = Pressable::::new( spacer() ).swallow( false ); + assert!( !p.has_handler() ); + } + + #[ test ] + fn id_builder_assigns_widget_id() + { + let id = WidgetId( "my_card" ); + let p = Pressable::::new( spacer() ).id( id ); + assert_eq!( p.id, Some( id ) ); + } + + #[ test ] + fn preferred_size_delegates_to_child() + { + let canvas = Canvas::new( 800, 600 ); + let child = spacer().width( 50.0 ).height( 30.0 ); + let p = Pressable::::new( child ); + assert_eq!( p.preferred_size( 400.0, &canvas ), ( 50.0, 30.0 ) ); + } +} diff --git a/src/widget/progress_bar.rs b/src/widget/progress_bar.rs new file mode 100644 index 0000000..3b75576 --- /dev/null +++ b/src/widget/progress_bar.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::{ Color, Rect }; +use crate::render::Canvas; +use super::Element; + +mod theme +{ + use crate::types::Color; + pub fn track_bg() -> Color { crate::theme::palette().divider } + pub fn fill() -> Color { crate::theme::palette().accent } + pub const TRACK_H: f32 = 6.0; + pub const HEIGHT: f32 = 20.0; +} + +/// A linear progress indicator for determinate operations. +/// +/// Renders a horizontal track with a coloured fill from the left edge to +/// `value × width`. `value` is clamped to `[0.0, 1.0]` at construction. +/// For indeterminate progress (the operation has no ETA) prefer an +/// animated spinner — `ltk` has no built-in spinner widget yet; build one +/// from a [`Container`](super::container::Container) that rotates a glyph +/// while [`crate::App::is_animating`] returns `true`. +/// +/// ```rust,no_run +/// # use ltk::{ column, progress_bar, text, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # struct App { progress: f32 } +/// # impl App { fn _ex( &self ) -> Element { +/// // In view(): +/// column() +/// .push( text( format!( "Downloading… {}%", ( self.progress * 100.0 ) as u32 ) ) ) +/// .push( progress_bar( self.progress ) ) +/// .into() +/// # }} +/// ``` +pub struct ProgressBar +{ + /// Current progress in `[0.0, 1.0]`. Always clamped at construction. + pub value: f32, + /// Fill colour. Defaults to the theme's `accent` palette slot. + pub fill: Color, +} + +impl ProgressBar +{ + /// Create a progress bar at the given fraction. `value` outside + /// `[0.0, 1.0]` is clamped silently. + pub fn new( value: f32 ) -> Self + { + Self { value: value.clamp( 0.0, 1.0 ), fill: theme::fill() } + } + + /// Override the fill colour. Useful for "danger" / "success" variants + /// (red for nearly-full disks, green for completed work). + pub fn color( mut self, color: Color ) -> Self + { + self.fill = color; + self + } + + /// Return the preferred `(width, height)`. Width fills the available + /// `max_width`; height is the theme-defined row height. + pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32) + { + ( max_width, theme::HEIGHT ) + } + + pub fn draw( &self, canvas: &mut Canvas, rect: Rect ) + { + let track_y = rect.y + ( rect.height - theme::TRACK_H ) / 2.0; + let track_r = theme::TRACK_H / 2.0; + + let track_rect = Rect + { + x: rect.x, + y: track_y, + width: rect.width, + height: theme::TRACK_H, + }; + canvas.fill_rect( track_rect, theme::track_bg(), track_r ); + + let fill_w = rect.width * self.value; + if fill_w > 0.0 + { + let fill_rect = Rect + { + x: rect.x, + y: track_y, + width: fill_w, + height: theme::TRACK_H, + }; + canvas.fill_rect( fill_rect, self.fill, track_r ); + } + } +} + +/// Create a [`ProgressBar`] at the given fraction (clamped to +/// `[0.0, 1.0]`). +/// +/// ```rust,no_run +/// # use ltk::{ progress_bar, ProgressBar }; +/// # struct App { download_fraction: f32 } +/// # impl App { fn _ex( &self ) -> ProgressBar { +/// progress_bar( self.download_fraction ) +/// # }} +/// ``` +pub fn progress_bar( value: f32 ) -> ProgressBar +{ + ProgressBar::new( value ) +} + +impl From for Element +{ + fn from( p: ProgressBar ) -> Self + { + Element::ProgressBar( p ) + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn value_clamped_on_creation() + { + let p = progress_bar( 1.5 ); + assert_eq!( p.value, 1.0 ); + let p = progress_bar( -0.5 ); + assert_eq!( p.value, 0.0 ); + } + + #[test] + fn preferred_width_fills_available() + { + let p = progress_bar( 0.5 ); + let ( w, _ ) = p.preferred_size( 300.0 ); + assert_eq!( w, 300.0 ); + } +} diff --git a/src/widget/radio.rs b/src/widget/radio.rs new file mode 100644 index 0000000..87d01e7 --- /dev/null +++ b/src/widget/radio.rs @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::{ Rect, WidgetId }; +use crate::render::Canvas; +use super::Element; + +mod theme +{ + use crate::types::Color; + pub fn ring_color() -> Color { crate::theme::palette().divider } + pub fn selected() -> Color { crate::theme::palette().accent } + /// Inner dot — uses the page-background colour so it reads as + /// the inverse of the accent fill regardless of mode. + pub fn dot_color() -> Color { crate::theme::palette().bg } + pub fn focus_color() -> Color { crate::theme::palette().accent } + pub fn label_color() -> Color { crate::theme::palette().text_primary } + pub const OUTER_SIZE: f32 = 24.0; + pub const DOT_SIZE: f32 = 12.0; + pub const BORDER_W: f32 = 2.0; + pub const GAP: f32 = 12.0; + pub const HEIGHT: f32 = 48.0; + pub const FOCUS_W: f32 = 3.0; + pub const FONT_SIZE: f32 = 16.0; +} + +/// One option inside a mutually-exclusive group. +/// +/// Renders a circle with a centred dot when selected. Unlike +/// [`Checkbox`](super::checkbox::Checkbox), a radio is meaningful only as +/// part of a group: the group is the application's responsibility — define +/// an enum for the choices, store the current value, and build one `Radio` +/// per variant with `selected = current == this_variant`. +/// +/// ```rust,no_run +/// # use ltk::{ column, radio, Element }; +/// #[ derive( Clone, PartialEq ) ] +/// enum Priority { Low, Medium, High } +/// # #[ derive( Clone ) ] enum Msg { SetPriority( Priority ) } +/// # struct App { priority: Priority } +/// # impl App { fn _ex( &self ) -> Element { +/// // In view(): +/// column() +/// .push( radio( self.priority == Priority::Low ).label( "Low" ).on_select( Msg::SetPriority( Priority::Low ) ) ) +/// .push( radio( self.priority == Priority::Medium ).label( "Medium" ).on_select( Msg::SetPriority( Priority::Medium ) ) ) +/// .push( radio( self.priority == Priority::High ).label( "High" ).on_select( Msg::SetPriority( Priority::High ) ) ) +/// .into() +/// # }} +/// ``` +/// +/// `ltk` does not enforce mutual exclusion automatically; the application's +/// `update` decides which variant becomes active when a radio is selected. +pub struct Radio +{ + /// Whether this option is currently selected. Drawn from this field + /// every frame. + pub selected: bool, + /// Message emitted when the user picks this option. `None` leaves the + /// radio inert. + pub on_select: Option, + /// Optional label drawn to the right of the circle. + pub label: Option, + /// Optional stable identifier for focus management. + pub id: Option, +} + +impl Radio +{ + /// Create a radio in the given state, with no label and no callback. + pub fn new( selected: bool ) -> Self + { + Self { selected, on_select: None, label: None, id: None } + } + + /// Set the message emitted when this option is picked. The + /// application's `update` is responsible for flipping the group's + /// current value. + pub fn on_select( mut self, msg: Msg ) -> Self + { + self.on_select = Some( msg ); + self + } + + /// Set a text label rendered to the right of the circle. + pub fn label( mut self, label: impl Into ) -> Self + { + self.label = Some( label.into() ); + self + } + + /// Assign a stable identifier for focus management. + pub fn id( mut self, id: WidgetId ) -> Self + { + self.id = Some( id ); + self + } + + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) + { + let w = if let Some( ref label ) = self.label + { + let text_w = canvas.measure_text( label, theme::FONT_SIZE ); + ( theme::OUTER_SIZE + theme::GAP + text_w ).min( max_width ) + } else { + theme::OUTER_SIZE.min( max_width ) + }; + ( w, theme::HEIGHT ) + } + + /// Focus ring on the outer circle extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px` + /// beyond the circle (which sits flush with the widget's left edge). + pub fn paint_bounds( &self, rect: Rect ) -> Rect + { + rect.expand( theme::FOCUS_W + 2.0 + theme::FOCUS_W * 0.5 + 1.0 ) + } + + pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool ) + { + let circle_y = rect.y + ( rect.height - theme::OUTER_SIZE ) / 2.0; + let outer_r = theme::OUTER_SIZE / 2.0; + let outer_rect = Rect + { + x: rect.x, + y: circle_y, + width: theme::OUTER_SIZE, + height: theme::OUTER_SIZE, + }; + + if self.selected + { + canvas.fill_rect( outer_rect, theme::selected(), outer_r ); + let dot_r = theme::DOT_SIZE / 2.0; + let cx = rect.x + outer_r; + let cy = circle_y + outer_r; + let dot_rect = Rect + { + x: cx - dot_r, + y: cy - dot_r, + width: theme::DOT_SIZE, + height: theme::DOT_SIZE, + }; + canvas.fill_rect( dot_rect, theme::dot_color(), dot_r ); + } else { + canvas.stroke_rect( outer_rect, theme::ring_color(), theme::BORDER_W, outer_r ); + } + + if focused + { + let ring = outer_rect.expand( theme::FOCUS_W + 2.0 ); + canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, outer_r + theme::FOCUS_W + 2.0 ); + } + + if let Some( ref label ) = self.label + { + let text_x = rect.x + theme::OUTER_SIZE + theme::GAP; + let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0; + canvas.draw_text( label, text_x, text_y, theme::FONT_SIZE, theme::label_color() ); + } + } + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> Radio + where + U: Clone + 'static, + Msg: 'static, + { + Radio + { + selected: self.selected, + on_select: self.on_select.map( |m| ( *f )( m ) ), + label: self.label, + id: self.id, + } + } +} + +/// Create a [`Radio`] option in the given state. +/// +/// Shorthand for [`Radio::new`]. See the [`Radio`] type-level docs for +/// the full mutual-exclusion pattern across multiple options. +pub fn radio( selected: bool ) -> Radio +{ + Radio::new( selected ) +} + +impl From> for Element +{ + fn from( r: Radio ) -> Self + { + Element::Radio( r ) + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn radio_default_state() + { + let r = radio::<()>( true ); + assert!( r.selected ); + assert!( r.on_select.is_none() ); + } + + #[test] + fn radio_unselected() + { + let r = radio::<()>( false ); + assert!( !r.selected ); + } +} diff --git a/src/widget/scroll.rs b/src/widget/scroll.rs new file mode 100644 index 0000000..3f38584 --- /dev/null +++ b/src/widget/scroll.rs @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::render::Canvas; +use crate::types::WidgetId; +use crate::widget::Element; + +/// A vertically scrollable viewport that clips its child to its allocated rect. +/// +/// The child can be any element — typically a [`Column`](crate::layout::column::Column) +/// for lists or a [`WrapGrid`](crate::layout::wrap_grid::WrapGrid) for icon grids. +/// +/// Scroll offset is driven by touch/pointer drag gestures within the viewport. +/// Dragging inside a `Scroll` does **not** trigger the app-level swipe-up gesture. +/// +/// ```rust,no_run +/// # use std::sync::Arc; +/// # use ltk::{ column, grid, icon_button, scroll, text, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex( rgba: Arc>, w: u32, h: u32 ) -> ( Element, Element ) { +/// // Scrollable list +/// let list = scroll( column().spacing( 8.0 ).push( text( "Item 1" ) ).push( text( "Item 2" ) ) ); +/// +/// // App-drawer style grid +/// let drawer = scroll( grid( 4 ).padding( 16.0 ).spacing( 12.0 ).push( icon_button( rgba, w, h ) ) ); +/// # ( list.into(), drawer.into() ) +/// # } +/// ``` +pub struct Scroll +{ + /// The single child element drawn inside the scrollable viewport. + pub child: Box>, + /// Optional stable identifier — used as scroll state key. + pub id: Option, +} + +impl Scroll +{ + /// Assign a stable identifier to this scroll widget. + pub fn id( mut self, id: WidgetId ) -> Self + { + self.id = Some( id ); + self + } + + /// Returns `(max_width, 0.0)` — the Scroll node claims all remaining space in + /// the parent layout, exactly like a [`Spacer`](crate::layout::spacer::Spacer). + /// The actual viewport height is determined at render time from the rect the + /// parent assigns. + pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32) + { + ( max_width, 0.0 ) + } + + /// No-op — rendering is handled entirely by `layout_and_draw` in `draw.rs`. + pub fn draw( &self ) {} + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> Scroll + where + U: Clone + 'static, + Msg: 'static, + { + Scroll + { + child: Box::new( self.child.map_arc( f ) ), + id: self.id, + } + } +} + +impl From> for Element +{ + fn from( s: Scroll ) -> Self + { + Element::Scroll( s ) + } +} + +/// Compute the clamped scroll offset given raw offset, content height, and viewport height. +/// +/// Extracted as a pure function so it can be unit-tested without a Wayland surface. +pub(crate) fn clamp_offset( offset: f32, content_h: f32, viewport_h: f32 ) -> f32 +{ + let max = (content_h - viewport_h).max( 0.0 ); + offset.clamp( 0.0, max ) +} + +#[cfg(test)] +mod tests +{ + use super::clamp_offset; + + #[test] + fn offset_zero_when_content_fits() + { + // Content shorter than viewport — no scrolling possible + assert_eq!( clamp_offset( 50.0, 300.0, 500.0 ), 0.0 ); + } + + #[test] + fn offset_clamped_to_zero_when_negative() + { + assert_eq!( clamp_offset( -10.0, 600.0, 400.0 ), 0.0 ); + } + + #[test] + fn offset_clamped_to_max() + { + // max = 600 - 400 = 200; offset 999 → clamped to 200 + assert_eq!( clamp_offset( 999.0, 600.0, 400.0 ), 200.0 ); + } + + #[test] + fn offset_within_range_unchanged() + { + // max = 600 - 400 = 200; offset 100 stays 100 + assert_eq!( clamp_offset( 100.0, 600.0, 400.0 ), 100.0 ); + } + + #[test] + fn zero_offset_stays_zero() + { + assert_eq!( clamp_offset( 0.0, 600.0, 400.0 ), 0.0 ); + } + + #[test] + fn exact_max_offset_is_valid() + { + assert_eq!( clamp_offset( 200.0, 600.0, 400.0 ), 200.0 ); + } + + #[test] + fn content_equal_to_viewport_gives_zero_max() + { + // No overflow — max = 0 + assert_eq!( clamp_offset( 10.0, 400.0, 400.0 ), 0.0 ); + } +} + +/// Create a scrollable viewport wrapping `child`. +/// +/// The parent layout controls the viewport size by assigning a rect to this widget. +/// Content that overflows vertically is scrolled via drag gesture. +/// +/// ```rust,no_run +/// # use ltk::{ column, scroll, text, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex() -> Element { +/// scroll( column().push( text( "A" ) ).push( text( "B" ) ) ) +/// .into() +/// # } +/// ``` +pub fn scroll( child: impl Into> ) -> Scroll +{ + Scroll { child: Box::new( child.into() ), id: None } +} diff --git a/src/widget/separator.rs b/src/widget/separator.rs new file mode 100644 index 0000000..56b9351 --- /dev/null +++ b/src/widget/separator.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use crate::types::{ Color, Rect }; +use crate::render::Canvas; +use super::Element; + +mod theme +{ + use crate::types::Color; + pub fn color() -> Color { crate::theme::palette().divider } + pub const THICKNESS: f32 = 1.0; + pub const PAD_V: f32 = 8.0; +} + +/// A horizontal divider line. +/// +/// Renders a 1 px (default) line across the full width of its layout rect, +/// with vertical padding above and below. Use to break a column into +/// visual sections — between settings groups, list categories or content +/// blocks. The line takes the divider colour from the active theme by +/// default; override with [`Self::color`] for custom palettes. +/// +/// ```rust,no_run +/// # use ltk::{ column, separator, text, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex() -> Element { +/// // In view(): +/// column() +/// .push( text( "General" ) ) +/// .push( separator() ) +/// .push( text( "Network" ) ) +/// .into() +/// # } +/// ``` +pub struct Separator +{ + /// Stroke colour. Defaults to the theme's `divider` palette slot. + pub color: Color, + /// Line thickness in logical pixels. + pub thickness: f32, + /// Vertical padding above and below the line, baked into + /// `preferred_size` so the divider visually centres in the row a + /// parent column allocates. + pub pad_v: f32, +} + +impl Separator +{ + /// Create a separator with the theme's default divider colour and + /// 1 px thickness. + pub fn new() -> Self + { + Self + { + color: theme::color(), + thickness: theme::THICKNESS, + pad_v: theme::PAD_V, + } + } + + /// Override the stroke colour. Useful for emphasised dividers between + /// destructive actions. + pub fn color( mut self, color: Color ) -> Self + { + self.color = color; + self + } + + /// Override the line thickness. Defaults to 1 logical pixel. + pub fn thickness( mut self, t: f32 ) -> Self + { + self.thickness = t; + self + } + + /// Return the preferred `(width, height)`. Width is `max_width`; + /// height is `thickness + 2 × pad_v`. + pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32) + { + ( max_width, self.thickness + self.pad_v * 2.0 ) + } + + /// Draw the divider line into `canvas` at `rect`. + pub fn draw( &self, canvas: &mut Canvas, rect: Rect ) + { + let y = rect.y + rect.height / 2.0; + canvas.draw_line( rect.x, y, rect.x + rect.width, y, self.color, self.thickness ); + } +} + +/// Create a default [`Separator`] (theme divider colour, 1 px thickness). +/// +/// ```rust,no_run +/// # use ltk::{ column, separator, text, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex() -> Element { +/// column() +/// .push( text( "Section A" ) ) +/// .push( separator() ) +/// .push( text( "Section B" ) ) +/// .into() +/// # } +/// ``` +pub fn separator() -> Separator +{ + Separator::new() +} + +impl From for Element +{ + fn from( s: Separator ) -> Self + { + Element::Separator( s ) + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn default_thickness() + { + let s = separator(); + assert_eq!( s.thickness, theme::THICKNESS ); + } + + #[test] + fn preferred_height_includes_padding() + { + let s = separator(); + let ( _, h ) = s.preferred_size( 200.0 ); + assert_eq!( h, theme::THICKNESS + theme::PAD_V * 2.0 ); + } +} diff --git a/src/widget/slider.rs b/src/widget/slider.rs new file mode 100644 index 0000000..7edfd85 --- /dev/null +++ b/src/widget/slider.rs @@ -0,0 +1,541 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use std::sync::Arc; +use crate::types::{ Point, Rect }; +use crate::render::Canvas; +use super::Element; + +/// Which axis a slider tracks. Used by input dispatch to pick the right +/// `value_from_*_in_rect` formula for a [`Slider`] (horizontal) or +/// [`crate::widget::vslider::VSlider`] (vertical). +#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] +pub enum SliderAxis +{ + Horizontal, + Vertical, +} + +/// Compute the slider value `[0.0, 1.0]` from a pointer position within a +/// slider's layout rect, dispatching to the axis-specific formula. +/// +/// Exposed alongside [`value_from_x_in_rect`] so `input.rs` can drive both +/// [`Slider`] and [`crate::widget::vslider::VSlider`] through the same call +/// site by consulting the [`SliderAxis`] stored in the widget's handler +/// snapshot. +pub fn value_from_pos_in_rect( rect: Rect, pos: Point, axis: SliderAxis ) -> f32 +{ + match axis + { + SliderAxis::Horizontal => value_from_x_in_rect( rect, pos.x ), + SliderAxis::Vertical => crate::widget::vslider::value_from_y_in_rect( rect, pos.y ), + } +} + +mod theme +{ + use crate::types::Color; + pub fn track_bg() -> Color { crate::theme::palette().divider } + pub fn track_fill() -> Color { crate::theme::palette().accent } + /// Thumb — uses the page-background colour so it reads as the + /// inverse of the accent fill regardless of mode. + pub fn thumb() -> Color { crate::theme::palette().bg } + pub fn thumb_border() -> Color { crate::theme::palette().text_primary } + pub const TRACK_H: f32 = 10.0; + pub const THUMB_SIZE: f32 = 24.0; + pub const HEIGHT: f32 = 36.0; + pub const THUMB_BORDER_W: f32 = 2.0; + /// White ring + inner coloured pill of the [`super::Slider::accent_thumb`] + /// variant. Outer diameter is [`ACCENT_THUMB_OUTER`]; the visible + /// pill is smaller than [`THUMB_SIZE`], which is the hit-target / + /// reserved layout footprint shared with the default thumb. + pub const ACCENT_THUMB_BORDER_W: f32 = 4.0; + pub const ACCENT_THUMB_OUTER: f32 = 20.0; + + /// Default theme slot id for the [`Slider`](super::Slider) track + /// background. Mirrors the equivalent constant in `vslider::theme` + /// so the same default-theme entries cover both axes — only the + /// rendering geometry differs between the widgets. + pub const SURFACE_TRACK: &str = "surface-slider-track"; + /// Default theme slot id for the [`Slider`](super::Slider) fill. + pub const SURFACE_FILL: &str = "surface-slider-fill"; +} + +/// Intersect `inner` with the `saved` outer clip and return the rect +/// list to install with [`crate::render::Canvas::set_clip_rects`]. +/// +/// `saved` is the snapshot returned by +/// [`crate::render::Canvas::clip_bounds`]: empty means "no clip is +/// installed" (paint everywhere), non-empty is the outer scissor list +/// — typically the partial-redraw damage rects. +/// +/// `Slider` / [`crate::widget::vslider::VSlider`] use this when they +/// need to clip the active-side fill paint to a band tighter than the +/// widget rect. Calling `set_clip_rects( &[ band ] )` directly would +/// REPLACE the outer clip, causing the fill to repaint outside the +/// damage region during partial redraws and clobbering pixels (most +/// visibly the thumb's white interior left from the previous frame). +/// Routing through this helper preserves the outer clip by +/// intersecting with it. +/// +/// Returns `Vec::new()` when there is no overlap; callers must skip +/// the paint entirely in that case — installing an empty rect list +/// means "no clip" which would paint outside the damage region. +pub( crate ) fn intersect_clip( saved: &[ Rect ], inner: Rect ) -> Vec +{ + if saved.is_empty() + { + return vec![ inner ]; + } + saved.iter().filter_map( |r| + { + let x0 = inner.x.max( r.x ); + let y0 = inner.y.max( r.y ); + let x1 = ( inner.x + inner.width ).min( r.x + r.width ); + let y1 = ( inner.y + inner.height ).min( r.y + r.height ); + if x1 <= x0 || y1 <= y0 + { + None + } + else + { + Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } ) + } + } ).collect() +} + +/// Compute the slider value `[0.0, 1.0]` from a tap/drag x position within a +/// slider's layout rect. Pure — depends only on `theme::THUMB_SIZE`. Lifted +/// out of [`Slider`] so input handlers can call it directly from +/// [`crate::widget::LaidOutWidget`] without needing the [`Element`] tree. +pub fn value_from_x_in_rect( rect: Rect, x: f32 ) -> f32 +{ + let pad = theme::THUMB_SIZE / 2.0; + let track_start = rect.x + pad; + let track_end = rect.x + rect.width - pad; + let track_w = ( track_end - track_start ).max( 1.0 ); + ( ( x - track_start ) / track_w ).clamp( 0.0, 1.0 ) +} + +/// A horizontal slider for selecting a value in a range. +/// +/// The track defaults to a theme-resolved surface; pass +/// [`Self::track_paint`] to override with a custom +/// [`Paint`](crate::theme::Paint) — typically a multi-stop linear +/// gradient for a hue / spectrum picker. When `track_paint` is set +/// the active-side fill is suppressed (the spectrum already +/// communicates the position visually through colour). +/// +/// ```rust,no_run +/// # use ltk::{ slider, Slider }; +/// # #[ derive( Clone ) ] enum Msg { SetBrightness( f32 ) } +/// # struct App { brightness: f32 } +/// # impl App { fn _ex( &self ) -> Slider { +/// slider( self.brightness ) +/// .on_change( |v| Msg::SetBrightness( v ) ) +/// # }} +/// ``` +pub struct Slider +{ + /// Current value in `[0.0, 1.0]`. + pub value: f32, + /// Callback invoked with the new value when the slider is dragged. + /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf + /// handler snapshot for O(1) dispatch on input events. + pub on_change: Option Msg>>, + /// Theme slot id for the track background pill. Defaults to the + /// generic `surface-slider-track`; override per-instance to opt + /// into the `-flat` (no per-surface backdrop) variant when the + /// slider already lives inside a panel-wide blur, or any other + /// custom slot. + pub track_surface: &'static str, + /// Theme slot id for the rising / leftward fill. Same shape as + /// [`Self::track_surface`] but for the active portion. + pub fill_surface: &'static str, + /// Paint the thumb with the active palette's `accent` colour and + /// a thicker white border (accent inner pill + 4 px white ring) + /// instead of the default white-on-text-primary thumb. + pub accent_thumb: bool, + /// Override the track paint with a custom + /// [`Paint`](crate::theme::Paint) — typically a + /// [`Paint::Linear`](crate::theme::Paint::Linear) for spectrum / + /// hue pickers. When set, the active-side fill is suppressed + /// because a spectrum already conveys position information + /// through colour and the thumb shows where the user is. + pub track_paint: Option, +} + +impl Slider +{ + /// Create a slider with the given value (clamped to `[0.0, 1.0]`). + pub fn new( value: f32 ) -> Self + { + Self + { + value: value.clamp( 0.0, 1.0 ), + on_change: None, + track_surface: theme::SURFACE_TRACK, + fill_surface: theme::SURFACE_FILL, + accent_thumb: false, + track_paint: None, + } + } + + /// Override the track paint with a custom + /// [`Paint`](crate::theme::Paint) — e.g. a multi-stop linear + /// gradient for a hue / spectrum picker. Disables the active- + /// side fill (the spectrum already encodes the position + /// visually). + pub fn track_paint( mut self, paint: crate::theme::Paint ) -> Self + { + self.track_paint = Some( paint ); + self + } + + /// Set the callback invoked when the slider value changes. + pub fn on_change( mut self, f: impl Fn(f32) -> Msg + 'static ) -> Self + { + self.on_change = Some( Arc::new( f ) ); + self + } + + /// Override the theme slot id used to paint the track background + /// — pass `surface-slider-track-flat` (or any other surface) to + /// drop the per-instance backdrop blur when the slider already + /// lives inside a panel-wide blur. See + /// [`crate::widget::vslider::VSlider::track_surface`] for the use + /// case. + pub fn track_surface( mut self, id: &'static str ) -> Self + { + self.track_surface = id; + self + } + + /// Override the theme slot id used to paint the fill. Mirrors + /// [`Self::track_surface`]. + pub fn fill_surface( mut self, id: &'static str ) -> Self + { + self.fill_surface = id; + self + } + + /// Switch the thumb to the accent-pill style: accent-coloured inner + /// + 4 px white border. Default thumb stays white-on-text-primary + /// for shells that haven't opted in. + pub fn accent_thumb( mut self, on: bool ) -> Self + { + self.accent_thumb = on; + self + } + + /// Return the preferred `(width, height)`. + pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32) + { + ( max_width, theme::HEIGHT ) + } + + /// Compute the value `[0.0, 1.0]` from a tap/drag x position within `rect`. + pub fn value_from_x( &self, rect: Rect, x: f32 ) -> f32 + { + value_from_x_in_rect( rect, x ) + } + + /// Thumb border stroke is centered on the thumb edge (which touches the + /// widget's left/right edges at value 0 / 1), so half the stroke width + /// plus ~1 px of antialiasing sits outside `rect`. + pub fn paint_bounds( &self, rect: Rect ) -> Rect + { + let border_w = if self.accent_thumb { theme::ACCENT_THUMB_BORDER_W } else { theme::THUMB_BORDER_W }; + rect.expand( ( border_w * 0.5 + 1.0 ).max( surface_shadow_margin( self.track_surface ) ) ) + } + + /// Draw the slider into `canvas` at `rect`. + /// + /// Track + fill paint through theme slots ([`Self::track_surface`] + /// / [`Self::fill_surface`]) when those slots resolve in the + /// active theme — the track pill spans the full inner width and + /// the fill is anchored to the same pill but scissor-clipped to + /// the left band so insets stay positioned against the track rect + /// rather than a shrinking fill rect. Falls back to + /// `theme::track_bg()` / `theme::track_fill()` when the active + /// theme has no entry for either slot — a bare-bones third-party + /// theme still paints a usable slider. + pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool ) + { + let pad = theme::THUMB_SIZE / 2.0; + let track_y = rect.y + (rect.height - theme::TRACK_H) / 2.0; + let track_start = rect.x + pad; + let track_end = rect.x + rect.width - pad; + let track_w = track_end - track_start; + let radius_bg = theme::TRACK_H / 2.0; + + // Background track — full pill across the inner span. + let track_rect = Rect + { + x: track_start, + y: track_y, + width: track_w, + height: theme::TRACK_H, + }; + if let Some( paint ) = self.track_paint.as_ref() + { + // Custom paint override (hue spectrum, custom gradient, …) + // short-circuits the theme-slot resolution. The + // active-side fill is suppressed below because a + // spectrum already encodes the position via colour. + canvas.fill_paint_rect( track_rect, paint, radius_bg ); + } + else if let Some( ( surf, outer ) ) = crate::theme::resolve_surface( self.track_surface ) + { + canvas.fill_surface + ( + track_rect, + &surf.fill, + &outer, + &surf.inset_shadows, + radius_bg, + ); + } + else + { + canvas.fill_rect( track_rect, theme::track_bg(), radius_bg ); + } + + // Filled portion. Paint the surface across the FULL track rect + // and clip the visible band to the left-aligned slice. This + // keeps inset shadows / glass rims anchored to the track pill + // instead of to a shrinking fill rect — the highlights don't + // shift as the user drags. + // + // Suppressed entirely when `track_paint` is set: a custom + // spectrum already conveys position through colour, and a + // solid accent band painted over part of it would obscure + // half the spectrum and read as a rendering bug. + let fill_w = track_w * self.value; + if self.track_paint.is_none() && fill_w > 0.5 + { + let visible = Rect + { + x: track_start, + y: track_y, + width: fill_w, + height: theme::TRACK_H, + }; + let saved_clip = canvas.clip_bounds(); + let band = intersect_clip( &saved_clip, visible ); + if !band.is_empty() + { + canvas.set_clip_rects( &band ); + if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface ) + { + canvas.fill_paint_rect( track_rect, &surf.fill, radius_bg ); + // Drop insets with negative-Y offset — they live + // near the top of the track pill and would slice + // the fill at the active band edge. See + // `VSlider::draw` for the full rationale. + for inset in surf.inset_shadows.iter().filter( |s| s.offset[1] >= 0.0 ) + { + canvas.fill_shadow_inset( track_rect, inset, radius_bg ); + } + } + else + { + canvas.fill_rect( visible, theme::track_fill(), radius_bg ); + } + canvas.set_clip_rects( &saved_clip ); + } + } + + // Thumb. + let thumb_cx = track_start + fill_w; + let thumb_cy = rect.y + rect.height / 2.0; + if self.accent_thumb + { + // Inner pill paints with the track's active fill surface + // so the thumb's centre always matches the line colour. + let outer_r = theme::ACCENT_THUMB_OUTER / 2.0; + let inner_size = ( theme::ACCENT_THUMB_OUTER - 2.0 * theme::ACCENT_THUMB_BORDER_W ).max( 0.0 ); + let inner_r = inner_size / 2.0; + let outer_rect = Rect + { + x: thumb_cx - outer_r, + y: thumb_cy - outer_r, + width: theme::ACCENT_THUMB_OUTER, + height: theme::ACCENT_THUMB_OUTER, + }; + let inner_rect = Rect + { + x: thumb_cx - inner_r, + y: thumb_cy - inner_r, + width: inner_size, + height: inner_size, + }; + canvas.fill_rect( outer_rect, crate::theme::palette().bg, outer_r ); + if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface ) + { + canvas.fill_paint_rect( inner_rect, &surf.fill, inner_r ); + } + else + { + canvas.fill_rect( inner_rect, crate::theme::palette().accent, inner_r ); + } + } + else + { + let thumb_r = theme::THUMB_SIZE / 2.0; + let thumb_rect = Rect + { + x: thumb_cx - thumb_r, + y: thumb_cy - thumb_r, + width: theme::THUMB_SIZE, + height: theme::THUMB_SIZE, + }; + canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r ); + canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r ); + } + } + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> Slider + where + U: Clone + 'static, + Msg: 'static, + { + // Wrap the existing Arc Msg> in a fresh closure + // that pipes its result through the user's mapper. The original + // closure is captured by the new one through `Arc::clone` so the + // original Slider's storage stays valid. + let on_change = self.on_change.map( |old| -> Arc U> + { + let mapper = Arc::clone( f ); + Arc::new( move |v| ( *mapper )( ( *old )( v ) ) ) + } ); + Slider + { + value: self.value, + on_change, + track_surface: self.track_surface, + fill_surface: self.fill_surface, + accent_thumb: self.accent_thumb, + track_paint: self.track_paint, + } + } +} + +/// Create a [`Slider`] with the given value (clamped to `[0.0, 1.0]`). +pub fn slider( value: f32 ) -> Slider +{ + Slider::new( value ) +} + +fn surface_shadow_margin( surface: &str ) -> f32 +{ + crate::theme::resolve_surface( surface ) + .map( |( _, shadows )| + { + shadows.iter() + .map( |s| s.offset[0].abs().max( s.offset[1].abs() ) + s.blur.max( 0.0 ) + s.spread.max( 0.0 ) + 1.0 ) + .fold( 0.0, f32::max ) + } ) + .unwrap_or( 0.0 ) +} + +impl From> for Element +{ + fn from( s: Slider ) -> Self + { + Element::Slider( s ) + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + use crate::types::Rect; + + #[test] + fn value_clamped_on_creation() + { + let s = slider::<()>( 1.5 ); + assert_eq!( s.value, 1.0 ); + let s = slider::<()>( -0.5 ); + assert_eq!( s.value, 0.0 ); + } + + #[test] + fn value_from_x_left_edge() + { + let s = slider::<()>( 0.5 ); + let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; + let v = s.value_from_x( rect, 0.0 ); + assert_eq!( v, 0.0 ); + } + + #[test] + fn value_from_x_right_edge() + { + let s = slider::<()>( 0.5 ); + let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; + let v = s.value_from_x( rect, 200.0 ); + assert_eq!( v, 1.0 ); + } + + #[test] + fn value_from_x_center() + { + let s = slider::<()>( 0.0 ); + let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; + let v = s.value_from_x( rect, 100.0 ); + assert!( (v - 0.5).abs() < 0.1 ); + } + + #[test] + fn axis_dispatch_horizontal_uses_x() + { + let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 36.0 }; + let v = value_from_pos_in_rect( + rect, + Point { x: 200.0, y: 9999.0 }, + SliderAxis::Horizontal, + ); + assert_eq!( v, 1.0 ); + } + + #[test] + fn axis_dispatch_vertical_uses_y() + { + let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 }; + // y=0 at the top of a vertical rect is value=1.0, regardless of x. + let v = value_from_pos_in_rect( + rect, + Point { x: 9999.0, y: 0.0 }, + SliderAxis::Vertical, + ); + assert_eq!( v, 1.0 ); + } + + #[test] + fn track_paint_default_is_none() + { + let s = slider::<()>( 0.5 ); + assert!( s.track_paint.is_none() ); + } + + #[test] + fn track_paint_builder_stores_paint() + { + use crate::theme::{ ColorStop, GradientSpace, LinearGradient, Paint }; + use crate::types::Color; + let g = Paint::Linear( LinearGradient + { + angle_deg: 90.0, + stops: vec![ + ColorStop { position: 0.0, color: Color::rgba( 1.0, 0.0, 0.0, 1.0 ) }, + ColorStop { position: 1.0, color: Color::rgba( 0.0, 0.0, 1.0, 1.0 ) }, + ], + space: GradientSpace::Srgb, + } ); + let s = slider::<()>( 0.5 ).track_paint( g ); + assert!( s.track_paint.is_some() ); + } +} diff --git a/src/widget/spinner.rs b/src/widget/spinner.rs new file mode 100644 index 0000000..11b68bd --- /dev/null +++ b/src/widget/spinner.rs @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Spinner — indeterminate progress indicator. +//! +//! A rotating arc that the renderer draws by stroking a fraction of a +//! circle and offsetting its starting angle by [`Spinner::phase`]. The +//! widget is *stateless*: the application owns the phase variable and +//! advances it from a clock or animation tick. Pair with +//! [`App::is_animating`](crate::App::is_animating) so the run loop keeps +//! requesting redraws while the spinner is on screen. +//! +//! ```rust,no_run +//! # use ltk::{ row, spinner, text, Element }; +//! # #[ derive( Clone ) ] enum Msg {} +//! # struct App { tick: f32 } +//! # impl App { fn _ex( &self ) -> Element { +//! // In view(): +//! row() +//! .push( spinner().phase( self.tick ) ) +//! .push( text( "Loading…" ) ) +//! .into() +//! # }} +//! ``` + +use crate::types::{ Color, Rect }; +use crate::render::Canvas; +use super::Element; + +mod theme +{ + use crate::types::Color; + pub fn fill() -> Color { crate::theme::palette().accent } + pub fn track() -> Color + { + // Quarter-opacity copy of the accent so the moving arc reads + // against a faint guide ring instead of empty surface. + let a = crate::theme::palette().accent; + Color::rgba( a.r, a.g, a.b, 0.20 ) + } + pub const SIZE: f32 = 32.0; + pub const STROKE_W: f32 = 3.0; + /// Fraction of the full circle that the moving arc covers. + pub const ARC_FRAC: f32 = 0.30; + /// Number of straight segments used to approximate one full circle. + /// 36 is enough for a smooth arc at the default 32 px diameter and + /// keeps the per-frame draw call count low. + pub const SEGMENTS: u32 = 36; +} + +/// An indeterminate progress spinner. +/// +/// Renders a rotating arc inside a square layout rect. The application +/// drives the rotation by passing a monotonically-increasing `phase` +/// value (any units — only the fractional part of `phase` is used). +pub struct Spinner +{ + /// Rotation phase. Only the fractional part is consumed, so any + /// monotonically increasing source works (`elapsed.as_secs_f32()`, + /// frame count divided by FPS, etc.). + pub phase: f32, + /// Arc / ring colour. Defaults to the theme's `accent` palette slot. + pub color: Color, + /// Square diameter in logical pixels. Both width and height of the + /// laid-out rect target this size. + pub size: f32, + /// Stroke width of the arc and the dim guide ring. + pub stroke_w: f32, +} + +impl Spinner +{ + /// Create a spinner with the theme's accent colour and default size. + pub fn new() -> Self + { + Self + { + phase: 0.0, + color: theme::fill(), + size: theme::SIZE, + stroke_w: theme::STROKE_W, + } + } + + /// Set the rotation phase. The widget consumes only the fractional + /// part, so callers can pass an unbounded monotonic clock value. + pub fn phase( mut self, p: f32 ) -> Self + { + self.phase = p; + self + } + + /// Override the arc colour. + pub fn color( mut self, c: Color ) -> Self + { + self.color = c; + self + } + + /// Set the square diameter in logical pixels. + pub fn size( mut self, s: f32 ) -> Self + { + self.size = s; + self + } + + /// Set the arc / ring stroke width in logical pixels. + pub fn stroke_width( mut self, w: f32 ) -> Self + { + self.stroke_w = w; + self + } + + /// Return the preferred `(width, height)` — the spinner is square. + pub fn preferred_size( &self, max_width: f32 ) -> ( f32, f32 ) + { + let s = self.size.min( max_width ); + ( s, s ) + } + + /// Draw the spinner into `canvas` at `rect`. The arc is centred on + /// `rect`'s minor diameter so the widget renders correctly even when + /// laid out into a non-square cell. + pub fn draw( &self, canvas: &mut Canvas, rect: Rect ) + { + let cx = rect.x + rect.width * 0.5; + let cy = rect.y + rect.height * 0.5; + let r = ( rect.width.min( rect.height ) - self.stroke_w ) * 0.5; + if r <= 0.0 { return; } + + let track = theme::track(); + let n = theme::SEGMENTS as i32; + let two_pi = std::f32::consts::TAU; + let dim = two_pi / n as f32; + + // Dim guide ring (full circle as polyline). + for i in 0..n + { + let a0 = i as f32 * dim; + let a1 = ( i + 1 ) as f32 * dim; + let x0 = cx + r * a0.cos(); + let y0 = cy + r * a0.sin(); + let x1 = cx + r * a1.cos(); + let y1 = cy + r * a1.sin(); + canvas.draw_line( x0, y0, x1, y1, track, self.stroke_w ); + } + + // Moving arc. `phase` is consumed modulo 1.0. + let frac = self.phase - self.phase.floor(); + let start_ang = frac * two_pi; + let end_ang = start_ang + theme::ARC_FRAC * two_pi; + let arc_segs = ( ( theme::ARC_FRAC * n as f32 ).round() as i32 ).max( 1 ); + for i in 0..arc_segs + { + let a0 = start_ang + i as f32 * dim; + let a1 = ( start_ang + ( i + 1 ) as f32 * dim ).min( end_ang ); + let x0 = cx + r * a0.cos(); + let y0 = cy + r * a0.sin(); + let x1 = cx + r * a1.cos(); + let y1 = cy + r * a1.sin(); + canvas.draw_line( x0, y0, x1, y1, self.color, self.stroke_w ); + } + } +} + +impl Default for Spinner +{ + fn default() -> Self { Self::new() } +} + +/// Create a [`Spinner`] with default size and theme colour. +pub fn spinner() -> Spinner +{ + Spinner::new() +} + +impl From for Element +{ + fn from( s: Spinner ) -> Self + { + Element::Spinner( s ) + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn defaults() + { + let s = spinner(); + assert_eq!( s.phase, 0.0 ); + assert_eq!( s.size, theme::SIZE ); + assert_eq!( s.stroke_w, theme::STROKE_W ); + } + + #[ test ] + fn builders_apply() + { + let s = spinner().phase( 0.42 ).size( 64.0 ).stroke_width( 5.0 ); + assert_eq!( s.phase, 0.42 ); + assert_eq!( s.size, 64.0 ); + assert_eq!( s.stroke_w, 5.0 ); + } + + #[ test ] + fn preferred_size_clamps_to_max_width() + { + let s = spinner().size( 100.0 ); + assert_eq!( s.preferred_size( 40.0 ), ( 40.0, 40.0 ) ); + assert_eq!( s.preferred_size( 200.0 ), ( 100.0, 100.0 ) ); + } + + #[ test ] + fn preferred_size_is_square() + { + let s = spinner(); + let ( w, h ) = s.preferred_size( 999.0 ); + assert_eq!( w, h ); + } +} diff --git a/src/widget/tab_bar.rs b/src/widget/tab_bar.rs new file mode 100644 index 0000000..c3aae5b --- /dev/null +++ b/src/widget/tab_bar.rs @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! TabBar — segmented selector built as a composition over existing +//! widgets. The widget itself is stateless: the application owns the +//! current selection (`usize`) and calls back through +//! [`TabBar::on_select`] when the user taps another tab. +//! +//! Returns an [`Element`] directly via [`TabBar::build`] / `Into`, so it +//! drops into any layout that accepts a child widget. +//! +//! ```rust,no_run +//! # use ltk::{ tabs, TabBar }; +//! # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) } +//! # struct App { tab: usize } +//! # impl App { fn _ex( &self ) -> TabBar { +//! tabs( [ "General", "Network", "Audio" ] ) +//! .selected( self.tab ) +//! .on_select( Msg::SelectTab ) +//! # }} +//! ``` + +use crate::types::Color; +use super::Element; + +mod theme +{ + pub const RADIUS: f32 = 100.0; + pub const PADDING: f32 = 4.0; + pub const SPACING: f32 = 4.0; +} + +/// Segmented horizontal tab selector. One row of pressable cells with +/// the active cell painted as a filled pill and inactive cells as plain +/// text. Build it from a slice of labels; produce an [`Element`] with +/// [`Self::build`] (or by `.into()`-ing it where an `Element` is +/// expected). +pub struct TabBar +{ + pub labels: Vec, + pub selected: usize, + pub on_select: Option Msg>>, + /// Background colour of the strip itself. `None` paints no + /// background, letting the parent surface show through. + pub strip_bg: Option, +} + +impl TabBar +{ + /// Build a tab bar from an iterable of labels. + pub fn new( labels: I ) -> Self + where + I: IntoIterator, + S: Into, + { + Self + { + labels: labels.into_iter().map( Into::into ).collect(), + selected: 0, + on_select: None, + strip_bg: Some( crate::theme::palette().surface_alt ), + } + } + + /// Index of the currently selected tab. Out-of-range values are + /// drawn as "no tab active". + pub fn selected( mut self, idx: usize ) -> Self + { + self.selected = idx; + self + } + + /// Callback invoked with the index of the tapped tab. + pub fn on_select( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self + { + self.on_select = Some( std::sync::Arc::new( f ) ); + self + } + + /// Override the strip background colour. Pass `None` (via + /// [`Self::strip_bg_none`]) to disable the background entirely. + pub fn strip_bg( mut self, c: Color ) -> Self + { + self.strip_bg = Some( c ); + self + } + + /// Paint no strip background. Useful inside containers that already + /// provide their own surface chrome. + pub fn strip_bg_none( mut self ) -> Self + { + self.strip_bg = None; + self + } + + /// Build the [`Element`] tree representing this tab bar. Equivalent + /// to `Element::from( self )`. + pub fn build( self ) -> Element + { + use super::{ button, container }; + use super::button::ButtonVariant; + use crate::layout::row::row; + use crate::layout::spacer::spacer; + + let selected = self.selected; + let cb = self.on_select.clone(); + let labels = self.labels; + + let mut r = row::().padding( theme::PADDING ).spacing( theme::SPACING ); + for ( i, label ) in labels.into_iter().enumerate() + { + let is_active = i == selected; + let mut btn = button( label ); + if is_active + { + btn = btn.variant( ButtonVariant::Primary ); + } else { + btn = btn.variant( ButtonVariant::Tertiary ); + } + if let Some( ref f ) = cb + { + let f = f.clone(); + let msg = f( i ); + btn = btn.on_press( msg ); + } + r = r.push( btn ); + } + // Trailing spacer so the strip claims the full row width and the + // chips left-align inside it. Callers that want full-width tabs + // can wrap the result in a Row of `flex` children themselves. + r = r.push( spacer() ); + + match self.strip_bg + { + Some( bg ) => container( r ).background( bg ).radius( theme::RADIUS ).into(), + None => Element::Row( r ), + } + } +} + +impl From> for Element +{ + fn from( t: TabBar ) -> Self + { + t.build() + } +} + +/// Create a [`TabBar`] from any iterable of label-likes. +/// +/// ```rust,no_run +/// # use ltk::{ tabs, TabBar }; +/// # #[ derive( Clone ) ] enum Msg { SelectTab( usize ) } +/// # struct App { tab: usize } +/// # impl App { fn _ex( &self ) -> TabBar { +/// tabs( [ "Inbox", "Sent", "Drafts" ] ) +/// .selected( self.tab ) +/// .on_select( Msg::SelectTab ) +/// # }} +/// ``` +pub fn tabs( labels: I ) -> TabBar +where + Msg: Clone + 'static, + I: IntoIterator, + S: Into, +{ + TabBar::new( labels ) +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ derive( Clone, Debug, PartialEq, Eq ) ] + enum Msg { Pick( usize ) } + + #[ test ] + fn defaults() + { + let t: TabBar = TabBar::new( vec![ "a", "b", "c" ] ); + assert_eq!( t.labels.len(), 3 ); + assert_eq!( t.selected, 0 ); + assert!( t.on_select.is_none() ); + } + + #[ test ] + fn selected_builder() + { + let t: TabBar = TabBar::new( vec![ "a", "b" ] ).selected( 1 ); + assert_eq!( t.selected, 1 ); + } + + #[ test ] + fn on_select_invokes_callback() + { + let t: TabBar = TabBar::new( vec![ "a", "b" ] ).on_select( Msg::Pick ); + let cb = t.on_select.as_ref().expect( "callback set" ); + assert_eq!( cb( 1 ), Msg::Pick( 1 ) ); + } + + #[ test ] + fn strip_bg_none_disables_background() + { + let t: TabBar = TabBar::new( vec![ "a" ] ).strip_bg_none(); + assert!( t.strip_bg.is_none() ); + } +} diff --git a/src/widget/text.rs b/src/widget/text.rs new file mode 100644 index 0000000..b93043f --- /dev/null +++ b/src/widget/text.rs @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use std::sync::Arc; + +use fontdue::Font; + +use crate::theme::FontStyle; +use crate::types::{ Color, Rect }; +use crate::render::Canvas; +use super::Element; + +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub enum TextAlign +{ + Left, + Center, + Right, +} + +pub struct Text +{ + pub content: String, + pub size: f32, + pub color: Color, + pub align: TextAlign, + pub wrap: bool, + /// Optional `(family, weight, style)` override resolved through + /// the active theme's font registry on every draw. `None` keeps + /// the canvas default font (Sora Regular in `ltk-theme-default`). + pub font: Option<( String, u16, FontStyle )>, +} + +impl Text +{ + pub fn new( content: impl Into ) -> Self + { + Self + { + content: content.into(), + size: 16.0, + color: Color::WHITE, + align: TextAlign::Left, + wrap: false, + font: None, + } + } + + /// Set a `(family, weight, style)` override for this text node. + /// The triple is resolved through [`Canvas::font_for`] at draw + /// time, so missing weights fall through the registry's nearest- + /// match precedence. + pub fn font( mut self, family: impl Into, weight: u16, style: FontStyle ) -> Self + { + self.font = Some( ( family.into(), weight, style ) ); + self + } + + /// Shorthand for [`Self::font`] when the desired override is just + /// a weight on the default Sora family (the family `ltk-theme- + /// default` declares). + pub fn weight( mut self, weight: u16 ) -> Self + { + self.font = Some( ( "sora".to_string(), weight, FontStyle::Normal ) ); + self + } + + pub fn size( mut self, s: f32 ) -> Self + { + self.size = s; + self + } + + pub fn color( mut self, c: Color ) -> Self + { + self.color = c; + self + } + + pub fn align( mut self, a: TextAlign ) -> Self + { + self.align = a; + self + } + + pub fn align_center( mut self ) -> Self + { + self.align = TextAlign::Center; + self + } + + /// Enable word-wrapping. With `wrap = true` the text breaks on + /// whitespace at `max_width` and the widget reports the natural + /// height of all the resulting lines. With `wrap = false` (the + /// default) the text stays on one line and is truncated with an + /// ellipsis when it overflows. + pub fn wrap( mut self, w: bool ) -> Self + { + self.wrap = w; + self + } + + fn resolve_font( &self, canvas: &Canvas ) -> Option> + { + self.font.as_ref().map( |( family, weight, style )| + { + canvas.font_for( family, *weight, *style ) + } ) + } + + fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc> ) -> f32 + { + match font + { + Some( f ) => canvas.measure_text_with_font( text, self.size, f ), + None => canvas.measure_text( text, self.size ), + } + } + + fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc> ) -> f32 + { + match font + { + Some( f ) => f.metrics( ch, self.size * canvas.dpi_scale() ).advance_width, + None => canvas.font_metrics( ch, self.size ).advance_width, + } + } + + fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc> ) + { + match font + { + Some( f ) => canvas.draw_text_with_font( text, x, y, self.size, self.color, f ), + None => canvas.draw_text( text, x, y, self.size, self.color ), + } + } + + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) + { + let line_h = canvas.font_line_metrics( self.size ) + .map( |m| m.ascent - m.descent ) + .unwrap_or( self.size ); + let font = self.resolve_font( canvas ); + + if self.wrap + { + let lines = wrap_lines( &self.content, self.size, max_width, canvas, font.as_ref() ); + let h = line_h * lines.len().max( 1 ) as f32; + ( max_width, h ) + } + else + { + let w = ( self.measure( &self.content, canvas, font.as_ref() ) + 8.0 ).min( max_width ); + ( w, line_h ) + } + } + + pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool ) + { + let ascent = canvas.font_line_metrics( self.size ) + .map( |m| m.ascent ) + .unwrap_or( self.size * 0.8 ); + let line_h = canvas.font_line_metrics( self.size ) + .map( |m| m.ascent - m.descent ) + .unwrap_or( self.size ); + let font = self.resolve_font( canvas ); + + if self.wrap + { + let lines = wrap_lines( &self.content, self.size, rect.width, canvas, font.as_ref() ); + for ( i, line ) in lines.iter().enumerate() + { + let line_w = self.measure( line, canvas, font.as_ref() ); + let slack = ( rect.width - line_w ).max( 0.0 ); + let pad = 4.0_f32.min( slack ); + let tx = match self.align + { + TextAlign::Left => rect.x + pad, + TextAlign::Center => rect.x + slack / 2.0, + TextAlign::Right => rect.x + rect.width - line_w - pad, + }; + let ty = rect.y + ascent + line_h * i as f32; + self.paint( canvas, line, tx, ty, font.as_ref() ); + } + return; + } + + let text_w = self.measure( &self.content, canvas, font.as_ref() ); + + let display = if text_w > rect.width && rect.width > 0.0 + { + let ellipsis = "..."; + let ell_w = self.measure( ellipsis, canvas, font.as_ref() ); + let budget = rect.width - ell_w; + if budget <= 0.0 + { + ellipsis.to_string() + } + else + { + let mut accum = 0.0_f32; + let truncated: String = self.content.chars().take_while( |ch| + { + let cw = self.measure_char( *ch, canvas, font.as_ref() ); + accum += cw; + accum <= budget + } ).collect(); + format!( "{truncated}{ellipsis}" ) + } + } + else + { + self.content.clone() + }; + + let disp_w = self.measure( &display, canvas, font.as_ref() ); + let slack = ( rect.width - disp_w ).max( 0.0 ); + let pad = 4.0_f32.min( slack ); + let tx = match self.align + { + TextAlign::Left => rect.x + pad, + TextAlign::Center => rect.x + slack / 2.0, + TextAlign::Right => rect.x + rect.width - disp_w - pad, + }; + + let ty = rect.y + ascent; + self.paint( canvas, &display, tx, ty, font.as_ref() ); + } +} + +/// Greedy word-wrap: split `text` on whitespace and pack words into +/// lines whose total width stays under `max_width`. A word longer +/// than `max_width` overflows on its own line rather than being +/// hyphenated. Routes through the font override when one is set so +/// measurement and rendering agree on advance widths. +fn wrap_lines( text: &str, size: f32, max_width: f32, canvas: &Canvas, font: Option<&Arc> ) -> Vec +{ + if max_width <= 0.0 || text.is_empty() + { + return vec![ text.to_string() ]; + } + let measure = |s: &str| -> f32 + { + match font + { + Some( f ) => canvas.measure_text_with_font( s, size, f ), + None => canvas.measure_text( s, size ), + } + }; + let space_w = measure( " " ); + let mut lines = Vec::new(); + let mut current = String::new(); + let mut current_w = 0.0_f32; + for word in text.split_whitespace() + { + let word_w = measure( word ); + if current.is_empty() + { + current.push_str( word ); + current_w = word_w; + } + else if current_w + space_w + word_w <= max_width + { + current.push( ' ' ); + current.push_str( word ); + current_w += space_w + word_w; + } + else + { + lines.push( std::mem::take( &mut current ) ); + current.push_str( word ); + current_w = word_w; + } + } + if !current.is_empty() { lines.push( current ); } + if lines.is_empty() { lines.push( String::new() ); } + lines +} + +impl From for Element +{ + fn from( t: Text ) -> Self + { + Element::Text( t ) + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::render::Canvas; + + fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } + + #[ test ] + fn new_uses_documented_defaults() + { + let t = Text::new( "hello" ); + assert_eq!( t.content, "hello" ); + assert_eq!( t.size, 16.0 ); + assert_eq!( t.color, Color::WHITE ); + assert_eq!( t.align, TextAlign::Left ); + } + + #[ test ] + fn size_builder_overrides_default() + { + let t = Text::new( "" ).size( 32.0 ); + assert_eq!( t.size, 32.0 ); + } + + #[ test ] + fn color_builder_overrides_default() + { + let t = Text::new( "" ).color( Color::rgb( 1.0, 0.0, 0.0 ) ); + assert_eq!( t.color, Color::rgb( 1.0, 0.0, 0.0 ) ); + } + + #[ test ] + fn align_builder_can_target_each_variant() + { + assert_eq!( Text::new( "" ).align( TextAlign::Left ).align, TextAlign::Left ); + assert_eq!( Text::new( "" ).align( TextAlign::Center ).align, TextAlign::Center ); + assert_eq!( Text::new( "" ).align( TextAlign::Right ).align, TextAlign::Right ); + } + + #[ test ] + fn align_center_shorthand_matches_explicit_align() + { + let a = Text::new( "" ).align_center(); + let b = Text::new( "" ).align( TextAlign::Center ); + assert_eq!( a.align, b.align ); + } + + #[ test ] + fn preferred_size_returns_non_negative_dimensions() + { + let canvas = make_canvas(); + let ( w, h ) = Text::new( "ltk" ).preferred_size( 200.0, &canvas ); + assert!( w >= 0.0 ); + assert!( h > 0.0, "non-empty text reports a positive line height" ); + } + + #[ test ] + fn preferred_size_caps_width_at_max_width() + { + // A long string must not exceed the parent-supplied bound — text + // elides at draw time, so the layout pass needs to see at most + // `max_width`. + let canvas = make_canvas(); + let long = "the quick brown fox jumps over the lazy dog"; + let ( w, _ ) = Text::new( long ).size( 24.0 ).preferred_size( 64.0, &canvas ); + assert!( w <= 64.0, "preferred width must be clamped to max_width (got {w})" ); + } + + #[ test ] + fn preferred_size_height_scales_with_font_size() + { + let canvas = make_canvas(); + let ( _, h_small ) = Text::new( "x" ).size( 12.0 ).preferred_size( 200.0, &canvas ); + let ( _, h_large ) = Text::new( "x" ).size( 48.0 ).preferred_size( 200.0, &canvas ); + assert!( h_large > h_small, "larger font must report taller line height" ); + } + + #[ test ] + fn empty_content_still_reports_a_line_height() + { + let canvas = make_canvas(); + let ( _, h ) = Text::new( "" ).preferred_size( 200.0, &canvas ); + // Even an empty string keeps the baseline metrics so a hidden field + // keeps its row height inside a column. + assert!( h > 0.0 ); + } + + #[ test ] + fn text_align_enum_implements_partial_eq() + { + // Compile-time guard: TextAlign must keep deriving PartialEq so + // downstream theming code can compare alignments. + assert_eq!( TextAlign::Left, TextAlign::Left ); + assert_ne!( TextAlign::Left, TextAlign::Right ); + } +} diff --git a/src/widget/text_edit.rs b/src/widget/text_edit.rs new file mode 100644 index 0000000..b70293a --- /dev/null +++ b/src/widget/text_edit.rs @@ -0,0 +1,2029 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use std::sync::Arc; +use crate::types::{ Rect, WidgetId }; +use crate::render::Canvas; +use crate::secure_mem::secure_zero; +use super::Element; + +/// One visual row of a wrapped multiline TextEdit. `start..end` is a +/// byte range inside the value; the renderer draws those bytes on a +/// single visual row, even if the underlying logical line (the run +/// between two `\n` characters) spans several rows. Soft wraps do +/// not mutate the buffer — the value stays a flat string with hard +/// breaks; only the rendering / hit-testing model treats wraps as +/// row boundaries. +#[ derive( Clone, Copy, Debug ) ] +pub( crate ) struct VisualLine +{ + pub start: usize, + pub end: usize, +} + +/// Wrap `value` into a list of visual lines that each fit within +/// `inner_width` when rendered at `font_size`. Hard `\n` breaks are +/// always row boundaries; long logical lines are additionally split +/// at the last whitespace before the row would overflow, falling +/// back to a per-character break inside a single oversized word. +/// +/// O(N) per call where N is `value.len()` — `draw_multiline` runs +/// it once per frame, which is fine for the kilobyte-scale buffers a +/// `text_edit` is meant to hold (anything larger should use a +/// dedicated text-area widget with cached layout). +pub( crate ) fn compute_visual_lines( + canvas: &Canvas, + value: &str, + inner_width: f32, + font_size: f32, +) -> Vec +{ + let mut out = Vec::new(); + if value.is_empty() + { + out.push( VisualLine { start: 0, end: 0 } ); + return out; + } + let max_w = inner_width.max( 1.0 ); + let mut byte_pos = 0usize; + for logical_line in value.split( '\n' ) + { + let line_start = byte_pos; + let line_end = byte_pos + logical_line.len(); + if line_start == line_end + { + // Empty logical line still occupies a row. + out.push( VisualLine { start: line_start, end: line_end } ); + } else { + wrap_logical_line( canvas, value, line_start, line_end, max_w, font_size, &mut out ); + } + byte_pos = line_end + 1; // step past the '\n' + } + out +} + +fn wrap_logical_line( + canvas: &Canvas, + value: &str, + start: usize, + end: usize, + max_width: f32, + font_size: f32, + out: &mut Vec, +) +{ + let mut cur = start; + while cur < end + { + let segment = &value[cur..end]; + let break_at = find_wrap_offset( canvas, segment, max_width, font_size ); + // Always advance at least one byte so a degenerate measure + // (zero-width font, ridiculously narrow rect) still + // terminates instead of looping forever. + let raw_next = cur + break_at; + let next = raw_next.max( cur + 1 ).min( end ); + out.push( VisualLine { start: cur, end: next } ); + cur = next; + } +} + +/// Walk `segment` left-to-right accumulating glyph widths and return +/// the byte offset (within `segment`) where the visual line should +/// break. Prefers a break *after* the most recent whitespace; falls +/// back to mid-character break if a single word is wider than +/// `max_width`. +fn find_wrap_offset( + canvas: &Canvas, + segment: &str, + max_width: f32, + font_size: f32, +) -> usize +{ + let mut acc_w = 0.0f32; + let mut last_break_after: Option = None; + for ( i, ch ) in segment.char_indices() + { + let glyph = ch.to_string(); + let w = canvas.measure_text( &glyph, font_size ); + if acc_w + w > max_width && i > 0 + { + return last_break_after.unwrap_or( i ); + } + acc_w += w; + if ch == ' ' || ch == '\t' + { + last_break_after = Some( i + ch.len_utf8() ); + } + } + segment.len() +} + +/// Axis-aligned intersection of two rects. Returns `None` when the rects +/// do not overlap (zero-area / negative-extent results count as +/// "no overlap" so the caller can `filter_map` straight into a clip +/// list). +fn rect_intersect( a: Rect, b: Rect ) -> Option +{ + let x0 = a.x.max( b.x ); + let y0 = a.y.max( b.y ); + let x1 = ( a.x + a.width ).min( b.x + b.width ); + let y1 = ( a.y + a.height ).min( b.y + b.height ); + if x1 > x0 && y1 > y0 + { + Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } ) + } else { + None + } +} + +/// Convert a pointer position inside the widget rect to the byte offset +/// in `value` the cursor should land on. Free function so the runtime +/// can call it with the value snapshot it already holds in +/// [`crate::widget::WidgetHandlers::TextEdit`] without re-walking the +/// element tree to find the original [`TextEdit`] struct. +/// +/// `multiline` and `secure` mirror the matching fields on the source +/// widget; `cursor_pos` is the current cursor — needed by the +/// multiline branch to reproduce the same vertical scroll the most +/// recent [`TextEdit::draw`] call computed. +pub( crate ) fn byte_offset_at( + canvas: &Canvas, + rect: Rect, + pos: crate::types::Point, + value: &str, + multiline: bool, + secure: bool, + cursor_pos: usize, + align: super::text::TextAlign, + font_size: f32, +) -> usize +{ + if value.is_empty() { return 0; } + + if multiline && !secure + { + let line_h = theme::FONT_SIZE * theme::LINE_H_MULT; + let inner_h = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h ); + let visible_lines = ( inner_h / line_h ).floor().max( 1.0 ) as usize; + let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE ); + + // Recompute the visual layout from current state. Same call + // as `draw_multiline` makes, so the click maps to the line / + // column the user actually sees. + let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE ); + let safe_cursor = cursor_pos.min( value.len() ); + let cursor_visual_idx = visual_lines.iter() + .position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end ) + .unwrap_or( visual_lines.len().saturating_sub( 1 ) ); + let total_lines = visual_lines.len(); + let max_first = total_lines.saturating_sub( visible_lines ); + let first_line = if cursor_visual_idx + 1 > visible_lines + { + ( cursor_visual_idx + 1 - visible_lines ).min( max_first ) + } else { 0 }; + + let visual_idx = ( ( pos.y - rect.y - theme::PAD_V_MULTI ) / line_h ) + .max( 0.0 ) as usize; + let target_idx = ( first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) ); + let vl = visual_lines[ target_idx ]; + + byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - theme::PAD_H, false, theme::FONT_SIZE ) + } else { + // Mirror the horizontal scroll *and* alignment the renderer + // applies so a click lands on the glyph the user actually + // sees, not on the byte offset that would correspond to an + // unscrolled, left-aligned layout. + let scroll_x = single_line_scroll_x( canvas, rect, value, cursor_pos, secure, font_size ); + let align_x = single_line_align_offset( canvas, rect, value, secure, align, font_size ); + byte_offset_in_line( + canvas, value, 0, value.len(), + pos.x - rect.x - theme::PAD_H - align_x + scroll_x, + secure, + font_size, + ) + } +} + +/// Horizontal scroll offset, in pixels, applied to a single-line +/// `text_edit` so the cursor stays visible inside the inner rect when +/// the value is wider than the box. Stateless — derived purely from +/// the current cursor position. Three regimes: +/// +/// * cursor in the **left half** of the visible band → no scroll, the +/// start of the text reads naturally; +/// * cursor in the **right half from the end** → anchor to end so the +/// tail of the value is in view (this is the "typing past the right +/// edge" case the user sees the most); +/// * cursor anywhere else → centre the cursor in the visible band so +/// navigation with the arrow keys keeps trailing context on both +/// sides instead of jumping the text on every keystroke. +/// +/// `secure` toggles bullet substitution before measuring so a +/// password field scrolls based on the displayed bullets, not the +/// underlying value's glyph widths. +pub( crate ) fn single_line_scroll_x( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, + secure: bool, + font_size: f32, +) -> f32 +{ + let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( font_size ); + let safe_cursor = cursor.min( value.len() ); + + let display_value: String = if secure + { + "\u{2022}".repeat( value.chars().count() ) + } else { + value.to_string() + }; + let prefix_text: String = if secure + { + "\u{2022}".repeat( value[..safe_cursor].chars().count() ) + } else { + value[..safe_cursor].to_string() + }; + + let total_w = canvas.measure_text( &display_value, font_size ); + if total_w <= inner_width { return 0.0; } + let prefix_w = canvas.measure_text( &prefix_text, font_size ); + let suffix_w = ( total_w - prefix_w ).max( 0.0 ); + const MARGIN: f32 = 4.0; + + if prefix_w <= inner_width / 2.0 + { + 0.0 + } else if suffix_w <= inner_width / 2.0 { + ( total_w - inner_width + MARGIN ).max( 0.0 ) + } else { + prefix_w - inner_width / 2.0 + } +} + +/// Horizontal offset, in pixels, applied on top of `single_line_scroll_x` +/// to honour the field's [`super::text::TextAlign`]. Only meaningful +/// while the value still fits inside `inner_width`; once the text +/// overflows, scrolling owns the layout and the alignment offset +/// collapses to `0` so anchoring the cursor / end of text reads +/// naturally without fighting the alignment. +pub( crate ) fn single_line_align_offset( + canvas: &Canvas, + rect: Rect, + value: &str, + secure: bool, + align: super::text::TextAlign, + font_size: f32, +) -> f32 +{ + use super::text::TextAlign; + if matches!( align, TextAlign::Left ) { return 0.0; } + let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( font_size ); + let display_value: String = if secure + { + "\u{2022}".repeat( value.chars().count() ) + } else { + value.to_string() + }; + let total_w = canvas.measure_text( &display_value, font_size ); + if total_w >= inner_width { return 0.0; } + match align + { + TextAlign::Left => 0.0, + TextAlign::Center => ( inner_width - total_w ) * 0.5, + TextAlign::Right => inner_width - total_w, + } +} + +/// Walk the slice `value[line_start..line_end]` char by char, +/// accumulating widths, and return the byte offset (within `value`) +/// where the click `target_x` falls. The "snap to nearest boundary" +/// rule is the standard `text input is text input` behaviour: if the +/// click is past the half-glyph mark, the cursor lands *after* the +/// glyph. `secure` toggles bullet substitution at measurement time so +/// the same logic works for password fields. +fn byte_offset_in_line( + canvas: &Canvas, + value: &str, + line_start: usize, + line_end: usize, + target_x: f32, + secure: bool, + font_size: f32, +) -> usize +{ + let line = &value[line_start..line_end]; + if target_x <= 0.0 { return line_start; } + let mut acc_w = 0.0f32; + let mut last_byte = line_start; + for ( ofs, ch ) in line.char_indices() + { + let glyph_str = if secure { "\u{2022}".to_string() } else { ch.to_string() }; + let w = canvas.measure_text( &glyph_str, font_size ); + if target_x < acc_w + w * 0.5 + { + return line_start + ofs; + } + acc_w += w; + last_byte = line_start + ofs + ch.len_utf8(); + } + last_byte.min( line_end ) +} + +/// Locate the visual line containing `cursor` in the wrap layout for +/// `value` inside `rect`. When the cursor sits exactly on a soft-wrap +/// boundary (so it satisfies both the previous line's `end` and the +/// next line's `start`), prefer the *previous* line — that matches the +/// rendering convention in `draw_multiline`, which paints the caret at +/// the trailing edge of the prior visual row rather than the leading +/// edge of the new one. +fn current_visual_line( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, +) -> Option<( Vec, usize )> +{ + let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE ); + let visual_lines = compute_visual_lines( canvas, value, inner_width, theme::FONT_SIZE ); + if visual_lines.is_empty() { return None; } + let safe_cursor = cursor.min( value.len() ); + let idx = visual_lines.iter() + .position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end ) + .unwrap_or( visual_lines.len() - 1 ); + Some( ( visual_lines, idx ) ) +} + +/// Move the text cursor one visual row up. Returns the new byte offset +/// or `None` when the cursor is already on the first visual row, so +/// the caller can fall through to sibling-widget keyboard navigation. +/// +/// Visual X is preserved across the move: the new cursor lands as close +/// as possible (with snap-to-nearest-glyph) to the same horizontal +/// position the cursor had on its origin line. No "preferred column" +/// state is kept across multiple consecutive Up presses, so a long +/// short → long sequence will drift toward the end of every short +/// line — same simplification GTK / Cocoa make for plain controls. +pub( crate ) fn cursor_visual_up( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, +) -> Option +{ + let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?; + if idx == 0 { return None; } + let cur = lines[ idx ]; + let safe = cursor.min( value.len() ); + let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE ); + let prev = lines[ idx - 1 ]; + Some( byte_offset_in_line( canvas, value, prev.start, prev.end, target_x, false, theme::FONT_SIZE ) ) +} + +/// Mirror of [`cursor_visual_up`] for the Down arrow. +pub( crate ) fn cursor_visual_down( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, +) -> Option +{ + let ( lines, idx ) = current_visual_line( canvas, rect, value, cursor )?; + if idx + 1 >= lines.len() { return None; } + let cur = lines[ idx ]; + let safe = cursor.min( value.len() ); + let target_x = canvas.measure_text( &value[cur.start..safe], theme::FONT_SIZE ); + let next = lines[ idx + 1 ]; + Some( byte_offset_in_line( canvas, value, next.start, next.end, target_x, false, theme::FONT_SIZE ) ) +} + +/// Byte offset of the start of the cursor's current visual line. +/// Falls back to `0` when the wrap layout cannot be computed (e.g. +/// degenerate rect), so the Home key always has a sensible target. +pub( crate ) fn cursor_visual_home( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, +) -> usize +{ + current_visual_line( canvas, rect, value, cursor ) + .map( |( lines, idx )| lines[ idx ].start ) + .unwrap_or( 0 ) +} + +/// Byte offset of the end of the cursor's current visual line. For a +/// hard-`\n`-terminated row this is the byte just before the newline; +/// for a soft-wrapped row it is the wrap point (which is also the +/// start of the next visual row, but the caret renders at the trailing +/// edge of *this* row by convention). +pub( crate ) fn cursor_visual_end( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, +) -> usize +{ + current_visual_line( canvas, rect, value, cursor ) + .map( |( lines, idx )| lines[ idx ].end ) + .unwrap_or( value.len() ) +} + +/// Hit rect for the eye icon at the right edge of a `TextEdit` +/// configured with [`TextEdit::password_toggle`]. Returned in the +/// same coordinate space as the field's `rect`. Pointer / touch +/// dispatch consult this before falling through to cursor placement +/// — a tap inside the zone fires the toggle message instead of +/// moving the caret. +pub fn password_toggle_hit_zone( rect: Rect ) -> Rect +{ + let zone_w = ( theme::PASSWORD_TOGGLE_SIZE + + theme::PASSWORD_TOGGLE_SLOP * 2.0 + + theme::PAD_H * 0.5 ).min( rect.width ); + Rect + { + x: rect.x + rect.width - zone_w, + y: rect.y, + width: zone_w, + height: rect.height, + } +} + +mod theme +{ + use crate::types::Color; + pub fn bg() -> Color { crate::theme::palette().surface_alt } + pub fn border() -> Color { crate::theme::palette().divider } + pub fn text() -> Color { crate::theme::palette().text_primary } + pub fn placeholder() -> Color { crate::theme::palette().text_secondary } + pub fn focus_border() -> Color { crate::theme::palette().text_primary } + pub fn cursor() -> Color { crate::theme::palette().text_primary } + /// Selection highlight — accent colour at low opacity so the text + /// underneath stays readable. + pub fn selection() -> Color + { + let a = crate::theme::palette().accent; + Color::rgba( a.r, a.g, a.b, 0.35 ) + } + pub const BORDER_W: f32 = 1.5; + pub const FOCUS_BORDER_W: f32 = 2.0; + pub const HEIGHT: f32 = 48.0; + pub const RADIUS: f32 = 100.0; + pub const FONT_SIZE: f32 = 16.0; + pub const PAD_H: f32 = 20.0; + /// Vertical padding inside multiline boxes — the single-line variant + /// centres the text vertically in `HEIGHT`, so it doesn't need this. + pub const PAD_V_MULTI: f32 = 12.0; + /// Line height multiplier on top of [`FONT_SIZE`] when laying out + /// multiline content. 1.4 leaves enough room above and below each + /// line that adjacent rows do not visually crowd each other. + pub const LINE_H_MULT: f32 = 1.4; + /// Default visible row count for a [`TextEdit`] in multiline mode. + pub const ROWS_DEFAULT: u32 = 5; + /// Corner radius applied to multiline boxes — the pill shape used by + /// the single-line variant looks wrong at multi-row heights. + pub const RADIUS_MULTI: f32 = 12.0; + /// Side length, in logical pixels, of the + /// [`super::TextEdit::password_toggle`] eye icon. + pub const PASSWORD_TOGGLE_SIZE: f32 = 20.0; + /// Extra horizontal slop on each side of the eye icon's hit zone + /// to make the toggle finger-friendly even on touch panels. + pub const PASSWORD_TOGGLE_SLOP: f32 = 4.0; +} + +/// A text input field. +/// +/// Single-line by default; switches to a multi-row text-area via +/// [`Self::multiline`]. Single-line mode honours the optional inline +/// builders for picker-style fields: +/// +/// * [`Self::align`] — horizontal alignment of the displayed text; +/// * [`Self::borderless`] — drop the surrounding pill / border so the +/// field can sit inside a parent that already paints its own +/// surface; +/// * [`Self::fixed_width`] — pin the preferred width to a specific +/// number of pixels instead of claiming `max_width`; +/// * [`Self::font_size`] — override the text font size; +/// * [`Self::select_on_focus`] — auto-select the value on focus so +/// the next keystroke replaces it (numeric pickers, short-form +/// inputs). +/// * [`Self::password_toggle`] — pin a built-in show / hide-password +/// eye icon to the right edge of the field; the bullet +/// substitution flips with the externally-owned `visible` state +/// on each tap. +/// +/// ```rust,no_run +/// # use ltk::{ text_edit, Element }; +/// # #[ derive( Clone ) ] enum Msg { UsernameChanged( String ), Submit } +/// # struct App { username: String } +/// # impl App { fn _ex( &self ) -> Element { +/// text_edit( "Username", &self.username ) +/// .on_change( |s| Msg::UsernameChanged( s ) ) +/// .on_submit( Msg::Submit ) +/// .into() +/// # }} +/// ``` +/// +/// ## Password field with show / hide toggle +/// +/// ```rust,no_run +/// # use ltk::{ text_edit, Element }; +/// # #[ derive( Clone ) ] enum Msg { PasswordChanged( String ), TogglePassword } +/// # struct App { password: String, password_visible: bool } +/// # impl App { fn _ex( &self ) -> Element { +/// text_edit( "Password", &self.password ) +/// .on_change( |s| Msg::PasswordChanged( s ) ) +/// .password_toggle( self.password_visible, Msg::TogglePassword ) +/// .into() +/// # }} +/// ``` +/// +/// `password_toggle` overrides [`Self::secure`] when both are set — +/// the toggle's `visible` parameter drives the bullet substitution +/// from then on. The widget still wipes the buffer on drop and +/// skips the IME registration (the same hardening +/// [`Self::secure`] gives) regardless of the current visibility, +/// so flipping the eye does not weaken the field's threat model +/// at runtime. +pub struct TextEdit +{ + /// Placeholder text shown when the field is empty. + pub placeholder: String, + /// Current field value. + pub value: String, + /// Callback invoked with the new value on every keystroke. + /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf + /// handler snapshot for O(1) dispatch on input events. + pub on_change: Option Msg>>, + /// Message emitted when the user presses Enter. + pub on_submit: Option, + /// When `true`, the value is rendered as bullet characters (password mode). + pub secure: bool, + /// When `true`, the widget renders as a multi-row text area: the + /// box grows to [`Self::rows`] visible rows, line breaks in the + /// value are honoured at draw time, and pressing Enter inserts a + /// `\n` rather than firing [`Self::on_submit`]. Ignored when + /// [`Self::secure`] is set — passwords are always single-line. + pub multiline: bool, + /// Visible row count when `multiline` is `true`. Drives + /// `preferred_size`'s height calculation so a multiline field + /// claims a sensible vertical slot in the parent layout. Ignored + /// when `multiline` is `false`. + pub rows: u32, + /// Byte offset of the text cursor within `value` (used by insert_str/backspace). + pub cursor_pos: usize, + /// Optional stable identifier for focus management. + pub id: Option, + /// Override the pointer cursor shape on hover. `None` falls back + /// to the I-beam default that matches every desktop convention. + pub cursor: Option, + /// Horizontal alignment of the displayed text inside the inner + /// content rect. Only takes effect on the single-line path when + /// the value fits inside the inner width — once the value + /// overflows, the internal `single_line_scroll_x` helper takes + /// over and the alignment offset collapses to `0` so scrolling + /// reads naturally. Default `TextAlign::Left`. + pub align: super::text::TextAlign, + /// Skip the field's background fill and border stroke. Useful + /// when the [`TextEdit`] is dropped inside another container + /// that paints its own surface (e.g. the digit cells inside + /// [`crate::widget::time_picker::TimePicker`]) and a second pill + /// would only add visual noise. + pub borderless: bool, + /// Override the preferred width reported to the parent layout. + /// Without this the single-line `TextEdit` claims `max_width` and + /// fills whatever rect the parent allocates — which is the right + /// default for forms but wrong when the field needs to be sized + /// to fit a fixed number of glyphs (date / time pickers, inline + /// numeric inputs). + pub fixed_width: Option, + /// Font size in pixels for the single-line draw path. Defaults to + /// the theme's `FONT_SIZE` constant. Multiline mode ignores this + /// for now and always uses the default — multiline soft-wrap + /// layout depends on the constant in several places that are not + /// yet parameterised. + pub font_size: f32, + /// When `true`, focusing the field selects the whole value so the + /// next keystroke replaces it. Standard behaviour for numeric + /// pickers and short-form fields where the user usually wants to + /// retype rather than edit. Default `false` — long-form fields + /// keep the cursor at the end on focus. + pub select_on_focus: bool, + /// Self-managed "show / hide password" eye affordance — when + /// `Some( ( visible, on_toggle ) )` the field renders an + /// `actions/visible` ↔ `actions/invisible` icon at its right + /// edge, taps on that icon dispatch `on_toggle` instead of + /// placing the cursor, and the bullet substitution flips with + /// `visible` (overriding `secure`). Set on a field that already + /// has `secure( true )` and the explicit flag becomes redundant + /// — the toggle controls the visibility from then on. + pub password_toggle: Option<( bool, Msg )>, +} + +impl TextEdit +{ + /// Create a text field with the given placeholder and initial value. + /// + /// The cursor is placed at the end of the initial value. + pub fn new( placeholder: String, value: String ) -> Self + { + let cursor_pos = value.len(); + Self + { + placeholder, + value, + on_change: None, + on_submit: None, + secure: false, + multiline: false, + rows: theme::ROWS_DEFAULT, + cursor_pos, + id: None, + cursor: None, + align: super::text::TextAlign::Left, + borderless: false, + fixed_width: None, + font_size: theme::FONT_SIZE, + select_on_focus: false, + password_toggle: None, + } + } + + /// Add a "show / hide password" eye toggle pinned to the right + /// edge of the field. `visible` controls whether the value + /// renders as bullets (`false`) or plain text (`true`); a tap on + /// the icon emits `on_toggle` so the caller can flip its own + /// `bool` state and re-render. Works with or without an explicit + /// [`Self::secure`] — when this is set, the toggle's `visible` + /// drives the bullet substitution and the `secure` field is + /// ignored. + pub fn password_toggle( mut self, visible: bool, on_toggle: Msg ) -> Self + { + self.password_toggle = Some( ( visible, on_toggle ) ); + self + } + + /// Effective secure flag honoured by drawing / measurement / + /// hit-testing — [`Self::password_toggle`] takes precedence over + /// the manual [`Self::secure`] when both are set. + pub fn effective_secure( &self ) -> bool + { + match &self.password_toggle + { + Some( ( visible, _ ) ) => !visible, + None => self.secure, + } + } + + /// Override the font size used by the single-line draw path. + /// Defaults to the theme's `FONT_SIZE` constant. Ignored in + /// multiline mode. + pub fn font_size( mut self, px: f32 ) -> Self + { + self.font_size = px.max( 1.0 ); + self + } + + /// Select the whole value when the field receives focus, so the + /// next keystroke replaces it. Default `false`. + pub fn select_on_focus( mut self, on: bool ) -> Self + { + self.select_on_focus = on; + self + } + + /// Set the horizontal alignment of the displayed text. Default + /// [`TextAlign::Left`](super::text::TextAlign::Left). + pub fn align( mut self, a: super::text::TextAlign ) -> Self + { + self.align = a; + self + } + + /// Skip the field's background fill and border stroke — useful + /// when the field is nested inside a container that already + /// paints its own surface. + pub fn borderless( mut self, on: bool ) -> Self + { + self.borderless = on; + self + } + + /// Override the preferred width reported to the parent layout. + /// Pass `None` (default) to fall back to claiming `max_width`. + pub fn fixed_width( mut self, w: f32 ) -> Self + { + self.fixed_width = Some( w ); + self + } + + /// Override the pointer cursor shape shown on hover. Defaults to + /// [`CursorShape::Text`](crate::CursorShape::Text) (I-beam). + pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self + { + self.cursor = Some( shape ); + self + } + + /// Switch to multiline (text-area) mode. The box is laid out with + /// [`Self::rows`] visible rows of height, line breaks in the value + /// are rendered as separate rows, and Enter inserts a `\n` instead + /// of firing [`Self::on_submit`]. Ignored when [`Self::secure`] is + /// `true`. + pub fn multiline( mut self, m: bool ) -> Self + { + self.multiline = m; + self + } + + /// Configure the number of visible rows in multiline mode. Defaults + /// to 5; ignored when [`Self::multiline`] is `false`. + pub fn rows( mut self, n: u32 ) -> Self + { + self.rows = n.max( 1 ); + self + } + + /// Set the callback invoked on every keystroke with the updated value. + pub fn on_change( mut self, f: impl Fn(String) -> Msg + 'static ) -> Self + { + self.on_change = Some( Arc::new( f ) ); + self + } + + /// Set the message emitted when Enter is pressed. + pub fn on_submit( mut self, msg: Msg ) -> Self + { + self.on_submit = Some( msg ); + self + } + + /// Enable or disable password mode. + /// + /// When `true`, this widget: + /// + /// 1. Renders the value as bullet characters (`•`) instead of the + /// raw glyphs. + /// 2. Forces single-line mode (multiline + secure is mutually + /// exclusive — passwords don't have line breaks). + /// 3. Wipes the underlying byte buffer with zero before the + /// `String` allocation is returned to the allocator. The wipe + /// runs in `Drop` for both the `TextEdit` itself and for the + /// per-frame [`crate::widget::WidgetHandlers::TextEdit`] snapshot + /// the runtime keeps for input dispatch — so the in-tree copies + /// that ltk owns never linger as plain text in freed memory. + /// + /// # Threat model — what `secure` covers + /// + /// Inside the widget tree the runtime keeps two copies of the + /// value for the lifetime of one frame: the `TextEdit` itself and + /// the `WidgetHandlers` snapshot. Both run the `secure_zero` wipe + /// on `Drop`, so when the next frame replaces them (the typical + /// case — `view()` rebuilds every frame) the freed allocations are + /// overwritten before being released back to the allocator. The + /// wipe uses volatile writes + a `compiler_fence` so the optimiser + /// cannot elide it as dead code (the implementation is in the + /// crate-private `secure_mem` module). + /// + /// # What `secure` does **not** cover + /// + /// * **The application's own state.** The `String` you pass in + /// through `text_edit( placeholder, &self.password )` lives on + /// **your** struct, not on the widget. Wiping it is + /// your job — typically a `Drop` impl on the credential + /// container, or an explicit + /// `secure_mem::secure_zero( password.as_bytes_mut() )` after + /// the auth handshake completes. + /// * **Callback-allocated copies.** Every keystroke passes through + /// `on_change( |s: String| ... )`, which receives a fresh + /// `String` clone. If your closure stores or forwards that + /// `String` (e.g. clone it into a worker thread for PAM), each + /// stored copy is the consumer's responsibility to wipe. ltk + /// only owns the buffers it allocated itself. + /// * **OS-level disclosure surfaces.** Swap-out, hibernation + /// images, and core dumps are outside any user-space wipe's + /// reach. For threat models that require resistance to these, + /// compile against an `mlock`-aware allocator, disable swap on + /// the credential mount, and restrict core-dump capability with + /// `prctl( PR_SET_DUMPABLE, 0 )` on the process. + /// * **Compositor-side records.** Wayland text-input protocols can + /// surface preedit / commit strings to the compositor's IME + /// stack; `secure` skips text-input-v3 registration so this path + /// stays closed. Verify your compositor honours that — most do, + /// but the protocol does not strictly require it. + /// + /// See the in-repo `SECURITY.md` for the full threat-model write-up + /// (the *Hardening features* section enumerates each guarantee and + /// its boundary). + pub fn secure( mut self, s: bool ) -> Self + { + self.secure = s; + self + } + + /// Assign a stable identifier for focus management. + pub fn id( mut self, id: WidgetId ) -> Self + { + self.id = Some( id ); + self + } + + /// Return the preferred `(width, height)` given available `max_width`. + /// + /// Single-line: theme-defined `HEIGHT`. + /// Multiline: enough room for [`Self::rows`] lines plus padding. + pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32) + { + if self.multiline && !self.effective_secure() + { + let line_h = theme::FONT_SIZE * theme::LINE_H_MULT; + let h = self.rows as f32 * line_h + theme::PAD_V_MULTI * 2.0; + ( max_width, h ) + } else { + let w = self.fixed_width + .map( |fw| fw.min( max_width ) ) + .unwrap_or( max_width ); + ( w, theme::HEIGHT ) + } + } + + /// `true` when the widget is laid out as a multi-row text area — + /// i.e. [`Self::multiline`] was set and [`Self::effective_secure`] + /// is `false`. A `password_toggle` field collapses to single-line + /// like an explicit `secure( true )` does. + pub fn is_multiline( &self ) -> bool + { + self.multiline && !self.effective_secure() + } + + /// Translate a pointer position inside `rect` to the byte offset + /// in [`Self::value`] that the cursor should land on. Thin + /// wrapper around `byte_offset_at` using this widget's value / + /// flags. + pub fn byte_offset_at_self( + &self, + canvas: &Canvas, + rect: Rect, + pos: crate::types::Point, + cursor_pos: usize, + ) -> usize + { + byte_offset_at( + canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, self.align, self.font_size, + ) + } + + /// Border stroke is centered on `rect`, so half the stroke width plus ~1 px + /// of antialiasing bleed sits outside. The widest stroke is the focused + /// border, so use that as the envelope. + pub fn paint_bounds( &self, rect: Rect ) -> Rect + { + rect.expand( theme::FOCUS_BORDER_W * 0.5 + 1.0 ) + } + + /// Return the display string — bullet characters in secure mode, plain value otherwise. + pub fn display_text( &self ) -> String + { + if self.effective_secure() + { + "\u{2022}".repeat( self.value.chars().count() ) + } else { + self.value.clone() + } + } + + /// Draw the field into `canvas` at `rect`. + /// + /// `cursor_pos` is the byte offset of the text cursor — supplied by the runtime + /// from its persistent cursor state rather than from the widget itself. + /// `selection_anchor` is the other end of the selection range; when + /// `selection_anchor == cursor_pos` no highlight is painted. + pub fn draw( + &self, + canvas: &mut Canvas, + rect: Rect, + focused: bool, + cursor_pos: usize, + selection_anchor: usize, + ) + { + if self.is_multiline() + { + self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor ); + return; + } + // Borderless fields skip the surface fill + border stroke + // entirely. They still get the inner clip + scroll + alignment + // treatment below, so the field behaves like a regular + // text input — only the chrome is suppressed. + if !self.borderless + { + let border_c = if focused { theme::focus_border() } else { theme::border() }; + let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W }; + canvas.fill_rect( rect, theme::bg(), theme::RADIUS ); + canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS ); + } + + let font_size = self.font_size; + let text_y = rect.y + (rect.height + font_size) / 2.0 - 2.0; + let text = self.display_text(); + let secure = self.effective_secure(); + // When `password_toggle` is set we reserve a column on the + // right edge for the eye icon: scroll / align / clip all + // behave as if the field were narrower so cursor + text + // never slide under the icon. + let toggle_reserve = if self.password_toggle.is_some() + { + theme::PASSWORD_TOGGLE_SIZE + theme::PASSWORD_TOGGLE_SLOP * 2.0 + } + else + { + 0.0 + }; + let text_rect = Rect + { + width: ( rect.width - toggle_reserve ).max( theme::PAD_H * 2.0 ), + ..rect + }; + let scroll_x = single_line_scroll_x( canvas, text_rect, &self.value, cursor_pos, secure, font_size ); + let align_x = single_line_align_offset( canvas, text_rect, &self.value, secure, self.align, font_size ); + + // Clip text / cursor / selection to the inner band so the + // scrolled-off portion does not bleed past the pill stroke. + // Save the parent clip first, intersect with our inner rect, + // and restore on exit so a `text_edit` wrapped in scroll(...) + // keeps the parent's clip honoured. + let outer_clip = canvas.clip_bounds(); + let inner_rect = Rect + { + x: rect.x + theme::PAD_H * 0.5, + y: rect.y, + width: ( rect.width - theme::PAD_H - toggle_reserve ).max( 0.0 ), + height: rect.height, + }; + let composed_clip: Vec = if outer_clip.is_empty() + { + vec![ inner_rect ] + } else { + outer_clip.iter() + .filter_map( |o| rect_intersect( *o, inner_rect ) ) + .collect() + }; + canvas.set_clip_rects( &composed_clip ); + + // Selection highlight (single-line). Painted before the text + // so the glyphs sit on top of the tinted band. + if focused && selection_anchor != cursor_pos + { + let ( s, e ) = ( + cursor_pos.min( selection_anchor ).min( self.value.len() ), + cursor_pos.max( selection_anchor ).min( self.value.len() ), + ); + let prefix_text = if secure + { + "\u{2022}".repeat( self.value[..s].chars().count() ) + } else { + self.value[..s].to_string() + }; + let span_text = if secure + { + "\u{2022}".repeat( self.value[s..e].chars().count() ) + } else { + self.value[s..e].to_string() + }; + let x0 = rect.x + theme::PAD_H + align_x - scroll_x + + canvas.measure_text( &prefix_text, font_size ); + let w = canvas.measure_text( &span_text, font_size ); + let sel_rect = Rect + { + x: x0, y: rect.y + 6.0, + width: w, height: rect.height - 12.0, + }; + canvas.fill_rect( sel_rect, theme::selection(), 2.0 ); + } + + if text.is_empty() + { + // Placeholder follows the same alignment as the value + // would, so an empty centred / right-aligned field still + // reads with the placeholder where the digits will land. + let placeholder_align_x = single_line_align_offset( + canvas, rect, &self.placeholder, false, self.align, font_size, + ); + canvas.draw_text( + &self.placeholder, + rect.x + theme::PAD_H + placeholder_align_x, + text_y, + font_size, + theme::placeholder(), + ); + } else { + canvas.draw_text( + &text, + rect.x + theme::PAD_H + align_x - scroll_x, + text_y, + font_size, + theme::text(), + ); + } + + if focused + { + let safe_cursor = cursor_pos.min( self.value.len() ); + let cursor_text = if secure + { + "\u{2022}".repeat( self.value[..safe_cursor].chars().count() ) + } else { + self.value[..safe_cursor].to_string() + }; + let cursor_x = rect.x + theme::PAD_H + align_x - scroll_x + + canvas.measure_text( &cursor_text, font_size ); + let cursor_rect = Rect + { + x: cursor_x, + y: rect.y + 8.0, + width: 2.0, + height: rect.height - 16.0, + }; + canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 ); + } + + // Restore whatever clip was active on entry. + if outer_clip.is_empty() + { + canvas.clear_clip(); + } else { + canvas.set_clip_rects( &outer_clip ); + } + + // Eye icon for `password_toggle`. Drawn outside the inner + // clip so it never gets occluded by overflowing text — the + // preceding clip-restore is the reason this lives here and + // not earlier in the function. Tinted with the placeholder + // colour so the icon reads as ambient affordance rather + // than a primary control. + if let Some( ( visible, _ ) ) = self.password_toggle.as_ref() + { + let icon_name = if *visible { "actions/invisible" } else { "actions/visible" }; + let icon_px = theme::PASSWORD_TOGGLE_SIZE.round() as u32; + if let Some( ( rgba, iw, ih ) ) = crate::theme::icon_rgba( icon_name, icon_px ) + { + let tinted = crate::theme::tint_symbolic( &rgba, theme::placeholder() ); + let zone = password_toggle_hit_zone( rect ); + let dest = Rect + { + x: ( zone.x + ( zone.width - iw as f32 ) / 2.0 ).round(), + y: ( rect.y + ( rect.height - ih as f32 ) / 2.0 ).round(), + width: iw as f32, + height: ih as f32, + }; + canvas.draw_image_data( &tinted, iw, ih, dest, 1.0 ); + } + } + } + + fn draw_multiline( + &self, + canvas: &mut Canvas, + rect: Rect, + focused: bool, + cursor_pos: usize, + selection_anchor: usize, + ) + { + let border_c = if focused { theme::focus_border() } else { theme::border() }; + let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W }; + canvas.fill_rect( rect, theme::bg(), theme::RADIUS_MULTI ); + canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS_MULTI ); + + let line_h = theme::FONT_SIZE * theme::LINE_H_MULT; + let inner_h = ( rect.height - theme::PAD_V_MULTI * 2.0 ).max( line_h ); + let visible_lines = ( inner_h / line_h ).floor().max( 1.0 ) as usize; + let inner_width = ( rect.width - theme::PAD_H * 2.0 ).max( theme::FONT_SIZE ); + + // Compute the visual-line layout once per draw. Iterators + // later go through this list; long logical lines are + // soft-wrapped at the last whitespace before the row would + // overflow without mutating the buffer. + let visual_lines = compute_visual_lines( canvas, &self.value, inner_width, theme::FONT_SIZE ); + + // Vertical auto-scroll: keep the cursor's visual line on + // screen. The cursor sits in the *first* visual line whose + // `end >= cursor` — at a wrap boundary that places it at the + // trailing edge of the previous line rather than the start of + // the next, matching the convention of every standard text + // editor. + let safe_cursor = cursor_pos.min( self.value.len() ); + let cursor_visual_idx = visual_lines.iter() + .position( |vl| safe_cursor >= vl.start && safe_cursor <= vl.end ) + .unwrap_or( visual_lines.len().saturating_sub( 1 ) ); + let total_lines = visual_lines.len(); + let max_first = total_lines.saturating_sub( visible_lines ); + let first_line = if cursor_visual_idx + 1 > visible_lines + { + ( cursor_visual_idx + 1 - visible_lines ).min( max_first ) + } else { 0 }; + + let baseline_0 = rect.y + theme::PAD_V_MULTI + theme::FONT_SIZE; + + // Clip text to the inner rect so partial lines at the bottom + // stay inside the box and do not bleed onto sibling widgets + // or the border stroke. Save the parent clip first, then + // intersect it with our inner rect, so a `scroll(...)`-wrapped + // multiline edit keeps the parent's clip honoured. + let outer_clip = canvas.clip_bounds(); + let inner_rect = Rect + { + x: rect.x + theme::PAD_H * 0.5, + y: rect.y + theme::PAD_V_MULTI * 0.5, + width: ( rect.width - theme::PAD_H ).max( 0.0 ), + height: ( rect.height - theme::PAD_V_MULTI ).max( 0.0 ), + }; + let composed_clip: Vec = if outer_clip.is_empty() + { + vec![ inner_rect ] + } else { + outer_clip.iter() + .filter_map( |o| rect_intersect( *o, inner_rect ) ) + .collect() + }; + canvas.set_clip_rects( &composed_clip ); + + // Selection highlight (multiline). Painted before the text so + // the glyphs sit on top of the tinted band(s). Iterates + // visual lines, so a soft-wrapped logical line gets one + // highlight rect per visual row automatically. + if focused && selection_anchor != cursor_pos + { + let s = cursor_pos.min( selection_anchor ).min( self.value.len() ); + let e = cursor_pos.max( selection_anchor ).min( self.value.len() ); + for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line ) + { + let visual_idx = i - first_line; + let y = rect.y + theme::PAD_V_MULTI + visual_idx as f32 * line_h; + if y > rect.y + rect.height - theme::PAD_V_MULTI { break; } + // Intersection of [s, e] with this visual line's + // byte range. Empty / non-overlapping → skip. + let span_start = s.max( vl.start ); + let span_end = e.min( vl.end ); + if span_start >= span_end { continue; } + let prefix_text = &self.value[ vl.start..span_start ]; + let span_text = &self.value[ span_start..span_end ]; + let x0 = rect.x + theme::PAD_H + canvas.measure_text( prefix_text, theme::FONT_SIZE ); + let w = canvas.measure_text( span_text, theme::FONT_SIZE ).max( 4.0 ); + let sel_rect = Rect + { + x: x0, y, + width: w, height: line_h, + }; + canvas.fill_rect( sel_rect, theme::selection(), 2.0 ); + } + } + + if self.value.is_empty() + { + canvas.draw_text( + &self.placeholder, + rect.x + theme::PAD_H, + baseline_0, + theme::FONT_SIZE, + theme::placeholder(), + ); + } else { + for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line ) + { + let visual_idx = i - first_line; + let y = baseline_0 + visual_idx as f32 * line_h; + if y > rect.y + rect.height - theme::PAD_V_MULTI + line_h + { + break; + } + let line = &self.value[ vl.start..vl.end ]; + canvas.draw_text( + line, + rect.x + theme::PAD_H, + y, + theme::FONT_SIZE, + theme::text(), + ); + } + } + + if focused + { + let vl = visual_lines[ cursor_visual_idx ]; + let line_prefix = &self.value[ vl.start..safe_cursor ]; + let cursor_x = rect.x + theme::PAD_H + + canvas.measure_text( line_prefix, theme::FONT_SIZE ); + let visual_y_idx = cursor_visual_idx.saturating_sub( first_line ); + let cursor_y = rect.y + theme::PAD_V_MULTI + visual_y_idx as f32 * line_h; + let cursor_rect = Rect + { + x: cursor_x, + y: cursor_y + 2.0, + width: 2.0, + height: theme::FONT_SIZE + 4.0, + }; + canvas.fill_rect( cursor_rect, theme::cursor(), 0.0 ); + } + + // Restore whatever clip was active on entry — a single empty + // `Vec` from `clip_bounds` means "no clip", which we model by + // calling `clear_clip` instead of pushing an empty slice. + if outer_clip.is_empty() + { + canvas.clear_clip(); + } else { + canvas.set_clip_rects( &outer_clip ); + } + } + + /// Wrap this widget in an [`Element`]. + pub fn into_element( self ) -> Element + { + Element::TextEdit( self ) + } + + /// Insert a string at the current cursor position, advance the cursor, and + /// return the `on_change` message if one is set. + pub fn insert_str( &mut self, s: &str ) -> Option + { + self.value.insert_str( self.cursor_pos.min( self.value.len() ), s ); + self.cursor_pos = (self.cursor_pos + s.len()).min( self.value.len() ); + self.on_change.as_ref().map( |f| f( self.value.clone() ) ) + } + + /// Delete the character before the cursor and return the `on_change` message + /// if one is set. Does nothing if the cursor is already at position 0. + pub fn backspace( &mut self ) -> Option + { + if self.cursor_pos == 0 { return None; } + let chars: Vec = self.value.chars().collect(); + let char_pos = self.value[..self.cursor_pos].chars().count(); + if char_pos == 0 { return None; } + let mut chars = chars; + let removed = chars.remove( char_pos - 1 ); + self.cursor_pos -= removed.len_utf8(); + self.value = chars.iter().collect(); + self.on_change.as_ref().map( |f| f( self.value.clone() ) ) + } + + pub( crate ) fn map_msg( self, f: &super::MapFn ) -> TextEdit + where + U: Clone + 'static, + Msg: 'static, + { + // Wrap on_change the same way the slider does. on_submit is a + // plain `Option`, so it goes through the user mapper once. + let on_change = self.on_change.clone().map( |old| -> Arc U> + { + let mapper = Arc::clone( f ); + Arc::new( move |s| ( *mapper )( ( *old )( s ) ) ) + } ); + TextEdit + { + placeholder: self.placeholder.clone(), + value: self.value.clone(), + on_change, + on_submit: self.on_submit.clone().map( |m| ( *f )( m ) ), + secure: self.secure, + multiline: self.multiline, + rows: self.rows, + cursor_pos: self.cursor_pos, + id: self.id, + cursor: self.cursor, + align: self.align, + borderless: self.borderless, + fixed_width: self.fixed_width, + font_size: self.font_size, + select_on_focus: self.select_on_focus, + password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ), + } + } +} + +impl Drop for TextEdit +{ + /// When `secure( true )` is set, scrub the value bytes before the + /// underlying `String` allocation is returned to the allocator. The + /// non-secure path is a no-op so the cost is paid only by widgets + /// that opted into credential handling. + fn drop( &mut self ) + { + if self.secure || self.password_toggle.is_some() + { + // SAFETY: as_mut_vec exposes the underlying byte buffer of the + // String. We only overwrite each byte with zero, which is valid + // UTF-8 (a sequence of NUL codepoints), so the String invariant + // is preserved through to the Vec drop that runs immediately + // after this fn returns. + let bytes = unsafe { self.value.as_mut_vec() }; + secure_zero( bytes ); + } + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::TextEdit; + + #[ derive( Clone, Debug, PartialEq, Eq ) ] + enum Msg + { + Changed( String ), + Submitted, + } + + fn new_te( value: &str ) -> TextEdit + { + TextEdit::new( "placeholder".into(), value.into() ) + } + + // ── Construction ────────────────────────────────────────────────────────── + + #[ test ] + fn new_places_cursor_at_end_of_initial_value() + { + let t = new_te( "hello" ); + assert_eq!( t.cursor_pos, 5 ); + assert_eq!( t.value, "hello" ); + } + + #[ test ] + fn new_with_empty_value_starts_with_zero_cursor() + { + let t = new_te( "" ); + assert_eq!( t.cursor_pos, 0 ); + assert!( t.value.is_empty() ); + } + + #[ test ] + fn new_with_multibyte_value_uses_byte_length_for_cursor() + { + // "café" is 5 bytes (c-a-f-é where é is 2 bytes in UTF-8). + let t = new_te( "café" ); + assert_eq!( t.cursor_pos, 5 ); + assert_eq!( t.value.len(), 5 ); + } + + // ── insert_str ──────────────────────────────────────────────────────────── + + #[ test ] + fn insert_str_at_end_appends_and_advances_cursor() + { + let mut t = new_te( "ab" ); + assert_eq!( t.cursor_pos, 2 ); + let _ = t.insert_str( "c" ); + assert_eq!( t.value, "abc" ); + assert_eq!( t.cursor_pos, 3 ); + } + + #[ test ] + fn insert_str_at_middle_inserts_and_advances_cursor_by_byte_len() + { + let mut t = new_te( "ab" ); + t.cursor_pos = 1; + let _ = t.insert_str( "X" ); + assert_eq!( t.value, "aXb" ); + assert_eq!( t.cursor_pos, 2 ); + } + + #[ test ] + fn insert_str_multibyte_advances_cursor_by_utf8_byte_count() + { + let mut t = new_te( "" ); + let _ = t.insert_str( "é" ); // 2 bytes + assert_eq!( t.value, "é" ); + assert_eq!( t.cursor_pos, 2 ); + let _ = t.insert_str( "🦀" ); // 4 bytes + assert_eq!( t.cursor_pos, 6 ); + } + + #[ test ] + fn insert_str_clamps_runaway_cursor() + { + // If cursor_pos is somehow past value.len() (defensive), insert should + // behave as "insert at end" rather than panic. + let mut t = new_te( "ab" ); + t.cursor_pos = 99; + let _ = t.insert_str( "c" ); + assert_eq!( t.value, "abc" ); + // Cursor is clamped to new value length. + assert_eq!( t.cursor_pos, 3 ); + } + + #[ test ] + fn insert_str_returns_on_change_with_new_value() + { + let mut t = new_te( "ab" ) + .on_change( |s| Msg::Changed( s ) ); + let msg = t.insert_str( "c" ); + assert_eq!( msg, Some( Msg::Changed( "abc".into() ) ) ); + } + + #[ test ] + fn insert_str_without_on_change_returns_none() + { + let mut t = new_te( "ab" ); + assert_eq!( t.insert_str( "c" ), None ); + } + + // ── backspace ───────────────────────────────────────────────────────────── + + #[ test ] + fn backspace_at_zero_cursor_is_noop() + { + let mut t = new_te( "abc" ); + t.cursor_pos = 0; + assert_eq!( t.backspace(), None ); + assert_eq!( t.value, "abc" ); + assert_eq!( t.cursor_pos, 0 ); + } + + #[ test ] + fn backspace_at_end_removes_last_char() + { + let mut t = new_te( "abc" ); + let _ = t.backspace(); + assert_eq!( t.value, "ab" ); + assert_eq!( t.cursor_pos, 2 ); + } + + #[ test ] + fn backspace_in_middle_removes_char_before_cursor() + { + let mut t = new_te( "abc" ); + t.cursor_pos = 2; + let _ = t.backspace(); + assert_eq!( t.value, "ac" ); + assert_eq!( t.cursor_pos, 1 ); + } + + #[ test ] + fn backspace_handles_multibyte_acute() + { + // "é" is two bytes; backspacing must remove the whole codepoint. + let mut t = new_te( "café" ); + let _ = t.backspace(); + assert_eq!( t.value, "caf" ); + assert_eq!( t.cursor_pos, 3 ); + } + + #[ test ] + fn backspace_handles_emoji_codepoint() + { + // Crab emoji is 4 bytes in UTF-8; cursor must back up by 4. + let mut t = new_te( "x🦀" ); + assert_eq!( t.cursor_pos, 5 ); + let _ = t.backspace(); + assert_eq!( t.value, "x" ); + assert_eq!( t.cursor_pos, 1 ); + } + + #[ test ] + fn backspace_returns_on_change_with_new_value() + { + let mut t = new_te( "ab" ) + .on_change( |s| Msg::Changed( s ) ); + let msg = t.backspace(); + assert_eq!( msg, Some( Msg::Changed( "a".into() ) ) ); + } + + // ── display_text + secure ───────────────────────────────────────────────── + + #[ test ] + fn display_text_plain_returns_value_verbatim() + { + let t = new_te( "hello" ); + assert_eq!( t.display_text(), "hello" ); + } + + #[ test ] + fn display_text_secure_returns_bullets_one_per_codepoint() + { + // One bullet per codepoint, NOT per byte. "café" has 4 codepoints, + // so the masked text must be 4 bullets — not 5 (the byte length). + let t = new_te( "café" ).secure( true ); + let masked = t.display_text(); + assert_eq!( masked.chars().count(), 4 ); + assert!( masked.chars().all( |c| c == '\u{2022}' ) ); + } + + #[ test ] + fn display_text_secure_with_emoji_one_bullet_per_codepoint() + { + // Crab emoji is one codepoint — exactly one bullet, even though it + // occupies four bytes in UTF-8. + let t = new_te( "🦀" ).secure( true ); + assert_eq!( t.display_text().chars().count(), 1 ); + } + + #[ test ] + fn secure_mode_does_not_touch_underlying_value() + { + let t = new_te( "secret" ).secure( true ); + assert_eq!( t.value, "secret" ); + } + + #[ test ] + fn secure_toggles_via_builder() + { + let t = new_te( "x" ).secure( true ).secure( false ); + assert_eq!( t.display_text(), "x" ); + } + + // ── secure-mode credential zeroize ──────────────────────────────────────── + + #[ test ] + fn secure_flag_propagates_to_widget_handler_snapshot() + { + use crate::widget::{ Element, WidgetHandlers }; + + // The runtime takes a `WidgetHandlers` snapshot of every interactive + // widget once per frame. The `secure` flag must travel with the + // snapshot so the handler's own `Drop` (which mirrors `TextEdit`'s + // wipe-on-drop) knows whether to scrub on release. + let widget: Element<()> = TextEdit::new( "p".into(), "secret".into() ) + .secure( true ) + .into_element(); + let h = widget.handlers(); + match h + { + WidgetHandlers::TextEdit { secure, .. } => assert!( secure, "secure flag must propagate" ), + _ => panic!( "expected TextEdit handler" ), + } + } + + #[ test ] + fn multiline_flag_propagates_to_widget_handler_snapshot() + { + use crate::widget::{ Element, WidgetHandlers }; + + let widget: Element<()> = TextEdit::new( "p".into(), "x".into() ) + .multiline( true ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { multiline, .. } => assert!( multiline, "multiline flag must propagate" ), + _ => panic!( "expected TextEdit handler" ), + } + } + + #[ test ] + fn secure_overrides_multiline_in_widget_handlers() + { + use crate::widget::{ Element, WidgetHandlers }; + + // `is_multiline()` requires `!secure` — a secure password field + // must remain single-line even if the caller also asked for + // multiline. The handler snapshot reads `is_multiline()`, so + // `multiline` here must be `false`. + let widget: Element<()> = TextEdit::new( "p".into(), "x".into() ) + .multiline( true ) + .secure( true ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { multiline, secure, .. } => + { + assert!( secure, "secure must propagate" ); + assert!( !multiline, "secure must override multiline" ); + } + _ => panic!( "expected TextEdit handler" ), + } + } + + #[ test ] + fn non_secure_flag_propagates_to_widget_handler_snapshot() + { + use crate::widget::{ Element, WidgetHandlers }; + + let widget: Element<()> = TextEdit::new( "p".into(), "x".into() ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { secure, .. } => assert!( !secure, "default is non-secure" ), + _ => panic!( "expected TextEdit handler" ), + } + } + + #[ test ] + fn secure_drop_zeros_underlying_string_buffer() + { + // Reach into the value's byte buffer right before the drop, run the + // drop logic by replacing the binding, and confirm the bytes that + // had been the credential are now zero. We cannot inspect the + // allocation after the actual drop (UB), so this test exercises the + // same path that `Drop::drop` runs internally. + let mut t: TextEdit<()> = TextEdit::new( "p".into(), "secret".into() ).secure( true ); + assert_eq!( t.value, "secret" ); + + // Manually run the wipe the way Drop will; the next assertion checks + // the buffer is zeroed before the auto-drop releases it. + // SAFETY: same as the production wipe path above — overwriting + // every byte with zero leaves the String holding a sequence of NUL + // codepoints, which is valid UTF-8. + let bytes = unsafe { t.value.as_mut_vec() }; + crate::secure_mem::secure_zero( bytes ); + assert!( bytes.iter().all( |&b| b == 0 ) ); + assert_eq!( bytes.len(), 6 ); + } + + #[ test ] + fn non_secure_text_edit_drop_is_a_noop_for_value() + { + // A non-secure widget must not pay the wipe cost. We cannot observe + // the absence of writes directly, but we can confirm that the + // underlying bytes still match the original content right up to + // the moment of drop. + let t: TextEdit<()> = TextEdit::new( "p".into(), "value".into() ); + assert_eq!( t.value.as_bytes(), b"value" ); + // Drop runs at end of scope without touching the bytes. + } + + // ── on_submit / on_change wiring ────────────────────────────────────────── + + #[ test ] + fn on_submit_stores_message_unchanged() + { + let t = new_te( "" ).on_submit( Msg::Submitted ); + assert_eq!( t.on_submit, Some( Msg::Submitted ) ); + } + + // ── full edit roundtrip ─────────────────────────────────────────────────── + + #[ test ] + fn type_word_then_clear_with_backspace() + { + let mut t = new_te( "" ); + for ch in "hola".chars() + { + let _ = t.insert_str( &ch.to_string() ); + } + assert_eq!( t.value, "hola" ); + assert_eq!( t.cursor_pos, 4 ); + while t.cursor_pos > 0 + { + let _ = t.backspace(); + } + assert!( t.value.is_empty() ); + assert_eq!( t.cursor_pos, 0 ); + } + + // ── multiline mode ─────────────────────────────────────────────────────── + + #[ test ] + fn multiline_default_off() + { + let t = new_te( "" ); + assert!( !t.multiline ); + assert!( !t.is_multiline() ); + } + + #[ test ] + fn multiline_builder_enables() + { + let t = new_te( "" ).multiline( true ); + assert!( t.multiline ); + assert!( t.is_multiline() ); + } + + #[ test ] + fn multiline_grows_preferred_height_with_rows() + { + use crate::render::Canvas; + let canvas = Canvas::new( 800, 600 ); + let single = new_te( "" ); + let ( _, h_single ) = single.preferred_size( 200.0, &canvas ); + assert_eq!( h_single, super::theme::HEIGHT ); + + let multi = new_te( "" ).multiline( true ).rows( 5 ); + let ( _, h_multi ) = multi.preferred_size( 200.0, &canvas ); + assert!( h_multi > h_single, "multiline must claim more vertical space" ); + + let bigger = new_te( "" ).multiline( true ).rows( 10 ); + let ( _, h_bigger ) = bigger.preferred_size( 200.0, &canvas ); + assert!( h_bigger > h_multi, "more rows → taller box" ); + } + + #[ test ] + fn rows_clamps_zero_to_one() + { + let t = new_te( "" ).multiline( true ).rows( 0 ); + assert_eq!( t.rows, 1 ); + } + + #[ test ] + fn secure_overrides_multiline_in_is_multiline_query() + { + // A `multiline + secure` configuration must remain single-line: + // passwords carrying a literal `\n` would defeat the password + // mode rendering and turn the credential boundary fuzzy. + let t: TextEdit<()> = TextEdit::new( "p".into(), "x".into() ) + .multiline( true ) + .secure( true ); + assert!( t.multiline ); // raw flag preserved + assert!( !t.is_multiline() ); // effective mode is single-line + } + + #[ test ] + fn insert_str_can_carry_newline_in_multiline_mode() + { + // The widget itself does not gate `\n` — that decision lives at + // dispatch time. We just confirm `\n` round-trips through + // insert_str + the value buffer untouched. + let mut t = new_te( "ab" ).multiline( true ); + let _ = t.insert_str( "\n" ); + assert_eq!( t.value, "ab\n" ); + assert_eq!( t.cursor_pos, 3 ); + let _ = t.insert_str( "c" ); + assert_eq!( t.value, "ab\nc" ); + } + + // ── builders for the inline / picker variants ──────────────────────────── + + #[ test ] + fn defaults_for_new_inline_builders() + { + let t: TextEdit<()> = TextEdit::new( "p".into(), "v".into() ); + assert_eq!( t.align, super::super::text::TextAlign::Left ); + assert!( !t.borderless ); + assert!( t.fixed_width.is_none() ); + assert_eq!( t.font_size, super::theme::FONT_SIZE ); + assert!( !t.select_on_focus ); + } + + #[ test ] + fn align_builder_sets_alignment() + { + use super::super::text::TextAlign; + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).align( TextAlign::Center ); + assert_eq!( t.align, TextAlign::Center ); + let t = t.align( TextAlign::Right ); + assert_eq!( t.align, TextAlign::Right ); + } + + #[ test ] + fn borderless_builder_toggles_flag() + { + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).borderless( true ); + assert!( t.borderless ); + } + + #[ test ] + fn fixed_width_builder_stores_value() + { + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 72.0 ); + assert_eq!( t.fixed_width, Some( 72.0 ) ); + } + + #[ test ] + fn font_size_builder_clamps_to_at_least_one_pixel() + { + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( 28.0 ); + assert_eq!( t.font_size, 28.0 ); + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).font_size( -3.0 ); + assert_eq!( t.font_size, 1.0 ); + } + + #[ test ] + fn select_on_focus_builder_toggles_flag() + { + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).select_on_focus( true ); + assert!( t.select_on_focus ); + } + + #[ test ] + fn fixed_width_overrides_preferred_size_width() + { + let canvas = crate::render::Canvas::new( 1, 1 ); + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 64.0 ); + let ( w, _h ) = t.preferred_size( 1000.0, &canvas ); + assert_eq!( w, 64.0 ); + } + + #[ test ] + fn fixed_width_capped_by_max_width() + { + let canvas = crate::render::Canvas::new( 1, 1 ); + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ).fixed_width( 500.0 ); + let ( w, _h ) = t.preferred_size( 200.0, &canvas ); + // fixed_width is capped by the parent's available width to + // avoid overflowing tight rows. + assert_eq!( w, 200.0 ); + } + + #[ test ] + fn no_fixed_width_falls_back_to_max_width() + { + let canvas = crate::render::Canvas::new( 1, 1 ); + let t: TextEdit<()> = TextEdit::new( "".into(), "".into() ); + let ( w, _h ) = t.preferred_size( 320.0, &canvas ); + assert_eq!( w, 320.0 ); + } + + #[ test ] + fn align_propagates_to_widget_handler_snapshot() + { + use crate::widget::{ Element, WidgetHandlers }; + use super::super::text::TextAlign; + let widget: Element<()> = TextEdit::new( "".into(), "".into() ) + .align( TextAlign::Center ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { align, .. } => assert_eq!( align, TextAlign::Center ), + _ => panic!( "expected TextEdit handler" ), + } + } + + #[ test ] + fn font_size_propagates_to_widget_handler_snapshot() + { + use crate::widget::{ Element, WidgetHandlers }; + let widget: Element<()> = TextEdit::new( "".into(), "".into() ) + .font_size( 28.0 ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { font_size, .. } => assert!( ( font_size - 28.0 ).abs() < 1e-6 ), + _ => panic!( "expected TextEdit handler" ), + } + } + + #[ test ] + fn select_on_focus_propagates_to_widget_handler_snapshot() + { + use crate::widget::{ Element, WidgetHandlers }; + let widget: Element<()> = TextEdit::new( "".into(), "".into() ) + .select_on_focus( true ) + .into_element(); + match widget.handlers() + { + WidgetHandlers::TextEdit { select_on_focus, .. } => assert!( select_on_focus ), + _ => panic!( "expected TextEdit handler" ), + } + } + + // ── password_toggle ─────────────────────────────────────────────────────── + + #[ derive( Clone, Debug, PartialEq, Eq ) ] enum ToggleMsg { Flip } + + #[ test ] + fn password_toggle_default_is_none() + { + let t = TextEdit::::new( "".into(), "".into() ); + assert!( t.password_toggle.is_none() ); + } + + #[ test ] + fn password_toggle_builder_records_visible_and_msg() + { + let t = TextEdit::::new( "".into(), "".into() ) + .password_toggle( true, ToggleMsg::Flip ); + assert_eq!( t.password_toggle, Some( ( true, ToggleMsg::Flip ) ) ); + } + + #[ test ] + fn effective_secure_falls_back_to_secure_field_when_no_toggle() + { + let plain = TextEdit::::new( "".into(), "".into() ); + let secret = TextEdit::::new( "".into(), "".into() ).secure( true ); + assert!( !plain.effective_secure() ); + assert!( secret.effective_secure() ); + } + + #[ test ] + fn password_toggle_overrides_secure_field() + { + // When the toggle is set, its `visible` controls bullets + // regardless of the explicit `secure` flag. + let visible_with_secure = TextEdit::::new( "".into(), "".into() ) + .secure( true ) + .password_toggle( true, ToggleMsg::Flip ); + let hidden_no_secure = TextEdit::::new( "".into(), "".into() ) + .password_toggle( false, ToggleMsg::Flip ); + assert!( !visible_with_secure.effective_secure() ); + assert!( hidden_no_secure.effective_secure() ); + } + + #[ test ] + fn password_toggle_hidden_substitutes_bullets_in_display_text() + { + let t = TextEdit::::new( "".into(), "abc".into() ) + .password_toggle( false, ToggleMsg::Flip ); + assert_eq!( t.display_text(), "•••" ); + } + + #[ test ] + fn password_toggle_visible_returns_value_verbatim() + { + let t = TextEdit::::new( "".into(), "abc".into() ) + .password_toggle( true, ToggleMsg::Flip ); + assert_eq!( t.display_text(), "abc" ); + } + + #[ test ] + fn password_toggle_collapses_multiline_to_single_line() + { + // `is_multiline()` follows `effective_secure` — a hidden + // password collapses to single-line even if `multiline(true)` + // was set, matching the behaviour of an explicit `secure`. + let t = TextEdit::::new( "".into(), "".into() ) + .multiline( true ) + .password_toggle( false, ToggleMsg::Flip ); + assert!( !t.is_multiline() ); + } + + #[ test ] + fn password_toggle_msg_propagates_to_widget_handler_snapshot() + { + use crate::widget::{ Element, WidgetHandlers }; + let widget: Element = TextEdit::new( "".into(), "".into() ) + .password_toggle( false, ToggleMsg::Flip ) + .into_element(); + // `handlers()` returns a value of `WidgetHandlers`, which + // implements `Drop` (wipe-on-drop for secure fields), so we + // cannot destructure it by value-move. Bind first, then match + // on a reference. + let h = widget.handlers(); + match &h + { + WidgetHandlers::TextEdit { password_toggle_msg, secure, .. } => + { + assert_eq!( password_toggle_msg.as_ref(), Some( &ToggleMsg::Flip ) ); + // Snapshot's `secure` is true even though `visible=false` + // because the field carries a password regardless. + assert!( *secure ); + } + _ => panic!( "expected TextEdit handler" ), + } + } + + #[ test ] + fn password_toggle_msg_none_when_no_toggle_configured() + { + use crate::widget::{ Element, WidgetHandlers }; + let widget: Element = TextEdit::new( "".into(), "".into() ) + .into_element(); + let h = widget.handlers(); + match &h + { + WidgetHandlers::TextEdit { password_toggle_msg, .. } => + { + assert!( password_toggle_msg.is_none() ); + } + _ => panic!( "expected TextEdit handler" ), + } + } + + #[ test ] + fn password_toggle_hit_zone_sits_at_right_edge() + { + use crate::types::{ Point, Rect }; + let rect = Rect { x: 100.0, y: 50.0, width: 240.0, height: 48.0 }; + let zone = super::password_toggle_hit_zone( rect ); + // Right edge of the zone matches the field's right edge. + assert!( ( ( zone.x + zone.width ) - ( rect.x + rect.width ) ).abs() < 0.01 ); + // Wide enough to cover the icon + slop on both sides + half pad. + assert!( zone.width >= super::theme::PASSWORD_TOGGLE_SIZE ); + // A point on the centre-right hits the zone. + let p = Point { x: rect.x + rect.width - 8.0, y: rect.y + rect.height / 2.0 }; + assert!( zone.contains( p ) ); + // A point well to the left of the zone misses it. + let p = Point { x: rect.x + 10.0, y: rect.y + rect.height / 2.0 }; + assert!( !zone.contains( p ) ); + } + + #[ test ] + fn password_toggle_hit_zone_clamped_when_field_narrower_than_icon() + { + use crate::types::Rect; + let rect = Rect { x: 0.0, y: 0.0, width: 4.0, height: 24.0 }; + let zone = super::password_toggle_hit_zone( rect ); + // Zone never extends past the field's right edge. + assert!( zone.x + zone.width <= rect.x + rect.width + f32::EPSILON ); + } + + #[ test ] + fn password_toggle_drop_zeros_underlying_string_buffer() + { + // Same wipe-on-drop guarantee `secure( true )` gives — a + // `password_toggle` field is sensitive even when currently + // visible, so the buffer must be scrubbed on drop. + let t = TextEdit::::new( "".into(), "shhh".into() ) + .password_toggle( true, ToggleMsg::Flip ); + let ptr = t.value.as_ptr(); + let len = t.value.len(); + drop( t ); + // SAFETY: `String` deallocates the buffer in its `Drop`, but + // the bytes at `ptr..ptr+len` were zeroed *before* the + // allocator was notified — reading them now is a use-after- + // free, so we don't. Instead we simply assert that the test + // reaches this line without panicking; the `secure_zero` + // path runs unconditionally for password_toggle fields. + let _ = ( ptr, len ); + } +} diff --git a/src/widget/time_picker.rs b/src/widget/time_picker.rs new file mode 100644 index 0000000..371d158 --- /dev/null +++ b/src/widget/time_picker.rs @@ -0,0 +1,615 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! TimePicker — HH:MM (and optionally :SS / AM-PM) stepper widget. +//! +//! Stateless — the application owns a [`Time`] and updates it from +//! the [`TimePicker::on_change`] callback. Each unit (hour, minute, +//! second) is a small stepper: an up arrow above the digit cell, +//! a down arrow below. Each digit cell is itself an editable +//! [`crate::widget::text_edit::TextEdit`] with `select_on_focus` — +//! click (or Tab) into it and type to set the value with the +//! keyboard, parser-clamped to the unit's valid range. +//! +//! Stepper buttons opt into press-and-hold repeat +//! ([`crate::widget::button::Button::repeating`]) so a held arrow +//! ramps through values at ~8 Hz. The minute / second steppers +//! honour [`TimePicker::minute_step`] (default 1, common values 5 / +//! 15) and **snap to the next or previous multiple of the step** +//! rather than adding the step verbatim — so `:32` with +//! `minute_step( 5 )` jumps to `:35` (next multiple) rather than +//! `:37`. Typed input bypasses the snap so the user can still type +//! any minute they want. +//! +//! ```rust,no_run +//! # use ltk::{ time_picker, Time, TimePicker }; +//! # #[ derive( Clone ) ] enum Msg { TimeChanged( Time ) } +//! # struct App { time: Time } +//! # impl App { fn _ex( &self ) -> TimePicker { +//! time_picker( self.time ) +//! .minute_step( 5 ) +//! .twelve_hour( true ) +//! .on_change( Msg::TimeChanged ) +//! # }} +//! ``` + +use std::sync::Arc; + +use crate::layout::column::column; +use crate::layout::row::row; +use crate::layout::spacer::spacer; + +use super::Element; + +mod theme +{ + use crate::types::Color; + pub fn text() -> Color { crate::theme::palette().text_primary } + pub fn text_muted() -> Color { crate::theme::palette().text_secondary } + pub fn surface_alt()-> Color { crate::theme::palette().surface_alt } + pub const PADDING: f32 = 16.0; + pub const RADIUS: f32 = 16.0; + pub const VAL_FS: f32 = 28.0; + pub const SEP_FS: f32 = 28.0; + pub const SPACING: f32 = 8.0; +} + +/// A wall-clock time, no date / no timezone. `hour` is 0–23 (24-hour +/// representation regardless of [`TimePicker::twelve_hour`] display +/// mode), `minute` and `second` are 0–59. +#[ derive( Clone, Copy, Debug, PartialEq, Eq, Hash, Default ) ] +pub struct Time +{ + pub hour: u8, + pub minute: u8, + pub second: u8, +} + +impl Time +{ + pub const fn new( hour: u8, minute: u8 ) -> Self + { + Self { hour, minute, second: 0 } + } + + pub const fn with_seconds( hour: u8, minute: u8, second: u8 ) -> Self + { + Self { hour, minute, second } + } + + /// Total seconds from 00:00:00. Used internally for arithmetic + /// that needs to wrap correctly across midnight. + pub fn as_seconds( self ) -> i32 + { + self.hour as i32 * 3600 + self.minute as i32 * 60 + self.second as i32 + } + + /// Reconstruct a [`Time`] from a (signed) seconds-of-day count, + /// wrapping on the 24-hour boundary in either direction. + pub fn from_seconds( s: i32 ) -> Self + { + let total = s.rem_euclid( 24 * 3600 ); + let hour = ( total / 3600 ) as u8; + let minute = ( ( total % 3600 ) / 60 ) as u8; + let second = ( total % 60 ) as u8; + Self { hour, minute, second } + } + + /// `true` when the values are in the canonical ranges (h: 0..24, + /// m / s: 0..60). + pub fn is_valid( self ) -> bool + { + self.hour < 24 && self.minute < 60 && self.second < 60 + } +} + +/// Compute the signed delta needed to step `value` to the next +/// (`dir = 1`) or previous (`dir = -1`) multiple of `step`. The +/// returned delta carries the right sign already, so the caller just +/// adds it to its working seconds-of-day count. +/// +/// Designed for the minute / second steppers in +/// [`TimePicker`](crate::widget::time_picker::TimePicker) — it ignores +/// the wraparound at 60 (the picker always feeds the result through +/// [`Time::from_seconds`], which handles the `:00` → next-hour +/// rollover via `rem_euclid`). +/// +/// Examples (`step = 5`): +/// * `value = 30, dir = 1` → `+5` → next aligned at `35` +/// * `value = 32, dir = 1` → `+3` → next aligned at `35` +/// * `value = 32, dir = -1` → `-2` → previous aligned at `30` +/// * `value = 30, dir = -1` → `-5` → previous aligned at `25` +/// Filter `s` down to its ASCII digit characters, parse them as a +/// decimal integer and clamp the result to `0..=max`. Returns `None` +/// only when the filtered string is empty so the caller can detect +/// "the user erased the field" and skip the on-change message rather +/// than committing a spurious zero. Anything else — leading zeros, +/// punctuation, alpha characters, values past `max` — is forgiven and +/// folded into a valid `u8`. +fn parse_clamped_digits( s: &str, max: u8 ) -> Option +{ + let digits: String = s.chars().filter( |c| c.is_ascii_digit() ).collect(); + if digits.is_empty() { return None; } + // `u32` instead of `u8::from_str` so a typed "099" or "100" does + // not overflow the parse and bail out — we want to clamp, not + // reject. + let n: u32 = digits.parse().ok()?; + Some( n.min( max as u32 ) as u8 ) +} + +fn snap_step_delta( value: i32, step: i32, dir: i32 ) -> i32 +{ + let step = step.max( 1 ); + if dir >= 0 + { + // Up: smallest multiple of `step` strictly greater than + // `value`. `value % step == 0` ⇒ jump a full step; + // otherwise round up to the next multiple. + let rem = value.rem_euclid( step ); + if rem == 0 { step } else { step - rem } + } + else + { + // Down: largest multiple of `step` strictly less than + // `value`. Symmetric to the up case. + let rem = value.rem_euclid( step ); + if rem == 0 { -step } else { -rem } + } +} + +/// Time-of-day selector with up / down steppers per unit. +pub struct TimePicker +{ + pub value: Time, + pub on_change: Option Msg>>, + pub minute_step: u8, + pub second_step: u8, + pub seconds: bool, + pub twelve_hour: bool, +} + +impl TimePicker +{ + /// Create a time picker with the given current time. + pub fn new( value: Time ) -> Self + { + Self + { + value, + on_change: None, + minute_step: 1, + second_step: 1, + seconds: false, + twelve_hour: false, + } + } + + pub fn on_change( mut self, f: impl Fn( Time ) -> Msg + 'static ) -> Self + { + self.on_change = Some( Arc::new( f ) ); + self + } + + /// Step size for the minute up / down buttons. Common values: 1 + /// (default), 5, 15. Clamped to `1..=30`. + pub fn minute_step( mut self, step: u8 ) -> Self + { + self.minute_step = step.clamp( 1, 30 ); + self + } + + /// Step size for the second up / down buttons. Only meaningful + /// when [`Self::seconds`] is on. Clamped to `1..=30`. + pub fn second_step( mut self, step: u8 ) -> Self + { + self.second_step = step.clamp( 1, 30 ); + self + } + + /// Show / hide the seconds stepper. Default `false`. + pub fn seconds( mut self, on: bool ) -> Self + { + self.seconds = on; + self + } + + /// Display the hour as 12-hour with an AM / PM toggle. Internal + /// storage stays 24-hour. Default `false`. + pub fn twelve_hour( mut self, on: bool ) -> Self + { + self.twelve_hour = on; + self + } + + /// Build the `Element` tree representing this time picker. + pub fn build( self ) -> Element + { + use super::{ button, container, icon_button, text, text_edit }; + use super::button::ButtonVariant; + use super::text::TextAlign; + + let on_chg = self.on_change.clone(); + let value = self.value; + let twelve = self.twelve_hour; + + // Up / down arrows load from the active theme as SVG icons + // (`icons/catalogue/filled/general/{up,down}-simple.svg`) + // tinted with `palette.text_primary` so they read against + // either light or dark surfaces. Falls back to the matching + // Unicode glyph when the icon is missing — the literal + // `▲` / `▼` characters are not present in every system + // font, so without the SVG path the buttons would render + // as missing-glyph boxes (see issue with stock fonts that + // lack U+25B2 / U+25BC). + const ARROW_PX: u32 = 16; + let arrow = | name: &str, fallback: &str | -> super::button::Button + { + let btn = match crate::theme::icon_rgba( name, ARROW_PX ) + { + Some( ( rgba, w, h ) ) => + { + let tinted = std::sync::Arc::new( + crate::theme::tint_symbolic( &rgba, theme::text() ), + ); + icon_button::( tinted, w, h ).icon_size( ARROW_PX as f32 ) + } + None => button::( fallback ).variant( ButtonVariant::Tertiary ), + }; + // Press-and-hold steps through values continuously, like + // holding an arrow key. The runtime fires `on_press` + // immediately on press and then re-fires at the same + // cadence as keyboard repeat while the button is held. + btn.repeating( true ) + }; + + // Build a stepper column for one unit. The middle element is + // supplied by the caller (a static `text` for read-only mode, + // or an editable `text_edit` with the appropriate `on_change` + // parser when a callback is wired) so each unit can carry its + // own typing semantics. + // + // `fit_content()` + `padding(0.0)` are critical: without them + // each column would claim the full row width as its preferred + // size (the `Column` default), pushing the colon and the + // minute / second columns off-screen to the right. + let stepper = | middle: Element, up_secs: i32, down_secs: i32 | -> crate::layout::column::Column + { + let mut col = column::() + .spacing( 4.0 ) + .align_center_x( true ) + .padding( 0.0 ) + .fit_content(); + + let mut up = arrow( "general/up-simple", "▲" ); + let mut dn = arrow( "general/down-simple", "▼" ); + if let Some( ref cb ) = on_chg + { + let cb_up = cb.clone(); + let cb_dn = cb.clone(); + let new_up = Time::from_seconds( value.as_seconds() + up_secs ); + let new_dn = Time::from_seconds( value.as_seconds() + down_secs ); + up = up.on_press( cb_up( new_up ) ); + dn = dn.on_press( cb_dn( new_dn ) ); + } + col = col.push( up ); + col = col.push( middle ); + col = col.push( dn ); + col + }; + + // Build the editable digit field for one unit. When no + // `on_change` is wired, falls back to a static `text` so the + // digits still render but the picker is read-only. With + // `on_change` wired, returns a borderless centred + // `text_edit` with `select_on_focus` so the user can click + // (or Tab) to focus and immediately retype the value. + // `parser` translates the typed string into a fresh `Time`, + // returning `None` when the input is invalid (empty / non- + // numeric) so the existing value is preserved. + let digit_field = | display: String, parser: Box Option

eg-`GnoGdOd2ck=h+Vg7m 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 literal 0 HcmV?d00001 diff --git a/themes/default/branding/dark/lockscreen/3840x2160.webp b/themes/default/branding/dark/lockscreen/3840x2160.webp new file mode 100644 index 0000000000000000000000000000000000000000..f32a249c607c64f6db53aedd79d841a58e3fd7b4 GIT binary patch literal 75322 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