Commit Graph
Select branches
Hide Pull Requests
main
Select branches
Hide Pull Requests
main
-
0872c9149b
Make a scroll or fading viewport's sub-canvas again when the window's scale changes, rather than keep drawing it at the old one Prompt opened on a screen at scale 2 and moved to one at scale 1 drew its tab labels and icons at twice their size, with only a couple of letters fitting in each tab, while the star, the + and the menu beside the tab row, and the terminal, came out right. Opened on the second screen directly, the tabs were right too. The sub-canvases of scrolls (
scroll_canvases) and fading viewports (viewport_canvases) are kept from one frame to the next and reused, resized when their size changes.Canvas::sub_canvascopies the parent'sdpi_scaleand layout viewport when it makes one, so a sub-canvas kept across a change of scale went on with the old one. Prompt's tab strip sits in a horizontal scroll, so its labels were drawn at px(13) × 2 on a canvas at scale 1.reuse_sub_canvastakes the pooled sub-canvas only while itsdpi_scalematches the parent's, resizing it as before, and otherwise makes a new one withparent.sub_canvas, which takes the current scale, density and viewport. Both the scroll and the fading viewport go through it. Not verified: the move of Prompt between the two screens has not been tried with this change. mainadmin
2026-11-04 13:13:47 +01:00 -
9900443ff1
Text in the theme's primary text colour unless given another, rather than white A
Textgiven no colour was drawn white, which on a light theme's white surfaces could not be read: Prompt's dialog to share tabs showed neither the code nor the time left in it, and the titles of the clip_path, file_chooser and tab_strip examples, drawn over the theme's background, did not show in the light theme.Text::newnow takes the theme'stext_primary, as the toast, the text field and the pickers already do, so a label given no colour reads on light and dark themes alike. Its documentation and the test of its defaults say so.admin
2026-11-03 17:05:43 +01:00 -
058a65546d
The print dialog in the CHANGELOG and the widget catalogue The commit that added
ltk::printleft both out.CHANGELOG.mdgains its entry under Added, afterltk::dirs: what the dialog offers, how the app answers its requests for a preview, CUPS loaded at first use, the printers, jobs and default printer for a settings page, the print portal for an app in a Flatpak withltk::print::gtkfor a portal backend, zbus in thewaylandfeature, and only saving as PDF on Windows, Android and in a browser.docs/widgets.mddescribesprint_dialogamong the pickers, afterfile_chooser, and links it from the table of contents: a component the app holds as it holds the file chooser, the preview the app renders on request, the job it delivers withltk::print::send, and the builders that choose where it starts. Its example opens the dialog, answers its events and sends the PDF to a printer, and is compiled bymake doctest-md.admin
2026-11-01 22:53:46 +01:00 -
45b8ddd483
A print dialog with a preview, CUPS and the print portal, a Windows backend, and ltk::dirs for the user's folders Printing. An app that wanted to print had nothing in ltk to ask the user where and how, nor to reach a printer.
ltk::print(src/print/) is a dialog built asFileChooseris: the app holds aPrintDialog, puts itsview()in its own and hands itsPrintDialogMsgs toupdate(), which returns aPrintEvent;wrapmakes the app's message of the dialog's, so the printer list reaches it through the app'sChannelSender. It offers the destination, the printers CUPS knows with their state and "Save as PDF", the number of copies, all pages or a range such as1-3, 5, 8-, the paper (A4, Letter, Legal, A3, A5), the orientation, the margins, the scale, two-sided printing, colour and background graphics, the copies, sides and colour only for a printer.PrintOptionsholds what was chosen and survives between dialogs,parse_pagesandformat_pagesread and write the ranges. On a desktop the dialog is a card with ltk'sdialog(), the preview on the left and the settings on the right;compact()makes it a whole page for a phone..printer( name )and.save_as_pdf()choose where it starts. The preview. With.preview( true )the dialog asks the app for one withPrintEvent::Preview( options )whenever a setting that moves the pages changes, and shows what the app hands back toPrintDialogMsg::previewed, aPreviewof some pages as RGBA with the document's total, one page at a time with buttons to move between them, greyed when colour is off. While one is being rendered a new request waits and is sent only if the layout changed meanwhile, so turning the scale quickly does not stack renders; until the first arrives the sheet's box shows "Preparing the preview…", kept to the sheet's width so it does not push the settings aside. CUPS.print/cups.rsloadslibcups.so.2withdlopenat first use, so ltk neither links against it nor needs its headers, and a system without CUPS only loses its printers. It uses the destinations API, which also lists the network's driverless printers that have no queue:printers()with the default first,print( printer, title, options, pdf )sending the PDF as one job withcopies,mediaas a PWG name,sidesandprint-color-mode,jobs(),cancel()andset_default(), which writes the user'slpoptions.ltk::print::send,list_printers,jobs,cancel_jobandset_defaultrun them on a thread and answer through the app's sender;ltk::print::blockingexposes them as they are, for code that has its own thread, such as a portal backend.print/none.rsstands in on Windows, Android and the browser, where only saving as PDF is offered. The portal. An app in a Flatpak cannot reach the CUPS socket. When/.flatpak-infoexists, the dialog shows nothing of its own: it callsorg.freedesktop.portal.Print.PreparePrintwith the options as GTK's print settings and page setup, and the portal's own dialog answers;Destination::Portalthen carries its token, andsendpasses the PDF toPrintthrough a memfd. The Response signal is subscribed to on the request's path before the call, so a quick answer is not missed.print/gtk.rstranslatesPrintOptionsto and from GTK's keys (n-copies,page-rangescounted from zero,duplex,use-color,scalein percent, the paper by its PWG name and size in millimetres), which a portal backend uses for the other direction. zbus 4, the one accesskit_unix already brings, joins thewaylandfeature for it. The dialog's strings are in ltk's seven locales.file_chooser::view::iconis shared with the dialog. Windows. Thewindowsfeature runsltk::runin a Win32 window on Windows 10 1703 or later, with Windows's own frame, drawn through ANGLE's EGL and OpenGL ES whenlibEGL.dllandlibGLESv2.dllsit beside the executable and in software through GDI otherwise (src/platform/windows/). It sizes the window byApp::window_size_hintat the display's DPI and follows per-monitor DPI changes at fractional scales and the accessibility text size; it takes the mouse, the wheel, touch throughWM_POINTER, and keys with Windows's repeat, dead keys and AltGr; it has the system clipboard, the cursor's shape, full screen, and moves and resizes begun from the app's own chrome, and keeps drawing while the window is moved or resized. Themes, fonts, keyboard layouts and MIME globs come fromltk\beside the executable, whichltk::resource_dirreturns there by default; the font fallback chain and the monospace face are Windows's own, the system face stays the embedded Sora, and the file chooser offers each drive's root. A Win32 message loop cannot wait on calloop, so the runtime's timers and channels go through the queue the web backend already used:build.rssetscfg( queued_sources )for both, andAppData::dispatch_sourcesmoves from the web backend toevent_loop/sources.rs.Canvas::present_topresents a GPU frame stretched over a window that a fractional scale left a pixel off it, andEglContext::new_windowsandcreate_native_surfacegive ANGLE's display and anHWND's surface.App::save_stateis saved into%LOCALAPPDATA%\<app_id>\every 30 seconds, on exit and when the user's session ends, and restored as on Wayland: the part of the Wayland session runtime that does not speak the protocol, deciding why the app starts and handing it its bytes, is nowsession_state::bootstrap, which both backends call, with the file modes and the check for a running instance done each platform's way.RunError::Windowreports a window Windows refused.make windows-depsandmake windows-checktypecheck the backend from Debian, compiling std fromrust-src.docs/windows.mddescribes building, what ships beside the executable and what the backend does. ltk::dirs.home,data_home,config_home,cache_homeandstate_home: XDG's on Linux, with their usual fallbacks under the home folder, and on WindowsUSERPROFILE,APPDATAfor data and settings andLOCALAPPDATAfor the cache and state, anXDG_*variable that is set winning everywhere. An app finds its files there instead of readingHOMEand the XDG variables itself, which on Windows are unset; ltk's own lookups of themes, keyboard layouts, MIME globs, the file chooser's folders and the session state now go through it, andstate_dirtakes the base it is given, its tests with it. Docs. The links toWidgetIdin the crate's,types' andApp's documentation name the struct, since theconst fnof the same name made them ambiguous to rustdoc.README.mdnames the other backends andCHANGELOG.mdrecords the Windows backend andltk::dirs. Not done. The print dialog has no CHANGELOG entry nor a page indocs/widgets.md. Under the portal there is no preview, since the portal's dialog gets the document only at the end. The desktop card's settings column does not scroll, so with a list open in a low window it may not fit. Tests cover the page ranges and the translation to GTK's keys, not the dialog, CUPS or the portal. On Windows there are no overlays, secondary surfaces or subsurfaces, no preedit for the input method and no accessibility.admin
2026-11-01 22:49:18 +01:00 -
02ab10b3ef
State restored on an ordinary launch for apps that ask, focus that follows its widget's id, buttons that do not take the focus when pressed, a focus ring only after the keyboard, and a browser app's surfaces copied rather than transferred, which WebKit got wrong Restore on launch.
restore_statefollows Android's saved-instance-state contract: the saved bytes come back after a crash or a session restore, and a launch from the launcher starts fresh. Some apps keep state their users expect to find however the last run ended, as an Android browser keeps its tabs in its own storage even when closed from the app switcher.App::restore_on_launch, false by default, has the runtime restore on a plain launch too;SessionRuntime::bootstrapasks it before deciding. Saving, the file layout and the window geometry are the same either way. Its documentation explains when it is asked, that it is not when persistence is off, thatrestore_statemust then merge what the launch itself asks for, such as a URL on the command line, and that whatsave_statereturns now outlives every exit; it carries a compiled example.restore_state's documentation,docs/onboarding.mdand the cookbook's session state recipe mention it. Focus on a press.Button::focusable( false )andWindowButtonkept out of the Tab cycle but still took the focus when pressed, and Enter then pressed them again, which closed a browser's tab when the user pressed Enter in a page after closing another tab.LaidOutWidgetgainsfocus_on_press, fromElement::focus_on_press: a button'sfocusable, aWindowButton's, a pressable's newPressable::focus_on_press, true by default, and true for every other widget.focus_pressedreplacesset_focusfor pointer and touch presses and leaves the focus where it was when the widget does not take it. Enter's fallback to the widget under the pointer, whichhovered_idxfollows, skips such widgets too, and a rebuild that leaves the focus on one drops it (drop_misplaced_focus). The close buttons oftab_strip, on the desktop and on the phone's cards, are not focusable;symbol_buttontakes whether its button is. Focus by id. The focus was held as the index of a widget in the laid-out tree, so a rebuild that inserted or removed widgets before it moved it to whatever widget took that index.WidgetIdbecomes a name and a key,WidgetId::keyed( name, key )telling apart widgets of the same kind, such as the tabs of a strip;WidgetId( "name" )still builds one, through aconst fnof that name, so the calls in every app compile unchanged, and only code that read.0, the toasts, the combo and the tooltip here, moves to the derivedHashorname(). After each layoutfollow_focused_idfinds the focused id and moves the focus to its new index with the field's pending text, cursor, selection and scroll; it drops the focus when the widget is gone, keeps it when a scroll only clipped it, and leaves it when the widget at the old index still has the id.draw_surfacereturns whether the focus moved, and its callers draw the surface again rather than marking it done, since the frame just drawn showed the focus in the old place. Widgets without an id keep the index. Each tab and each card oftab_stripcarriesWidgetId::keyedwith the tab's key, so a chosen tab keeps the focus while others close. Focus ring. A press that focused a button, a toggle or a list item left its ring drawn, which browsers have not done since:focus-visible.SurfaceState::focus_visibleis set by Tab and Shift+Tab and cleared by a press that moves the focus,show_focus_ringredrawing the whole surface when it changes, since the partial redraw only sees indices. The draw is given the focused index only while the ring shows or the widget is a text field, which draws its cursor however it was focused; the focus itself is unchanged, so Enter and Space still press the widget. Key repeat. A key repeated by the platform rather than by ltk's own timer reached the focused widget even whileApp::claims_keyboardwas true;key_repeatedchecks it askey_pressdoes. Browser surfaces. Every surface is painted through the app's one WebGL context on oneOffscreenCanvas, and was handed to its own canvas, abitmaprenderer, withtransferToImageBitmap. WebKit reuses the buffer of a bitmap it was handed, so in a WebKit browser each surface showed another's frame, or none, and only the last one painted was right: forge/web's background, dock and windows came and went in a WPE browser while Chromium showed them all. A test page with two canvases fed from one offscreen canvas showed their colours swapped in WPE and right with a copy. Each surface's canvas is now a2done, andpresent_copycopies the offscreen canvas withdrawImageincopycomposition, set before every copy since resizing a canvas resets its context, so a transparent frame replaces the last rather than leaving trails behind an animation. Subsurfaces go the same way.WebSurfaceStatelosesbitmapandnewitsgpuargument, and web-sys loses theImageBitmapandImageBitmapRenderingContextfeatures. CHANGELOG records each of these. Not done. The tests that buildLaidOutWidgetby hand set the new field, but no test covers a press on a non-focusable button, the focus following an id or the ring. For one frame after a rebuild moves the focused widget, its ring and its cursor may show in the old place.admin
2026-11-01 09:19:23 +01:00 -
da34eee9e6
External textures drawn at their own size, a repaint without rebuilding the view, an icon size for list rows, and lighter tabs on the desktop strip External textures.
ExternalSource::Texturestretches its texture over the widget's rect, so a producer that is a few frames behind a new rect shows its old picture stretched or squeezed. A web page while its window is resized is the clearest case: the page keeps sending frames of the old size until it has laid itself out again, and the content wobbles as the window moves. The newExternalSource::Frameclosure returns the texture with its size in physical pixels, and the texture is drawn at that size from the rect's top left corner, cut to the rect. A late frame then leaves an empty edge or loses one for a moment, as other browsers do, instead of being distorted. The cut usesinterior_clip, which moves from the text field's drawing code torender/clip.rsso both widgets can use it, and the clip that was active before is restored afterwards. Repaint without a view. An application whose only change is a new frame in an external texture had to reportInvalidationScope::All, which rebuilds the whole view just to draw the same widgets again.App::needs_repaintis asked once per iteration, after the messages are processed. When it returnstrue, the main surface and the secondary surfaces are redrawn from the cached view, without callingview. The default isfalse, so existing applications behave as before. List rows.ListItem::icon_sizeoverrides the size of the leading icon, which was always the theme's 24 design px, for dense lists such as a menu, where an icon of that size dominates the row. Tab strip. On the desktop, a tab's title is drawn at the regular weight whether it is selected or not; the selected tab already stands out by its fill and edge, and the bold title made the strip heavy. The desktop tab's close icon is 10 widget pixels instead of the 16 that the cards of the tab overview keep, soparts::closenow takes the icon size from its caller.admin
2026-10-27 12:46:20 +01:00 -
8882078743
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 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_charmeasures a character's advance once per font and size and keeps it (text_shaping::char_advance_cached, emptied with the shaped lines), andcompute_visual_lineskeeps 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::drawreturns the view it showed, which the frame keeps intext_scroll, so the view moves only when the cursor leaves it, and by the least; aTextCommand::Selectno longer resets it.Window::showngives 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 typetextwhile 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 toApp::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 oneTextFont, the size and the face, whichtext_input_geometrynow gives the runtime's paths where it gave them the size;Canvas::measure_char_with_fontismeasure_charin 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 rectgutter::text_rectgives.borderless()now also drops a multiline field's background and border. Talking to the field.App::take_text_commandhands a field, by itsWidgetId, aTextCommandafter the next frame:Copy,Cut,PasteandSelectAllthrough the paths the keys take, andSelect { 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, overltk::files, alike natively and in a browser. The app holds it, puts itsview()in its own and hands itsFileChooserMsgs toupdate(), which returnsChosen::PathorChosen::Cancelledonce the user chose;wrapmakes the app's message of the chooser's, so its listings reach it through the app'sChannelSender. 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).dirstarts elsewhere,filter( &[ "text/*" ] )shows only some types of files,compact( true )puts the places behind a button for a phone,sortstarts in an order kept fromcurrent_sort, andtake_focus_requestgives 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.mddescribes it andexamples/file_chooser.rsshows it, inmake examples. ltk::files::callback. The operations ofltk::fileswith a function in place of a message (list,stat,read,read_range,write,create_dir,rename,remove,remove_all,copy,copy_all,mountandunmount), for code with no run loop'sChannelSenderto take one, such as Prompt's session daemon, which in a browser runs in the page beside the window.doneis called on ltk's worker thread natively and on the page's thread in a browser. Closing. An app that keeps its window open inon_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 throughAppData::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 theapp_id. AShellMode::Windowapp was titled "ltk" on Wayland, whatever the app, with no way to name it but the deprecatedwindow_config; in a browser its title was itsapp_id, and is now this.Text::fitandText::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, withwrap, 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 thatStackaligned 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 withpush_placedand the offsets ofpush_translatedare as they were. Tests cover Tab with and withouttab_text, the text commands,Revealleaving the focus alone, a highlight into a character,on_cursorreporting 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::fitand a stack's aligned children.CHANGELOG.mdanddocs/widgets.mddescribe the changes.admin
2026-10-26 23:00:33 +01:00 -
18a0e02249
A pull from the top that is unwound back to where it started still reports its release, so an app that followed the pull hears that it was not committed A scroll view at its top turns a downward drag into overscroll, which
on_movereports asMoveOutcome::OverscrollDown { progress }, and on releaseReleaseEvent::OverscrollDown { committed }tells the app whether the pull went past its threshold, as pull-from-top-to-dismiss needs. The release was reported only whileoverscroll_ywas above zero. A pull dragged back up until it was unwound to zero therefore ended without any release event, and an app that had moved its view with the pull's progress was left waiting for one, its view where the last progress put it.GestureState::overscroll_engagedis set once a gesture has reported overscroll progress and cleared with the rest of the gesture's state. On release, a gesture that reported progress now always gets itsOverscrollDown, uncommitted when nothing is left of the pull. A gesture that never reached overscroll still reports nothing. Tests cover a pull unwound to zero, which reports an uncommitted release, and a drag that only scrolled, which reports none.CHANGELOG.mddescribes the change.admin
2026-10-25 17:33:32 +01:00 -
8f689ec479
An ltk app pastes what other applications copied, the middle button pastes the selection just made, a browser desktop's applications share the clipboard and the primary selection, and the office icons are drawn like the terminal's Pasting from other applications. Neither Ctrl+V nor the middle button pasted into an ltk app anything copied in another application; only what was copied within the same process pasted. The Wayland backend bound its
wl_data_deviceand its primary-selection device inSeatHandler::new_seat, which smithay-client-toolkit calls only for seats announced after start-up:SeatState::newbinds the seats already in the registry without calling it, and on every compositor the seat is there when the app connects. The app therefore never had either device, the compositor never offered it another client's selection, and what it copied did not reach other applications through the compositor either. A Wayland trace of prompt shows both managers bound and neitherget_data_devicenorget_devicesent. Both devices are now bound innew_capability, which comes for every seat, the first time it does;new_seatdoes nothing. The middle button and a selection just made. 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 arrives together with the offer. ltk reads an offer on a thread of its own and pasted at once what it held, so after selecting in another application an ltk app pasted the selection before that.Platform::primary_selection_pendingsays whether another application's selection is still being read: the Wayland backend counts the reader threads it starts and the selections that arrive (PrimarySelection::pending), and the other backends answerfalse. A middle-button paste, into the text field under the pointer (PrimaryPaste::Field) or for an app that asked withSelectionKind::Primary(PrimaryPaste::App), goes throughAppData::paste_primary, which while a read is pending keeps it inpending_primary_paste;check_primary_paste, run before every draw, makes it once the selection has arrived or after 500 ms with what there is, andnext_primary_paste_wakeupwakes the loop every 10 ms meanwhile, since the reading thread does not. A field paste is made only if the field still has the focus. The clipboard and the primary selection in a browser desktop. On a page with forge/web, what an app copies goes to forge/web as aset_selectionrequest as well as to the browser's clipboard, and the selection the middle button pastes, which did nothing in a browser, asset_primary_selection(Connection::set_selection). forge/web'sselectionandprimary_selectionevents, which every client hears when one sets them and each hears on connecting, becomePageEvent::Selectionand soAppData::clipboardandAppData::primary_selection, as another client's selection does on Wayland. Copying, and selecting and pasting with the middle button, thus work between the page's applications whether or not the browser lets a page write its clipboard. Apastefrom the browser still brings what other applications of the computer copied; a browser has no primary selection a page can reach. The office icons. The default theme's word processor, spreadsheet and presentation icons came from Figma with aforeignObjectbackdrop blur that resvg ignores, and showed as flat white discs with pale glyphs. They are drawn again as the terminal's are: a dark gradient disc in each one's colour (blue, green, orange), a bright glyph and the terminal's filter. The doc comment ofltk::fileslinkswrite()as a function, sincewritealone was ambiguous with the macro for rustdoc.CHANGELOG.mddescribes the changes.admin
2026-10-23 19:20:10 +02:00 -
cf187a3dcc
The user's files through ltk::files, alike natively and in a browser, and a shell's requests answered in a browser through ltk::web::shell_request An app read the user's files with
std::fs, which a browser does not have: there a page reaches a folder of the user's computer only through forge/web, which holds it through the File System Access API and answers asynchronously (forge/web/DESIGN.md, section 11). An app that builds for both, such as Crate, had no way to list, read or write a file in a browser, and a page's memory cannot hold a large file whole. ltk::files. Asynchronous operations on the user's files whose results reach the app as messages through itsChannelSender:list,stat,read,read_range,write,open_write,create_dir,rename,remove,remove_all,copy,copy_all,mountandunmount, each taking the sender and adonethat turns theio::Resultinto a message. Results come in no set order, so an app that has moved on checks that one is still the one it waits for. Errors are the sameio::ErrorKinds on every platform,Interruptedfor a cancelled copy.EntryandMetadatagive a file's kind, size and times, the access time natively only.ltk::vfsstays what reads an app's own resources. Natively.std::fson one worker thread of ltk's, in the order the app asked (files/native.rs), and each copy on a thread of its own, so a long one holds nothing else up (files/copy.rs). A file written whole or through aWritergoes beside its target as a temporary file, synced and renamed over it on commit and removed if dropped before, so a file being written is never seen half done.renamenever replaces what is atto, as in a browser. In a browser. Each operation is one of forge/web'sfs_*requests over the app's connection (platform/web/files.rs), carrying a number itsfs_replyanswers with; contents travel as aUint8Arraybeside the JSON, the second argument of the connection'srequestand of the client's event handler, soon_eventnow takes it. What is asked before the app has connected is sent once it has; on a page without a desktop, or once the connection has closed, it fails withUnsupported. forge/web's error codes become theirio::ErrorKinds, andfs_progressreaches a copy'sprogress. Without the web backend, on wasm32 every operation fails withUnsupported(files/unsupported.rs). Chunks. Contents never travel whole unless they are small: a large file is read withread_rangeand written through aWriter, chunk by chunk,files::CHUNK(1 MiB) at a time, the next sent once the last is answered. AWriterdropped withoutcloseis aborted and the file left as it was; in a browser a new file shows empty until closed. A copy runs where the files are, its contents never passing through the app: it reports its progress every 100 ms, never replaces an existing file, and, cancelled through itsOperationor failed, leaves nothing behind. Mounts and the desktop's notices.mountasks the browser for a folder of the user's computer, from the user's press, andunmountlets it go; natively both fail withUnsupported.App::on_files_changed,App::on_mounts_changedandApp::on_open_fileshear of forge/web'sfs_changed,fs_mountsandopen_files, which the web backend queues as page events; natively they are not called yet. Types.files::mime_type( name )gives a file's type by shared-mime-info'sglobs2, with its weights, its case-sensitive globs and fnmatch's*,?and bracket classes, from the resource directory'smime/first, then the user's and the system's.scripts/android-assets.sh, and soweb-assets.sh, now copies shared-mime-info'sglobs2andsubclassesinto the tree'smime/(MIME_DIRoverrides where from), so a page has them. ltk::web::shell_request. A shell message that waits for forge/web's reply, such asget_window_thumbnail: sent asshell_sendsends one and answered at once with forge/web's reply as JSON, orNonebefore 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.shell.rsparses a message once for both, andsendreturns forge/web's reply. Tests cover writing and committing, aborting, listing, ranges, removing, copying a tree with its progress, a cancelled copy, a copy that would replace, and the globs.CHANGELOG.mddescribes the changes.admin
2026-10-22 00:15:39 +02:00 -
af4009374d
A browser app takes part in the desktop as a Wayland client does: fullscreen, the desktop's windows, activation and the shell protocol through forge/web, its own files through ltk::vfs, and images its caller names On a page with forge/web an ltk app had a main surface, overlays and subsurfaces, but none of what a shell such as crustace needs from the compositor: fullscreen did nothing, the app heard nothing of the other windows, an activation token it asked for never came, forge/web was never told when a launched window had shown, the shell could not speak its protocol, and the app had no way to read its own files in a browser. Each now goes through the app's connection to forge/web, in the shape of the Wayland protocol it stands for. Fullscreen. On a page with forge/web,
App::fullscreenandApp::start_fullscreensend aset_fullscreenrequest for the main window, asxdg_toplevel.set_fullscreendoes, instead of doing nothing; alone on a page the canvas still takes the browser's fullscreen.examples/web_hello.rsgains a button that toggles it. The desktop's windows. On connecting, the backend asks for the list of toplevels, and forge/web'stoplevel_openedandtoplevel_closedevents becomeToplevelEvents forApp::on_toplevel_event, asext-foreign-toplevel-list-v1does on Wayland, so a shell's dock knows which applications have windows open. Activation. The tokens an app asks for withApp::take_activation_requestsare issued before it draws: forge/web's, through itsactivation_tokenrequest, or empty without a desktop, as on Wayland without xdg-activation, and handed toApp::on_activation_tokenwith their messages processed. A browser app that launched others through tokens waited for one that never came. Each surface's first presented frame is reported with apresentedrequest (WebSurfaceState::presented), so forge/web knows when a window it launched has shown and can report it activated, which dismisses crustace's splash. The shell protocol.platform/web/shell.rscarries the JSON a shell speaks to forge over its socket over the app's connection to forge/web:ltk::web::shell_send( json )sends it as ashellrequest, and eachshellevent's message reaches the listenerltk::web::set_shell_listenergives, as JSON. What is sent before the app has connected is kept and sent once it has, so a shell can tell forge/web its theme while it starts. An overlay's role now carries itsApp::overlay_namespace, as its layer-shell namespace does on Wayland, so forge/web can tell a shell's overlays apart. The cursor over every surface. The CSScursorwas set on the main surface's element only, so over an overlay the pointer kept the page's arrow.WebSurfaceStatekeeps the element that takes its input, the shape moves to that of the surface the pointer enters, andWebPlatform::cursorkeeps the last one asked for. An app's own files.ltk::vfs, through which ltk reads its themes, fonts and keyboard layouts, is public:read,read_to_string,read_dir,is_fileandis_dirread the file system natively and, in a browser, the files the page loaded into memory.ltk::web::load_tree( base, root ).awaitloads any tree served with aMANIFESTunderroot, andload_resourcesis that into/ltkwith the directory made the resource directory.ltk::time::Instantis public too,std's natively andweb_time's on wasm32, wherestd::time::Instant::nowpanics, so an app that builds for both measures time with ltk's clock. wasm-bindgen-cli.scripts/wasm-bindgen-cli.sh TOOLS_DIRbuilds wasm-bindgen-cli intoTOOLS_DIR/binat the version of thewasm-bindgencrate ltk pins in itsCargo.toml, from its sources with a lock resolved for Debian's rustc, which is whatmake web-depsdid inline and now runs. Sincemake installcopiesscripts/into the registry, a project that builds for the browser against the installed ltk, such as forge/web or crustace, gets the same CLI without a checkout of ltk. The README and docs/architecture.md say so. Images named by their caller. Every draw throughdraw_image_datafingerprints its pixels to find them in the image caches, which for a terminal means hashing every glyph of every frame.Canvas::draw_image_keyed( key, rgba, w, h, dest, opacity )isdraw_image_datawithkeynaming the pixels in place of their fingerprint: every draw with the same key must pass the same pixels, which are read only when the image is not cached. Both backends' caches are keyed byImageId,Contentfor a fingerprint andKeyedfor a caller's key, so a key never names an image drawn by content even when it equals its fingerprint.GlesCanvasandSoftwareCanvasgaindraw_image_keyed, and theirdraw_image_dataand it share one privatedraw_image. Tests check that a keyed image draws as its pixels do and that a key equal to a fingerprint is another image. A held button is not also tapped. A button withon_long_pressand noon_drag_startstayed a tap after its hold fired, so releasing it sent itson_pressas well.GestureState::long_press_msg_firedis set when the hold fireslong_press_msgand suppresses the press message on release; a test covers it.CHANGELOG.mddescribes these changes.admin
2026-10-21 10:21:11 +02:00 -
cd1a233b2e
A browser app joins the page's desktop: on a page with forge/web its main surface, overlays and subsurfaces are surfaces of that desktop, all painted through one WebGL context The browser backend ran an app alone on a canvas over the page. An Eydos desktop in a browser needs crustace as its shell and other ltk apps as its windows on one page, managed by forge/web (forge's web/ directory), which publishes
globalThis.forgeWeband stacks, places and focuses the surfaces its clients ask for (forge/web/DESIGN.md). The desktop's client. On a page whereglobalThis.forgeWebexists andset_canvasnamed no canvas,platform/web/desktop.rsconnects as the app'sapp_idand asks for the main surface in the role the app's hooks give, as a Wayland compositor reads them:shell_mode, and for a layerlayer_anchor,layer_size,exclusive_zoneandkeyboard_exclusive, for a windowapp_idandwindow_size_hint, and forSessionLocka full overlay that holds the keyboard. The app draws into the canvas forge/web gives and listens on the input element above it, takes its size and scale from forge/web'sconfigurerather than from the window'sresize, sends its input region in logical pixels when it changes, and leaves the keyboard focus to the desktop, which honours the surfaces that hold it exclusively. Acloseof the main surface reachesApp::on_close_requested; on exit the surface is destroyed and the connection closed. The newRunError::Desktopsays forge/web refused the main surface. Fullscreen inside a desktop does nothing yet. A page without a desktop, or one that named a canvas, runs the app as before. One WebGL context. A page holds few WebGL contexts, and the browser loses the oldest beyond them, while a shell has many surfaces. Every surface of an app now paints through one WebGL2 context on anOffscreenCanvas: the surface's FBO is blitted onto it, taken as anImageBitmapand handed to the surface's own canvas, abitmaprenderer, without a copy. Without WebGL2 each surface draws in software on a 2D context of its own. The context's loss is heard on theOffscreenCanvas, and now drops the GPU canvases of every surface, not only the main one's. Overlays.platform/web/overlays.rskeeps the app's overlays in step withApp::overlays(), as the Wayland backend'soverlays_reconcile.rsdoes with layer-shell: a new one becomes a layer surface or, forLayer::Window, a window, with its size and exclusive zone in logical pixels; a changed one gets anupdate_role; one that went is destroyed; forge/web'scloseof one delivers itson_dismiss. Popups anchored to a widget and the tooltip wait for a popup role forge/web does not have, and without a desktop an app still shows no overlays. What every backend does as overlays come and go, resolving anOverlaySpec::sizeand moving a drag and the focuses off an overlay that goes, moved out of the Wayland backend intoevent_loop/overlays.rs(AppData::overlay_size,AppData::remove_overlay); Wayland's behaviour is unchanged. Subsurfaces.platform/web/subsurfaces.rsmakes each ofApp::subsurfaces()a surface of the desktop sized to its parent with an empty input region, painted on one shared GPU canvas only when it appears, its size changes or itscontent_versiondoes, its frame then staying on its own canvas, and otherwise moved with anupdate_role, which the browser composes without painting.App::on_subsurface_committedfollows each paint. A subsurface whose parent overlay was destroyed and made again is made again, since forge/web destroys a subsurface with its parent.frame_presentednow knows whether a surface hosts subsurfaces, so while the app animates by subsurface motion only its frames keep coming without painting the parent, as Wayland keeps a parent's frame callbacks. Painting a view with no focus, hover or press on a canvas of its own, which both backends' subsurfaces do, moved out of the Wayland backend intodraw::paint_detached. Input per surface.input::listentakes the surface'sSurfaceFocus, which pointer, touch and keyboard focus events carry; a surface's blur no longer takes the keyboard from the surface focused after it. Listeners remove themselves when dropped, and a surface's live in itsWebSurfaceState, so they go with it. The frames owed at the next animation frame are a list of surfaces instead of one, which with several surfaces left the main one waiting on a frame that never came.examples/web_shell.rsis a shell for the desktop: a background, a bar in the top layer with an exclusive zone, a panel that takes the keyboard, and a card on a subsurface that slides.make web-examplebuilds it with web_hello (WEB_EXAMPLES); forge/web'smake helloserves both at /shell.html. web-sys gainsOffscreenCanvas,ImageBitmapandImageBitmapRenderingContext.CHANGELOG.mddescribes the change. Not done. Popups and the tooltip in a desktop, overlays and subsurfaces without one, fullscreen and window moves through forge/web, and the cursor's shape over an overlay, which is still set on the main surface's element. The offscreen canvas is resized for each surface it paints, which reallocates its drawing buffer when the surfaces differ in size. There are no tests for the browser backend.admin
2026-10-19 22:42:12 +02:00 -
95beeda66a
Image caches that find an entry in O(log n), so drawing many small images no longer slows down as the caches fill Prompt's window had come to spend 35 ms of CPU on every frame after some hours open, most of it in
GlesCanvas::draw_image_data. Every cache hit looked its key up in the LRU order withVecDeque::iter().position()and moved it to the back withremove, both linear in the number of entries. A terminal draws each glyph as an image, one per colour it is drawn in, so the cache filled towards its 32 MiB with thousands of small textures, 7573 in the window measured, and every glyph of every frame walked all of them. The software canvas's premultiplied cache and the GLES surface cache did the same.render/lru.rsis a least-recently-used cache bounded by a byte budget. Each entry keeps the tick it was last used at and aBTreeMaporders the keys by it, so a hit, an insertion and an eviction cost O(log n).insertreturns the values it evicted, for the GLES caches to delete their textures, and never evicts the one just inserted, so a value larger than the whole budget is still kept; inserting a key again returns its old value. Tests cover the eviction order, a value over the budget and a replaced key.ImageCacheholds its images and its surfaces in oneLrueach, with the same budgets of 32 and 16 MiB;ImageCache::imageuploads a texture only when it is not cached, andevict_overflowand the two linear lookups are gone. The software canvas keeps its premultiplied images in anLruin place ofPremulCache. What is cached and evicted is unchanged.admin
2026-10-19 22:39:34 +02:00 -
1a39c43762
Composite modes on the GLES canvas: Canvas::set_composite_mode and CompositeMode, completing the blend state the previous commit already applies A canvas only composited source-over. A host that draws Porter-Duff modes, as Android's Canvas does with an xfermode, had to read the pixels back, combine them on the CPU and upload them again, which on the GLES backend meant a glReadPixels and a fresh texture for every such draw. The new
CompositeModein types.rs, exported from the crate root, names the Porter-Duff operators (Clear, Src, Dst, SrcOver, DstOver, SrcIn, DstIn, SrcOut, DstOut, SrcAtop, DstAtop, Xor) and the separable modes with an exact premultiplied form (Plus, Multiply, Screen); SrcOver is the default. Darken and Lighten are left out, since GL can only approximate them where alpha is below one.Canvas::set_composite_modesets how the following draws, blits included, combine with the canvas. On a GLES canvas it stores the mode, whichactivate_targetturns into the blend function before every draw: each canvas carries its own, because the GL blend state is global and canvases share one context. A sub-canvas starts in SrcOver and does not inherit its parent's mode. A software canvas ignores the call and keeps compositing source-over. The field, its initialisation andapply_compositelanded with the previous commit; this one adds the type and the public entry point they use, without which that commit does not build. The inset shadow, which switches the blend for one draw, now restores the canvas's mode instead of always going back to source-over.admin
2026-10-19 22:16:17 +02:00 -
20dc21448e
A browser backend: ltk runs on a page's canvas through WebGL2, on a core that builds for wasm32, and a surface's scale may be fractional ltk ran on Wayland and Android only. Running crustace and its apps in a browser needs, first, ltk apps running there; a window manager for the page is the next step. The runtime was already split from Wayland by the platform layer, so what kept it out of a browser was what the core and the runtime took from the operating system: calloop's epoll loop, EGL, a monotonic clock, a file system, threads and environment variables. The core builds for wasm32-unknown-unknown.
ltk::ChannelSenderis ltk's own type instead of a re-export of calloop'sSender, with the samesendandClone, so apps that only send and clone it build unchanged. The runtime arms its timers and channels throughevent_loop/sources.rsinstead of a calloopLoopHandle: calloop's natively, and in a browser a queue the backend runs, which takes the due timers out, runs them onAppDataand hands them back, and does not arm again one removed meanwhile. calloop, khronos-egl and raw-window-handle are dependencies off wasm32 only, whereegl_context.rs,UiSurface::try_new_glesandfrom_current_gles_loaderand the native GL names ofBorrowedGlesTextureare left out;sub_canvasclones the uniform locations, which areCopynatively and onlyCloneon wasm. The monotonic clock iscrate::time::Instant,web_time's on wasm32, wherechronotakes itswasmbindfeature; the one subtraction that could go below the clock's origin, a surface's firstlast_draw, saturates. Themes, fonts, keyboard layouts and images are read throughvfs.rs, the file system natively and on wasm32 files held in memory, so a wallpaper or anImage::from_pathis now decoded from the file's bytes and its format told from its contents rather than its extension. The text-scale watcher, which runs gsettings on a thread, is left out on wasm32, and the built-in keyboard takes its language fromplatform::language(),LANGor the page's. criterion is a dev-dependency off wasm32 only, since rayon does not build there. The browser backend is thewebfeature,platform/web/, with wasm-bindgen, wasm-bindgen-futures, js-sys and web-sys.build.rsrefuses it with another backend or for another target. A page's main thread must not block, soltk::runsets the app up and returns, and the browser drives the loop:AppDatalives in a closure a thread-local loop holds, which asetTimeoutruns for messages, timers and page events and arequestAnimationFrameruns to draw. Event listeners andChannelSender::sendonly queue and schedule, never touchingAppData, so nothing they set off runs inside an iteration. The app draws on a canvas laid over the whole page, or the oneltk::web::set_canvasgives, through WebGL2 with the GLES canvas unchanged, its frame kept in its FBO, or without WebGL2 throughputImageData. A lost context drops the GPU canvases and draws nothing until it is restored on a newglow::Context, so the glyph atlas and the caches of the old one are not reused. The main surface's logical size is the canvas's CSS size; the window'sresizerefits it, and on ltk's own canvas a hidden element padded byenv( safe-area-inset-* )gives the insets. Input comes from Pointer Events, a mouse as the pointer and fingers and pens as touches, the wheel, in detents when it scrolls lines, and keys, whose keysym comes fromKeyboardEvent.keyand code from.codeas the Linux input code Wayland reports; keys repeat on the browser's clock, AltGr types its character, and on macOS Command is Ctrl. ltk takes every key the browser lets a page have, and while fullscreen thosenavigator.keyboard.lock()adds in Chromium. Copies go tonavigator.clipboard.writeText; a page reads the clipboard only from apasteevent, so Ctrl+V is let through to the browser and held until itspastehas put the text inAppData::clipboard, and a paste from the browser's menu comes with a Ctrl+V of its own. The cursor's shape is the canvas's CSScursor. The built-in keyboard serves text where the page is used by touch. Panics go to the browser's console. When the app exits, the listeners are removed, the canvas receives anltk-exitevent and the app is dropped. The newRunError::NoDocumentis returned where there is no page, as in a worker. Resources are the tree an Android package carries.scripts/web-assets.shbuilds it throughandroid-assets.sh, andltk::web::load_resources( base ).awaitfetches itsMANIFESTand every file it lists, with the manifest's hash in their URLs so a changed tree is not served from the browser's cache, into memory under/ltk, which becomes the resource directory. The per-script fallback fonts are read throughresources::read_font, so a path that does not exist is found by file name in the resource directory'sfonts/, where a page bundles them. A surface's scale may be fractional.SurfaceState::scale_factoris anf32, whichPlatform::prepare_canvas,Presentation::scaleand the paint path take as it is; a surface's physical size is its logical size times the scale, rounded once, andbuffer_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'sdevicePixelRatio, so the canvas holds the display's own pixels at any zoom.App::on_scale_changedkeeps itsu32, the scale rounded, andApp::on_resizegives the exact physical size. The fallback-theme banner placed its text as ifdraw_texttook the top of the line rather than its baseline, so most of the text fell above the banner, out of the surface; it is now centred as the title bar's is. Building.make web-checktypechecks the backend.make web-depsinstalls Debian's std for wasm32 and wasm-bindgen-cli, whose version must be the one of the wasm-bindgen crate inCargo.lock: the crate is pinned to 0.2.117, the last whose CLI builds with Debian's rustc 1.85, and since the lock the CLI was published with needs a newer rustc andcargo installignores rust-version when it resolves, the CLI is built from its sources with a lock resolved for the rust-version it declares, intotarget/tools, where the Makefile finds it.make web-assetsbuilds the resources with the in-tree default theme and the fallback fontsWEB_FONTSnames.examples/web_hello.rs, a counter and a text field, andweb/index.htmlare the smoke test:make web-examplebuilds them intotarget/web/andmake web-serveserves them.make installnow copiesweb/into the registry.README.md,docs/architecture.mdandCHANGELOG.mddescribe the backend, anddocs/cookbook.mdpoints toChannelSender's new home. Not done. In a browser there are no overlays, secondary surfaces or subsurfaces, no dead keys or input methods, no accessibility tree and no primary selection, and a canvas that changes size while the window does not is not refitted. Pasting relies on the browser sendingpasteto a focused canvas, which Chrome and Firefox do. The page fetches its whole resource tree, about 7 MB, before the app starts. There are no tests for the browser backend,Sources' queue orvfs.rs's in-memory files, and fractional scales may leave one-pixel artefacts where edges fall between pixels.admin
2026-10-19 20:20:12 +02:00 -
4c7e73d492
Text and RichText take a font face the host loaded itself, for hosts that resolve their own system families
TextandRichTextcould only draw in a theme family, named by(family, weight, style)and resolved through the active theme's font registry, or in the canvas default. A host that finds its fonts elsewhere could not use them: rustdroid draws Android's sans-serif, which is Roboto looked up through fontconfig, so that an app's text keeps the widths it was designed with, and those faces are not in any ltk theme. Both widgets gainface( Arc<FontFace> ). A face given this way takes precedence overfontinresolve_font, so it is used for drawing and for measuring alike, andRichText::mapcarries it over. Without it nothing changes. .gitignore lists what a Debian package build leaves under debian/: the debhelper directories and logs, the staged package trees of libltk-dev, libltk-doc and ltk-theme-default, the build stamp, debian/files and the substvars.admin
2026-10-18 18:01:18 +02:00 -
ef6847b12c
Rectangles with square corners are filled with hard edges on the GLES backend, so rectangles that share an edge leave no seam between them On the GLES backend, Prompt's selection showed a dark line between every two of its rows.
GlesCanvas::fill_rectalways grew the quad by 1 px and passedu_pad = 1, so the rect shader never took its solid path, which only runs with no radius, no stroke and no pad, and antialiased every rectangle over a band from −1 to +1 px around its edge. A pixel along an edge, its centre half a pixel inside, was covered at 84 % even when the edge lay on a whole pixel, so two rectangles that met there each left 16 % of what lay beneath showing through, which over a dark background reads as a line. The software backend does not show it, since tiny-skia covers a pixel-aligned edge fully. A rectangle with square corners now gets no pad, which takes the shader's hard-edged path, as tiny-skia fills it. Rounded rectangles keep the pad and their antialiased edges. A square rectangle whose edges fall between pixels now has a hard edge where it had a soft one.admin
2026-10-17 20:04:40 +02:00 -
cacf871ddd
An Android backend, a platform layer that keeps Wayland out of the runtime, the built-in keyboard for an app's own text input, and APKs packaged with Debian's own tools ltk only ran on a Wayland compositor: the runtime, the input handlers and the draw paths named smithay-client-toolkit's types throughout, so no other windowing system could be added without touching all of them. Prompt, the Eydos terminal, has to run on an Android phone, with its sessions and its sharing, and needs a toolkit that runs there. The platform layer. Input reaches the runtime as platform-neutral events:
input/event.rsdefinesKeyInput,PointerInputandTouchInput, and the pointer, touch and keyboard handlers take them through a few entry points (pointer_input,touch_input,key_press,key_release,key_repeated,modifiers_changed,keyboard_enter,keyboard_leave,set_repeat_info). The sctk handler implementations moved toplatform/wayland/input.rs, which records the serials later requests need, resolves surfaces and translates. What the runtime asks of the windowing system (moving, resizing, minimising and fullscreening the window, a bare frame request, the cursor's shape, the input method, publishing the clipboard and the primary selection, preparing a canvas, presenting it and its damage) forms theplatform::Platformtrait, with its surface type asPlatform::Surface. The protocol state moved fromAppDataintoplatform::wayland::WaylandPlatform, held asAppData::platform, and each surface's Wayland half (its kind, SHM pool, EGL window surface and what the compositor was last told) fromSurfaceStateintoWaylandSurfaceState, held asSurfaceState::platform. The compositor, shell, seat and output handlers, the data device, the primary selection, the run loop, overlay and subsurface reconciliation, session management, output probing and window fitting moved fromevent_loop/toplatform/wayland/. Drawing is split from presenting:draw/paint.rspaints a surface in full or under its dirty rects, the same for the software and GPU canvases, in place of the four near-identical paths ofdraw/software.rsanddraw/gles.rs, and the backend presents it (platform/wayland/present.rs, which also took the opaque and input regions andpick_shm_format). The runtime is generic over the platform,AppData<A, P: Platform = NativePlatform<A>>andSurfaceState<Msg, S = NativeSurface>, resolved at compile time. The backend-neutral half of a loop iteration isevent_loop/step.rs(process_messages,draw_due,next_wakeup,before_draw,after_draw), andevent_loop/setup.rsbuilds the state and the event sources every backend has; the accessibility tree and its actions moved toevent_loop/a11y.rs.platform/mock.rsis a platform that logs what is asked of it, andevent_loop/tests.rsdrives the runtime on it through input, update, painting and presentation. The behaviour on Wayland is unchanged, except that a frame whose presentation fails leaves the next one to repaint in full.ltk::Keysymnow comes fromxkeysym, the crate sctk re-exported it from, so it is the same type. Features.wayland, on by default, brings the Wayland backend and its dependencies, now optional, AT-SPI2 accessibility among them.androidbrings android-activity 0.6, ndk 0.9 and jni 0.21. A newbuild.rsrefuses both at once and setscfg( backend )when one is on, which builds the runtime behindrunandtry_run. Without a backend only the runtime-free core builds:UiSurface, widgets, layout, theming and rendering. The Android backend.ltk::runruns inside a NativeActivity onceandroid_mainhas handed itsAndroidApp, re-exported asltk::AndroidApp, to the newltk::set_android_app; without itrunfails withRunError::NoAndroidApp. The first call in the process sends standard output and error, panics included, to logcat, setsHOMEto the app's internal storage and, unless set,SSL_CERT_DIRto Android's CA certificates andLANGto the device's language, and unpacks the bundled resources; every call keeps the latest activity, since Android starts the next one in the same process. The backend draws through EGL on the activity's window, or in software into a locked window buffer, and sizes the main surface at the display's density in whole multiples of 160 dpi. Fingers and styluses become touches, a mouse the pointer, and key events keysyms and text through the device's character map. calloop's descriptor rides on the activity'sALooper, so the app's channel and timers keep working. Text is typed on ltk's own keyboard, since a NativeActivity gets only key events from the system's. Through JNI on the activity (platform/android/java.rs): copies go toClipboardManagerand its text is read back whenever the app gains the focus; back with nothing left to go back to minimises the app withmoveTaskToBackor closes it, asApp::back_at_rootsays, which the newPlatform::back_leavesdecides per backend; and the window's insets come fromgetRootWindowInsets()on Android 11 and later, the status bar and any cutout on top and only bars that take taps on the other edges, so there is no gap above the view nor below it in gesture navigation, and a bar of buttons still keeps its room; earlier releases fall back to the content rect. When the app's last surface closes, ltk callsfinishAndRemoveTaskand waits for the activity to be destroyed, since a second activity started in a process whoseandroid_mainhas returned never came up; an app should end the process afterrunreturns.ltk::keep_running( Some( text ) )starts a foreground service withtexton an ongoing notification, so Android does not freeze the app once it leaves the screen, andNonestops it. Surface insets.SurfaceState::insetsholds the logical pixels the system covers along each edge; the view is laid out inside them while the background fills the whole surface, and positions stay absolute, so hit testing is unchanged. The built-in keyboard is placed inside them too, above a bar of navigation buttons. On Wayland they stay zero. Resources in a package.ltk::set_resource_dir( dir )names an app's own copy of ltk's resources andltk::resource_dir()returns it, so an app also finds its own bundled files there. Itsthemes/is searched after the user's themes and before/usr/share/ltk/themes, itskeyboards/before/usr/share, and a font a theme names by an absolute path that does not exist is read from itsfonts/by file name, so a theme written against Debian's paths works unchanged. On Androidset_android_appunpacksassets/ltk/into the internal storage and uses it as the resource directory;assets/ltk/MANIFESTlists the tree, since the asset manager cannot list subdirectories, and its first line, a hash of the contents, decides whether to unpack again. On Android the per-script fallback fonts come from/system/fontsand the primary face is the embedded Sora. APKs.scripts/android-assets.shbuilds that tree from installed themes, without their cursors, with the fonts eachtheme.jsonnames and ltk's keyboard layouts.scripts/android-apk.sh OUT.apk LIB.so PACKAGE LABEL [ASSETS_DIR]packages an app built as acdylibwith Debian's aapt, zipalign and apksigner, with no Gradle and no SDK download: it writes the manifest, with the library loaded aslibmain.so, optional permissions and a theme without an action bar, which the content rect would otherwise leave room for; links it againstandroid-framework-res; adds the assets and the library; aligns and signs, creating a debug keystore when there is none.ICONtakes an SVG, rasterised withrsvg-convertfor every density, or a PNG.KEEP_ALIVE=1compilesjava/net/liberux/ltk/KeepAlive.javawith javac anddxfrom art-standalone against the platform 34android.jar, against which aapt then links too, since Debian's framework-res predates thespecialUseservice type, and declares the service withKEEP_ALIVE_USEas its reason and the permissions it needs.examples/android_hello.rsis the smoke test:make android-checktypechecks the backend with Debian's rustc,rust-srcand NDK through-Zbuild-std,make android-apkandmake android-installpackage and install the example,make depsandmake android-depsinstall what the targets need, andmake installnow copiesexamples,scripts,javaandbuild.rsinto the registry. The built-in keyboard for an app's own text input. WhileApp::external_text_inputasks for text and no ltk text field has the focus, the built-in keyboard shows withApp::external_text_purpose, so a terminal gets its row of Escape, Tab, Ctrl, Alt and arrows, comes back when the serial changes and hides when the request ends. Its keys reach the app as physical ones, pressed and released throughApp::on_raw_key_eventwith the panel's Ctrl and Alt held for them. Before, it only served ltk's text fields, so such an app had no keyboard where the compositor offers no input method. Clippy.make clippyis clean again: derivedDefaults replace the hand-written ones,div_ceil,then_some, struct update syntax for strokes, the map entry API in the text renderer, function paths in place of closures in the examples and tests, and fewer casts and explicit lifetimes.README.md,docs/architecture.mdandCHANGELOG.mddescribe the features, the backend, the events and their units, and what stays with a backend. Not done. On Android there are no overlays, secondary surfaces or subsurfaces, no dead keys from a physical keyboard and no saved state for the activity. Why a second activity in the same process never comes up was not found. The keep-alive service does not ask for the notification permission, so on Android 13 and later its notification may not show until it is granted in the app's settings.admin
2026-10-16 23:46:50 +02:00 -
85aa85a357
The GLES backend keeps one glyph atlas per GL context, split into pages, so a surface that is created again does not rasterise and upload its text again Every GLES canvas and sub-canvas had a glyph atlas of its own, a 2048×2048 texture created empty with the canvas, so every surface that was torn down and built again, and every scroll viewport inside it, rasterised and uploaded all of its glyphs on its first frame. crustace's app launcher pays this on every opening: the sliding subsurface and its scroll sub-canvas are destroyed when the launcher closes, and the interactive overlay that replaces the subsurface at the top has scroll sub-canvases of its own. With
LTK_PERF_RASTER=1on a PinePhone Pro (RK3399, Mali-T860, panfrost) the first raster of the subsurface took 175 to 330 ms of CPU and the first full raster of the overlay 175 to 260 ms, on every opening, with the GPU done 3 ms later; the user saw a delay of two or three tenths of a second before the launcher followed the finger and a stall of about one more when it reached the top. On a Librem 5 (i.MX8MQ, GC7000L, etnaviv) the same rasters took 18 to 40 ms. Panfrost makes each glyph upload expensive. The glyphs of a line are written into the atlas withglTexSubImage2Dafter earlier lines were drawn from it, and when a pending batch still samples a texture that is written to, Mesa 25.0 allocates a new buffer for the texture and copies the whole old one into it with the CPU (panfrost_ptr_mapinpan_resource.c), 4 MiB for the old atlas, read from write-combined memory. Etnaviv flushes and waits for the GPU instead (etna_transfer_map), whose cost does not depend on the size of the texture. The newgles_render/glyph_atlas.rsholds oneGlyphAtlasper GL context, found throughshared_glyph_atlasas the image cache is throughshared_image_cache, and every canvas and sub-canvas keeps anRcto it, so glyphs rasterised by one surface are resident for the next. The context is held as aWeak, so the atlas of a context that was dropped is forgotten instead of being found again by a new context allocated at the same address. The atlas is made of 512×512 pages, created as glyphs need them, up to 8 MiB in all; a glyph larger than a page gets a page of its own, up to the 2048 px of the old atlas, and those pages are freed when the atlas is reset, the regular ones kept and rewound. A write into a page that a pending draw samples then costs panfrost a copy of 256 KiB, not of 4 MiB. The glyph cache moves into the atlas withGlyphEntry, which records the page of each glyph, and its key; the cap of 8192 glyphs before a reset is now for the context as a whole, not for each canvas.draw_textuploads through the atlas and groups the quads of a line by page, one draw call per page instead of one per line, which only differs when the glyphs of a line are split across pages.GlesCanvas::newandsub_canvasno longer create an atlas texture each, and dropping a canvas no longer deletes one.docs/architecture.mdsays that glyphs are cached per GL context too and how the atlas is paged. Measured withLTK_PERF_RASTER=1, opening and closing the launcher a dozen times. On the PinePhone Pro the subsurface raster of the first opening went from 330 to 95 ms and of the later ones from 175–215 ms to 2–10 ms, with peaks of 17 and 35 ms, and the first raster of the overlay from 175–260 ms to under 1.5 ms; the other frames stay at 2 to 13 ms. On the Librem 5 the subsurface raster went from 22.7 ms on average, 85.6 ms on the first opening and over 15 ms in 9 of 14 openings, to 13.2 ms, 69.7 ms and 3 of 14, and the other frames from 7.7 ms on average and 43 ms at most to 6.8 ms and 28.5 ms. Not done. The first raster of a session still uploads its glyphs interleaved with the draws that sample them; uploading all of them before the first draw would need a second pass over the view on every frame. There are no tests for the atlas. The image cache still identifies its context by address only.admin
2026-10-15 22:34:03 +02:00 -
3edd822918
Toplevel events name the window, apps hear the pointer hover a surface and where their blurred cards were laid out, and rows can align to the bottom A shell could not tell the windows of an app apart, follow the mouse over one of its overlays, or tell its compositor where the translucent cards of a surface ended up. crustace's floating dock needs all three, to activate and close single windows, to magnify the icons under the pointer and to have forge blur behind its pill and its Quick Settings cards.
ToplevelEvent::Openedcarries the handle'sidentifier, the compositor's token for that one window, and itstitle, both fromForeignToplevelInfo;toplevel_openedbuilds the event for the new and the updated handle. Matches onOpened { id, app_id }have to add the fields or...App::on_pointer_hover( SurfaceTarget, Option<( x, y )> )is called on every mouse motion over a surface, in its physical pixels, and withNonefrom the pointer'sleave, which ltk did not handle before.SurfaceFocus::targetgives theSurfaceTargetof a focus.Container::blur_behind()marks a container whose rect and largest corner radius, capped at half its shorter side, the layout pass records in surface coordinates through theDrawCtx::originoffset of the sub-canvases, clipped by the viewports it sits in.commit_draw_ctxkeeps them inFrameState, and after a frame where they changeddraw_framehands them toApp::on_blur_regions( SurfaceTarget, &[ ( Rect, f32 ) ] ), for the main surface and each overlay.Row::align_bottom()pins the children to the bottom edge, so taller siblings grow upward.docs/architecture.mdlistson_pointer_hoveramong the input hooks. Not done.on_blur_regions,blur_behindandalign_bottomare not in the documentation, and there are no tests for them. The regions are reported after the frame is committed, so a compositor that receives them over IPC is one frame behind a surface whose cards move.admin
2026-10-15 14:49:25 +02:00 -
35ab8ac2b0
The tab strip example in make examples
make examplesran every example buttab_strip, added with the widget, so it never came up among the others. It now runs last, afterclip_path, with the repository's themes as the rest.admin
2026-10-15 11:25:03 +02:00 -
a63e905a7a
A text field keeps the cursor where an edit left it unless it asks to be pinned to the end, which only the time picker does, and a hook for a window that loses the keyboard A field built with
select_on_focus( true )kept its cursor at the end of the value after every insertion and deletion. That was meant for the time picker, whoseon_changenormalises what was typed, "2" becoming "02", so that a cursor placed at the end of the typed text would land in the middle of the normalised one. But the flag was also the only way to have a field select its whole value on focus, so every field that wanted that got the pinning too, and in an address bar it is a defect: tapping inside the address to correct a word, the cursor was placed where the finger was, and the first character typed sent it to the end, so the rest of the edit went to the wrong place. The two behaviours are now separate.select_on_focusonly selects the value when the field takes the focus. The newTextEdit::pin_cursor_to_end( bool ),falseby default, keeps the cursor at the end of the displayed value through theusize::MAXsentinel, as before, for fields whoseon_changerewrites the value. It reaches the runtime in a newpin_cursor_to_endfield ofWidgetHandlers::TextEdit, set where the snapshot is built inElementand copied in itsClone, and the four places ininsert_delete.rsthat anchored the cursor, for an insertion, a delete forward, the deletion of a selection and the move after a replacement, read it instead ofselect_on_focus. The time picker's value fields ask for it, so they behave as they did. A field that usesselect_on_focusonly to select on focus now edits with an ordinary cursor. The one other user known is a dialog incrate, which gets the ordinary behaviour; if itson_changenormalises the text it has to ask forpin_cursor_to_end( true )as the time picker does. Documentation of the previous commit and of what building crate showed.docs/widgets.mdsays that apressableloses the tap and the right click to an interactive widget inside it even when that widget has no handler, as alist_itemwithouton_press, so its content should be built from pieces that take no input; and that the hint ofButton::tooltipis an xdg-popup beside the button in a window, placed by the compositor and taking no grab, and an overlay spanning the output on a layer surface.docs/theming.mdgains "Application icons": a desktop entry can point at an installed theme icon, the Figma glass effects resvg does not reproduce, abackdrop-filterin a<foreignObject>ignored andplus-lighterrewritten toscreenbydecode_svg_bytes, and the advice to flatten them; the table row ofdecode_svg_bytesmentions the rewrite and that its pixels come back premultiplied.docs/cookbook.mdsays that requesting the focus for the text field that already has it puts the cursor at the end of its value without a secondon_focus, which is how an application moves the cursor after replacing the value, and that Tab and the vertical arrows reachApp::on_key_with_modifiersbefore the field, while Escape reaches it after ltk has closed any transient UI and taken the focus off the field. Losing the keyboard. An application that repeats keys on its own timer, as prompt does for its terminal, never heard that the window lost the keyboard, so a key held while the focus went elsewhere kept repeating until the next key, since its release went to the other window. The newApp::on_keyboard_leave(), a no-op by default, is called from the keyboard'sleave, right after ltk stops its own repeat, for the application to stop its repeat and forget the modifiers it kept. Documentation of the previous commit's hooks and widgets.docs/architecture.mdlistson_mouse_buttonwithMouseButtonEventandMouseButton,on_modifiersandon_keyboard_leaveamong the input hooks.docs/widgets.mddescribesListItem::ink( label, secondary ), which draws a row's label, subtitle, trailing text and hover and press tints in colours of its own for a surface the theme does not know about, and says thaticon_buttonkeeps a bitmap's proportions, fitting one that is not square into its size and centring it rather than stretching it. Not done. There is no test for the new flag, asselect_on_focushas one intext_edit/tests.rsfor its builder and its propagation to the handler, and the documentation of the widget does not mention it.admin
2026-10-15 11:22:01 +02:00 -
312fb7e0fd
Tooltips in a window are popups beside their widget, theme SVGs with plus-lighter highlights are no longer painted grey, and the folder-files icon is shaped as a folder A tooltip was a layer-shell surface spanning the output, placed at the coordinates of its widget in the main surface. In a layer-shell shell the two share an origin, but an xdg-shell window does not know where it sits on screen, so in a window the tooltip appeared shifted by the window's position, wherever that put it. The application icons of the default theme, exported from Figma, light their glass with
feBlend mode="plus-lighter", which resvg does not know and paints asnormal, so the highlights came out as grey edges; at dock size those edges cover whole pixels and the icon looks dirty. Andarchives/folder-files, the icon applications show for a folder, was a clipboard and a sheet rather than a folder. Tooltips.tooltip_popuptells when the tooltip must be an xdg-popup, that is when the main surface is a window and the widget is on it, and gives its anchor rect and logical size.reconcile_overlaysthen creates the tooltip's overlay as a popup child of the window, anchored to the widget's rect, above it with a 6 px gap (TOOLTIP_GAP), flipped below or slid sideways when it does not fit, so the compositor puts it beside the widget. It takes no grab and keeps the empty input region, so it never takes the pointer from the widget it describes. Its view is the pill centred in the popup. Layer-shell surfaces keep the previous placement, now intooltip_on_screen.tooltip_idandtooltip_sizehold the id and the estimated size both paths share.OverlaySpecis unchanged, so applications that build overlays need no change. Blend modes.decode_svg_bytesreplacesplus-lighterwithscreen, the closest mode resvg supports, before parsing; a document without it is parsed as it comes, without a copy. A test covers both cases. The folder icon. Both variants ofarchives/folder-filesare now the outline offolder-emptywith the sheet of the previous icon inside, placed and sized as the lock offolder-locked: drawn in strokes inline, cut out of the filled folder infilled.admin
2026-10-15 10:32:50 +02:00 -
dcb1c7dff3
A tab strip that scrolls to the selected tab and while a tab is dragged against its edge, and hooks for every mouse button and for the modifiers, for an application that hands the mouse on A strip with more tabs than fit left the selected one wherever it was, so a tab opened at the end or chosen with the keyboard could stay out of sight, and a tab could only be dragged as far as the visible part reached. A terminal that passes the mouse to the program in it, as xterm does, also needs every click with its modifiers, which ltk kept to itself apart from the middle button, and the modifiers held while the wheel turns, which the wheel does not carry. Scrolling to the selected tab.
TabRownow knows the key of the selected tab, and in the layout it asks theTabMotionwhere the scroll of the tabs should go before drawing it; the answer is written into the scroll's own offset, which is keyed by the same index. When the selection changes the strip scrolls just enough to show that tab, through the scroll'soffset_into_view, and keeps following it while it grows from nothing, so a new tab at the end is shown whole once it has opened. After that the strip is left where the user scrolls it. Dragging against the edge. While a tab is dragged and its lifted copy touches either edge of the visible part, the strip scrolls 8 widget pixels further that way on each frame (EDGE_SCROLL), and every layout places the dragged tab under the pointer again, so it travels with the strip although the pointer holds still and drops among the tabs that come into view. Pointer coordinates. A scroll and a viewport draw their child on a canvas of their own, so the tabs were laid out in coordinates that no longer matched the pointer once the strip scrolled.DrawCtx.originnow holds where the canvas being drawn on sits on the surface, set around the child of everyScrollandViewport, andTabCellsandTabCardshand it to theTabMotion, which takes it off the pointer indrag_move. The mouse.App::on_mouse_button( &MouseButtonEvent )reports every press and release of the left, middle and right buttons, with the position in physical pixels and Ctrl, Shift and Alt, before ltk handles it;MouseButtonandMouseButtonEventare exported.App::on_modifiers( ctrl, shift, alt )is called whenever the modifiers held change. Both do nothing by default. Tests cover a selected tab scrolled into view once and back, and a tab dragged against the edge scrolling the strip. The documentation oftab_stripdescribes the scrolling.admin
2026-10-15 10:25:31 +02:00 -
ce98c7b82c
TabStrip, the open documents of an application as a browser or a terminal shows them, with tabs that grow, shrink and can be dragged, an overview of cards for a phone, a widget anchored under another one and list items in colours of their own Trust and prompt each drew their own strip of tabs, their own phone bar with a counter and their own grid of cards, and neither animated anything nor let a tab move.
tab_stripdoes all of it once, in colours the application chooses, so the same widget serves a black terminal and a browser that follows the theme. Two things the menus of those applications needed come with it: a way to place a menu under the button that opens it, and list items that are not drawn in the theme's colours. The widget.tab_strip( tabs )takesTabItems, made withtab( key, label ), where the key stays the same while the tab is open, plus.image( ImageData )for a page's icon,.symbol( name )for a theme icon drawn in the tab's ink, and.closable( bool ). The builder sets theselectedtab,on_select,on_close,on_overview, the buttons before the tabs (leading), right after the last one (after_tabs) and at the far right (trailing), the phone bar'smobile_center, theTabStripColors, theTabLayout,reorderableand theTabMotion.TabStripColorssets the strip's background and ink and the fill, edge and ink of the active tab and of the others; the default follows the theme. On a desktop.[leading] [tabs…][after_tabs] clear [trailing]. The tabs share the width between 90 widget pixels and a fifth of the screen's width and scroll sideways when even the minimum does not fit, and 7.5 % of the width between the leading and trailing buttons is always left clear, also while they scroll. A horizontal scroll measures its child with no width limit, so the tabs inside it cannot see how much room there is; two new elements solve that.TabRow, outside the scroll, works the tab width out of its own rect, less the clear part and the buttons after the tabs, and hands it to theTabMotion;TabCells, inside the scroll, places the tabs, the gaps of closing ones and the buttons where the motion says. Each tab is clipped by aviewport, so it can grow from nothing and shrink away. On a phone.TabLayout::Mobileis a bar with the leading buttons, the active tab's icon and title or the application'smobile_center, a counter that sendson_overviewand the trailing buttons.tab_overview( tabs )is the grid of cards it opens, with atitle, abackmessage and headeractions:buildgives the whole page, header and scrolling cards, andcardsthe grid alone for an application that lays its page out itself. The cards are placed by a third new element,TabCards, every one as tall as the tallest, so a dragged card can follow the finger. The motion.TabMotionis the only state, kept by the application, handed to every view and reported fromApp::is_animating; it is anArc<Mutex>becauseviewonly gets&self, and clones share it. A tab that opens grows from nothing while the others narrow, and one that closes leaves a gap that shrinks away while the others keep their width for a second, so the close button of the tab that slides in lands under the pointer; another close starts the second again. Changes take 180 ms, ease-out. The first layout and a resize alone place the tabs without moving them. Reordering. Withreorderable( true )each tab, and each card of the overview, carries its select message ason_drag_start, so it is dragged after 24 px of mouse motion or a long press, and dragging a tab selects it. The drag reaches the application, which forwardsApp::on_drag_movetoTabMotion::drag_moveand answersApp::on_dropwith the( from, to )thatTabMotion::drag_endreports. The motion remembers where it drew every tab: the first move takes the tab under it and returnsfalsewhen there is none, the tab then follows the pointer within the tabs and is drawn on top, and the others slide out of its way as its centre passes theirs, or as it enters another cell of the overview. On release it slides into its place. A strip and its overview are never shown at once and may share one motion. Anchored.AnchoredOverlay, which only the combo used, is public asanchored( child, id ): it draws its child under the widget built with thatWidgetId, as it was laid out on the previous frame.alignlines it up with the anchor's left edge, its right edge or its middle,gapsets the space under it, and the child is now kept within the rect its parent gives it, which also keeps a combo's list inside the window. List items and icons.ListItem::ink( label, secondary )draws the label, the subtitle and the trailing text in colours of the application's own and tints hover and press with the label's, for rows on a surface that is not the theme's. A button's icon is now fitted into its square without being stretched: a wide icon such asactions/remove, a 25 by 4 minus, was drawn as a block as tall as it was wide. Documentation, example and tests.docs/widgets.mddescribestab_stripandanchoredand the newink.cargo run --example tab_stripopens three documents that can be added, closed and dragged, on a desktop strip or a phone bar with its overview. The tests cover the share of the width, its limits and the clear part, the buttons after the tabs, the drawing order of a dragged tab, the builder's defaults, the overview's rows, and inmotion.rsthe growth, the gap, the wait and its restart, a resize, and dragging a tab and a card;anchoredand the icon fit have tests of their own. Not done. A strip that scrolls does not scroll to the active tab or follow a tab dragged towards its edge, and the overview does not scroll while a card is dragged to the edge of the screen.admin
2026-10-15 08:22:41 +02:00 -
99b195ea13
The primary selection is kept in step with other applications, an application can paste either selection and hear the middle button, raw keys carry the text dead keys compose, and an application can ask for a cursor where the pointer is A terminal drawn by the application itself needed four things ltk did not give it: the selection the middle button pastes, a way to read the clipboard, the text a key types once dead keys are applied, and a text cursor over its text area that does not hide the window's resize cursors. Text fields gain the primary selection along the way, and the symbol fallback and a missed field in the previous commit are fixed. The primary selection.
event_loop/primary_selection.rsbindszwp_primary_selection_device_manager_v1when the compositor offers it, creates the device with the data device innew_seat, and holds the manager, the device, our source while we own the selection, its text and the inbox the reader threads post to. A selection offered by another client is read on a thread of its own as the clipboard is; a peer asking for ours is served from the cached text, and a cancelled source is dropped.data_device.rsgives both the pieces they share:pick_text_mime,read_selectionandwrite_selection, so the clipboard behaves as before. Text fields. Once a press ends,sync_text_fieldpublishes a new selection in the focused text field as the primary selection; password fields never publish theirs. It remembers what it last published rather than comparing with the current primary selection, so a selection made in another application is not taken back. A middle-button press on a text field focuses it, puts the cursor where it landed and inserts the primary selection there. The application's side.App::take_primary_requestmakes a text the primary selection, astake_clipboard_requestdoes for the clipboard.App::take_paste_requestnames aSelectionKind,ClipboardorPrimary, and the runtime answers withApp::on_pasteand that selection's text, never with an empty one.App::on_pointer_middlereports a middle-button press anywhere but on a text field. All default to doing nothing. Raw keys.App::on_raw_key_with_altgives way toApp::on_raw_key_event, which takes aRawKeywith the keysym, the keycode, the press, Ctrl, Shift and Alt, andtext: what sctk reports the key types once dead keys and compose sequences are applied,Noneon release, halfway through a sequence and for keys that type nothing. A terminal that took the character from the keysym typedafor an acute accent followed bya. The default still forwards toon_raw_key, and prompt was the only application overriding the old hook. Every key press now also records its serial inlast_input_serial, so a selection set from a keyboard shortcut carries a serial a strict compositor accepts rather than the last click's. The cursor.App::cursor_at( x, y )is the cursor the application wants at a point of the main surface. It comes after the resize edges andcursor_override, and before the cursor of the widget under the pointer, so a terminal shows the text cursor over its cells and the resize cursors still show at the window's edges, which acursor_overridewould hide. Fonts. Noto Sans Symbols 2 and Noto Sans Symbols follow DejaVu Sans in the fallback chain, for the media controls, arrows and shapes DejaVu lacks, which drew as the missing glyph.TextEdit::map_msgnow carriespad_hover: the previous commit added the field but left it out there, and ltk did not build.admin
2026-10-14 16:41:54 +02:00 -
e3b76423fb
A text field can take its horizontal padding from its host, the primary finger reaches the pointer hooks, and the screen probe left out of the previous commit is added The previous commit described the size hint fitted to the screen before the first commit and the pointer hooks being called for the primary finger, but left out the files that do it, so
run.rscalled anoutput_probemodule that did not exist. They are here, together with a text field option that a host laying out its own padding needs. Text field padding. A single-lineTextEditinsets its text byPAD_Hscaled with the field height, so a tall and narrow field has almost no room left for its text: rustdroid's Android fields, which already subtract the padding the Android view asked for, showed a verification code digit in a 24 px wide gap minus ltk's own inset, scrolled it as if it overflowed and clipped half of it.TextEdit::padding_h( px )sets that inset in physical pixels;0lets the text use the whole rect. Without it nothing changes.single_line_pad_htakes the override and every single-line consumer passes it: the draw path,single_line_scroll_x,single_line_align_offsetandbyte_offset_at, so a click still lands on the glyph that is drawn. The runtime sees it through a newpad_hfield inWidgetHandlers::TextEdit, read bytext_input_padwhere a click is turned into a text position (pointer down, word selection, drag selection and the context menu's paste offset). The password toggle zone keeps the theme inset. The screen probe.output_probe::output_logical_sizereads the logical size of the first output on an event queue of its own, before any surface exists:xdg_output's logical size when the compositor offers it, else the current mode over the scale; the objects it binds are destroyed or released before it returns.window_fit::output_sharegives the part of an output a window may take, the same 90 % the fit after the first configure uses, which now goes through it too. When the size hint is unpinned on the first configure the minimum size is released along with the maximum, as the documentation ofwindow_resizablesays. Touch as pointer. The primary finger's down, motion and up are also reported throughApp::on_pointer_moveandApp::on_pointer_button, as the left mouse button is, so an application that handles the pointer gets the touch without a separate path. The module documentation says so.admin
2026-10-13 20:36:29 +02:00 -
c385605de0
An application can keep the keyboard to itself and see Alt, an external field declares what it is for, and the size hint is fitted to the screen before the first commit A terminal takes every key and turns it into bytes for its program, which ltk's key handling got in the way of in three ways. Enter and Space pressed the widget that had the focus, or the one hovered, as well as reaching the application: in prompt a tap on the extra keys row's up arrow left that button focused, so the next Enter ran the command and then pressed the arrow again, bringing the command back on the prompt. The field held by
external_text_inputwas always enabled with the normal purpose, so the on-screen keyboard never offered its row of terminal keys to an application that draws its own text area. Andon_raw_keyreports Ctrl and Shift but not Alt, which a terminal sends as a prefix or a parameter. Keeping the keyboard.App::claims_keyboard, false by default, makespress_keystop afteron_raw_keywhile it is true: the key is not dispatched to the widgets, so neither Enter nor Space presses anything and arrows do not move the keyboard hover, and ltk does not start its own repeat; a repeat already running is stopped as before.XF86_Backstill goes through the dispatch andon_back. An application returns it while its own surface has the keys, and false when one of its menus is up, which then behaves as before. The external field's purpose.App::external_text_purpose,Purpose::Normalby default, is whatsync_external_text_inputenables the text-input with, and what it renews it with when the serial changes, soPurpose::Terminalreaches the compositor asContentPurpose::Terminaland forge shows the terminal row above the layout. Alt.AppDatagainsalt_pressed, kept byupdate_modifierswith the other two. The runtime now callsApp::on_raw_key_with_alt, on press and on release, with the same arguments ason_raw_keyplus Alt; its default forwards toon_raw_key, so the applications that implement only that one see no change, and an application that needs Alt overrides the new one instead. The size hint. A window size hint bigger than the screen used to be fitted only after the first configure, so the first commit asked the compositor for a size it could not give, and a mobile compositor would rescale the window to honour it. Before the first committry_runnow reads the logical size of the first output throughoutput_probe, on an event queue of its own, and fits the hint withinwindow_fit::output_shareof it, the same 90 % the later fit uses; that size is what the toplevel is pinned to and whatfitted_size_hintstarts with, and the fit after the first configure is skipped when it was done. The documentation ofwindow_resizablenow says both the minimum and the maximum are released from the first configure, since a minimum left in place has the same effect on a mobile compositor. Pointer hooks. The documentation ofon_pointer_moveand of the left-button hook now says they are also called for the primary finger's touch down, motion and up. Not done. A key held when the window loses the keyboard is stopped by ltk's own repeat but not by an application that repeats keys itself, since there is no hook for the keyboard leaving. Logo and AltGr are still not reported toon_raw_key_with_alt.admin
2026-10-13 20:31:51 +02:00 -
6e4bf83c91
The default theme declares Noto Serif as a font family
theme.jsongains anoto-seriffamily with its Light 300, Regular 400, Medium 500 and Bold 700 weights, read from thefonts-noto-corefiles thatltk-theme-defaultalready depends on, and falling back to Sora. Only the families declared in the theme resolve throughText::font, so a widget that wants a serif face, such as the crustace clock with its bold time over a regular date, needs the family to be listed here.admin
2026-10-11 16:26:01 +02:00 -
0d492f7c1d
Text measuring for hosts, colour emoji, caller-supplied faces, aligned rich text and a wheel hook in detents A host that lays text out with its own measurement (rustdroid sizes Android views from it before ltk draws them) needs ltk to measure exactly as it renders. Several gaps made the two disagree; this closes them and adds what such a host needs from the pointer and the paragraph widgets. Shaping.
shape_lineshaped the first bidi paragraph only, so any text after a line break measured zero width; it now shapes every paragraph. Shaped glyphs keep the cluster rustybuzz reports (ShapedGlyph::cluster, andPositionedGlyph::clusteras a byte offset into the line), whichcaret_offsetsuses to give one x per char boundary, splitting a cluster's advance evenly across its chars.caret_offsets,caret_offsets_with_font(the same with a given face leading the resolver) andline_metricsare exported. The per-codepoint resolver shared bymeasure_textand the new functions isdefault_resolver: primary font, then the system fallback chain, then the primary again for.notdef, the same choice the renderers make. Caller-supplied faces.draw_text_with_fontandmeasure_text_with_fontbuilt aFontHandlewith empty bytes for a face that was not the canvas default, so HarfBuzz could not shape it and the text silently fell back to the system chain.FontFacenow exposes the file it was parsed from (data,face_index) andFontHandle::ofbuilds a complete handle from a face; both renderers use it. The GLES renderer also registers the preferred face in its glyph font list so its glyphs are rasterised. Colour emoji.FontFace::color_glyphdecodes, once per glyph, the PNG strike a CBDT or sbix font draws a glyph with, as aColorGlyph(straight RGBA plus its box in ems). Both renderers draw such glyphs as images at the pen position, scaled by the text size and faded by the text colour's alpha, instead of rasterising an empty outline. NotoColorEmoji joins the fallback chain after DejaVu Sans.ColorGlyphis exported. Rich text alignment.RichTextgainsalign( TextAlign ), applied per wrapped line; link hit rects follow the aligned line, so a centred paragraph with links stays tappable where it is drawn. Wrap pad. A wrappingTextwraps its lines to the rect less 4 px and insets a left-aligned line by it. The constant is now public asltk::TEXT_WRAP_PAD, documented as the extra width a host must add when it sizes a label's rect from its own measurement; without it a label sized to its text wrapped its last word. Wheel.App::on_pointer_wheel( x, y, notches_x, notches_y ) -> boolis called beforeon_pointer_axisfor a scroll outside any ltk viewport that carries discrete detents (value120, ordiscreteon older compositors), with the number of detents per axis, fractional on high-resolution wheels. Returningtrueconsumes the event; the defaultfalsekeeps the previous behaviour, whereon_pointer_axisreceives the raw axis value scaled by 10 for wheels. A host that maps detents to its own unit (Android's AXIS_VSCROLL is one unit per detent) no longer has to guess the compositor's axis scale. debian/changelog gains the 0.3.0-4 entry describing the shaping, caret and caller-supplied face changes.admin
2026-10-08 22:03:10 +02:00 -
4a6ec797a2
Wrapped text breaks a word wider than the line instead of letting it overflow
wrap_linesbroke lines only on whitespace, so a word wider thanmax_width(a long path, URL or file name) was laid out whole on its own line and painted past the right edge of its widget. In the notifier it ran out of the notification card. Such a word is now broken between characters into pieces that fit the line, each piece continuing on the next line. The last piece stays the current line, so the following word can still share it. Breaking oncharboundaries keeps multi-byte UTF-8 sequences intact.preferred_sizealready reported the height of every resulting line, so the widget grows by as many lines as the break produces. The wrap width also leaves room for the left inset.drawindents each wrapped line by4.0_f32.min( slack ), which drops to zero for a line that fills the full width, so such a line started a few pixels left of its neighbours. Breaking long words makes full-width lines common.preferred_sizeanddrawnow wrap to the width minus the newWRAP_PADconstant (4 px), anddrawtakes the inset from the same constant, so every line keeps it. As a consequence, every wrappedtexthas 4 px less usable width per line. The new testwrap_breaks_words_wider_than_the_linechecks that a word wider than the line is split over several lines, that no line is wider than the wrap width, and that the lines together hold the whole text.admin
2026-10-08 12:48:29 +02:00 -
17e649dd32
The on-screen keyboard is capped at 40 % of the screen height, and the runtime publishes the logical size of the output the main surface is on The keyboard. The keys of the on-screen panel are as tall as one unit of the widest row is wide, so the panel's height follows its width. On a portrait phone that is about a quarter of the screen; turned to landscape the width becomes the long side, the keys grow with it and the six rows of a terminal keyboard take more than the screen.
Panel::height_within( width, scale, screen_height )keepsheightwithin 40 % of the screen height, the newMAX_HEIGHT_FRACTION, andplace_oskuses it for the keyboard of a surface of ltk's own.set_rectno longer assumes it was given the natural height: the key height is the smaller of the unit and what the rectangle left after the padding and the gaps shares among the rows, so a capped panel gets wide and low keys, as the keyboards of other platforms have in landscape, while the key widths and the centring of the narrower rows are as before. A portrait screen is not affected, the natural height being below the cap there. A host that lays the panel out itself, as a compositor does, callsheight_withinfor the height it reserves andset_rectwith the rectangle it gets, so both agree. The output's size.ltk::output_logical_size()returns the logical size of the screen the main surface is on, orNonebefore the compositor has described one, andset_output_logical_sizerecords it.viewport_sizefollows the window, which is the wrong reference for a thing that has to be sized relative to the display, such as a window that must not be wider than the screen. The runtime now publishes the size when an output appears, changes or goes away, and when the main surface enters one, preferring the output of the main surface to the first one listed, and taking the current mode over the scale when the compositor sent no logical size.window_fitshares that lookup,output_logical_size, with what it did before, so the bounds it keeps a window size hint inside are unchanged. Not done. The 40 % is a constant and not a setting, the key labels are not scaled to the lower keys, and nothing offers the keyboard at the side of the screen in landscape, which would give the application more room on a wide display.admin
2026-10-06 23:09:43 +02:00 -
1ad9898f66
A subsurface maps with the main surface's first buffer, not a frame after it The run loop drew the main surface first and reconciled the subsurfaces afterwards, in every iteration including the first. On the first frame the main surface was therefore committed and mapped on its own, and the subsurfaces only got created, rastered and placed in a later parent commit. The lockscreen shows the problem: its main surface always holds the unlock form, and the idle clock panel that covers it lives on a subsurface, so at greeter startup and whenever a forge session locked, the form was visible for a moment before the idle panel appeared on top. In the greeter,
on_first_frame_committedalso sent the daemon its readiness byte with that frame, so the VT switch showed the form. Until the main surface has committed its first frame,reconcile_subsurfacesnow also runs beforedraw_frame. It creates each subsurface, rasters it and commits its buffer, which a desynchronised subsurface applies on its own commit, and sets its position, which waits for the parent. In that pass it no longer commits aMainparent itself: a bufferless commit is a protocol error on an ext-session-lock surface, and the main surface's first buffer commit, made right after bydraw_frame, applies the placement anyway, so the main surface and its subsurfaces reach the screen in the same commit. Overlay parents and the reconcile after the draw are unchanged, and once the first frame is out the loop behaves as before.admin
2026-10-02 18:30:29 +02:00 -
0fd23acd2b
A
Length::dpfont size keeps its logical size on a scaled output instead of growing with the scale On a 14.1" 2560x1440 laptop, where forge picks a 2.25 output scale and ltk draws at buffer scale 3, the title of every settings screen but the first came out about three times its design size, while on a 1920x1080 laptop at scale 1 the same title was smaller than the 28 px heading of the home screen. That title is alist_itemwith.font_size( Length::widget( 20.0 ) ), which inWidgetScaling::PhysicalisLength::dp( 20.0 ).Canvas::resolve_fontresolved aDplength with the canvas' density, which the runtime sets to the output scale, and returned it as a font size. Font sizes are logical: the raster path multiplies them bydpi_scale. ADpfont size was therefore scaled twice, 20 × 3 = 60 logical px and 180 physical, against 20 at scale 1. Geometry did not have the problem, becauseresolve_geomreturns physical pixels that nothing scales again, and neither did a stock widget without an override, becausefont_pxalready divided bydpi_scalein Physical mode. The samedpvalue thus gave one size throughfont_pxand another through an explicit override.resolve_fontnow resolvesDpwithdensity / dpi_scale, so that× dpi_scaleat raster yieldsn × densityphysical pixels, the same asresolve_geomgives the same length. Pixel, viewport and fluid lengths are untouched, since they do not depend on the density. The Physical branch offont_pxnow delegates toresolve_font( Length::dp( design_px ) ), which is the formula it carried on its own. Every caller ofresolve_fontis affected: thefont_sizeoverrides ofbutton,text_editandlist_item, and thesizeoftextandrich_textwhen given aDpor, in Physical mode, aLength::widget. At scale 1 nothing changes. The new testdp_font_matches_dp_geometry_on_hidpichecks at scale 3 that a 20 dp font resolves to 20 logical px, equalsfont_px( 20 )forLength::widget, and times the scale equals the same length resolved as geometry.admin
2026-10-02 07:55:23 +02:00 -
88f69ebffe
A horizontal swipe commits on a fling;
App::overlay_namespace; flat Quick Settings slots in the default theme; the on-screen keyboard's rows twice as far apart, with presses between keys kept;LTK_PERF_RASTERFlings. A horizontal swipe committed only pastswipe_horizontal_thresholdof the surface width, so a quick flick that stopped short, the usual way to turn a page on a phone, snapped back. The newinput/gesture/fling.rsholds aFlingTrackerthat measures the finger's speed along one axis over the last 100 ms from the events' own timestamps, so a finger that rested before lifting reads as still.GestureStatekeeps one for the x axis,fling_x, cleared on every press; the touch handler feeds it on down, motion and up with thewl_touchtime, which it used to ignore. On release a horizontal swipe also commits when the finger leaves at 500 logical px/s or more in the direction of travel after at least 24 px, and does not commit when it leaves that fast the other way, whatever the distance; both thresholds scale with the surface. Pointer drags and the vertical swipes keep the distance rule, and the gesture tests, which never feed the tracker, are unchanged. Two tests cover the speed and a rest before lifting.App::overlay_namespace. Every layer overlay was created with the namespaceltk-overlay, so a compositor could not tell one panel from another. The new method returns the namespace of overlayid, read when its surface is created, andNone, the default, keepsltk-overlay. It followsoverlay_titleandoverlay_opaque_regionand breaks no app. Crustace names its Quick Settings and launcher overlays with it, and forge draws a blurred backdrop behind them. Flat Quick Settings slots. The default theme gainssurface-qs-tile,surface-qs-tile-active,surface-qs-slider-trackandsurface-qs-slider-fillin both modes: a solid translucent fill, white or navy for the tiles and tracks and cyan for the active tile and the fill, with no shadows, no inset shadows and no gradients. The glass slots the panel used put a blurred shadow, one to five inset shadows, some with blend modes, and a gradient under each of its nineteen surfaces, all rasterised on every frame of its slide. The shared slots are untouched, so the dock and other applications look as before. The on-screen keyboard. The rows are nowROW_GAP, twiceGAP, apart, 12 logical px against the 6 between keys;heightcounts it, so the panel grows by 18 to 24 px depending on its rows and a host that reserves its height follows. A press between keys, which the wider gap would have made more common, no longer goes nowhere:key_atgives it to the nearest key within one row gap.LTK_PERF_RASTER. With it set, every GLES raster, full, partial or subsurface, logs its surface size, the time to submit and, after aglFinish, the time until the GPU finished. TheglFinishstalls the pipeline, so it is for measuring only; it gave the cost of the old Quick Settings panel, 27 to 40 ms a raster on the Librem 5 with peaks of 57 to 143 ms.admin
2026-09-30 10:17:22 +02:00 -
a0ceef51ef
WallpaperBundle::blurred; the on-screen keyboard at the right size on a scaled output, keys as tall as they are wide, a terminal row that fits and a Spanish symbols layer that does not squeeze the rest The keyboard of the previous commit was drawn and tried on a desktop at scale 1. On the Librem 5, at scale 2 and hosted by forge, it showed what that could not: letters twice their size, a terminal row whose modifier labels did not fit, keys shorter than they were wide, and a Spanish numbers layer with a gap at each side. This fixes those, and adds the wallpaper helper the shell and the compositor now use for their frosted panels. A frosted wallpaper. Crustace decoded the theme's lockscreen image at full size a second time for the launcher and the quick settings, and forge decoded it again for the app switcher, blurred it with a floating-point Gaussian at 1920 px and composed a copy per panel size; the switcher's decode alone left some 60 MB in a thread arena that glibc never gives back. A backdrop that is blurred needs no resolution.WallpaperBundle::blurred( divisor, sigma )returns a bundledivisortimes smaller on each side, reduced with a triangle filter and blurred withimageops::fast_blur, the box approximation that works in place on the 8-bit pixels; the renderer scales it back up with linear filtering, and the blur hides what the reduction lost. It is an ordinary bundle, sofor_sizecrops it for portrait as it does the original. Two tests: the size is divided and never reaches zero, and a flat colour stays that colour.docs/architecture.mdmentions it next tofor_size. Text twice its size.Panel::drawpassed the canvas a font size already multiplied by the output scale, and the canvas multiplies every font size by itsdpi_scaleonce more, as it must for widget text, which is sized in font space. At scale 1 the two agree; at 2 the labels came out at four times the area. The keys and the variants popup now passTEXT_SIZEandACTION_SIZEunscaled, anddraw_centredcomputes the baseline offset from the size in physical pixels,size * dpi_scale(), so the label stays centred on the key. Radii, key geometry and icons were already right: they are physical pixels and never go throughdpi_scale. The runtime's built-in panel and forge's both draw throughPanel::draw, so both had it. Keys as tall as they are wide. Rows were a fixed 46 px high whatever the width, so on a phone the keys came out wide and flat. The newkey_unit( width, scale )is the width one key unit takes when the widest row of the layer fills the panel, the width of aq;set_rectuses it as both the width unit and the height of every row, the terminal row included, and the variants popup, which was already as tall as its key, follows. The panel's height therefore depends on its width:height( scale )becomesheight( width, scale ),place_oskpasses the surface width, andKEY_HEIGHTgoes. The architecture document says so. In landscape the keys grow with the screen's width; a cap on the height has not been needed yet. The terminal row. Esc, Tab, Ctrl and Alt were one unit wide and their labels did not fit. They are 1.5 units now, and to keep the row at 13 units\and~leave it: the row is Esc, Tab, Ctrl, Alt, the four arrows,-,/and|. Neither is lost:\is a long press on/, next to?, and~one on-, next to_and–;|takes over the variants\had, ``,^,$,#and*, beside its own&and!. The arrows are drawn at 14 instead of the 22 of the other action icons, which filled the narrow key. The built-in bar's test checks|in the row and\and~among the variants. The Spanish symbols layer. Its third row carried¡,!,¿and?, 12 units against the 10 of every other row; since all rows share the key unit of the widest, the numbers and@ # € %rows came out narrower and centred, with a gap at each side, and switching to English and back looked right only because that layout's row has 10.¡and¿leave the row and become long presses on!and?, as on Android, and stay among the variants of.where they already were. The new testshipped_layers_are_no_wider_than_their_basereadsen.jsonandes.json` and fails when any row of any layer is wider than the widest row of the base layer, so a layout file cannot bring this back unnoticed.admin
2026-09-27 21:43:33 +02:00 -
a1a4d51677
An on-screen keyboard of ltk's own, driven by layout files;
TextEdit::purposefor the row a terminal needs; the wheel scrolls a multiline field; a notebook page fills its height Text entry on a phone comes fromzwp_text_input_v3: a focused field enables the protocol and the compositor's input method, squeekboard today, draws a keyboard and commits text through it. Eydos wants to drop that process, so the keyboard has to come from somewhere else, and the natural place is the toolkit that already draws everything else and knows which field has the focus and what it is for. This commit puts one in ltk, as data files plus a panel any host can drive: ltk's runtime shows it under a focused text field when no input method is there, and forge will draw the same panel in its chrome and deliver the keys to the focused client, which is the second half and is not in this commit. Along the way the demo that exercises it exposed two gaps in the widgets, fixed here too. Layouts are data.keyboards/<language>.json, one file per layout, searched inLTK_KEYBOARDS_DIRwhen set, then$XDG_DATA_HOME/ltk/keyboardsor~/.local/share/ltk/keyboards, then/usr/share/ltk/keyboards;osk::layout::search_paths(),available()andload( name )are the API, and the English layout is compiled in as the last resort. A file hasname, alabelfor the layout-switch key,layersandvariants. A layer is a list of rows; a row a list of keys; a key is a bare string for the text it types, or an object with anaction(shift,backspace,enter,space,layer,layout,hide), the targetlayer, alabel, and awidthin key units,1by default, which share the row's width so a row of nine keys comes out centred under one of ten.baseis mandatory; a missingshiftlayer is derived from it by uppercasing.variantsmaps a key's text to what a long press offers, and the shifted form of a key uses its base's, uppercased. A key may also be a named key,{ "key": "Escape" },Tab, the arrows,Home,End,Page_Up,Page_Down,Delete,Insert,F1toF12, or a sticky modifier,{ "modifier": "ctrl" }oralt.en.jsonandes.jsonship, with symbol layers and the accented variants each language needs. The files go into the registry copy throughmake installand intoltk-theme-defaultunder/usr/share/ltk/keyboards. The terminal row. A terminal needs Escape, Tab, Ctrl, Alt, the arrows and the shell's symbols, and no other application does, so those are not a layout but a row laid above any language's layout:keyboards/bar-terminal.json, made of the named keys and modifiers above plus-,/,|,\and~with their variants. Files namedbar-*.jsonare rows, not layouts, andavailable()leaves them out, so the layout-switch key does not cycle onto one; the first version listed it and a tap on that key made the keyboard vanish behind a duplicated bar. Which applications get the row is decided by what the field says it is for:TextEdit::purpose( InputPurpose::Terminal ), snapshotted into the handlers likesecure, which the built-in panel reads throughset_purposeand which also goes out as theterminalcontent purpose ofzwp_text_input_v3, the same one VTE-based terminals declare, so a compositor hosting the panel can pick the row from the protocol; for a client that declares nothing it can decide byapp_id.osk::Panelis a state machine a host drives. It holds the loaded layouts, the current layer, shift as off, one-shot or locked, the sticky modifiers, the purpose and its extra row, the geometry of all of it inside the rectangle the host gave it withset_rect( rect, scale ), and the press in flight. The host feeds itpress_down,press_move,press_upand, from its own timer,tick, each returning anEvent:Text,Backspace,Enter,Hide,Key( keysym, modifiers )for what text cannot carry, an arrow, Escape, Tab, or a character while Ctrl or Alt is held, orChangedwhen only the panel's look changed. A key types on release; a press held 450 ms opens its variants, which the finger slides onto and the release picks; backspace fires on press and repeats every 60 ms after the same delay; shift is one-shot and locks on a double tap within 350 ms; a modifier stays down for the next key and a character typed under it goes out as aKey, since text cannot carry Ctrl.next_deadline()says whenticknext has work,height( scale )what the panel needs,press_downanswersNonefor a press outside it so the host treats that as its own.draw( canvas )paints it with the active palette; the action keys show theme icons rather than glyphs,keyboard/shift,keyboard/backspace,keyboard/enter,general/down-simplefor hide,accessibility/languagesfor the layout key, thegeneralarrows for the terminal row, rasterised once per size and tint, because the first version drew⇧and🌐and the fallback chain has no glyph for the latter, which showed as a question mark; a layout key with no label of its own shows the active layout's. Nothing inPanelknows about text fields or Wayland, which is what lets a compositor host it. Fifteen tests cover the layout parser, the derived shift layer, variants following case, the bar filtering, and the panel's taps, one-shot and locked shift, variants, repeat, layer and layout switches and the terminal row. The runtime shows it.event_loop/osk.rsputs aPanelon a surface when a text field takes the focus andbuiltin_oskholds: the compositor advertised nozwp_text_input_manager_v3, orLTK_BUILTIN_OSK=1is set, which is how to try it on a desktop that has an input method, or the app returnstruefrom the newApp::builtin_keyboard(). The panel is placed at the bottom of the surface by both renderers, and the widget tree is laid out in what is left above it, so the focused field is never covered. Presses, motion and releases from the pointer and from touch are offered to the panel before the gesture machine, a press on the already focused field brings a hidden panel back, and the run loop's timeout now takes the minimum of the long-press, tooltip and panel wakeups so variants and repeat fire on time.Textgoes through the same insertion as a physical key,Keythrough the same dispatch, with Ctrl applied for its duration,Entersubmits or inserts a newline in a multiline field,Hidekeeps the focus and takes the panel away; losing the focus removes it. The layout is picked from$LANG.ltk::InputPurposeis exported, anddocs/architecture.mdgains the section that says all of this, with the compositor's part sketched. The demo.examples/inputsbecomes a notebook with the login page it had and a Shell page: one multiline field that holds a transcript, prompt included, declared a terminal, so the row appears; Enter runs the line under the last prompt (echo,ls,pwd,clear), the arrows move through the transcript, and a Ctrl combination interrupts the line the way a terminal shows it. Escape quits only from the login page, because a key the app answers never reaches the field, which is what first made the arrows dead. Two widget gaps the demo found.Notebookgave its page the page's preferred height, so a page that fills its space, a scrolled list or a flex, got none and showed nothing: the active page is now wrapped inflexand gets the height under the strip. And the multiline field had no way to scroll: it chose its first visible line from the cursor alone on every draw, so the transcript ran under the keyboard and the wheel did nothing.text_edit/scroll.rsgives it aTextScrollthe runtime keeps per field beside the cursor and the selection, in the frame state, the draw context and thecoreruntime alike: the first line asked for by hand and the cursor at that moment. The view holds there while the cursor stays put and, once it moves, follows it by the least it has to, as an editor does; without a request the result is exactly the old rule.Window::resolvecomputes the visible lines once for drawing, hit testing and the wheel, so they cannot disagree, and replaces the copy each of the first two had. Inon_pointer_axisthe vertical motion is offered to the multiline field under the pointer first, three lines per notch fromvalue120ordiscrete, touchpad pixels accumulated until they add up to a line, and only when the field cannot move that way does it go to ascrollaround it.TextScrollis public but opaque, sinceElement::drawis public and needs it;Defaultis the cursor-following behaviour. Six tests cover the hold, the follow, the clamping, the notch and the accumulation. A read-only field is not interactive and does not take the wheel; touch drags inside a field do not scroll it yet. Not done. Forge does not host the panel yet, so on the phone squeekboard is still what appears. The panel's look is a first pass, sized in logical units with the theme's palette, to be restyled from mockups. Onlyenandesship.admin
2026-09-25 12:50:58 +02:00 -
b591950f67
Glyphs on demand:
FontFacereplaces fontdue, DejaVu ahead of the CJK collection in the fallback chain;App::take_clipboard_requestandApp::on_pointer_secondaryEvery face fontdue opened was parsed whole:Font::from_byteswalks thecmapand builds the outline of every glyph before returning. For the theme's Sora that is a few hundred glyphs and nobody notices. For a fallback it is the whole file: the first string with one arrow or one bullet resolved through the chain toNotoSansCJK-Regular.ttc, and a compositor or a notification daemon that had drawn a dozen labels was suddenly holding 330 MB of outlines for 44 810 glyphs, after 1.7 s of CPU, to paint the hundred or so glyphs a screen ever shows. Measured on forge: 240 MB before the first app-switcher label, 542 MB after it, and never given back, because the table is live, not garbage.FontFace. The newsrc/font_face.rsis the face type the whole crate now rasterises with, built on thettf-parserthat rustybuzz already brings in and on tiny-skia, which already fills paths.from_bytes( data, index )keeps the bytes and reads only what a face needs up front: units per em and the vertical line metrics. Attf_parser::Faceis rebuilt on every use rather than kept, since parsing one is table-offset arithmetic, in the noise next to the outline work it guards. Each glyph's outline is extracted the first time it is asked for, throughOutliner, into atiny_skia::Path, and cached for the process lifetime in aHashMap<u16, Arc<Outline>>; codepoint lookups are cached the same way. Rasterising fills that path into aMaskand hands back the 8-bit coverage, top row first, which is what both canvases stored before. The metrics keep fontdue's conventions, so no widget changed: sizes scale bypx / units_per_em,Metrics::xmin/yminare whole-pixel placements with the fractional remainder folded into the raster offset,boundsis the outline box in subpixels,LineMetricscarries ascent, descent, line gap and their sum. One thing is deliberately not whatglyfdeclares: the box comes from the points the outline actually reaches, quadratic and cubic extrema included, because declared boxes may be loose and a box wider than the ink shifts the glyph on the pixel grid. Tests cover the turning-point arithmetic and the line-metric scaling.FontFace,Metrics,LineMetricsandOutlineBoundsare exported, sinceCanvas::fontandfont_forhand them out. What changed around it.fontdueleavesCargo.toml. The two canvases, their text and setup modules, the registry, the rich text and text widgets swapArc<Font>forArc<FontFace>and nothing else; the glyph caches still key on(glyph_id, size_bits, font_id).FontRegistrynow reads a source into anArc<Vec<u8>>and gives the face the bytes it will pull outlines from, so the registry no longer owns a second copy; its parse error names the file rather than a library message.FontHandlekeepsfontandbytesside by side, now naming the same buffer, so shaping needs no second read and the comment that explained the duplication is gone.system_fontsbuilds fallback faces from the bytes each slot already holds, and the slot stays two-staged: the bytes are read once to probe coverage withttf-parser, the face is built only when a glyph in that slot is needed, and a face that is built no longer costs anything until it draws. The chain. DejaVu Sans moves from last resort to just before the CJK collection. It owns the geometric shapes, arrows and box-drawing blocks, which are what a user interface most often needs outside its own family; behind the collection, every one of those pulled the 19 MB file in, since probing a slot for coverage reads it whole. The CJK entries stay last for the same reason. The two hooks are for a host that embeds another UI, trust in this case, and complete the onesAppalready had.take_clipboard_requestis the counterpart of nothing: an app could paste from the system clipboard through its text fields but had no way to put text into it. ReturnSome( text )once and the run loop stores it and publishes it as the seat's selection through the data device, the same pathhandle_copytakes for Ctrl+C in a text field, so other applications see it.on_pointer_secondaryreports a right-button press that no widget of ltk's own answered, in physical pixels likeon_pointer_button:on_pointer_right_clickalready dispatched a widget'son_long_pressand the built-in text menu, and in the remaining case only dismissed an open menu; it now also tells the app, which can forward the press to an embedded view that has a context menu of its own. Not changed. Variable fonts are read at their default instance, as before. Colour glyphs,COLRor bitmap, are not rendered by either rasteriser; the outline path draws what it can. The glyph caches are still unbounded per face, but they now hold what was drawn, not what the file contains.admin
2026-09-25 07:43:47 +02:00 -
494fb26f9e
README: ltk is not mobile-first, its surfaces adapt to any screen Two lines carried the mobile-first framing that Eydos is dropping across its components. "mobile-first Linux shells" appeared among the cases the toolkit is especially relevant for, and "attention to mobile power usage" among the priorities its origin explains. Neither describes what ltk does. There is no mobile edition and no desktop edition of anything built with it: the same widgets are designed to work at any size and with any input, touch or pointer, and the layout is chosen per screen at run time. A reader who takes "mobile-first" at face value concludes that a desktop application is a secondary use of the toolkit, which is the opposite of the intent. The same framing is being dropped from forge and crustace. The power usage line is imprecise for a second reason. What makes a toolkit care about wakeups, redraws and idle cost is that the device runs on a battery, not that it is a phone: a laptop has the same constraint and a board on mains power has neither. "attention to power usage on battery-powered devices" states what the priority is actually about and still covers the phones it was written for. Documentation only. No code and no public API is touched, and these were the only two occurrences of the word in the file.
admin
2026-09-23 20:55:07 +02:00 -
919df60681
App::take_blur_request, and a press on a focusedselect_on_focusfield positions the cursor Two gaps found while using trust's address box on a phone, both about what happens to a text field once it holds the focus. Dropping the focus. An app could ask for the focus of a widget, throughApp::take_focus_request, but had no way to give it up. A browser needs to: once an address is submitted the field has done its job, yet it kept the focus, so the on-screen keyboard stayed over the page that had just been asked for, and came back whenever the window was raised, since ltk enables the input method again on the text-inputenterthat follows while a text field holds the focus.App::take_blur_requestis the counterpart: returntrueonce and the run loop callsset_focus( keyboard_focus, None ), the same call the back request already uses to blur a text field, so the text-input disable andon_text_input_focused( false )come with it. It is handled just before the focus request, so an app that asks for both in one cycle ends up focused, which is what "open a new tab and type" needs; a pendingfocus_retryfrom an earlier cycle is cleared, or it would hand the focus straight back to the widget it was taken from. Positioning the cursor in aselect_on_focusfield.handle_text_pointer_downandhandle_text_pointer_dragreturned at once for a field built with.select_on_focus( true ), so that the press that focuses it keeps the whole-value selectionset_focushas just installed. The guard did not distinguish that press from a later one: in a field that already had the focus, no press ever placed the cursor and no drag ever selected a range, with a finger or with a mouse. That suits a numeric picker poorly and an address box not at all, where correcting one character of a long URL meant retyping it.AppDatagainspress_on_focused, computed in both press paths, touch and pointer, right before theirset_focus: whether the press landed on the widget that already held the focus. The two guards now apply only when it did not. The first press on such a field selects everything, as before; a press on it while focused collapses the selection to the byte offset under it, and a drag from there extends the selection. Not changed.handle_text_select_wordkeeps itsselect_on_focusguard, so a double press on such a field still leaves the whole value selected rather than a word. The right-button path, which opens the context menu without touching the selection, never reached these functions and is unaffected.set_focusstill moves the cursor to the end and reselects when it is called for the widget that already has the focus; a programmatic focus request on a focusedselect_on_focusfield therefore selects everything again, which is what an address-box shortcut wants.admin
2026-09-21 22:45:16 +02:00 -
1db7a026c9
Default theme:
navigation/close-smalldrew nothing, in either variant Bothclose-small.svgfiles,lineandfilled, held the two diagonals of the cross as bare segments,M785 3221L799 3235and its mirror, painted withfill="#000000"and no stroke. A straight segment encloses no area, so filling it paints no pixel, andstrokedefaults tonone: the file is valid SVG that renders as a fully transparent image, in ltk's rasteriser and in Inkscape alike. The rest of the catalogue is drawn as closed outlines, which is what lets an icon be filled and tinted without depending on a line width; this one looks like it was exported with its strokes never converted to paths. A scan of the 432 icons of the catalogue for the same pattern, no stroke and no closed subpath, turns up no other icon with the defect. Each diagonal is now a closed shape: two parallel sides 1.5 units apart joined by a half circle at each end, the rounded endsclose.svghas. The width is a little heavier in proportion than that icon's (2 units on a 23.6 box), since this one exists to be drawn small. TheviewBox, each variant's owntranslateand the visual extent of the original drawing, 0.5 to 14.5, are kept: the axes are inset by the half width so the caps end where the tips of the segments were. Both diagonals are subpaths of a single path filled with the defaultnonzerorule, on purpose: theevenoddthatclose.svgcarries would punch a hole where they overlap.lineandfilledshare the shape, as they do forclose.svg. The outlines use elliptical arc commands, which no other icon of the catalogue does; they were checked by rasterising both files with Inkscape.admin
2026-09-21 13:53:49 +02:00 -
2af281520b
External text input for fields ltk does not own;
TextEdit::colorandTextEdit::on_focusTwo additions driven by apps that host another UI inside an ltk surface: trust, whose pages have their own form fields, and rustdroid, which projects Android views into ltk widgets. External text input. ltk only ever enabledzwp_text_input_v3for its ownTextEdits, so nothing told the compositor that an editable inside an embedded web view had the focus: the on-screen keyboard never came up inside a page, which makes a browser unusable on a phone without a hardware keyboard. Three hooks onAppclose that.external_text_input() -> Option<u64>asks for the input method on behalf of such a field: while it returnsSome( serial )and no ltk text widget holds the focus, the runtime keeps a text-input enabled, and the compositor raises its keyboard.on_external_text_commit( text )andon_external_text_delete( before, after )receive what the input method sends, the latter in UTF-8 bytes as the protocol counts them. Keys such as Backspace and Enter keep arriving as key events, which hosts already forward. The request is a serial rather than aboolso that it can be renewed. A compositor that folds its keyboard while the field keeps the focus, which forge does on back, has nothing to observe when the user taps that same field again, because the text-input never stopped being active; a change of serial makes the runtime sendenableagain throughreenable_text_input, the same signal ltk now sends for its own fields. The host chooses what bumps it; trust counts taps on the page, so dragging to scroll does not bring a folded keyboard back.AppData::sync_external_text_input, intext_editing/ime.rs, is polled after every batch ofupdates next tosync_fullscreen. An ltk text widget always wins: while one has the focus the function only clears its record, andset_focusmanages the text-input as before. When that widget loses the focusset_focusdestroys the text-input, and the next poll finds the request still standing with no text-input and enables a new one, so the two owners hand it back and forth without either knowing about the other. Thezwp_text_input_v3dispatch routescommit_stringanddelete_surrounding_textto the app only when the external request is held and no ltk text field is focused; that second check covers the one iteration between a widget taking the focus and the next poll. The empty-string filter oncommit_stringis folded into theOptionit already lived in.TextEdit::color( c ). The colour of a field's value always came from the theme. A field placed over a surface the theme knows nothing about, an AndroidEditTextwith its own text colour on its own background, could end up unreadable.colorsets the value's colour in both draw paths, single-line and multiline; the placeholder keeps the theme's, and a field without it draws exactly as before.TextEdit::on_focus( msg ).App::on_text_input_focused( bool )says that some text field is focused, not which. An app that mirrors focus into another model needs to know which: rustdroid keeps Android's single focused view in step, and could only learn the field from its firston_change, soView.isFocused()stayed false until the user typed. AWidgetIdcannot carry that, being a&'static strwhile such handles are created at run time. A per-widget message can, since the app builds it and puts in it whatever it needs. It rides theWidgetHandlers::TextEditsnapshot, andset_focuspushes it when the focus actually moves to the field, from a press, Tab orApp::take_focus_request, and not again for a press on the field that already holds it. Every other match on that handler variant uses.., so the new field touches its definition, its clone and the snapshot inelement.rs. Documented in theApprustdoc, the trait surface list ofdocs/architecture.md, thetext_editentry ofdocs/widgets.mdand the changelog. Neither path has a test: both need a live Wayland connection and a compositor to exercise.admin
2026-09-21 12:45:09 +02:00 -
96a9e88051
Back as a navigation request:
App::on_back,BackAtRoot,NavStackand docs/navigation.md; renew the text-input enable on a press; fit an oversizedwindow_size_hintto the screen Three changes that came out of making the shell's back button work across Eydos the way it does on Android. The first is the feature; the other two are gaps it exposed. Back is no longer Escape. forge turns its back gesture and button into theXF86Backkey for the focused client, and ltk treated that key as an alias ofEscape: close transient UI, else drop the focus and reachon_key_with_modifiers. That is "cancel", and it is all an app could get: nothing navigated, and nothing ever left the app.XF86Backis now a back request with its own order, one layer per request, top-most first: transient UI the runtime owns (open xdg-popup overlays, the built-in context menu, a text selection, the top-most pressable carrying anon_escape, i.e. a dialog's cancel), then text-input focus, then the newApp::on_back, thenon_key_with_modifiers( XF86_Back )so an app that bound the key itself keeps receiving it (crustace does, for its launcher), thenApp::back_at_root. The first step is what Escape already did; it moves out ofhandle_key_escapeintoAppData::dismiss_transientin the newinput/keyboard/back.rs, which both paths call, so Escape behaves exactly as before. Text focus comes before the app on purpose: with a keyboard covering half the screen, back means "put the keyboard away", and navigating instead would discard what the user was typing. A compositor that folds its keyboard itself, as forge now does, only lets the next request through, which makes this step the fallback for the ones that do not.App::on_backreturns aBackResponse:Message( msg )with the message that undoes the top-most piece of navigation state,Unhandledat the root, orConsumedfor a host whose back stack lives elsewhere and cannot be inspected ahead of time.Consumedexists for rustdroid: an Android app keeps its activities, fragments and back callbacks in its own code, so the host can only hand the request over.App::back_at_rootthen picksMinimize(the default),Close(throughon_close_requested, which can still veto) orIgnore. Minimising is what a root activity does on Android since version 12 and what suits an app the user comes back to: a browser keeps its tabs, a call goes on. It is also the single point where the two stacks meet: the navigation stack belongs to the app's model, the task stack to the compositor, andxdg_toplevel.set_minimizedis the one standard request between them, so no custom protocol carries navigation state out of the process and clients that are not ltk apps keep working. OnlyShellMode::Windowsurfaces react at the root; a panel or a lock screen that minimised itself would be a bug. Three consequences of back being a navigation request rather than input: the key leaves the defaultApp::key_repeatsset, since a held button would empty the whole stack; it is no longer reported throughApp::on_raw_key, so a host forwarding raw keys into an embedded web view or guest toolkit stops leaking it as a keystroke; and the mouse's back button (BTN_SIDE/BTN_BACK) enters through the samehandle_back.focused_text_inputbecomespub( crate )for the new module. The answer is derived, not recorded, and that is the design decision worth writing down. The runtime keeps no navigation stack of its own. Its entries would have to be messages, which are not serialisable (the trait puts no serde bound onMessage, by design), so it could not survive a relaunch; and a list of "things to undo" drifts the first time the user leaves a screen some other way, through its own close button or a link, leaving a back press that does nothing visible. An answer computed from the model cannot disagree with it. This is also what ties back to the session persistence added earlier: navigation state is both whaton_backreads and whatsave_stateshould persist, and keeping the two the same is what makes back retrace the same places after a session restore or a crash, with nothing extra to serialise. Persist less and a restored app opens on the right screen with nothing behind it; persist more and it restores a menu the user had closed.NavStack<R>packages that rule for the common case, an app made of screens. It is a plain value the app keeps in its model, never seen by the runtime:push,pop,replace(the sideways move back should not retrace),pop_to_root,reset,current,current_mut,iter,depth,can_pop, andback( msg ), which answerson_backdirectly. It is stored as a root plus the routes above it rather than as aVec, so "never empty" holds by construction, including after deserialising an old or damaged state file, and it derives serde wheneverRdoes. Six unit tests cover it, a serde round trip among them, and its rustdoc example is a completeAppwhere one object answerson_back,save_stateandrestore_state. Documentation, since the point is for app authors to take navigation state into account. The newdocs/navigation.mdis the reference: transient UI versus navigation state and the test that separates them, the back order and the reason for each step, why the answer is derived, how it lines up with session state,NavStack, apps that embed a web view (whose page history is navigation state too, to be persisted with ltk-webkit'ssession_state), hosts with a foreign back stack, the root, who owns which stack, and an author's checklist.architecture.mdgains a group in the trait surface and a paragraph in the state patterns,onboarding.mdand the README index link to it, and in the cookbook the multi-screen recipe now usesNavStackwithon_backand persists the stack without serde, which keeps it compiled by the Markdown doctest harness (that harness linksltkalone, so the serde example innavigation.mdis markedignoreand compiled asNavStack's own doctest instead).BackResponse,BackAtRootandNavStackare exported at the crate root and in theltk::windowfacade. Behaviour change for existing apps, also recorded under Changed: a window app that implements neitheron_backnor the key is now minimised on back at its root, where nothing happened before;BackAtRoot::Ignorerestores that. With a text input focused, back only drops the focus and the app sees the next request, where it used to get the key in the same press. Renew the text-input enable on a press. Moving the focus between two text fields, or pressing the one already focused, only committed the refreshed content type on the existingzwp_text_input_v3.activate_text_inputnow sendsenablefirst, as GTK does when a focused entry is clicked. A compositor that folded its on-screen keyboard while the field kept the focus, which is what back now does, has no other way to learn that the user wants it again: the text-input never stopped being active, so there was nothing for it to observe. ltk sends neither surrounding text nor a cursor rectangle, so the only stateenableresets is the content type set right after it. Fit an oversizedwindow_size_hintto the screen. The hint is pinned asmin_size == max_sizebefore the first commit, when no output is known yet, so an app asking for a phone-shaped 720x1440 window on a 1080p desktop got a toplevel taller than the screen; rustdroid carried a dead branch that tried to avoid this from a screen size nothing could give it. The first configure is the earliest point the hint can be checked, and the protocol has the right input for it:xdg_toplevel.configure_bounds, exposed by sctk asWindowConfigure::suggested_boundsand ignored until now. The newevent_loop/window_fit.rsholdsfit_within, which scales a size down keeping its aspect ratio until it fits and leaves one that fits untouched (four unit tests), andAppData::fit_size_hint, run once behind the newpending_size_hint_fitlatch: it uses the compositor's bounds when sent and otherwise 90 % of the first output's logical size, since panels and decorations are unknown from the client side. When the hint has to shrink,min_size(andmax_sizefor a non-resizable window) is re-pinned to the fitted size, which is kept infitted_size_hintand replaces the app's hint as the fallback for later configures that carry no size; without that the window would grow back on the next one. On that one configure the fitted size wins over the compositor'snew_size, which was derived from the oversized pin.App::window_size_hintdocuments the rule with the worked example (720x1440 opens as 486x972 on a 1080p output). The back order needs a live Wayland connection to exercise and has no test; the unit tests coverNavStackandfit_within.admin
2026-09-20 23:23:26 +02:00 -
2252a63f16
App: claim raw touch per press with
claims_raw_touch_at, and toggle fullscreen at runtime withfullscreenTwo additions to theApptrait, both needed by an application that embeds a self-contained input consumer next to regular widgets. The case at hand is trust, the Eydos browser: an ltk-webkitWebViewbelow a toolbar built from stock widgets, with an F11 fullscreen toggle. Raw touch per press.App::claims_raw_touchis all or nothing: once it returnstrueevery finger, the primary included, bypasses the gesture machine, so widget presses, taps and swipes stop working on touch. That suits an app whose whole surface is one WebView (the eydos-settings portal), but not one that shows the view under its own chrome: claiming raw touch made the address box and the toolbar buttons dead to the finger, and not claiming it fed the page a mouse-like primary slot, so a drag selected text instead of scrolling and pinch-zoom was impossible. The newApp::claims_raw_touch_at( x, y )is consulted onwl_touch.downwith the press position in physical pixels of the main surface; a finger landing where it returnstruegoes through the rawon_touch_down/on_touch_move/on_touch_upstream while fingers landing elsewhere keep driving the gesture machine. The default isfalse, so existing apps are unaffected, and it is only consulted for the main surface: overlays have their own coordinate space and the hook carries no surface argument. The implementation reuses the auxiliary-slot path instead of adding a second notion of raw ownership. InTouchHandler::downthe position is now resolved before the takeover check; a claimed finger is recorded intouch_slotswithout becomingprimary_touch_id, which is precisely the state of a second finger today, somotion,upandreset_touch_statealready route it to the app and report its release point with no change. A claimed press skips the plain-press takeover (it must not commit a tap in progress on the chrome) and callsset_focus( focus, None, qh ), mirroring what a pointer press outside any widget does: without it a focused text field would keep the text-input protocol, the on-screen keyboard and the key stream after the user had moved on to the page.pointer_posis still assigned after the takeover, sincerelease_primaryuses it as the release point of the finger being committed. Runtime fullscreen.App::start_fullscreenonly covers the first commit; there was no way to enter or leave fullscreen afterwards.App::fullscreen() -> Option<bool>declares the desired state and is polled after every batch ofupdates, next torequested_exit.AppData::sync_fullscreencompares it withapplied_fullscreen, the last value sent, and callsxdg_toplevel.set_fullscreen( None )orunset_fullscreenonly on a change;None, the default, leaves the window state alone.applied_fullscreenis seeded withstart_fullscreen(), so an app that starts fullscreen and reportsSome( true )does not send a redundant request, and one that reportsSome( false )is not pushed out of a fullscreen it asked for a moment earlier. It is a no-op for layer-shell and session-lock surfaces, which have no toplevel. Theconfigurehandler now also zeroestitlebar_heightwhileWindowConfigure::is_fullscreen()holds, as it already did for server-side decorations. The built-in titlebar was otherwise painted over a fullscreen surface, which is what thestart_fullscreendoc comment described as happening "unless the app opts out" without there being a way to opt out. The height follows the compositor's reported state rather than the app's request, so it stays correct when the compositor refuses the request or leaves fullscreen on its own. Both hooks are documented on the trait and listed in the changelog under Unreleased.admin
2026-09-20 08:22:46 +02:00 -
eaf88754f3
text_edit: scale the single-line horizontal padding with the field height The previous commit put the password eye, the caret and the whole multiline layout in physical pixels, but the single-line variant still inset its text by the raw
theme::PAD_H: 20 physical px whatever the scale. On a scale-2 panel a field 88 px tall, drawn as a pill of radius 44, kept a 20 px inset, so the first glyph sat inside the rounded end while everything else in the field had grown. The padding now follows the field height, like the eye already did:metrics::single_line_pad_h( rect )isPAD_H × rect.height / theme::HEIGHT, built onsingle_line_scale, which replaces the privatepassword_toggle_scaleofdraw.rs. The height is the right reference rather thancanvas.geom_px( PAD_H ): the inset exists to clear the pill's rounded end, whose radius is half the height. With the default height the two agree, since that height is itselfgeom_px( HEIGHT ); when the app pins the height the padding follows that choice instead of the fluid mode, which on a large surface would push it to 1.5 times the design value next to a field the app deliberately kept smaller. It also needs no canvas, sopassword_toggle_hit_zone( rect )keeps its signature.drawresolves the padding once and uses it for the narrowed text rect, the inner clip, the selection band, the placeholder, the text and the caret;password_toggle_hit_zoneuses it for the half padding beside the eye.single_line_scroll_x,single_line_align_offsetand the single-line branch ofbyte_offset_atuse the same helper, so a click still maps to the glyph that was drawn.hit_test.rsno longer referencesthemeand drops the import. A fieldHEIGHTtall at scale 1 keeps exactly the previous geometry.admin
2026-09-19 16:14:21 +02:00 -
495968fca5
text_edit: size the password eye and the caret in physical pixels, and resolve the multiline layout through the canvas Canvas coordinates are physical pixels while font sizes are handed to the raster path in font space and multiplied by
dpi_scalethere.TextEditmixed both: on a 2560×1440 panel at scale 2 a field 44 logical px tall measures 88 physical px and its text 32, yet the eye ofpassword_togglewas rasterised at the 20 px ofPASSWORD_TOGGLE_SIZEand the caret wasfont_size + 4= 20 px tall and 2 px wide, so both came out at less than half their intended size next to a field that otherwise scaled correctly. Password toggle. The icon, the slop around it and the column reserved for it on the right edge now follow the field height, which is already physical:password_toggle_scaleisrect.height / theme::HEIGHTandpassword_toggle_reserveapplies it to the icon plus its slop.password_toggle_hit_zone( rect )keeps its signature and is built from the same helpers, so the zone thathandle_password_toggle_presstests still matches whatdrawpaints. A fieldHEIGHTtall at scale 1 gives exactly the previous geometry, which is what the existing hit-zone tests cover. Caret. The single-line caret multiplies its height and width bycanvas.dpi_scale(), the same factor the glyphs are rasterised with. The literals becometheme::CARET_Wandtheme::CARET_OVERHANG, shared with the multiline variant. Multiline. The multiline variant had the same defect throughout:preferred_size,draw_multiline, the multiline branch ofbyte_offset_atand thecursor_visual_*helpers all usedtheme::FONT_SIZE,LINE_H_MULT,PAD_V_MULTIandPAD_Has raw numbers. The text was rasterised at16 × dpi_scalebut the line pitch stayed at 22.4 physical px, so at scale 2 the rows overlapped, the first one was clipped at the top, the caret was half height and the box claimed half the height its rows needed. It also ignored.font_size( … )and the widget-scaling mode, which the single-line variant honours. The newmetrics.rsholdsMultilineMetrics, resolved once per call from the canvas and the field'sfont_size: the font throughresolve_font_size, the glyph and line heights from it timesdpi_scale, the paddings throughcanvas.geom_pxlike the other stock widgets, and the caret size. It also owns the inner width, the visible row count and the first visible row, whichdraw_multilineandbyte_offset_ateach computed on their own. Sizing, drawing, hit-testing and vertical caret navigation now agree by construction.cursor_visual_up,cursor_visual_down,cursor_visual_homeandcursor_visual_endgain afont_size: Option<Length>parameter; their callers inevent_loop/text_editing/cursor.rsalready received it fromtext_input_geometryand discarded it. One visible change at scale 1: the multiline paddings and the default multiline font now go throughgeom_pxandfont_px, so in fluid mode they scale with the surface between 0.7 and 1.5 times the design value instead of staying at 20 / 12 / 16 px. The horizontal padding of the single-line variant is still the rawPAD_H.admin
2026-09-19 16:00:58 +02:00 -
53e7ccc46e
Keep the GLES path when the scale arrives first, stop re-rendering what does not change, and let the compositor skip what the surface covers Measured on a Librem 5 (Cortex-A53, GC7000Lite, 720×1440 at scale 2) with a frame trace on both sides of the socket: the shell's main surface repainted in 110 to 136 ms, quick settings drew at 20 fps with the GPU saturated, and every panel open uploaded its wallpaper and icons again. Most of it was the toolkit doing work the frame did not need. Backend choice. When the compositor announces the output scale before the first
configure,scale_factor_changedallocated an SHM pool for the surface andon_configuretook that pool as the sign that the software path had been chosen, locking the surface to tiny-skia for its whole life. Every ltk main surface on a scale-2 phone ran in software while its overlays, which inherit the scale from the main and never receive that event, ran on the GPU. The scale handler now only reallocates a pool that already exists; before the first configure neither path is chosen. Wallpapers.WallpaperBundle::for_sizereturned the crop at the source resolution, a 1103×2160 slice of a 4K image for a 720×1440 panel, 10 MB uploaded per texture and a hundred milliseconds each time. The crop is now downscaled to the surface (bilinear, once per size, cached), also for landscape surfaces smaller than the source. Viewport.Element::Viewportcreated a fresh sub-canvas every frame, on GLES a framebuffer, a 2048² glyph atlas and empty image and glyph caches, so a panel clipped to a height that animates re-uploaded its wallpaper and re-rasterised every glyph on every frame. Without a bottom fade the clip is now a scissor on the parent canvas (a mask on the software backend): no offscreen pass, no blit, and the child's textures stay in the parent's caches.local_viewportis honoured through a temporary override of the layout viewport. Viewports with a fade keep their sub-canvas, which, like the scroll sub-canvases, is now kept across frames and resized in place instead of reallocated when its size changes. Caches. The image texture cache moves from the canvas to the GL context: every canvas and sub-canvas shares one content-keyed LRU (32 MiB), so an overlay or subsurface that is destroyed and recreated finds its images resident. A GLES canvas dropped with an overlay is pooled inAppData::canvas_pooland handed to the next overlay, with its compiled programs and glyph atlas, which took 130 to 290 ms to build on that GPU. Subsurface slots keep their scroll and viewport sub-canvases between rasters. Surfaces. Acontainer.surface()with shadows costs a Gaussian drop-shadow pass, a fill and one pass per inset shadow, all full-quad fragment shaders; a dozen glass cards were 40 ms of GPU per frame for chrome that never changes.Canvas::fill_surfaceon GLES now renders the chrome once per size, corners and style into a texture held by the context cache (gles_render/surface_cache.rs, 16 MiB LRU) and blits it, drawing directly only for surfaces withPlusLighterorOverlayinsets, which depend on the backdrop. Opaque regions. ltk never calledwl_surface.set_opaque_region, so the compositor blended every ltk surface and repainted everything underneath a full-screen homescreen. A surface whosebackground_coloris opaque now declares itself opaque, and the newApp::overlay_opaque_region( id )lets an overlay declare the physical rects it paints opaque, such as a panel column over an opaque wallpaper.sync_opaque_regionsends the rects only when they change. Frame pacing.eglSwapBuffersblocked 13 to 37 ms per frame waiting for Mesa's own throttle callback; ltk paces itself onwl_surface.frame, somake_currentsets the swap interval to 0. A subsurface move is now applied only when its parent has no frame callback outstanding, and the parent commit that applies it requests the next callback: a drag commits once per vblank with the latest finger position instead of once per input sample, without the app throttling samples itself. While the app reportssubsurface_motion_only, the main's callback, and the callback of an overlay that hosts a subsurface, no longer force a full repaint; they mark the surface and the reconcile keeps the cadence with a bare commit when nothing moved. Swipe releases arm a settle the same way instead of dirtying every cache. Two hooks,on_overlay_frame_committedandon_subsurface_committed, let an app hand a panel over between its host and a sliding subsurface on real commits rather than loop ticks. Lines. A diagonaldraw_lineon GLES stamped onefill_rectper pixel of length, 190 draw calls for a spinner and over 2000 for a pattern-lock stroke; it is now a single rotated quad through the rect shader, with round caps and antialiased edges. A pixel test covers the scissor viewport,for_sizegets a downscale test, anddocs/architecture.md,docs/widgets.mdanddocs/backends.mddescribe the new hook, the viewport paths, the shared caches and the line rasteriser.admin
2026-09-18 09:55:09 +02:00 -
03bb7eeede
button: elide the label against a minimal inset instead of the horizontal padding
draw_text_buttonelided the label against the granted width less twicePAD_H. That padding is 24 design px resolved through the widget-scaling mode, up to 36 logical px in fluid mode, so on a scale-2 laptop each side took 72 physical px off the label area. A PIN key pinned at 80 logical px kept 16 px for its digit: "1" fitted, "2" onwards did not, andelidereturned "..." although that did not fit either. loginmanager's keys, at 56 dp, ended with a negative area and drew no digit at all. Icon buttons never went through that cut, which is why backspace and OK still showed. The padding belongs to the preferred size of an auto-width button, whose granted rect already contains it, so it has nothing to protect at draw time. A pinned width, or a row shrunk below its preferred width, may leave less than the padding; the label now keeps onlyLABEL_INSET, 4 design px, off the frame and uses the rest of the rect before eliding.admin
2026-09-16 22:12:14 +02:00 -
58b8dd7b90
Focus requests reach widgets clipped by a scroll viewport, and stop retrying after a few frames A scroll viewport keeps in
widget_rectsonly the widgets at least partly inside its visible area (draw/layout.rs, the translation of the sub-canvas rects to surface space). A focus request whose target sits below the fold therefore never finds it:App::take_focus_requestwas honoured only for widgets already laid out on screen. The Wi-Fi page of settings is one such case by construction: tapping a secured network inserts the password form under the row and asks for focus on the field, so for a row in the lower part of the list the field is born outside the viewport and the request misses. Worse, the miss branch parked the id infocus_retry, marked the view dirty and requested a redraw on every iteration, so the application rebuilt and repainted at the compositor's frame rate for as long as the widget stayed hidden. The layout pass now records the widgets it drops for carrying aWidgetId:DrawCtx::scroll_clippedholds(id, scroll flat_idx, rect)with the rect in the scroll's content coordinates, the same frame the navigable-item list uses, anddraw_framehands it toFrameState. When a focus request misses,AppData::reveal_clipped_widgetlooks the id up in that list on the main surface and the overlays, finds the viewport inscroll_rects, and moves its offset on the axes the scroll allows so the rect lands inside; the next frame lays the widget out and the retry focuses it. Nested scrolls resolve one viewport per frame. The rule that computes the new offset isscroll::offset_into_view, extracted from the keyboard navigation ininput/keyboard/nav.rs, which now calls it instead of carrying its own copy; unit tests cover the three cases.focus_retrycarries a budget of drawn frames (FOCUS_RETRY_FRAMES, four), decremented only on iterations that actually drew, so a request made while a frame callback is pending does not lose its chance. A fresh request from the application replaces a pending retry with a full budget. When the budget runs out the request is dropped, which ends the redraw loop for a widget that never appears. Documented in thetake_focus_requestdoc comment, the cookbook and the changelog; Debian revision 0.3.0-3.admin
2026-09-14 11:50:27 +02:00 -
5b8ae35830
[ltk — src/input/gesture/mod.rs, src/input/gesture/tests.rs, src/widget/handlers.rs, src/input/keyboard/dispatch.rs, src/render/image.rs, docs/architecture.md, CHANGELOG.md] Keep a tap on a button while the finger rolls inside it, treat XF86Back like Escape, and convert ARGB8888 by whole pixels Any travel past the slop turned a press into a vertical or horizontal drag and its release skipped the button, so a thumb that rolled on a large key lost the tap.
GestureState::on_movenow stays idle while the pressed widget fires on press (the newWidgetHandlers::has_press) and the finger is still inside its rect; the swipe machinery takes over from the original press point once the finger leaves. Presses inside a scroll viewport are unaffected, since a list of full-width rows is scrolled by dragging on them. Two tests cover the tap that survives motion inside the button and the swipe that arms once the finger leaves it; the architecture notes and the changelog describe the rule. The compositor now sends XF86Back for the Android back gesture, an inward swipe from either side edge. With a text input focused that keysym fell intohandle_key_defaultand was swallowed, so an app never saw it.XF86Backnow dispatches throughhandle_key_escape, so every ltk app gets the back gesture with Escape's layering: open popups, then the context menu, then a selection, then a dialog's cancel, and finally the focus drop andon_key_with_modifierswith the keysym, where an app can act on it.SoftwareCanvas::write_to_wayland_bufswapped red and blue with a byte-by-byte loop, unrolled by hand over sixteen bytes. It now reads each pixel as au32, exchanges the two channels with masks and shifts, and leaves the vectorisation to the compiler. Compositors that offerABGR8888never took this path.admin
2026-09-14 10:57:10 +02:00 -
75be800390
input, render: keep fast touch typing whole, fire pattern nodes once, and make the software image path cheap Two ways a tap was lost on a phone. The gesture machine serves one finger: a second finger landing while the first was still down went to the auxiliary slot and never reached it, so with two thumbs every overlapping tap on a keypad vanished. A finger landing on a primary that is still a plain press (GestureState::is_plain_press: no drag, scroll, swipe or slider in flight) now commits that press as a tap at its last position and takes the primary slot; the earlier finger's release reports through on_touch_up, and a second finger during a pattern stroke or a slider drag stays out as before. The touch-up body moves to release_primary so both paths share it. The other loss needed one finger: the 8 px travel that turns a press into a scroll, swipe or drag, and the 6 px stray that cancels a touch long-press, were compared against physical coordinates, so at buffer scale 2 they were 4 and 3 logical px and an ordinary thumb tap became a vertical drag whose release skipped the button. SwipeConfig carries the surface's scale and the machine reads the thresholds through slop() and stray(), which are logical. Pattern grids fired on_node on every motion event within a node's snap radius, and the Slider move outcome requested a repaint each time, so drawing a pattern rebuilt and re-rastered the surface at the touchscreen's motion rate. The machine remembers the node under the finger (last_pattern_node) and emits only on change; the handlers split pattern_node_at from pattern_msg, with pattern_node_msg kept on top of them; a move outcome without a message no longer requests a redraw. SoftwareCanvas::draw_image_data premultiplied the whole source in floating point on every call, so a full-surface backdrop paid a pass over every pixel per frame before the blit. Premultiplied copies now live in a 32 MiB least-recently-drawn cache keyed by size, content fingerprint and alpha, the key the GLES texture cache already used; fingerprint_rgba moves to render::helpers and both backends share it. The entry records whether the source was fully opaque, and an opaque image drawn 1:1 with no clip and full alpha is copied row by row into the canvas instead of going through tiny-skia's bilinear pipeline. docs/architecture.md describes the primary-slot hand-over, docs/widgets.md the once-per-node contract, and the CHANGELOG lists the five fixes.
admin
2026-09-12 10:43:51 +02:00 -
0a4afd8f81
tree, text_edit, render: hit-test only the visible part of scrolled widgets; keep the clip when a text field's interior misses the dirty region Two partial-repaint bugs seen in Settings. A row scrolled half out of a
scrollviewport kept its full layout rect for hit testing: onlypaint_rectis clamped to the viewport, andfind_widget_attestedrectalone. The row whose bottom sliver showed just below the search field therefore owned, for the pointer, its whole height above the viewport, so hovering the field showed the hand cursor and highlighted that row, and a click opened it.find_widget_atnow requires the point to lie inpaint_rectas well. Inside ascrollorviewportthat is the visible part; elsewherepaint_rectalways enclosesrect, so nothing else changes.rectis left intact because sliders, focus scrolling, the IME rect and tooltips need the full geometry. Two tests cover a clipped row and an unclipped button. The search field's placeholder darkened as the pointer moved over the list, until a full repaint restored it.TextEdit::drawcomposes its interior clip as the intersection of the parent clip with the inner rect; during a hover repaint of a row the intersection is empty, andset_clip_rectswith an empty list means "no clip", so the placeholder was painted unclipped over the previous pixels on every partial frame. Withtext_secondarybeingtext_primaryat 0.6 alpha, two or three passes read as bold.interior_clipnow returns a zero-area rect for an empty intersection, shared by the single-line and multiline paths, which the GLES backend already turns into a zero scissor; the software backend gains the matching rule, keeping an all-zero mask when the rect list is non-empty but has no area instead of dropping the clip. A test pins the three cases.admin
2026-09-11 19:24:49 +02:00 -
92b090a441
App: fix the on_main_frame_committed doc, the hook orders a swap, it does not make it atomic The comment promised that a subsurface mounted or retired from the hook lands in the same flush as the main frame. It does not: the main buffer goes out with its swap, which flushes the connection at once, and the subsurfaces are reconciled afterwards. What the hook guarantees is ordering, the main frame is already sent when it fires, which is enough to retire a subsurface safely but not to mount one without a gap.
admin
2026-09-10 22:57:30 +02:00 -
87f52565c3
App: add on_main_frame_committed, called after every main-surface commit A host that hands part of its content to input-transparent subsurfaces has no way to know when the main frame that replaces that content has actually been sent:
view()runs when the tree is dirty, but the draw can be deferred by a pending frame callback, so a subsurface dropped on the strength of the rebuild alone can vanish a frame before the main paints its replacement. The new hook fires indraw_frameright after the main buffer is rendered and committed, before the subsurfaces are reconciled for the same iteration, so the host can retire a subsurface knowing the main frame behind it is already out.on_first_frame_committedkeeps its one-shot role.admin
2026-09-10 22:54:27 +02:00 -
c9ae5438f8
pattern_grid: explicit side through PatternGrid::size The grid's preferred size was the theme's fluid constant (264 px at the reference surface) capped by the width the parent allocates, so a caller could only shrink it: a lock screen that wants the grid to span most of a phone's width had no way to ask for it, and a container's max_width did nothing past the default. PatternGrid gains a size field with a size( impl Into<Length> ) builder; preferred_size resolves it through Canvas::resolve_geom like the other geometry overrides and still caps it at max_width, and map_msg carries it across. Without the builder the fluid default applies as before. docs/widgets.md describes the builder next to hide_stroke and error; CHANGELOG lists it under Unreleased.
admin
2026-09-10 21:33:55 +02:00 -
7c1ef86afe
Resolve fluid geometry bounds in logical space and let the runtime set the density from the buffer scale Stock widgets size themselves with
Length::fluid, whose auto-clamp[0.7·px, 1.5·px]is documented as logical px, yetCanvas::resolve_geomresolved it against the physical viewport and applied the bounds to the physical value. On any HiDPI surface whose physical short side exceeds 618 px (412 × 1.5) the cap engaged: a 1080 × 1920 phone at scale 2 got its geometry at 1.5 physical px per design px, that is 0.75 logical, while fonts, resolved byfont_pxagainst the logical viewport and scaled at raster time, kept growing to 2.62. Rows, paddings and icons came out at 60 % of their intended size with the text overflowing them; at scale 1 physical and logical coincide, so a winit session never showed it.Length::resolve_physicaltakes the buffer scale and multipliesmin_px/max_pxby it before clamping;resolve_with_densitydelegates to it with scale 1.Canvas::resolve_geompasses itsdpi_scale, so every stock-widget geometry shares the surface factor of the fonts, andoverlays_reconciledoes the same forOverlaySpec::size, which had the same mix. A zero scale falls back to unscaled bounds. The event loop now callsset_density( sf )right beforeApp::on_scale_changed, both onconfigureand onscale_factor_changedof the main surface, soLength::dp( n )means n logical px in every app without plumbing; an app wanting a DPI-derived density still overrides it from that callback, and crustace already sets the same value there. Two tests cover the clamp at scale 2 and the geometry/font parity on a 1080 × 2460 canvas.admin
2026-09-10 21:18:24 +02:00 -
2cee9a1c57
event_loop: drop the text-input focus when the focused field vanishes from a rebuilt view A view rebuild that no longer contains the focused text field left
focused_idxpointing at a stale index and thezwp_text_input_v3object enabled. The compositor kept seeing an active text-input, so the on-screen keyboard stayed up over a screen with nothing to type into. The lockscreen's inactivity return from the password form to the idle screen is the visible case: the form disappears, the keyboard does not.focus.rsgainssurface_focused_text_input, which resolves the focused widget by id when it has one and by index otherwise and reports whether it is a text input, andreconcile_text_focus, which clears the focus, deactivates the text-input and callson_text_input_focused( false )when it no longer resolves to one.draw_framenotes, before drawing the main surface and each overlay, whether its focus was a text input, and reconciles those surfaces once every draw is done and no view borrow is live. Focus that lands on the field again throughtake_focus_requestre-enables the text-input as before.admin
2026-09-10 21:12:45 +02:00 -
492553829e
widgets, input: PatternGrid for pattern unlock, framed icon buttons, and text keys that bubble past a focused non-text widget; Debian 0.3.0-2 PatternGrid is a 3×3 drag widget for the unlock pattern. It is stateless: the app owns the visited nodes and passes them back in for drawing; a press or a drag that reaches a node within the snap radius fires on_node with the index, 0 to 8 row-major, and release fires on_release, so the crossing and minimum-length rules stay in the app. Geometry lives in widget/pattern_grid/theme.rs (size, dot and node radii, stroke width, snap radius, ring alpha), and the snap radius is resolved through the widget-scaling mode at layout time like the slider thumb (set_pattern_snap_px). The gesture machine reuses the slider's drag ownership: WidgetHandlers::is_drag_target covers both, so hover painting, pointer capture and release behave the same, and hover_affects_paint no longer special-cases sliders. The a11y tree maps it to GenericContainer, since a drawn gesture has no assistive equivalent and the app must offer another method. Exported as pattern_grid / PatternGrid, documented in widgets.md, theming.md and architecture.md, with tests for node centres, a wide rect, snapping, the builders, the handler snapshot and map_msg, and a Security tab in examples/widgets.rs that shows the drawn path. Icon buttons given a width or height are framed keys: preferred_size honours the explicit size instead of the icon size, and drawing paints the variant's background, border and focus ring, shared with text buttons through paint_frame, then centres the icon. An icon button without a size keeps the circular hover overlay it had. This is what lets the PIN keypad's backspace and OK keys sit in the same circles as the digits. Keyboard routing now matches what App::on_key documents. handle_key_default, Backspace, Delete and Space checked focused_idx.is_some() and sent the key into text insertion whenever any widget had focus, which is a no-op on a button, so a focused button or pressable swallowed typed characters. focused_text_input replaces the five copies of that check; characters, Backspace and Delete stay in a focused text input and reach the app otherwise, Return and Space still go to the focused widget's submit or press message when it has one, and the arrow, Home and End handlers use the same helper. The lockscreen relies on this to take PIN digits with its dots row focused. debian/changelog: 0.3.0-2.
admin
2026-09-09 17:27:42 +02:00 -
a2752a5bd3
Handle SIGTERM/SIGINT with a sigaction self-pipe instead of a signalfd, and clear the signal mask of spawned children The signalfd-based source (calloop's
signalsfeature) needs SIGTERM and SIGINT blocked in every thread of the process, since the kernel hands a process-directed signal to any thread that does not block it. calloop only blocks them in the thread that creates the source, so the runtime depended onltk::runbeing entered before the app spawned any thread — a constraint apps do not honour: crustace-notifier builds its tokio runtime and its D-Bus thread first, and in that configuration a SIGTERM lands on a tokio worker and kills the process outright, skipping the clean exit (save_state,mark_clean_exit) entirely. The blocked mask had a second consequence: it is inherited across fork and exec, and Rust's std deliberately does not reset it in the child. Every process an ltk app spawns from a thread created afterltk::runtherefore started with SIGTERM blocked. For thegsettings monitorchild of the text-scale watcher this meant it never died on systemd's stop, so the unit's cgroup stayed populated untilTimeoutStopSec(90 s) expired and systemd resorted to SIGKILL — visible as a stop job holding up every session shutdown.install_signal_sourcenow installs asigactionhandler for SIGTERM and SIGINT that writes the signal number to a non-blockingO_CLOEXECpipe (an atomic load and awrite, errno preserved); the read end is a calloopGenericsource that drains the pipe and setsexit_requested. A handler is process-wide, so the clean exit works no matter which thread receives the signal or when it was created, and no thread blocks anything, so children inherit a clean mask.SA_RESTARTkeeps the handler from injecting spurious EINTRs into the app's own threads; the pipe wakes the event loop regardless.text_scaleadditionally clears the child's mask inpre_execbefore runninggsettings, as a guard against a mask already dirty when the process was launched. Thesave_statedocs drop the caveat about threads started beforerun, the ordering comment at the call site goes with the constraint it described, and thesignalsfeature is dropped from calloop.libcbecomes a direct dependency forsigaction,pipe2andsigprocmask. Tests:child_starts_with_clear_signal_maskblocks SIGTERM in the test thread, spawns a child through the helper and checks itsSigBlkin/proc, with the inverse control asserting that std really does inherit the mask (the reason the helper exists);handler_writes_signal_to_pipeexercises the handler directly and through a realsigaction+raise, then restoresSIG_DFL.admin
2026-08-25 09:47:08 +02:00 -
902e23e7f2
Fallback fonts: probe glyph coverage with ttf-parser before building the fontdue face The first string containing a glyph outside Sora stalled the caller for over a second, even on a desktop i7.
system_fonts::lookup_handlewalked the fallback chain building afontdue::Fontfor every slot just to consult itscmap, and fontdue parses the whole face up front: reaching a dingbat such as U+2733 meant paying forNotoSansCJK-Regular.ttc(19 MB) although the glyph is not even there and only DejaVu, the last slot, carries it. In forge that showed as the app switcher taking more than a second to appear the first time a window title carried such a symbol; the top bar never triggered it because its text is all Latin. Each slot now has two lazily resolved stages: the file bytes, read once, and the fontdue face. Coverage is answered from the bytes alone withrustybuzz::ttf_parser::Face::parse(...).glyph_index(ch), which only touches the tables it is asked for, and the fontdue face is built solely for the slot that actually owns the glyph. A CJK codepoint still pays the CJK parse, since it has to be rasterised, but a symbol that lives in DejaVu no longer drags the collection into memory. The sticky negative results are kept at both stages, and the shaping-sidebytesinFontHandlenow share theArcwith the coverage stage instead of a second copy.admin
2026-08-18 11:36:28 +02:00 -
3046d86337
Layer::Window overlays: an OverlaySpec can now be an ordinary xdg toplevel, named through App::overlay_title; showcase persists the last pressed button too A shell built on ltk had no way to show something as a regular window: every overlay is a layer-shell surface, and layer-shell surfaces are either below all application windows or above all of them. crustace's "closing applications…" card ran into exactly that — on the overlay layer it covered the applications' own "save changes?" prompts, on the bottom layer it vanished behind their windows — and what that card wants is to be one window among the others: decorated by the compositor, stackable, listed in the switcher and the dock.
Layergains aWindowvariant. AnOverlaySpeccarrying it is materialised by the reconciler as anxdg_toplevel(sctkWindow, server-side decorations) instead of a layer surface:sizeis its fixed size, applied through min/max size (a zero component falls back to a default, since a toplevel cannot "fill"); anchor, exclusive zone and keyboard exclusivity are ignored. The window's app_id isApp::app_id()and its title comes from the new, defaultedApp::overlay_title( id ), so the compositor's window lists show something meaningful.WindowHandler::configureandrequest_closenow resolve which surface a window belongs to: the main window keeps its behaviour, an overlay window is configured through its ownSurfaceState(with the window geometry set to the configured size) and the compositor's close button delivers the spec'son_dismissinstead of exiting the app. Layer-shell applications now bindxdg_wm_basewhen the compositor offers it, so they can open such a window later. Adding a variant keeps every existingOverlaySpecliteral compiling; the new trait method has a default. The showcase example now also saves and restoreslast_pressed, bumping its private state format to version 2 with the note kept last so its line breaks survive.admin
2026-08-15 17:56:06 +02:00 -
ccf07de593
Session management: xdg-session-management-v1 client, mandatory App::app_id / save_state / restore_state, runtime-managed state persistence and clean exit on signals (0.3.0) Applications built on ltk had no way to come back where the user left them: the toolkit hardcoded
app_id = "ltk"on every toplevel, never wrote anything to disk, and died on SIGTERM without a chance to save. This release gives the runtime the whole plumbing and asks each application only for the bytes worth keeping, in the spirit of Android's saved-instance state. TheApptrait gains three mandatory methods, deliberately without default bodies so every application states its position:app_id()(reverse-DNS, used forxdg_toplevel.set_app_id, the AccessKit application name and the state directory — theapp_idelement of the deprecatedwindow_configtuple is now ignored and a one-time warning reports a mismatch),save_state() -> Option<Vec<u8>>andrestore_state(Vec<u8>). The bytes are opaque; the trait carries no serde bound. Their rustdoc is the contract: when the runtime saves, where the files live, when the bytes come back and when they do not, what must never go in them, and a worked serde_json example. The runtime persists under$XDG_STATE_HOME/<app_id>/(falling back to~/.local/state):session.jsonholds the compositor session id, a clean-exit marker and the writer's pid;state.binholds the application bytes. Writes are atomic (temp file + rename, mode 0600, directory 0700) and best-effort. State is saved every 30 s when the bytes changed, once after the event loop exits (which coverson_close_requested,requested_exitand lost connections), and on SIGTERM/SIGINT — a calloop signal source, installed before any thread exists, now turns those into a clean exit of the loop instead of process death.restore_stateruns synchronously intry_runbefore the window is created and before the firstview(), and only when the process is relaunched as part of a session restore (LTK_SESSION_RESTORE=1, removed from the environment before the app can spawn children) or when the previous run leftclean_exit: false; a plain launch starts fresh. A second concurrent instance detects the live pid and runs with persistence disabled rather than clobbering the first. The compositor side of geometry restore goes throughxdg-session-management-v1. Neither wayland-protocols nor sctk ship generated code for it yet, so the XML is vendored underprotocols/andwayland-scannergenerates the client module in-tree (src/protocol/), resolving the crate names through sctk's reexports so the bindings stay on the crate instances sctk links. Before the first commit of aShellMode::Windowtoplevel the runtime bindsxdg_session_manager_v1, callsget_session(reason, stored_id)andrestore_toplevel(toplevel, "main"); the three window-creation paths inrun.rsare folded into onemake_windowhelper so the attach always sits immediately beforecommit().createdpersists the id,replaceddestroys the objects and stops persisting. Compositors without the global lose only the geometry half. Layer-shell and session-lock surfaces skip the whole machinery. EveryAppimplementor in the tree is updated: the twelve examples (showcase,scrollandmini_shellpersist real state; the rest returnNone), both integration tests (event_loop_flowgainssave_restore_round_trip), the in-source and markdown doctests, README, onboarding, cookbook (new recipe "Surviving relaunch: session state") and architecture docs, and the changelog.src/session_state.rscarries unit tests over a temporary state directory.Makefile installnow copiesprotocols/into the cargo registry — without it downstream builds would fail inside the proc-macro — anddebian/copyrightcovers the vendored XML. The trait change is breaking, hence 0.3.0. Also fixes the pre-existingviewport_testsmodule inrender/mod.rs, which usedLengthwithout importing it and brokecargo test.admin
2026-08-15 10:16:30 +02:00 -
78053b8b01
Cover every output from a background shell: the session-lock secondary machinery generalizes to layer surfaces The runtime already covered outputs beyond the first, but only for
ShellMode::SessionLock:lockedcreated oneext-session-locksurface per output, tracked inlock_extras, rendered fromApp::lock_secondary_view, excluded from pointer/touch routing and repainted through the shared invalidation path. A background shell had nothing equivalent, so on a multi-monitor session its wallpaper simply did not exist on the extended screens — the compositor had to fake one by cover-scaling the primary's background, wrong scale and wrong crop included. The machinery is shared rather than duplicated. The state and the hook lose their lock-specific names —lock_extrasbecomessecondaries,main_lock_outputbecomesmain_output,App::lock_secondary_viewbecomesApp::secondary_view— and everything downstream of creation (the draw loop in frame.rs, invalidation, per-surface scale handling, the input exclusion in pointer/touch, redraw scheduling in run.rs) is the same single copy serving both kinds. What is genuinely new is the second creation site: innew_output, when the app's shell mode isLayer( Background ), every output beyond the main surface's gets its own background layer surface — anchored to all four edges with size delegated to the compositor, exclusive zone 0, no keyboard interactivity — bound to that output at creation and torn down inoutput_destroyed. The main surface's output is now recorded when it materializes so the runtime knows which output not to cover. Layer-shell events learn about secondaries too, and theconfigurebranch is not optional: secondaries live outside the focus map, so their configure used to fall into theNonearm and resize the main surface.closedon a secondary now just drops it instead of asking the app to exit.secondary_viewkeeps its default ofNone(surface filled withbackground_color), so nothing changes for apps that do not opt in; the session-lock behaviour is unchanged aside from the renames.admin
2026-08-14 23:20:58 +02:00 -
99a4767815
event_loop: cover every output with a session-lock surface, with the app's backdrop on the secondary ones ext-session-lock-v1 obliges the compositor to blank any output that has no lock surface, and the runtime only ever created one, on the first output — so on a multi-monitor session the extended screens went black the moment the lock engaged. The lock grant now creates one lock surface per advertised output: the first keeps hosting the app's main surface exactly as before, and each remaining output gets its own surface tracked in
lock_extras, paired with itsWlOutputso configure, scale and teardown can be routed per surface. What those extra surfaces show is up to the app: a newApp::lock_secondary_view( width, height )returns the content for a secondary output of that physical size, and the defaultNonepaints the surface withbackground_color(). The draw pass renders them without requesting frame callbacks — their content is static between invalidations, and a callback that is never re-requested would leaveframe_pendingstuck — and invalidations that touch the main surface also mark the extras, so a backdrop that follows main-surface state (wallpaper swaps, theme changes) stays in sync. The per-surface plumbing that only ever knew about the main surface learns to route:SessionLockHandler::configurematches the surface against main and the extras instead of assuming main (an extra's configure used to resize the main surface's target), and the scale-change resize moves into anapply_surface_scalehelper shared by main, overlays and the lock extras, so a HiDPI secondary gets a correctly scaled buffer instead of staying at factor 1.new_outputcreates a lock surface for an output hotplugged while locked — the compositor would blank it otherwise — andoutput_destroyeddrops the matching extra. Input on the extras is deliberately inert, with one asymmetry: pointer and touch events landing on them are discarded rather than falling back toMain, because their coordinates would hit-test the main surface's widgets and a click on a secondary screen could press whatever sits at those coordinates on the form — the power button included. Keyboard keeps its existing fall-through toMain, so typing works wherever the compositor decides to put the lock focus.admin
2026-08-14 13:01:15 +02:00 -
adbbc0248b
render: pre-scale font_line_metrics by dpi_scale The text pipeline works in physical pixels: measure_text and draw_text multiply the font size by dpi_scale internally, and the layout rects widgets receive are physical too. font_line_metrics was the one exception — it returned line metrics at the logical size — so the line height and ascent the text and rich_text widgets derive from it came out divided by the surface scale factor. On any output with a scale other than 1 (e.g. a 200% display, or any fractional setting the compositor rounds up to buffer scale 2), wrapped lines overlapped, baselines sat too high, and preferred_size reported half the real text height. Scale the size handed to horizontal_line_metrics by dpi_scale in both the Canvas wrapper and the GLES backend, matching the already pre-scaled font_metrics. At scale 1 the behaviour is unchanged.
admin
2026-08-11 22:04:22 +02:00 -
98c82385c7
tooltip: compute the hover pill in the overlay's physical space, drop it on scale change Layout runs in the physical pixels of each surface's buffer, but tooltip_overlay positioned the pill in logical ones: the anchor was divided by the source surface's scale and clamped against the logical screen size, and the resulting x/y were then applied verbatim as a physical stack translation. At scale 1 the two spaces coincide and the mix is invisible; at scale 2 the tooltip painted at half its position, towards the top-left corner and away from its icon. The whole computation now lives in the overlay's physical space: the anchor converts logical → physical with the tooltip overlay's own scale (falling back to the main surface's before the overlay exists), and the pill's estimated size folds in that scale plus the accessibility text_scale, which the text raster path already applied but the estimate ignored — the reason centring and edge clamping drifted even at scale 1 on long labels. Padding, radius, the gap above the anchor and the screen margins scale along, so the pill no longer renders with shrunken chrome around full-size text on scaled outputs. scale_factor_changed additionally cancels any pending or visible tooltip — its anchor rect was captured in the previous scale's physical pixels and nothing invalidated it; hovering re-arms it at the now-correct position — and marks overlays_dirty so overlay specs rebuild with the new scale instead of keeping geometry baked under the old one.
admin
2026-08-11 15:40:23 +02:00 -
477ef13ff4
toggle, widget: scale the pill with an explicit row height, and give elide a half-pixel tolerance Toggle::height used to adjust only the row: the resolved value was floored at the theme track height and the pill kept its theme size, so a toggle capped below the fluid row height still rendered a full-size pill — on large surfaces, visibly out of scale next to controls that honour their cap. The floor is gone; when the resolved height falls below the theme row height, track and thumb now scale down proportionally (never up — the factor is capped at 1), and preferred_size reports the scaled track width so layout, focus ring and centring stay consistent. A toggle without an explicit height is untouched. elide compared measure( text ) <= max_w strictly, which breaks when the caller sized itself from the same measurement: a button reports text + 2×pad as its preferred width, the layout grants exactly that, and draw hands elide back rect.width − 2×pad. In f32 the add-then-subtract round-trip can land a few ULP under the original measurement, the strict comparison fails, and the truncation branch then costs the full width of the ellipsis — a sub-pixel deficit turned "Empezar" into "Empez...". The fit check now allows half a pixel of slack, which absorbs any mismatch of this class while leaving genuine overflows to truncate as before.
admin
2026-08-09 12:07:35 +02:00 -
1290f9400e
row, button, widget: distribute a row's width shortfall instead of overflowing, elide button labels into the rect they are granted, icon_size takes a Length Row deficit distribution (layout/row.rs).
Rowknew how to hand out leftover width but had no notion of a shortfall:leftoverwas floored at zero and every child was laid out at its preferred width, so a cluster wider than its rect simply overflowed — and symmetrically, because the no-spacer branch centres the block, which is why both end labels of a segmented control were clipped at once rather than only the trailing one. The shortfall now comes off the widest children first, by water filling:width_capsorts the flexible widths and returns the largest per-child capcfor whichsum( min( w, c ) ) <= available, so a long title absorbs the whole deficit while an icon button beside it keeps its size, and equal siblings — a segmented control — share it evenly. A uniform scale-down, which was the first attempt, was wrong precisely there: it thinned a header's back arrow along with the title it sat next to. Spacers and flex children stay out of the flexible set, so a pinned gap keeps the width it was given — an explicit spacer is a decision, not slack — andRow::no_shrink()opts a row out entirely, for strips deliberately wider than their viewport such as a carousel rail meant to be scrolled.align_rightand the centring branch now measure the laid width rather than the preferred one, so a shrunken row is positioned against what it actually occupies. Button label elision (widget/button/mod.rs, widget/mod.rs, widget/list_item/mod.rs). Shrinking a row is only half the fix: a leaf handed less width than it asked for still painted its full string, so the deficit came out clipped instead of truncated.draw_text_buttonnow elides the label against the rect the layout granted, less the horizontal padding, for all three variants. The truncation rule moves out ofListItem, where it was a private helper, into a single crate-internalwidget::elidethe two share.Textkeeps its own inline copy for now: it measures through an optional font override that this signature does not carry, and folding the two together is a separate change. icon_size as a Length (widget/button/mod.rs).Button::icon_sizewas the one geometry setter that ignored the widget-scaling mode — it took a baref32, pinned it, and used0.0as the "unset" sentinel. It now takesimpl Into<Length>behind anOptionand resolves throughCanvas::resolve_geom, the wayheight,widthandfont_sizealready do. A bare number still meansLength::px, so every existing call keeps its exact size; what it adds isLength::widget( n ), which follows the active mode the way stock icons do. Without it a back arrow pinned at 21 px sat next to alist_itemchevron that fluid sizing had grown well past 21, and read as visibly smaller on the same row.admin
2026-08-05 12:37:03 +02:00 -
530a5696b9
event_loop, slider, list_item: overlay exclusive zones follow the surface, on_release for sliders, list labels elide against the trailing slot Exclusive zones (event_loop/overlays_reconcile.rs, event_loop/surface.rs).
OverlaySpec::sizeis documented as physical pixels and converted to logical forlayer_surface.set_sizeby dividing by the parent's integer scale;exclusive_zonesat right beside it in the sameLayerConfigand was passed through raw, so at scale 2 the reserved band was expressed in a unit twice as coarse as the surface it is meant to match. Worse,set_exclusive_zoneappeared exactly once in the whole crate — at materialize time — while the reconcile loop propagated later size changes throughlast_requested_size, so an overlay whose size kept being recomputed carried a reservation frozen at whatever the first frame produced. Crustace's dock is the visible case: it derives both numbers from the samedesktop_pill_height, and with the zone stuck at the density-1 value (icon at its 40 px floor, 40 × 1.70 = 68 logical) while the surface grew to 87, a maximized window overlapped the top fifth of the dock — measured on a 1.75 output as 151 physical px of painted dock against a 120 px reserved band. The zone now goes through the same divisor as the size, with-1(ignore other zones) and0(reserve nothing) passing through untouched as the sentinels they are, andSurfaceStatetrackslast_requested_zoneso the reconcile loop re-sends it whenever the spec moves, committing once for both. Slider::on_release / VSlider::on_release (widget/slider, widget/vslider, widget/handlers.rs, widget/element.rs, input/gesture). Sliders only hadon_change, which fires on every motion event, so an app whose commit is expensive — a subprocess, a D-Bus round trip, a compositor reconfigure — paid for it per pixel of travel: dragging the text-scale slider in Eydos Settings wrotegsettingsonce per motion and swept the whole desktop through a repaint each time. The new builder fires once with the final value when the drag ends, leavingon_changeto move the thumb and nothing else;on_changealone behaves exactly as before, and the mapped variant propagates throughmap_msglike its sibling. The handler snapshot carries the callback next toon_changeand exposesslider_release_msg; the gesture machine's slider branch ofon_release, which previously returned an empty event list, now resolves the widget throughfind_widget, recomputes the value from the release position with the sameslider_value_from_posthe drag path uses, and pushesReleaseEvent::PushMsg— the variant whose documentation already described "button press or final slider value on release". A unit test covers the new emission; the existing one asserting an empty release still holds, because it passes an empty widget list and the lookup finds nothing. ListItem elision (widget/list_item/mod.rs). The label and subtitle were painted withdraw_textand no width budget, so a row title longer than its width ran under the trailing text or the disclosure icon instead of truncating. The trailing slots are now measured and positioned before the text is painted — their extent is what decides how much room the label has — and both lines are elided with an ellipsis against the space left over, accumulating per-character widths the wayTextalready did, with one icon gap kept between the text and whatever follows it.Textkeeps its own inline copy of that algorithm for now; folding the two into a single crate-internal helper is the natural follow-up, and a precondition for teachingButtonto elide onceRowlearns to distribute a width deficit instead of only leftover space.admin
2026-08-04 18:44:21 +02:00 -
d9652dce98
accessibility text scale: global font multiplier synced to the desktop's text-scaling-factor New set_text_scale / text_scale process global (clamped [0.5, 3.0]) multiplied into every resolved font size — Canvas::resolve_font for explicit Lengths and the Physical branch of font_px (the Fluid branch routes through resolve_font) — so the whole tree's text follows the accessibility "large text" factor while geometry stays untouched, mirroring GNOME's text-scaling-factor semantics. Unit test covers fonts-scale-geometry-doesn't. The run loop keeps the factor synced on its own: a watcher thread (event_loop/text_scale.rs) reads org.gnome.desktop.interface text-scaling-factor via gsettings get at startup and streams external changes from gsettings monitor into a calloop channel; on a change the loop stores the factor, invalidates the view caches and repaints main and overlays. Since fonts resolve at paint time nothing else needs rebuilding. Every ltk app tracks the settings slider live with zero app-side wiring, the same way GTK apps follow the key; missing gsettings degrades silently to a fixed 1.0. Embedders driving core::UiSurface (forge) call set_text_scale themselves — the multiplication only runs at widget font resolution, so raw Canvas::draw_text callers keep hand-computed sizes. architecture.md documents the multiplier in the font-space paragraph and CHANGELOG gains the entry.
admin
2026-08-02 22:50:19 +02:00 -
e343142347
viewport: local_viewport() opt-out of root-viewport inheritance; list_item: height/font_size builders
Viewport::local_viewport()resolves the child's viewport-relative (vw / vh / vmin) and fluid Lengths against the viewport's own rect instead of the root layout viewport the sub-canvas inherits since the fluid-resolution inheritance change. That inheritance is right for scroll-like clips (content renders the same size inside and outside), but it is exactly wrong for a fixed-size floating mini-UI — crustace's phone-shaped quick-settings pill pinned to a corner of a desktop-wide surface resolved its vw text and fluid stock geometry against the whole monitor, inflating the content past the pill's fixed clip and cutting off the bottom stripe. The flag pins the sub-canvas's layout viewport to its own size via the new crate-internal Canvas::set_local_layout_viewport, nested sub-canvases keep propagating the pinned value, and a unit test covers the override plus propagation.ListItem::height( impl Into<Length> )andListItem::font_size( impl Into<Length> )mirror the Toggle / Radio height() builders: override the theme row height (floored at the label's rendered height so the text never clips) and the primary-label font size (subtitle and trailing keep their theme sizes), letting dense context menus trade the stock touch-target generosity for row density. Docs: widgets.md gains both builder sets, architecture.md documents the layout-viewport inheritance model next to the per-canvas density it parallels, the cookbook slide-in panel recipe notes when the pill needs local_viewport(), and the toggle / radio height() rustdoc demotes its private theme::HEIGHT link to a code span so cargo doc is warning-free again. CHANGELOG entries added.admin
2026-08-02 13:38:16 +02:00 -
4247613bb0
widget/toggle, widget/radio:
height()builder to override the theme row height, floored at the control's visual so it never clips Toggle and Radio report a fixed preferred height of 48 design px (theme::HEIGHT), which the fluid widget-scaling mode inflates to 72 px on large surfaces. That makes dense settings-style lists — several rows of label + control — the dominant consumer of vertical space on short landscape windows, with no way for the application to trade the built-in touch-target generosity for row density. Both widgets gain aheight( impl Into<Length> )builder storing an optional override thatpreferred_size()resolves throughcanvas.resolve_geom(), so callers can pass viewport-relative lengths (e.g.Length::vh( 5.0 ).clamp( 30.0, 48.0 )) and have the row height track the surface. The resolved value is floored at the control's visual size — the track height for Toggle, the outer circle for Radio — so the pill or ring never clips regardless of how aggressive the override is; the visual itself keeps its theme size and stays vertically centred in whatever rect the layout assigns, exactly as before. Without the builder both widgets behave identically to the previous fixed-height code.admin
2026-08-01 12:07:56 +02:00 -
806dee5167
render, types, ci: resolution-time dp with per-canvas density, bounded GLES image cache, clippy gate, backend capability matrix Length::dp no longer collapses to absolute pixels at construction: the design value travels in a new LengthBase::Dp variant and the density multiplication happens when the length is resolved. Previously dp( n ) baked in whatever density() returned at view-build time, so correctness across output changes depended on the view being rebuilt after set_density and in that order; now a density change is picked up by the very next paint with no reconstruction. Length::resolve keeps its signature (process density), and the new Length::resolve_with_density takes an explicit factor. dp becomes const in the bargain. Density also becomes overridable per canvas, the first step towards surface-local responsive state. SoftwareCanvas and GlesCanvas carry a density: Option<f32> analogous to the layout_viewport introduced for sub-canvas fluid resolution: None means "use the process global", Canvas::set_density pins a local factor, and sub-canvases inherit it. All canvas-routed resolution honours it — geom_px / font_px for stock-widget design pixels, and the new Canvas::resolve_geom / resolve_font for explicit Length values, which every widget now uses in place of the raw l.resolve( canvas.viewport_layout(), EM ) pattern (row, column, wrap_grid, spacer, container, separator, button, text, rich_text, text_edit, list_item, vslider, image, and the container draw path). Overlay sizing keeps resolving against the main surface with the global density, which is what it describes. New tests cover explicit-density resolution, resolution-time application, the local-over-global override and sub-canvas inheritance. The GLES image texture cache is now bounded. It was content-keyed but unbounded and never evicted, so a stream of distinct buffers — a photo carousel, video thumbnails — grew GPU memory for the lifetime of the canvas. The cache now tracks an estimated byte total (RGBA8, w × h × 4) against a 32 MiB budget and evicts least-recently-drawn textures on insert; the most recent entry is never evicted, so a single texture larger than the whole budget still draws and simply owns the cache until replaced. Drop-time cleanup is unchanged: drain deletes whatever the map holds. CI gains a Clippy step (workspace, all targets, test-support, -D warnings) sharing the build cache of the test job, with make clippy mirroring the invocation locally and CONTRIBUTING listing it. Run make clippy locally before pushing the first time — the gate has not seen the tree yet and pre-existing lints will fail CI until addressed. docs/backends.md formalises the software/GLES capability matrix that was previously scattered across per-method rustdoc: parity set (fills, strokes, text, images, paths, path clips), graceful degradations on software (flat-fill gradients, no shadows, no backdrop blur, hard bottom edge), GPU-only features (external textures), the shared Oklab-fallback limitation, and the cross-backend blit panic. Linked from README, onboarding and architecture's known-gaps list, which now states the parity gaps explicitly. The dp/density prose in architecture.md, lib.rs and the Length rustdoc is updated for resolution-time semantics and the per-canvas override.
admin
2026-08-01 10:17:00 +02:00 -
a7f953ca42
layout: adaptive grid and vertical flex; enforce the mechanical style rules grid_min_cell( width ) adds the adaptive mode WrapGrid was missing: instead of a fixed column count, the count is derived at layout time from the available width so every cell is at least
widthwide (any Length, so a fluid threshold works; never fewer than one column), re-derived on every resize. Cells then share the width equally, and WrapGrid::max_columns( n ) caps the derived count so cells grow instead of multiplying on very wide surfaces — the cap only applies to the adaptive mode, grid( n ) keeps the fixed behaviour untouched. Four new layout tests cover width derivation, spacing accounting, the one-column floor and the cap; the row-wrap assertions check X positions rather than Y because zero-height spacer children produce zero-height rows. flex( child ) now distributes leftover height inside a Column, mirroring its leftover-width behaviour in a Row: flex children join the weight pool alongside weight-only spacers and y-scrolls, draw their child inside the allocated share at full inner width, and contribute zero to the column's natural height exactly like a row counts flex width. This closes the documented "vertical flex is not yet implemented" gap; the Flex rustdoc and the widgets.md entry now describe both axes. Add scripts/style-check.sh and a make stylecheck target, wired into CI: mechanical verification of the grep-checkable subset of code_style_guide.md — tab indentation and spaces inside attribute brackets — with comment lines skipped so prose may cite raw attribute syntax. To turn the check on green, the 82 unspaced attribute sites across 24 files (#[test], #[derive(...)], #[cfg(...)], #[allow(...)]) were normalized to the spaced form. CONTRIBUTING and the style guide reference the new target; brace placement and paren spacing remain review concerns.admin
2026-07-30 22:20:21 +02:00 -
1fd697aa6d
docs overhaul, orientation API, fluid-sizing fixes, examples made honest Documentation pass: every claim in docs/ and the meta files was audited against the source and the drift fixed — around ninety corrections. CONTRIBUTING and the CI workflow now run cargo test with --features test-support (the gated test_support module made both the documented commands and the CI build fail to compile), make example becomes make examples, make doctest-md and the debhelper requirement of make clean are documented, and patch shape asks for a CHANGELOG entry. theming.md loses the nonexistent surface.backdrop, gains the real gradient defaults (linear-rgb, oklab), the six slot variants including typography, the ten-field palette, a truthful effects-consumer table, the ThemePreference/from_hour API and a responsive-sizing note; the stale docstrings in src/theme that fed the drift are fixed too. architecture.md's "Known gaps" section is rewritten against reality (multi-touch slots, xdg-activation, a11y live regions and SetValue/Increment/Decrement are implemented), gains a module map, subsurfaces and window-lifecycle coverage, and correct crustace/loginmanager paths. widgets.md fixes the ten factual errors (stateless spinner, toast/combo via overlays(), tooltip hover contract, row has no max_width, scroll axes, multiline text_edit, dialog panic wording) and now states the column() 16 px default padding — the recurring ambush — plus row's differing 0 default and dialog's max_width. onboarding, README and cookbook get the remaining sweep: build/test instructions, complete example lists, img_widget, clipping-parity honesty, ~30 Hz software cap, read_rgba_pixels signature, tab indentation in snippets, and rustdoc-style links that rendered literally are gone everywhere. CHANGELOG is restructured per Keep a Changelog with the missing entries (window_resizable, claims_raw_touch, Row::align_top/fill_height, caret fixes, dependency pins) and the pad_v Added/Changed contradiction resolved. New adaptive-layout API: ltk::orientation() with the Orientation enum, backed by viewport_size()/set_viewport_size — the runtime records the main surface's physical dimensions on every configure, before App::on_resize, so view() can branch a layout on portrait vs landscape without hand-tracking resizes. The portrait rule matches Length::orient (square counts as portrait); embedders driving core::UiSurface call set_viewport_size themselves. Documented in the crate root's responsive-design section and architecture.md. Fluid-vs-fixed sizing fixes in widgets, all the same disease — fluid content inside a fixed-pixel box. TextEdit::fixed_width takes impl Into<Length> (f32 call sites keep compiling as px) and the time picker's digit fields move to Length::fluid( 72.0 ), matching their fluid font so digits can no longer outgrow the box. Dialog::max_width takes impl Into<Length> with a Length::fluid( 480.0 ) default so the card scales with the stock buttons inside it, and the card's interior no longer stacks the column() default 16 px padding on top of CARD_PADDING — that double inset squeezed the action row until its buttons clipped on narrow windows. App::on_pointer_axis now triggers a view rebuild and repaint; previously state mutated in the hook did not paint until the next unrelated event. Examples reworked to be honest demos: responsive's mode/density controls become stock buttons in a grid/column so they follow the modes they demonstrate instead of overflowing; dialog's openers stack vertically, and the example gains the app-level ESC handler so the ESC chain closes an open dialog first and quits second; widgets' tab strip now switches real per-tab pages; carousel gains pointer/touch drag through the horizontal-swipe hooks (crustace's pager pattern), one-tile-per-detent mouse wheel, and snap math driven by the real surface width from on_resize instead of a hardcoded 800; clip_path arranges its cells by ltk::orientation() and sizes them from the counter-axis of the flow.
admin
2026-07-30 19:28:26 +02:00 -
14572ebfb6
render: sub-canvases inherit the root layout viewport for fluid resolution Content drawn inside a scroll rendered smaller than identical content outside it: the scroll viewport draws its child into a sub-canvas sized to the viewport rect, and Canvas::viewport_layout / viewport_logical — the viewports that geom_px and font_px resolve fluid Lengths against — returned the canvas's own size. Inside a 312 px sub-canvas on a 360 px surface every fluid geometry (icon sizes, row heights, paddings) and font size resolved against 312 instead of 360, shrinking the scroll content by the width ratio, ~13 % in a phone-sized window with 24 px margins. First observed in Eydos Settings' Wi-Fi list, where the connected network row sits outside the scroll and the rest inside: the same full-fan signal icon measured 21x18 px outside and 18x16 px inside. Add a layout_viewport field to SoftwareCanvas and GlesCanvas, None on root canvases and set by sub_canvas to the parent's effective layout viewport, so nested sub-canvases keep propagating the root surface size. viewport_layout and viewport_logical consult the inherited value before falling back to the canvas size. Root canvases are untouched, and the GLES clip layers, which reuse sub_canvas, get the same correction.
admin
2026-07-30 15:23:52 +02:00 -
9b99a18e26
style guide: rewrite for Rust, guard against cargo fmt code_style_guide.md was the generic C++ formulation of the Modified Allman style: it mandated camelCase for functions (every function in this crate is Rust snake_case, and rustc warns otherwise), three of its five examples taught try/catch brace placement for a construct Rust does not have, all examples were C++, and it instructed saving a .clang-format that does not exist and would not format Rust anyway. Its final YAML block was also missing the closing fence, so renderers swallowed the tail of the document. Since README defers to CONTRIBUTING and CONTRIBUTING defers here for the full rules, the contributor path terminated in the one document that contradicted the code. Rewrite it for Rust: same principles (tabs, opening brace on its own line, compact "} else {", spaces inside non-empty parentheses), plus the rules the code follows but no document stated — snake_case for functions and variables, spaces inside non-empty attribute brackets, no spaces inside generics, aligned columns, comments in English. Examples are now idiomatic Rust: if/else, match, Result with the ? operator in place of try/catch, if let, struct + impl with attributes. The file declares itself the canonical reference, with CONTRIBUTING's bullets as the summary. Replace the .clang-format section with the verified rustfmt situation: rustfmt cannot express this style (no option exists for spaces inside parentheses or attribute brackets, the brace-style options are nightly-only, and even on nightly Allman opening braces cannot combine with a compact "} else {"). Ship a rustfmt.toml with disable_all_formatting = true — a stable option, verified a no-op on rustfmt 1.8.0 — so an accidental cargo fmt or editor format-on-save can no longer rewrite the tree.
admin
2026-07-30 11:37:34 +02:00 -
d31e7222da
list_item: trailing icon slot and horizontal inset override Disclosure arrows in list rows could only be text: the trailing slot draws a string, so apps fell back to the "›" glyph at 14 px, which cannot use the theme's arrow SVGs and looks thin next to 24 px leading icons. Add ListItem::trailing_icon( rgba, w, h ): a right-aligned icon drawn at the new theme::TRAILING_ICON_SIZE (21 px, 1.5x the old glyph em box), vertically centered. It coexists with trailing text, which shifts to the icon's left. Like the leading icon, symbolic assets are pre-tinted by the caller (tint_symbolic), so the widget stays colour-agnostic. Add ListItem::pad_h( impl Into<Length> ), a per-item override of the horizontal inset between the row edge and its content (leading icon/label on the left, trailing text/icon on the right). Resolution follows the Separator pattern: an explicit Length wins, otherwise the theme default (16 px through geom_px) applies, so existing rows are unaffected. This lets an app whose enclosing view already provides the margin bring row content flush instead of stacking both insets. Document both builders in docs/widgets.md and the changelog, and rework the list_item rustdoc example to point at trailing_icon with a theme icon for disclosure arrows instead of the text glyph.
admin
2026-07-30 11:19:37 +02:00 -
16c04f4159
event_loop, text_edit, layout: focus-time cursor as an end-of-value sentinel, caret height tied to the text line, row align_top/fill_height modes Focus-time cursor (event_loop/focus.rs): focusing a text input pinned the cursor to a concrete
value.len()snapshot. When the value keeps growing after focus without the widget seeing keystrokes — crustace's launcher search field is fed over IPC while forge routes the type-to-search keys around Wayland — that snapshot goes stale, and the first key delivered normally afterwards inserts mid-string. The cursor is now seeded with theusize::MAXsentinel ("end of whatever value is rendered"), the same conventionselect_on_focusfields already rely on; every consumer (insert/delete, arrow keys, draw, hit-test, context-menu paste offset, a11y tree) clamps viacursor.min( value.len() ), so the cursor tracks external growth and collapses to a concrete position on the first real keystroke or click. Click placement is untouched — the pointer path writes its own hit-tested offset. Caret height (widget/text_edit/draw.rs): the single-line caret spannedrect.height - 16, so a field taller than its text line (the launcher's borderless search pill) grew a caret about twice the glyph height. It now measuresfont_size + 4, vertically centered like the text — matching what the multiline caret already did. Row alignment modes (layout/row.rs):Rowgainsalign_top()(children pinned to the top edge instead of the default vertical centering) andfill_height()(every non-spacer child stretched to the row's inner height, the row itself still sized by its tallest child). Both exist for siblings whose natural heights differ by a few font-metric pixels — like the QS media card next to the wifi/bluetooth chip column — where equal-height layouts cannot be achieved by estimating text heights:new_line_sizeis font-dependent, so px arithmetic in the app always drifts. Containers paint their chrome over the full rect they receive and columns absorb the extra in weighted spacers, so a stretched card keeps its content anchored where its internal spacers put it.admin
2026-07-30 01:02:30 +02:00 -
1234943e86
Pin fontdue to =0.9.3 — 0.9.4 requires Rust 1.87 and the toolchain is 1.85 fontdue 0.9.4 started using
u{8,16,32}::cast_signed, stabilised only in Rust 1.87, so any lockfile regeneration that floats the "0.9" requirement to 0.9.4 breaks the build on the project's rustc 1.85 with E0658 (integer_sign_cast). The exact pin keeps 0.9.3 — the version everything has been building against — and also protects downstream consumers of ltk (crustace, forge) whose own lockfiles re-resolve against this requirement. Relax back to "0.9" once the toolchain moves to 1.87 or newer.admin
2026-07-29 13:43:57 +02:00 -
0062bc565c
app, event_loop/run: add
App::window_resizable()so a window_size_hint can declare a permanently fixed-size toplevelwindow_size_hint()pinsmin_size == max_sizebefore the first commit so the compositor's first configure honours the requested size, and the runtime then releasesmax_sizefrom the first configure handler (thepending_size_hint_unpinlatch) to leave the surface user-resizable. That release is wrong for kiosk-style windows such as the installer's--windowsizetest mode: the moment the pin is gone the compositor is free to resize the toplevel, and a compositor with per-app geometry persistence (forge, eydos #7) will happily restore a remembered rect over the requested one — which is exactly how the installer kept opening at 1600x900 after a singlemake start-desktoprun had been recorded, no matter what--windowsizeasked for. New defaulted trait methodApp::window_resizable() -> bool(defaulttrue, existing apps unaffected). When it returnsfalseand awindow_size_hintis set, the unpin latch is never armed: themin == maxpin stays in place for the lifetime of the toplevel, declaring a fixed-size window that compositors must not resize or restore a remembered geometry over. Ignored whenwindow_size_hint()isNone— fullscreen and compositor-sized windows keep their current behaviour. Pairs with the forge-side change that makes both the geometry-restore hook skip windows whose current constraints reportmin == maxand the reparenting XWM aware of them; ltk keeping the pin alive is what makes that detection possible past the first configure.admin
2026-07-29 10:20:45 +02:00 -
142096827e
event_loop: drop the built-in titlebar when the compositor decorates ltk already asked for server-side decorations through SCTK (WindowDecorations::RequestServer on window creation) but painted its 36 px titlebar unconditionally, so under forge an xdg window carried two stacked bars: forge's SSD on top of ltk's own. The WindowHandler configure now honours the negotiated decoration_mode: Server zeroes titlebar_height, Client — which is also what SCTK reports when the compositor lacks xdg-decoration, e.g. GNOME — restores it from the new titlebar_base field. Every titlebar consumer (draw, close-button hit test, drag-move) already guards on the height being positive, so the suppressed bar costs nothing and the fallback path is unchanged.
admin
2026-07-24 23:20:16 +02:00 -
3d7f3c53de
ltk: opt-in raw touch stream for apps that consume input themselves Add App::claims_raw_touch(), an opt-in hook that routes the primary finger through the raw on_touch_down/move/up callbacks instead of the built-in single-slot gesture machine. Until now the primary finger was consumed entirely by the widget gesture machine (taps, swipes, presses) and never reached the app, which made it impossible to drive a self-contained input consumer like an embedded WebView or a game canvas from a touchscreen: the widget tree saw the taps, the embedded content never did. With the hook active every finger surfaces verbatim through the raw stream, widget gestures never arm, and per-finger positions are cached in SurfaceState::touch_slots for all slots so wl_touch.up (which carries no coordinates) can still report a release point. The touch module doc and the on_touch_down docs are updated to describe the new contract; the default remains unchanged for every existing app. Pin the transitive dependency ignore to 0.4.23: rust-i18n pulls it via globwalk, 0.4.24+ requires a rustc newer than Debian's 1.85, and its manifest does not declare that MSRV, so a fresh resolution breaks the build on stable.
admin
2026-07-21 09:56:05 +02:00 -
8762ab9ce0
text_edit font sizing on Length; add Button::width and Text::line_height Unify TextEdit's font size with the button label. The button resolved its
font_sizeLength in font space (viewport_logical, so the× dpi_scaleat raster lands the right physical size), whileTextEdit::font_sizetook a rawf32that the draw / hit-test paths treated as a logical size. A caller that resolved a Length against the physical surface and passed the result as thatf32therefore double-counteddpi_scaleand got a font that rendered too large.TextEdit::font_sizenow takesimpl Into<Length>and is resolved againstviewport_logicalexactly like the button, so the two paths agree. The field storesOption<Length>(Nonefollows the widget-scaling mode at the theme default), retiring the oldf32sentinel (0.0= mode, negative = fluid design px).font_size_fluidbecomes shorthand forLength::fluid( n ). TheOption<Length>flows through theWidgetHandlers::TextEditsnapshot andtext_input_geometryand is resolved at draw / hit-test time, both of which carry a canvas; the inner measure helpers (wrapping,hit_test) keepf32because they receive the already-resolved value. Backward compatible:f32call sites still compile viaf32: Into<Length>(→Length::px), with the same result as before. AddButton::width( impl Into<Length> ). Text buttons size to their label plus padding; some layouts need a pinned width instead — a full-width or surface-proportional button. The new builder mirrorsheight: resolved in physical layout space, clamped to the availablemax_width, propagated throughmap_msg, and a no-op for icon buttons. AddText::line_height( mult ). Wrapped multi-line text used the font's declared leading (new_line_size), which is tight for some labels; the multiplier scales the gap between wrapped lines (1.0, the default, keeps the natural leading — every othertextis unchanged — and a0.5floor keeps lines from overlapping). Applied uniformly inpreferred_sizeanddrawso the reported height and the drawn baselines stay consistent. Tests:buttongains a pinned-width and a size-to-content case;textgains a line-height default / clamp test and a check that doubling the line height doubles a wrapped block's reported height. Docs:docs/widgets.mdtext/button/separatorsections updated for the new builders, and aCHANGELOG.md"Unreleased" section covering this batch alongside the responsive work already landed.admin
2026-07-10 10:38:30 +02:00 -
ce893ac776
responsive fluid/physical scaling, widget-API stabilization, and perf guardrails Responsive scaling. ltk now offers two first-class ways to size a UI so it adapts across screens, chosen per process via
WidgetScaling { Fluid, Physical }(set_widget_scaling/widget_scaling, defaultFluid). Fluid sizing (Length::fluid( px )) makes a design pixel a proportion of the surface's smaller side, calibrated against a reference width (set_fluid_reference/fluid_reference, 412 px default) and bounded byFLUID_MIN/FLUID_MAX; physical sizing (Length::dp( px )) is a constant-physical-size pixel scaled by display density (set_density/density).Lengthgainsorient( portrait, landscape )— resolve one value in portrait, another in landscape — pluswidget( px ), which picks fluid or dp per the active mode. Canvas exposesgeom_px(geometry, resolved in physical layout space) andfont_px(font size, bridging logical / physical per mode) so widgets and apps share one resolution path. Note the rename:set_design_reference/design_referencebecameset_fluid_reference/fluid_reference, andLength::dpchanged meaning — the old surface-proportional behaviour now lives onLength::fluid. Widgets. Every stock widget resolves its default geometry and font through the widget-scaling mode instead of frozen pixels, so a whole UI scales coherently without per-call units. New size builders where they were missing:buttongainsfont_size/height,text_editgainsheight/font_size_fluid,separatorgainspad_v, and assorted widgets accept aLengthwhere they previously took onlyf32. Overlays.OverlaySpec::sizeis now( Length, Length )instead of( u32, u32 ), resolved against the main surface when the overlay is materialized, so overlays can scale with the display;Length::px( … )reproduces the old fixed sizing. API stabilization (toward 1.0). Widget struct fields are nowpub( crate )— they are configured through builders, not field access — except the value / state types apps genuinely read or construct (Time,Date,ComboState), which stay public. The internaltest_supporthelpers move behind atest-supportCargo feature (off by default, so third-party builds never see them; ltk's ownmake testenables it).Separatordrops its0.0-means-mode sentinel forOption<Length>, so an explicitpad_v( 0.0 )is a real flush divider distinct from the mode-following default. Performance guardrails. Opt-in diagnostics viaLTK_PERF_WARN=1warn about stuck animations, sustained software-render animation, and lowpoll_interval; software-rendered animation is capped near 30 Hz to spare CPU on machines that fall back off EGL. Apps can override the cap withApp::cap_software_animation. Docs and build. The two scaling modes are documented in README, onboarding and architecture, with the earlier gradient / backdrop doc drift cleaned up. The Makefile now ships thelocales/directory into the packaged crate (fixing i18n keys rendering raw for downstreams), builds the newresponsiveexample, and runs tests with--features test-support.admin
2026-07-07 17:40:33 +02:00 -
d4d7ee742e
Bump to 0.2.0: SW/GLES paint parity, shared font resolution, FrameState refactor, docs and packaging fixes Rendering parity (software ↔ GLES). The software backend now rounds glyph pen positions and image destinations to the nearest integer pixel, matching what the GLES backend already did; previously it truncated, so text and 1:1 images could land up to half a pixel off between the two backends and the bilinear sample read ~1 px softer than the source. Gradients and shadows are deliberately left unimplemented on the software backend, and the GLES multi-rect
glScissorclip is left coarse on purpose: making it exact would need stencil bits the EGL config does not carry, or routing the partial-redraw path through the offscreen clip layer, which would break itsfill/clear_rects_transparentscissor semantics. Adds software-backend pixel tests covering the snapping. Font resolution unification. The system-font candidate chain,find_font_optandload_default_font_byteslived in two copies (render/helpersandgles_render/helpers) that had already diverged — one resolved throughfind_font_opt, the other inlined the candidate loop — and now live once insystem_fonts. The two per-backendOnceLockdefault-font caches andprimary_handlecollapse into a singlesystem_fonts::default_handle. Module docs and thefont_registrycaller are updated accordingly. Shared image validation and rect inflation.draw_image_data's dimension check and its one-line warning were byte-duplicated across both backends and are nowrender::helpers::validate_rgba_dims. The six manual symmetricRect-inflate literals in the GLES primitives reuse the existingRect::expand. FrameState and DrawCtx de-duplication. The elevenSurfaceStatefields the draw pass owns and threads throughDrawCtx—widget_rects, the cursor / selection maps, the scroll state,accessible_extras,prev_focused/prev_hovered/prev_pressed— move into aFrameStatesub-struct. The per-framebuild_draw_ctx/commit_draw_ctxhelpers can then borrow&mut ss.frame, disjoint fromss.canvasandss.pool, so the four frame paths (software / GLES × full / partial) replace their duplicatedDrawCtxconstruction and write-back with a single helper call each. A whole-SurfaceStateborrow could not express this (partial borrows do not cross function boundaries), which is why the helpers take the sub-struct.content_dirtystays onSurfaceState— it is an invalidation flag, not frame state — and is reset at the call site. rich_text tests. Adds the previously-missingtests.rsfor theRichTextwidget: one hit rect per visual line a link spans (the widget's core invariant), the single-line and no-link cases, preferred-size growth with hard line breaks, andmap_msgrange preservation — all headless against a softwareCanvas, with line counts forced by\nso they do not depend on any system font's measured width. Documentation. Fills the rustdoc gaps on the embedder-facing surface:core::UiSurfaceaccessors, theegl_contextpublic API, theGlesCanvasmethods,theme::typographyandtheme::error, and theRichText/Textbuilders; adds a crate-level "Rendering backends" overview.CHANGELOG.mdis added (0.2.0 / 0.1.0), anddocs/widgets.md/docs/cookbook.mdgainrich_text,external, and the CPU-draw / path-clip / externally-laid-out-tree recipes.debian/changeloggets the 0.2.0-1 entry. Private intra-doc links tosystem_fontsare demoted to code spans socargo docis warning-free. Packaging. Thelibltk-devregistry crate shipped aCargo.tomldeclaring thelookupbench while theMakefileinstall copied onlysrc/, so Cargo refused to parse the manifest over a missingbenches/lookup.rs; the install now shipsbenches/as well (the file alone satisfies the parse — criterion is a dev-dependency and is not resolved when the crate is consumed as a library).Cargo.tomlis bumped to 0.2.0 to match the package version and theltk-0.2.0registry directory.admin
2026-06-25 12:43:40 +02:00 -
fb3552e9f7
Embedder primitives: placed-child clipping, offscreen RGBA readback, standalone text measurement Three additions an embedder needs to drive ltk as the render backend for a retained, externally-owned widget tree, each kept general rather than tied to one consumer. Stack placed-child clipping.
Stack::push_placed_clipped(e, rect, clip)places a child at an exact rect likepush_placedbut clips its subtree's drawing toclip(in the Stack's coordinate space) — Android's clipChildren: content that overflows, such as a scrolled list row reaching above the list or an inner card past a rounded bubble, is not painted. The Stack child tuple grows an 8thOption<Rect>for the clip, andlayout_and_drawbrackets a clipped child withset_clip_rects/restore around the recursion. The clip is shifted by the Stack's own origin to match the placed rect (whichStack::layoutalready offsets), so a Stack laid out at a non-zero origin clips in the right place rather than off by that origin. Offscreen RGBA readback.Canvas::read_rgba_pixels(out)reads any canvas into tightly packed straight-alpha RGBA8, top-left row first. Unlikeread_gles_rgba_pixelsit also serves the software backend, un-premultiplying its pixmap, so an offscreen software canvas (an embedder's scratch bitmap) can be read back into a straight-alpha buffer.Canvas::is_software()lets a caller branch on the backend — e.g. to honour a real path clip on software but only a bounding rect on GLES. Standalone text measurement.measure_text(text, size), re-exported at the crate root, measures one line with the default UI font and the system fallback chain, returning(width, line_height)in pixels without a liveCanvas— for an embedder's measure pass that must produce the same metrics the renderer will later use. It is backed bysystem_fonts::primary_handle(), a process-wide cached handle for the primary UI font (the same default a canvas loads, with the bundled-font fallback), which widensrender::helpers::load_default_font_bytestopub(crate).admin
2026-06-24 22:42:50 +02:00 -
8809313be1
theme: replace Figma-exported launcher SVGs with renderer-compatible versions
yamabush1
2026-06-21 11:27:03 +02:00 -
588a3f7e36
Fix vertically-flipped content in the GLES path-clip composite The clip-layer composite shader sampled the offscreen layer at the wrong vertical position, so path-clipped content (e.g. a circular avatar) came out upside down on the GLES backend.
v_uv.yruns bottom-to-top in screen space — the layer FBO has GL's lower-left origin, andortho_rectplus the texture shader's flip establish thatv_uv.y == 1is the top edge. The composite computed the fragment's screen Y asbbox.y + v_uv.y * bbox.h, which is the inverted Y, so it read the layer mirrored about the horizontal axis. Compute it asbbox.y + (1 - v_uv.y) * bbox.hinstead. The mask sampling was already correct (and a symmetric circle hid the flip in the example; a photo reveals it). The software backend is unaffected — it clips through a coverage mask with no layer round-trip.admin
2026-06-19 00:04:24 +02:00 -
f8c45f0e30
Add Canvas::set_clip_path — anti-aliased arbitrary-path clipping on both backends Add
Canvas::set_clip_path(&[PathCmd]), clipping subsequent draws to an arbitrary vector path with an anti-aliased edge, on both the software and GLES backends. It complements the existing rect clip (set_clip_rects) and is what an embedder needs to render a shaped clip — a circular avatar, a rounded card, aVectorDrawablemask — rather than a bounding box. Kept general rather than tied to any one consumer. Software backend: rasterise the path into an anti-aliased tiny-skia coverageMask(Winding fill) and install it as the active clip mask. Every software primitive already threadsclip_maskthrough tiny-skia (fills, strokes, lines, paths, images, text, blit), so the path clip applies uniformly with smooth edges.clip_boundsreports the path's bounding box while it is active. GLES backend: a 1-bit stencil would clip exactly but leave a hard, aliased edge, so instead the clipped draws are captured into an offscreen layer and composited back through an anti-aliased coverage mask.set_clip_pathrasterises the path coverage (tiny-skia, anti-aliased), uploads it as a mask texture, allocates a full-canvas layer FBO on first use, and redirects subsequent draws to it viaactivate_target. Ending the clip (clear_clip/set_clip_rects/ a newset_clip_path) composites the layer back onto the canvas FBO with a new two-sampler program (CLIP_COMPOSITE_FRAG_SRC) that multiplies the layer colour by the mask coverage and blends it premultiplied-over. The layer attaches to the canvas's own shadow FBO, so it needs no stencil bits in the EGL config; it is freed and reallocated on resize and freed on drop, and shared programs/uniforms are copied to sub-canvases like the rest. Usage: a path clip is bracketed —set_clip_paththen, after the clipped draws,clear_cliporset_clip_rectsto flush it (on GLES this is when the layer is composited). Snapshot the prior clip withclip_boundsbeforehand and restore it withset_clip_rectsto compose with an outer clip without leaking state. Add anexamples/clip_path.rsdemo (rounded rect, circle, triangle — same smooth result on both backends) and software-backend unit tests covering the bounding box, the empty-path clear, and a pixel-level check that a triangular clip masks a fill to the path silhouette rather than its bounding box. The GLES layer-composite path needs a live GL context and is exercised by the example. Also fix three rustdoc intra-doc-link warnings surfaced along the way: a private-item link inapp.rs(scroll) and the new GLES doc (SoftwareCanvas::set_clip_path) demoted to code spans, and a redundant explicit link target inchassis.rs.admin
2026-06-18 23:59:38 +02:00 -
b00cf460bb
Mature ltk to host an externally-laid-out Android view tree Add the primitives rustdroid needs to project an Android view hierarchy onto an ltk surface, all kept general rather than Android-specific. Canvas gains arbitrary vector path fill and stroke:
Canvas::fill_path/stroke_pathover a newPathCmdcommand list (MoveTo/LineTo/QuadTo/CubicTo/Close in surface coordinates). The software backend rasterises directly with tiny-skia; the GLES backend rasterises into a tiny-skia pixmap and blits it (CPU fallback, no GPU path shader). This is what renders an AndroidPath, aVectorDrawable, or a Lottie frame.ExternalSource::Cpu(and theExternal::cpuconstructor) adds an immediate-mode CPU drawing closure, invoked once per frame with the canvas and the widget's laid-out rect, working on both the GLES and software backends. It hosts a customView.onDrawstraight onto the ltk canvas without a GL texture round-trip, unlike the existingTexturesource which only renders on GLES.Stack::push_placedappends a child at an exact rect, bypassing alignment and intrinsic sizing. This lets a view tree whose geometry is computed elsewhere — Android's measure/layout pass, which yields an absolute rect per view — be projected onto a Stack in paint order. NewRichTextwidget: wrapped paragraph text carrying a message per clickable link range, with the layout pass emitting one hit rect per link line so taps land on the link rather than the whole paragraph. It is the ltk side of an AndroidSpannedcarryingURLSpan/ClickableSpan. gesture: only drive the horizontal pager once the axis locks horizontalon_moveemitted horizontal swipe progress wheneverswipe_axis != Vertical, which includes the pre-lock window whereswipe_axisis stillNone(the first ~8 px of travel). Buthorizontal_drag_startedonly flips oncedx.abs() > 8. A vertical gesture that opened with a few pixels of lateral drift — a swipe-up to the launcher, a scroll — therefore emitted a tiny horizontal progress sample, which armed the consumer's pager, then locked vertical withoutdxever passing 8, sohorizontal_drag_startedstayed false. On release the horizontal branch was skipped, noon_swipe_horizontal_progress(0.0)fired, and the pager stayed stuck active — on the crustace homescreen that froze the surface (the main stays motion-only behind stale page subsurfaces) until an unrelated gesture reset it. Emit horizontal progress only onceswipe_axis == Some(Horizontal). Locking onto the horizontal axis already impliesdx.abs() > 8, sohorizontal_drag_startedis set in the same step, restoring the invariant that a frame which drives the pager always has a matching release event to settle it. The cost is that the first ~8 px of a horizontal drag no longer move the page, which is the same deadband the axis lock already imposes on the vertical panels. Addspre_lock_lateral_drift_does_not_drive_horizontal_pager.admin
2026-06-12 22:14:02 +02:00 -
df8fcbf757
app: client-side xdg-activation token requests Adds an outbound path so an app can obtain an
xdg-activation-v1token from the compositor and hand it to a child it is about to launch (the$XDG_ACTIVATION_TOKENconvention). Until now ltk only honoured inbound activation (self-activating the main surface from an inherited token); requesting a token for another app was out of scope. Two newApptrait methods, both defaulting to no-op:take_activation_requestsreturns the tags the app wants tokens for this iteration, andon_activation_tokendelivers the issued token paired with its tag. The run loop drains the requests right afterpoll_externaland callsActivationState::request_token, carrying the tag inRequestData::app_id;ActivationHandler::new_tokenreads the tag back out and routes the token to the app throughon_activation_token. When the compositor never advertised the activation global, each request is answered immediately with an empty token so the caller still proceeds and can fall back to its own matching instead of stalling.admin
2026-06-09 23:53:31 +02:00 -
cfa0faff26
ltk: subsurface slides over overlays, axis-locked swipes, physical-space layout, touch reset on resume Subsurfaces can now be parented to an overlay surface, not just the main surface.
SubsurfaceSpecgainsparent: SubsurfaceParent { Main, Overlay(id) }so a sliding panel can ride above app windows the way an overlay panel does, and an optionalgpu: boolso content that uses thesurface-panelbackdrop-filter glass (a GLES-only pass) keeps it while sliding instead of dropping to the software rasteriser.reconcile_subsurfacesresolves each spec's parent independently — skipping anOverlayparent that is absent, unconfigured, or zero-sized — tracks the rastered size on the slot, and commits each touched parent once per frame. The ~14 GLES shader programs are compiled once into a sharedAppData::subsurface_gles_canvasreused across every subsurface, so a lazily re-created sliding panel never recompiles them (hundreds of ms on a mobile GPU). Vertical and horizontal swipes are now mutually exclusive: a gesture locks onto its dominant axis within the first 8 px of travel (newSwipeAxis) and ignores the perpendicular axis for the rest of the gesture, so a vertical swipe that drifts sideways no longer also drives the pager (and vice-versa). The upward swipe progress is no longer clamped at 1.0 — follow-the-finger panels can keep tracking the finger past the commit threshold — and a release below threshold delivers a finalprogress = 0.0cancellation pulse. A vertical swipe also no longer re-rasters the full-screen main surface on every motion event: only the overlays it drives are refreshed, while the horizontal pager (which does move the main surface) still redraws it. Re-rastering the main on every frame of a vertical drag was wasted work that stalled the loop and made the gesture feel laggy to start on a slow GPU. Layout-affectingLengthvalues (widths, paddings, gaps, widget sizes, includingVw/Vh) now resolve against the physical viewport via the newCanvas::viewport_layout()— the space the layout tree is actually computed in — so aVw(100)fills the surface on a HiDPI (scale 2) output instead of covering half of it. Font sizes are unchanged: they still resolve against the logical viewport and are scaled at raster time. Switched column, row, spacer, wrap_grid, container, image, and vslider over to it;img_widgetandvslidernow documentLength::vw/vhsizing and the showcase example demonstrates a viewport-relative image. Touch gesture state is reset when the touch capability is added or removed — suspend / resume on devices that power the touchscreen down. A yanked capability never delivers the pendingup/cancel, so the sharedreset_touch_state()(also used by thewl_touch.cancelhandler) drops the strandedprimary_touch_id/ slot state across the main surface and every overlay, keeping the first post-resume gesture clean. Also drops an accidental duplicateon_scale_changedcall from the scale-change handler.admin
2026-06-07 16:45:59 +02:00 -
68c6a87bf6
Support viewport-relative widget sizing
yamabush1
2026-06-04 12:38:59 +02:00 -
ae8380b1ac
event_loop: retry focus requests that land before the view is rebuilt
take_focus_request()looks up the target widget byWidgetIdinwidget_rects. Read-onlyTextEditwidgets are not tracked as interactive and therefore absent fromwidget_rects. A swipe-reveal flips the fields from read-only to interactive onSlideRevealed, but an intervening input event (e.g. the touch-up at the end of the swipe) can causedispatch()to return before the vblank frame callback clearsframe_pending, skipping the draw that would have rebuilt the widget tree. When the focus request fires in that same iteration it finds nothing and is consumed without effect.AppDatagains afocus_retryfield. Whentake_focus_request()(or a previous retry) returns an id but the widget is not inwidget_rects, the id is stored infocus_retry,view_dirtyis set and a redraw is requested. The next time around the view has been rebuilt with interactive fields and the widget is present.admin
2026-06-02 14:10:37 +02:00 -
e40ab637a6
bench: add missing LaidOutWidget accessibility fields to lookup bench
yamabush1
2026-06-02 08:43:13 +02:00 -
d221d5a0cd
app: add
App::subsurface_motion_onlyso a slide-to-reveal panel animates without re-rastering the main surface An app whose gesture/animation only moves an input-transparent subsurface (a slide-to-reveal panel over a static main surface) still paid a full main-surface re-raster on every frame of the slide, because the runtime force-dirties the main view on two paths it cannot tell apart from a real content change:MoveOutcome::Swipecallsdirty_caches()+request_redraw()per motion sample, and theWlCallbackMain handler setsview_dirty+request_redraw()on every frame callback whileis_animating. The subsurface reconcile already repositions the panel with a cheapset_position+ bare parent commit, so the main re-raster is wasted work — and on slow targets (Librem5) it competes with the reposition and makes the slide stutter. New opt-inApp::subsurface_motion_only(defaultfalse, so existing apps are unaffected). When it returnstrue: - the swipe dispatch (input/dispatch/outcomes.rs) still calls theon_swipe_progressfamily but skipsdirty_caches()/request_redraw(); incoming motion events pump the loop and the per-iterationreconcile_subsurfacescarries the move. - the frame-callback animation pump (event_loop/handlers.rs) keeps the vsync cadence without a re-raster by requesting a barewl.frame()+commit()on the main surface (no buffer attach) instead of dirtying the view;poll_externaladvances the animation and the reconcile repositions each frame. The main surface still redraws for genuine content changes (messages, resize, the one-shot redraw on swipe release) — only the per-frame slide raster is dropped.admin
2026-06-01 21:53:40 +02:00 -
b8a32cfb96
Added pkg-config debian dependency
admin
2026-06-01 20:51:45 +02:00 -
34b3e76ac1
test: make button-paints-content render check theme-robust
yamabush1
2026-06-01 11:33:35 +02:00 -
48c5a89712
touch: treat a compositor re-grab
downas a drag continuation When the surface that owns a touch drag is destroyed mid-gesture, the compositor re-opens the touch grab by issuing a freshdownon the surface now under the finger (smithay pins touch focus atdownand won't re-target on motion). Thatdownarrives for a slot the main surface already holds as its primary, with the drag state already migrated here. Handling it through the normaldownpath would either restart the gesture or, since the slot is taken, demote it to an auxiliary touch — both abandon the in-flight drag.TouchHandler::downnow early-returns when the focused surface'sprimary_touch_idalready equals the incoming slot, so the redundantdownis a no-op and the following motion/up keep driving the migrated drag. Completes the cross-surface drag fix whose other half (migratingprimary_touch_idon overlay teardown) already landed.admin
2026-06-01 00:19:49 +02:00