A text field that serves as an editor, a file chooser, ltk::files with callbacks, Text::fit, App::window_title, and a stack that keeps its children inside it
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

A multiline text field was a box for a few lines: it wrapped its whole value by shaping each character alone on every frame, every click and every caret motion, so a file of some size made it crawl; it drew a tab as the font's glyph for it, had no scroll bar, no page keys and no way to number its lines, kept no record of the view it showed, so once the cursor had left the first screen it stayed on the last row and the arrows moved the text, and an app could neither colour its text, type a tab with Tab, give it a face of its own, ask it to copy, cut, paste or select, nor hear where its cursor was. Notes, the Eydos text editor, needs all of it, and every app that opens or saves the user's files needed a dialog of its own.
Speed. `Canvas::measure_char` measures a character's advance once per font and size and keeps it (`text_shaping::char_advance_cached`, emptied with the shaped lines), and `compute_visual_lines` keeps the last four layouts by the value's hash and length, the row width, the size and the face, so a value is wrapped again only when one of them changes, and drawing, hit testing and caret motion over the same value wrap it once.
The view. `TextEdit::draw` returns the view it showed, which the frame keeps in `text_scroll`, so the view moves only when the cursor leaves it, and by the least; a `TextCommand::Select` no longer resets it. `Window::shown` gives the lines as the last frame showed them, wherever the cursor went since, and a press, the wheel, the scroll bar and the page keys act on them: a press that gave a field the focus, which puts its cursor at the end, landed where the view would have gone to show that cursor, near the end of the text, instead of on the character pressed. The cursor a scroll keeps is taken back to the value's length as the view's is, so a scroll made with the cursor at its end sentinel holds instead of going back to the cursor at once.
Tabs. In a multiline field a tab reaches the next stop, every four digits' widths, as four monospace columns are (`theme::TAB_COLUMNS`, `text_edit/tabs.rs`), when it is drawn, selected, clicked and crossed with the arrows. `TextEdit::tab_text( text )` has Tab type `text` while the field has the focus instead of moving the focus on; Shift+Tab still moves it back and Ctrl+Tab still reaches the app.
The scroll bar. A field whose lines do not all fit shows a scroll bar along its right edge (`Window::scrollbar`): its thumb is held and dragged, or a press on its track brings the thumb there, by pointer or touch, without moving the cursor (`AppData::text_scrollbar_grab`, `handle_text_scrollbar_press`, `handle_text_scrollbar_drag`); the pointer over it is the arrow rather than the text cursor, and a long press that holds it no longer opens the Copy / Cut / Paste menu.
Page Up and Page Down move the cursor of a multiline field by the lines on screen but one, at the same height on screen, and the view with it, to the start or the end from the first or the last page (`cursor_visual_page`, `handle_cursor_page`); Shift extends the selection, and with Ctrl, or in any other widget, the keys still reach the app. A key without Ctrl that types no text, such as F3, was dropped while a text field had the focus; it now goes to `App::on_key_with_modifiers`, as it does with no field focused.
The text as an editor's. `TextEdit::highlights( Arc<Vec<TextHighlight>> )` draws runs of a multiline value, byte ranges in order, in colours of their own, what no run covers in the field's colour and a run off a character boundary left out. `TextEdit::font( face )` lays a multiline field's text out in a face of its own at the field's size: wrapping, drawing, the selection, the cursor, clicks, tab stops and the arrows all measure through one `TextFont`, the size and the face, which `text_input_geometry` now gives the runtime's paths where it gave them the size; `Canvas::measure_char_with_font` is `measure_char` in another face. `ltk::monospace_font()` finds a monospace face, DejaVu Sans Mono from the app's resources, which a page and an APK carry, else the first of the system's installed, loaded once. `TextEdit::line_numbers( true )` numbers the lines in a column at the field's left, in the monospace face when there is one, as wide as the number of the last line needs, a wrapped line numbered on its first row and the cursor's in the text colour while the field has the focus; the text starts right of the column alike when drawn and in every runtime path, since they all lay it out in the rect `gutter::text_rect` gives. `borderless()` now also drops a multiline field's background and border.
Talking to the field. `App::take_text_command` hands a field, by its `WidgetId`, a `TextCommand` after the next frame: `Copy`, `Cut`, `Paste` and `SelectAll` through the paths the keys take, and `Select { start, end }`, which focuses the field, selects the range with its ends taken back to the value and to its characters' starts, and scrolls to it. `Reveal { start, end }` does the same without moving the focus, as a search does while its own field keeps the keys, and a multiline field without the focus now shows its selection dimmed (`theme::selection_inactive`). `TextEdit::on_cursor( |at| … )` sends the cursor's byte offset once it is placed and whenever it moves or the value changes length (`report_text_cursors`, after every frame), as an editor's status bar showing the line needs.
A file chooser. `ltk::FileChooser` (`src/file_chooser/`) picks a file to open or a folder and a name to save one as, over `ltk::files`, alike natively and in a browser. The app holds it, puts its `view()` in its own and hands its `FileChooserMsg`s to `update()`, which returns `Chosen::Path` or `Chosen::Cancelled` once the user chose; `wrap` makes the app's message of the chooser's, so its listings reach it through the app's `ChannelSender`. It offers the home folder, the xdg-user-dirs folders that exist, the root and the folders mounted in a browser, with a press there to mount another; it lists folders first, the hidden entries left out, each with its type's icon, a file with its size and the date of its last access or change, sorted as Crate sorts, by name, last access or size, each key in a direction of its own (`Sort`, `SortKey`, `Directions`). `dir` starts elsewhere, `filter( &[ "text/*" ] )` shows only some types of files, `compact( true )` puts the places behind a button for a phone, `sort` starts in an order kept from `current_sort`, and `take_focus_request` gives the field to focus. Saving asks before replacing a file and can create a folder; its errors are in the user's words, and its strings in ltk's seven locales. `files::mounts()` gives the folders mounted in a browser as the desktop last said, which the web backend now keeps. `docs/widgets.md` describes it and `examples/file_chooser.rs` shows it, in `make examples`.
ltk::files::callback. The operations of `ltk::files` with a function in place of a message (`list`, `stat`, `read`, `read_range`, `write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`), for code with no run loop's `ChannelSender` to take one, such as Prompt's session daemon, which in a browser runs in the page beside the window. `done` is called on ltk's worker thread natively and on the page's thread in a browser.
Closing. An app that keeps its window open in `on_close_requested`, to ask whether to save, was not drawn again, so its question appeared only at the next event, such as a press. Every close request, from the compositor, the title bar's close button, back and the browser, now goes through `AppData::request_close`, which draws the view again when the app refused it.
`App::window_title`. The main window's title, read when the window is made, which the title bar, the task switcher and the dock show; by default the `app_id`. A `ShellMode::Window` app was titled "ltk" on Wayland, whatever the app, with no way to name it but the deprecated `window_config`; in a browser its title was its `app_id`, and is now this.
`Text::fit` and `Text::valign`. `fit()` draws a text at the largest size up to its own at which it fits the rect the layout gives it, on one line within its width and height or, with `wrap`, all its lines within its height, so a text whose length varies, such as a calculator's display, neither overflows a box the window sizes nor needs its size worked out from the window and its content; its preferred size is still the one at its own size. `valign( VAlign )` places the lines at the top, the centre or the bottom of the rect.
A stack keeps its children inside it. A child that `Stack` aligned to an edge or the centre took its preferred size even when that was larger than the stack, so one aligned to the bottom started above the stack's top and one aligned to the right left of its left edge, drawn over whatever was there. Each child's preferred size is now capped by the stack's, less its margin, before it is aligned; children placed with `push_placed` and the offsets of `push_translated` are as they were.
Tests cover Tab with and without `tab_text`, the text commands, `Reveal` leaving the focus alone, a highlight into a character, `on_cursor` reporting once, a refused close drawing again, a key that types nothing reaching the app from a field, tab stops, a click on a tab, the kept layouts, the scroll bar, a view kept while the cursor moves inside it and after a scroll with the cursor at its sentinel, a click landing on the view shown, the page keys, the width of the line numbers, the file chooser's listing, filters, names, user folders, sizes, sorting and its open, save and replace flows, `Text::fit` and a stack's aligned children. `CHANGELOG.md` and `docs/widgets.md` describe the changes.
This commit is contained in:
2026-10-26 23:00:33 +01:00
parent 18a0e02249
commit 8882078743
62 changed files with 3191 additions and 185 deletions
+16
View File
@@ -6,6 +6,16 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Added
- **`App::window_title`** — the main window's title, read when the window is made, which the title bar, the task switcher and the dock show; by default the `app_id`. A `ShellMode::Window` app was titled "ltk" on Wayland, whatever the app, with no way to name it but the deprecated `window_config`; in a browser its title was its `app_id`, and is now this.
- **`Text::fit` and `Text::valign`** — `fit()` draws a text at the largest size up to its own at which it fits the rect the layout gives it: on one line within the rect's width and height, or, with `wrap`, all its lines within its height. A text whose length varies, such as a number, a total or a title, in a box whose size the window sets, so neither overflows the box nor needs its size worked out from the window and the length of its content. Its preferred size is still the one at its own size. `valign( VAlign )` places the lines at the top (the default), the centre or the bottom of a rect taller than they are, as a fitted text's may be.
- **A text field as an editor: `TextEdit::highlights`, `TextEdit::tab_text` and `App::take_text_command`** — `highlights( Arc<Vec<TextHighlight>> )` draws runs of a multiline value, byte ranges in order, in colours of their own, as a syntax highlighter marks keywords, strings and comments; what no run covers keeps the field's colour, and a run that does not fall on character boundaries is left out. `tab_text( text )` has Tab type `text`, such as four spaces, while the field has the focus, instead of moving the focus on; Shift+Tab still moves it back, and Ctrl+Tab still reaches the app. `App::take_text_command` returns once a `WidgetId` and a `TextCommand` for that field, `Copy`, `Cut`, `Paste`, `SelectAll` or `Select { start, end }`, which the runtime carries out after the next frame, focusing the field first: a field's cursor and selection are the runtime's, so an app's Edit menu or its search had no way to copy what was selected or to select what it found. `Select` takes its offsets to character boundaries within the value and scrolls a multiline field to them. `borderless` now also leaves out a multiline field's background and border, as an editor filling its window wants. Tests cover Tab with and without `tab_text`, selecting and copying, offsets inside a character, cutting, selecting all, and a run inside a character.
- **A file chooser: `ltk::FileChooser`** — picks a file to open (`FileChooser::open`), or a folder and a name to save one as (`FileChooser::save`), over `ltk::files`, alike natively and in a browser, so that an app need not make its own. The app holds it, puts `view()` in its own view and hands its `FileChooserMsg`s to `update()`, which returns `Chosen::Path` or `Chosen::Cancelled` once the user chose; `wrap` makes the app's message of the chooser's, so its listings and folders reach it through the app's `ChannelSender`. It shows the home folder, the xdg-user-dirs folders that exist, the root and the folders mounted in a browser, with a press there to mount another; folders come first, then files by name with their size, each with its type's icon, the hidden ones left out. `dir( path )` starts elsewhere, `filter( &[ "text/*" ] )` shows only files of those types, and `compact( true )` puts the places behind a button for a narrow screen. A button of its header sorts the folder as Crate does, by name, last access or size, each key in a direction of its own, folders always first, showing beside each file its size and the date of its last access or change; `sort( Sort )` starts in an order the app kept from `current_sort()`. Saving asks before replacing a file, and can create a folder; `take_focus_request()` gives the field to focus. Its strings are in ltk's locales; `docs/widgets.md` describes it and `examples/file_chooser.rs` shows it. `files::mounts()` gives the folders mounted in a browser as the desktop last said, which the web backend now keeps.
- **`TextCommand::Reveal { start, end }`** — `Select` without moving the focus: the field's bytes `start..end` are selected and a multiline field scrolls to them while the keys stay where they were, as a search needs while its own field keeps them. A multiline field without the focus now shows its selection, dimmed (`theme::selection_inactive`), where it showed none.
- **`TextEdit::font( face )`** — a multiline field's text in a face of its own, such as `ltk::monospace_font()` for an editor of code, at the field's size. Wrapping, drawing, the selection, the cursor, clicks, tab stops and the arrows all measure in it: they lay the text out through one `TextFont`, the size and the face, which `text_input_geometry` gives the runtime's paths as it gave them the size. `Canvas::measure_char_with_font` is `measure_char` in a face other than the canvas's.
- **`ltk::monospace_font()`** — a monospace face for text that lines up in columns: DejaVu Sans Mono from the app's resources, which a page and an APK carry, else the first of the system's monospace faces installed (DejaVu, Hack, Noto Sans Mono; Android's Droid Sans Mono), loaded once; `None` when there is none.
- **`TextEdit::line_numbers( true )`** — a multiline field numbers its lines in a column at its left, in the monospace face when there is one, as wide as the number of the last line needs, a line that wraps numbered on its first row and the cursor's in the text colour while the field has the focus. The text starts right of the column, alike when drawn, clicked, scrolled and moved through with the keys, since they all lay it out in the same rect (`gutter::text_rect`, which `text_input_geometry` gives).
- **A multiline field's tabs, scroll bar and cursor: `TextEdit::on_cursor`** — a tab in a multiline value reaches the next stop, every four digits' widths, as four monospace columns are (`theme::TAB_COLUMNS`), when it is drawn, selected, clicked and crossed with the arrows, where it was drawn as the font's glyph for it, so `tab_text( "\t" )` makes an editor of tabs. A field whose lines do not all fit shows a scroll bar along its right edge: its thumb is held and dragged, or a press on its track brings the thumb there, by pointer or touch, without moving the cursor, and the pointer over it is the arrow rather than the text cursor. Page Up and Page Down move the cursor by the lines on screen but one, at the same height on screen, and the view with it, to the start or the end from the first or the last page; Shift extends the selection, and with Ctrl, or in any other widget, the keys still reach the app. `on_cursor( |at| … )` sends the cursor's byte offset once it is placed and whenever it moves or the value changes length, as an editor's status bar showing the line needs.
- **`ltk::files::callback`** — the operations of `ltk::files` with a function in place of a message (`list`, `stat`, `read`, `read_range`, `write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`), for code with no run loop's `ChannelSender` to take one, such as Prompt's session daemon, which in a browser runs in the page beside the window. `done` is called on ltk's worker thread natively and on the page's thread in a browser.
- **The clipboard and the primary selection between a browser desktop's applications** — on a page with forge/web, what an app copies (`publish_clipboard`) goes to forge/web as a `set_selection` request as well as to the browser's clipboard, and the selection the middle button pastes (`publish_primary_selection`, which did nothing in a browser) as `set_primary_selection`. forge/web's `selection` and `primary_selection` events, which every client hears when one sets them and each hears on connecting, become `AppData::clipboard` and `AppData::primary_selection`, as another client's selection does on Wayland, so copying, and selecting and pasting with the middle button, work between the page's applications whether or not the browser lets a page write its clipboard. A `paste` from the browser still brings what other applications copied; a browser has no primary selection a page can reach.
- **The user's files, alike natively and in a browser: `ltk::files`** — asynchronous operations on the user's files whose results reach the app as messages through its `ChannelSender`: `list`, `stat`, `read`, `read_range`, `write`, `open_write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`, each taking the sender and a `done` that turns the `io::Result` into a message. Natively they run `std::fs` on a worker thread of ltk's, and each copy on a thread of its own; in a browser they are forge/web's `fs_*` requests over the app's connection, contents travelling as a `Uint8Array` beside the JSON as the second argument of the connection's `request` and of the client's event handler, and what is asked before the app connects is sent once it has, or fails with `Unsupported` on a page without a desktop. Contents travel in chunks: a large file is read with `read_range` and written through a `Writer`, chunk by chunk, `files::CHUNK` (1 MiB) at a time, and its contents appear only once the writer is closed, a new file showing empty until then in a browser; natively it is written beside the file and renamed over it, and a writer dropped unclosed leaves the file as it was. A copy runs where the files are, reports its progress every 100 ms, never replaces an existing file, and, cancelled through its `Operation` or failed, leaves nothing behind. Errors are the same `io::ErrorKind`s on every platform. `Entry` and `Metadata` give a file's kind, size and times, the access time natively only. `mount` asks the browser for a folder of the user's computer, from the user's press, and fails with `Unsupported` natively. `App::on_files_changed`, `App::on_mounts_changed` and `App::on_open_files` hear of forge/web's `fs_changed`, `fs_mounts` and `open_files`. `files::mime_type( name )` gives a file's type by shared-mime-info's `globs2`, the resource directory's `mime/globs2` first, which `scripts/android-assets.sh`, and so `web-assets.sh`, now copies into the tree with shared-mime-info's `subclasses` (`MIME_DIR` overrides where from), then the system's. `rename` never replaces what is at `to`, natively as in a browser. Tests cover writing, aborting, listing, ranges, removing, copying a tree with its progress, a cancelled copy, a copy that would replace, and the globs.
- **`ltk::web::shell_request( json )`** — a shell message that waits for forge/web's reply, such as `get_window_thumbnail`: sent as `shell_send` sends one and answered at once with forge/web's reply as JSON, or `None` before the app has connected, since a request is not kept for later, and when forge/web replies nothing. A shell's requests that wait for an answer natively got none in a browser.
@@ -69,6 +79,8 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Changed
- **A key that types nothing reaches the app from a text field** — with a text field focused, a key without Ctrl that types no text, such as F3, was dropped; it now goes to `App::on_key_with_modifiers`, as it does with no field focused, so an editor can search again with F3 and back with Shift+F3.
- **A long multiline value no longer slows every frame** — wrapping measured each character by shaping it alone, on every draw, click and caret motion. Each character's advance is now measured once per font and size (`Canvas::measure_char`), and the last layouts are kept by the value, width and font, so a value is wrapped again only when one of them changes.
- **A surface's scale may be fractional.** `SurfaceState::scale_factor` is an `f32`, the physical pixels per logical one, which `Platform::prepare_canvas`, `Presentation::scale` and the paint path take as it is; a surface's physical size is its logical size times the scale, rounded once (`physical_width`, `physical_height`), and `buffer_scale()` gives the whole number Wayland's buffer scale and regions need. Wayland and Android still set whole scales, so nothing changes there; the browser backend sets the page's `devicePixelRatio`. `App::on_scale_changed` keeps its `u32`, the scale rounded, and `App::on_resize` gives the exact physical size.
- **`ltk::ChannelSender` is ltk's own type** instead of a re-export of calloop's `Sender`, with the same `send`, returning `Result<(), std::sync::mpsc::SendError<T>>`, and `Clone`, so apps that only send and clone it build unchanged; code that named calloop's type has to name ltk's. The runtime arms its timers and channels through `event_loop/sources.rs` instead of a calloop `LoopHandle`. calloop, khronos-egl and raw-window-handle are dependencies only off `wasm32`, where `egl_context`, `UiSurface::try_new_gles`, `UiSurface::from_current_gles_loader` and the native GL names of `BorrowedGlesTexture` (`texture_id`, `framebuffer_id`) do not exist; the monotonic clock is `crate::time::Instant`, which is `web_time::Instant` on `wasm32`, and `chrono` takes its `wasmbind` feature there.
- **Themes, fonts, keyboard layouts and images are read through `vfs.rs`**, which is the file system natively and on `wasm32` a set of files held in memory, filled through `vfs::insert`. A wallpaper or an `Image::from_path` is now decoded from the file's bytes, so its format is told from its contents rather than its extension.
@@ -90,6 +102,10 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Fixed
- **A close the app refuses shows what it asks** — an app that keeps its window open in `on_close_requested`, to ask whether to save, was not drawn again, so its question appeared only at the next event, such as a press. Every close request now goes through `AppData::request_close`, which draws the view again when the app refused it.
- **A multiline field keeps the view it shows** — the first line on screen was worked out on every frame from the last one scrolled to by hand, or from the top, and never kept, so once the cursor had gone below the first screen it stayed on the last row: the arrows moved the text instead of the cursor, and a click brought the line clicked to the bottom. `TextEdit::draw` now returns the view it showed, which the frame keeps, so the view moves only when the cursor leaves it, and by the least; a `TextCommand::Select` no longer resets it. A press, the wheel, the scroll bar and the page keys act on the lines on screen as the last frame showed them (`Window::shown`): a press that gave a field the focus, which puts its cursor at the end, landed where the view would have gone to show that cursor, near the end of the text, instead of on the character pressed. A long press that holds the scroll bar no longer opens the Copy / Cut / Paste menu.
- **A multiline field scrolled by hand stays where it was taken** — focusing a field puts its cursor at the end through a sentinel past the value, and a scroll by the wheel kept that sentinel while the view compared it with the cursor taken back to the value's length: they never matched, so the view went back to the cursor at once and the wheel seemed to do nothing. The kept cursor is now taken back the same way.
- **A stack keeps its children inside it.** A child that `Stack` aligned to an edge or the centre took its preferred size even when that was larger than the stack, so one aligned to the bottom started above the stack's top, and one aligned to the right left of its left edge, drawn over whatever was there. Each child's preferred size is now capped by the stack's, less its margin, before it is aligned, so a child larger than the stack gets the stack; children placed with `push_placed` and the offsets of `push_translated` are as they were.
- **A pull from the top unwound back to where it started still reports its release.** `ReleaseEvent::OverscrollDown` was sent only while some overscroll was left, so a pull dragged back up to zero ended without one, and an app that had moved its view with the pull's progress was left waiting for it. `GestureState::overscroll_engaged` remembers that the gesture reported progress, and its release now always reports `OverscrollDown`, uncommitted when nothing is left of the pull; a drag that never reached overscroll still reports nothing.
- **Pasting what another application copied.** The Wayland backend bound its `wl_data_device` and its primary-selection device in `SeatHandler::new_seat`, which smithay-client-toolkit calls only for seats announced after start-up, not for the seat already there when the app connects. An app therefore never had either device, the compositor never offered it another client's selection, and neither Ctrl+V nor the middle button pasted anything copied outside the app, nor did what it copied reach other applications through the compositor. Both devices are now bound on the seat's first capability, which comes for every seat.
- **The middle button pastes the selection just made in another application.** A compositor offers the primary selection to the window it gives the keyboard, and gives it on the press, so a middle click on a window that did not have the keyboard reaches it together with the offer. ltk reads an offer on a thread of its own and pasted at once what it had read before, so after selecting in another application an ltk app pasted the selection before that. A middle-button paste, into the text field under the pointer or one an app asks for with `SelectionKind::Primary`, now waits while a selection is being read (`Platform::primary_selection_pending`, which the Wayland backend counts) and is made once it has been, or after 500 ms with what there is.