Commit Graph
Select branches
Hide Pull Requests
main
Select branches
Hide Pull Requests
main
-
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. mainPedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.
Pedro M. de Echanove Pasquin
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.
Pedro M. de Echanove Pasquin
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.
Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.
Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.
Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.
Pedro M. de Echanove Pasquin
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.
Pedro M. de Echanove Pasquin
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.
Pedro M. de Echanove Pasquin
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.
Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.
Pedro M. de Echanove Pasquin
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.
Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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).Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
2026-06-01 21:53:40 +02:00 -
b8a32cfb96
Added pkg-config debian dependency
Pedro M. de Echanove Pasquin
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.Pedro M. de Echanove Pasquin
2026-06-01 00:19:49 +02:00 -
582f9e1a37
Removed extra debug prints
Pedro M. de Echanove Pasquin
2026-05-31 02:14:34 +02:00 -
042652ec73
windows: centralise input focus on a single FocusTarget, raise+focus on every window interaction Focus was scattered across three stores —
Layouts::focus,Layers::focusandWindows::activated— plus a hack that smuggled X11 surfaces throughLayers::focus, with the keyboard target derived each frame from alayers.focus.or(layout)precedence. As a result "focused", "activated" and "has the keyboard" could drift apart: a window could be raised without taking the keyboard, the recurring "click a console window, then click it again before you can actually type" symptom. Newwindows/focus.rswithFocusTarget { Layout(Weak<Window>), Layer(WlSurface, app_id), X11(WlSurface) }.Windowsnow holds onefocus_target: Option<FocusTarget>as the single source of truth;activatedsurvives only as a reconciliation cache for the xdgActivatedstate and is never set by hand.set_focustakes oneOption<FocusTarget>and is the only entry point for changing focus: it brings the target to the front (z_promote+ desktop stacking), refreshes the MRU and stores the target, and keyboard focus plus activation are applied from it on the nextfocus().focus()resolves a baseline (the active layout's window, which carriesActivated) and an overlay (a focused layer/X11 surface that takes the keyboard over the baseline without de-activating it), mirroring the old precedence but from one field, so keyboard focus follows focus by construction.Layers::focusis removed and X11 no longer rides inside it; the exclusive-keyboard layer path andreap_layerdrivefocus_targetdirectly, and every click/touch/activation site collapses toset_focus(Some(FocusTarget::…)). surface_at popup tie-break. When a layout hit is a popup or subsurface, itswl_surfaceis not tracked intoplevel_z_order, so the layout-vs-X11 z compare resolved toNoneand the X window underneath wrongly won — clicking a GTK menu (e.g. Firefox's) over an X11 window pressed the X window instead of the menu item. The tie-break now compares the owning toplevel's root z, taken from the hit'sFocusTarget::Layoutweak, so the popup's parent stacking decides. Raise and focus on every window interaction, matching standard desktop behaviour. Client-driven (un)maximize, the SSD maximize/minimize/close buttons and the maximize keybind now bring the window to the front in both the render z-order and the hit-test stacking through a newraise_toplevelhelper; previously only the stacking was updated, so an unmaximised window could stay visually behind another. Starting a move or resize — including a press on the resize edge — now routes throughset_focus, so the window comes to the front and takes the keyboard before the drag instead of being resized or moved while it stays behind. Input robustness around grabs and drags. A press under an active pointer grab (Wayland popup, drag-and-drop or explicit client grab) is routed to the grab instead of forge's own surface_at handling, so a click cannot leak to a window underneath a grabbing popup and a click outside the popup dismisses it cleanly. Only the press is blocked, never the release, so an in-flight move/resize drag or topbar swipe always reaches its end handler and can never stay glued to the cursor if a grab appears mid-gesture.on_touch_upnow ends any active move/resize drag before its other early-return paths (armed SSD button, topbar swipe, app switcher) and drops a stale armed button, fixing a race where pressing a second, overlapping window left the drag running forever after release. fix(input): keep the primary touch slot when migrating a drag across surfaces A touch drag started on an overlay that hides mid-gesture (e.g. the app launcher when dropping an icon onto the dock or the homescreen) froze: it neither moved nor dropped. Destroying the origin surface migrated the drag state (long_press_fired / long_press_origin) and touch_focus to the main surface, but not its primary_touch_id; subsequent touch motion/up events then fell through the auxiliary path and never reached the gesture machine, leaving on_drag_move and on_drop uncalled. reconcile_overlays and discard_overlay now adopt the destroyed overlay's primary_touch_id when a drag is in flight, so the rest of the touch sequence keeps driving the main surface. Touch-only; the mouse already migrated correctly via pointer_focus. ltk: add a chassis module for full-screen ambient surfaces Newsrc/chassis.rs, re-exported from the crate root, gathers the scaffolding that every full-screen ambient surface — greeter, lock screen, kiosk — otherwise repeats by hand over the existing theme andWallpaperBundleprimitives.set_default_theme(mode)finds, installs and activates thedefaulttheme document, returning the failure message instead of exiting so the caller decides how to abort.theme_logo_rgba(size)decodes the active theme's horizontal logo to RGBA, andtheme_icon_tinted(name, size, tint)loads a symbolic theme icon and tints it.branding_bundle_or_solid(name)resolves a theme branding image ("wallpaper","lockscreen", …) to aWallpaperBundle, falling back to a solid fill of the palette background when the theme ships none;wallpaper_bundle_or_solid()is the"wallpaper"convenience.backdrop(content, &wallpaper, w, h)stacks content over the wallpaper resolved for the surface size. No new capability — thin convenience over what the theme module andWallpaperBundlealready expose — but it removes the per-applicationload_theme_logo/build_wallpaper_bundle/ theme-bootstrap duplication. ltk: animatable, input-transparent child surfaces via App::subsurfaces() NewApp::subsurfaces() -> Vec<SubsurfaceSpec<Msg>>(default empty) describes input-transparent child surfaces composited over the main surface, withSubsurfaceSpec { id, view, x, y, content_version }and the stableSubsurfaceId. The motivating use is a slide/reveal that tracks a finger without the per-frame full-screen CPU re-raster a single-surface opacity or translate would cost: the content buffer is rasterised once and the compositor moves it.SubcompositorStateis bound inevent_loop/run.rsfrom the compositor'swl_compositor(absent →App::subsurfacessilently degrades to none);delegate_subcompositor!is added onAppData, which gains asubcompositorbinding and asubsurfacesmap. Newevent_loop/subsurface.rsreconciles the live subsurfaces against the specs: each spec becomes awl_subsurfacesized to the main surface with an empty input region, so all pointer/touch falls through to the parent and the host keeps a single gesture/input model. The content — its own SHM pool andCanvas, drawn through the existingDrawCtx/layout_and_drawpath — is rasterised only when the surface size or the spec'scontent_versionchanges; a position-only change emitswl_subsurface.set_positionand commits the child surface, then a bare parent commit for placement. Committing the child is deliberate: desync subsurface state (the position) is applied on the child surface's own commit, not the parent's, so without it the move is queued but never lands. Positions are given in layout (physical) pixels and divided by the surface scale for the logicalset_position. The reconcile pass runs on every run-loop iteration rather than being gated behind a main-surface redraw, so a finger-driven move repositions at input-event rate, decoupled from the frame-callback cadence that paces full redraws — without this the subsurface only moved when the main surface happened to redraw, so a drag over a static background froze the panel in place.Pedro M. de Echanove Pasquin
2026-05-29 23:28:48 +02:00 -
9ca3b60f3a
ltk: responsive padding/spacing and scrolling, expanded theme palette, and bundled Adwaita cursors A mixed pass over the default theme and the layout/input core, plus the toolkit's own cursor set. Grouped by area below. == Responsive sizing == Add
Length::dp( px )— a "design pixel". It interpretspxagainst a configurable reference vmin (default 412 px, the eydos mobile reference width) and returnsVmin( px / reference * 100 ).clamp( px * 0.7, px * 1.5 ), so a value authored against a mock-up scales with the surface without collapsing on tiny screens or ballooning on a 4K desktop. The reference is process-global, set viaset_design_reference()and read viadesign_reference()(stored as f32 bits in an AtomicU32); both are re-exported fromlib.rs. Make container and grid insets relative.Container's four padding fields becomeLengthinstead off32; every setter (padding,padding_h,padding_v,padding_top/right/bottom/left) now takesimpl Into<Length>, so existingf32call sites keep compiling via theFrom<f32>shim. The values are resolved against the viewport inContainer::preferred_sizeand in the container draw path (draw/layout.rs).WrapGrid'sspacing_x,spacing_yandpaddingget the same treatment, with aresolved( canvas )helper funnelling the per-frame resolution andgrid()seedingLength::pxdefaults. Container tests now compare againstLength::px( … ). == Scrolling ==Scroll::preferred_sizeis now axis-aware. A horizontal-only scroll reports its child's natural height rather than claiming all remaining vertical space, so it no longer steals Y from its siblings when it sits inside aColumn; vertical and both-axis scrolls keep the spacer-like( max_width, 0.0 ).Column's space-distribution correspondingly treats aScrollas a vertical space-claimer only when its axis allows Y. Disambiguate nested scroll viewports by direction. On press the gesture state now collects every scroll viewport under the point (scroll_candidates, innermost first) instead of committing to one; on the first 8 px of motion it locks onto the candidate whose axis matches the dominant direction (scroll_locked), so a horizontal scroller nested inside a vertical list no longer grabs the wrong axis. The pointer scroll hit test is aligned to the same innermost-first ordering. == Theme palette ==themes/default/theme.jsongains named colours (green / green-deep, yellow, orange / orange-deep, pink / pink-soft, sky-deep, error / error-soft, neutral-tertiary) and new semantic slots in both light and dark modes:danger,text-tertiary,accept,chip/chip-active/chip-active-fg, andavatar-1…avatar-9. == Cursors == Bundle GNOME's Adwaita cursor theme — the cursors GNOME Shell uses — intothemes/default/cursors/so a Wayland compositor can draw consistent, complete pointers for ltk applications without the toolkit rasterising cursors itself and without depending on adwaita-icon-theme being installed on the target. The cursors are copied verbatim in XCursor binary format: 35 image files, one per CSS/freedesktop cursor name (default, text, pointer, *-resize, …), plus 27 customary X11 alias symlinks (arrow → default, hand2 → pointer, …); a siblingcursor.thememakes the tree a valid XCursor theme. The existingltk-theme-default.installcopiesthemes/defaultrecursively, so the directory ships with no packaging change. Applications keep declaring aCursorShapeper widget overwp_cursor_shape_v1; the compositor resolves it against the active theme'scursors/directory by name, and the set covers all 34CursorShapevariants. Document the set inthemes/default/cursors/README.md(what it is, the XCursor layout, the full shape list, how the compositor consumes it, guidance for forks) andthemes/default/cursors/LICENSE.md(attribution and licence options, modelled on the icons catalogue LICENSE).lib.rslists the cursors in its third-party-assets section. Close out licensing indebian/copyright: aFiles: themes/default/cursors/*paragraph records the upstream dual offer (CC-BY-SA-3.0 or LGPL-3, and CC-BY-SA-4.0 for the newer assets) attributed to the GNOME Project, with standalone CC-BY-SA-3.0, CC-BY-SA-4.0 and LGPL-3 paragraphs (summary-plus-canonical-URL for the CC licences, matching the existing CC-BY-4.0 entry; LGPL-3 referencing /usr/share/common-licenses/LGPL-3). The files are unmodified from upstream, so there is nothing to declare under the ShareAlike "indicate if changes were made" clause. Addtests/cursor_assets.rs: everyCursorShapename resolves to a valid XCursor file (Xcur magic, following symlinks),cursor.themeis present, no entry is a dangling symlink, and the expected-name list stays in sync with the enum's 34 variants.Pedro M. de Echanove Pasquin
2026-05-28 23:11:14 +02:00 -
3d8523533c
text-input: re-sync on
enterand flag secure fields as Password LTK drives the on-screen keyboard through zwp_text_input_v3: focusing a text widget enables text-input so the compositor's input-method (squeekboard) brings the keyboard up. Two gaps are fixed here. Handle theenterevent by re-emitting enable + content type + commit. The compositor sendsenterwhen it (re)focuses the text input — notably when an input-method connects after the field has already enabled text-input (a startup race). LTK previously ignoredenter, so that activation was lost and the keyboard never appeared; it now re-declares its state and the OSK comes up. Declare the content type from the field'ssecureflag: secure fields are flaggedContentPurpose::PasswordwithContentHint::SensitiveData | HiddenText(so the IME/OSK skips prediction, autocorrect and storing the value), everything else staysNormal/None. The content type is also refreshed when focus moves between two text fields (e.g. username → password) without re-creating the text-input object, and a newAppData::text_input_securelets theenterre-emit path preserve the current field's type. Correct the docs:TextEdit::secure()and SECURITY.md claimed secure "skips text-input-v3 registration". It does not — the field still activates text-input so the OSK works on it; the protection is the Password / sensitive flagging, and the value still reaches a trusted compositor/IME.Pedro M. de Echanove Pasquin
2026-05-27 22:31:36 +02:00 -
1e2cb836f4
ltk: convert physical sizes to logical for overlays and input regions on HiDPI Overlay
set_sizeand input regions were handed physical (layout-space) pixels straight to layer-shell andwl_region, both of which expect logical coordinates. They only coincide at scale 1, so on a scale-2 output every overlay requested a surface twice its intended size and the input region covered the wrong area.apply_input_regionnow takes the surface scale and divides eachRectdown to logical before adding it to the region; the four draw paths (software and gles, full and partial) forward their scale.reconcile_overlaysconvertsOverlaySpec::sizeto logical for bothset_sizeand the initialOverlayConfig(0 = fill survives the divide), and seeds the new surface'sscale_factorfrom the parent so the first configure allocates a HiDPI buffer instead of rendering at scale 1 untilscale_factor_changedlands a frame or two later.Pedro M. de Echanove Pasquin
2026-05-26 22:22:18 +02:00 -
fc045a9c22
ltk: ext-session-lock-v1 client surface mode, plus a read-only mode for text fields Add a third Wayland surface type to the runtime so an ltk
Appcan be a screen locker, alongside the existing xdg-shell window and wlr-layer-shell surfaces. A newShellMode::SessionLockmakesrun()bindext_session_lock_manager_v1and request the lock at startup; the lock surface itself is created in the newSessionLockHandler::lockedcallback (one surface on the first advertised output) and replaces theSurfaceKind::PendingLockplaceholder the main surface holds until the compositor grants the lock. Theconfigureevent routes through the sameon_configurepath as layer and xdg surfaces, so sizing and rendering are unchanged, andfinished(the compositor denied or ended the lock) tears the loop down. The whole thing is additive and opt-in: theWindowandLayerpaths are untouched and nothing enters lock mode unless anAppreturnsShellMode::SessionLock, so existing apps are unaffected — the only non-additive edits are the two exhaustivematches onSurfaceKind(wl_surface/try_wl_surface), which gain arms for the two new variants. Doing the locker as a first-class surface rather than compositing a static texture into an offscreenUiSurfaceis the whole point: the compositor gives the lock surface keyboard focus, so ltk's existing text-input, editing, focus and IME machinery works inside the lock exactly as on any other surface — cursor, click-to-focus, Tab, character input. A locker built on top of this is just a normal interactive ltk app that happens to be presented on the lock layer, with no special input plumbing on the compositor or the app side.App::requested_exit()is the new way an app asks the runtime to tear the surface down and leave the loop; it is polled after every batch ofupdates. It exists because of the one hard invariant ofext-session-lock-v1: a locker that disconnects without sendingunlockleaves the compositor's outputs blanked forever — that is the protocol's deliberate anti-bypass guarantee. So whenrequested_exit()returns true and the surface is a session lock, the loop callssession_lock.unlock()and round-trips the connection before settingexit_requested, lifting the lock cleanly; for aWindoworLayersurface there is no lock and it simply exits. The consequence for lock apps is that they must stop callingprocess::exitfrom the lock path and instead flip a flag they return fromrequested_exit().text_editgains aread_only( bool )builder. A read-only field still renders its box and value in the normal field style but takes no keyboard focus and accepts no input:Element::is_focusableandElement::is_text_inputnow return false for a read-onlyTextEdit, which keeps it out of the Tab cycle, off the keyboard-edit path, and stops the cursor from ever being drawn on it. The flag is carried throughmap_msgso it survivesElement::map. This is for presenting a known, non-editable value in the same visual idiom as the editable fields beside it — for example the already-known user shown on a session lock, where letting that field take focus or blink a cursor would be wrong. Theshell_mode()doc comment and the README now list theSessionLocksurface type and point atrequested_exit()for the unlock. Two warnings are cleared along the way: the runtime no longer stores theSessionLockStateafter requesting the lock — it has noDrop, so the manager object outlives the dropped handle inside the connection and the lock lifecycle runs entirely off the returnedSessionLock, which removes a never-read field — and a pre-existing rustdocprivate_intra_doc_linkswarning inlist_item(a public doc comment linking to the privatetheme::ICON_SIZE) is downgraded to plain code formatting.Pedro M. de Echanove Pasquin
2026-05-26 00:11:33 +02:00 -
cff4b12a4a
event_loop: drop the duplicate on_resize that clobbered physical dimensions with logical ones Both
LayerShellHandler::configureandWindowHandler::configurecalledself.on_configure( w, h )— which already forwardsapp.on_resize( w * sf, h * sf )in physical pixels — and then immediately calledself.app.on_resize( w, h )again with the surface-local logical size. The second call won, so on any scale > 1 surface the app saw logical dimensions while the layout pass kept working in physical pixels. On a Librem 5 at scale 2 that meantscreen_width/screen_heightcame through as 360×720 against a 720×1440 layout: homescreen icons rendered at half their intended footprint and the mobile wallpaper, sized explicitly via.size( iw_at_h, sh ), filled only the top half of the screen (the launcher / lockscreen / greeter use.cover()so they were unaffected). Remove the redundantapp.on_resizefrom both handlers;on_configureis the single source of truth for the physical dimensions the app and the layout both expect.Pedro M. de Echanove Pasquin
2026-05-25 11:42:45 +02:00 -
f7ef932976
list_item: optional leading icon
yamabush1
2026-05-25 09:00:00 +02:00 -
24f4d2703a
ltk: introduce viewport-relative
Lengthso any size, padding, spacing or font height can scale with the surface instead of being frozen at a px constant, fixtext::preferred_sizeto honour the font-declared line gap, and add a responsive typographic scale The motivating bug was a lockscreen in a downstream app (eydos-loginmanager) where the clock at 87 px overlapped the date at 24 px on a Pinephone but not on a winit dev screen. The root cause split in two: the layout was wired with a singlef32spacing constant that worked at the dev resolution and broke at the smaller one, andtext::Text::preferred_sizewas returningascent - descentfor the line height — fontdue's terminology for "the minimum bounding box of an unaccented line", which deliberately drops theline_gapthat every typographic renderer (Pango, CoreText, DirectWrite) reserves between adjacent rows. At Sora's 200/em line gap, an 87 px row was visually 17 px taller than the rect the column allocated for it; stacked tight against the row above, the descenders bled into the row below. This commit fixes both halves at the toolkit level so every consumer benefits without bolting on a per-screenSizinghelper in their own view code.types::Length(with theLengthBaseenum behind it) is the new currency for any "how big" or "how far apart" parameter. Six variants —Px,Vw,Vh,Vmin,Vmax,Em— cover the cases a real UI hits: absolute pixels for fixed-chrome decisions, viewport-relative percentages for sizes that have to survive a portrait/landscape rotation, and root-font-size multiples for typographic hierarchy. Optionalmin_px/max_pxbounds attach to the sameLengthvalue via.clamp( lo, hi )(both ends),.at_least( lo )and.at_most( hi )(one-sided); the names are intentionally divergent fromf32::min/f32::maxto avoid being read with the opposite semantics (x.min(24)in std means "the smaller of x and 24", which is the inverse of what a min bound expresses). The bounds are stored as rawf32rather than nestedLengthvalues, which keepsLengthCopyand avoids aBoxallocation per widget per frame — the bounded-by-relative case (Vmin(20).clamp(Vmin(10), Vmin(40))) is rare enough that the trade is the right one.From<f32>,From<i32>andFrom<u32>are implemented so every legacy.size( 24.0 )/.padding( 8.0 )/.spacing( 4.0 )call keeps compiling unchanged; the migration is opt-in per call site. TheEM_BASE_DEFAULT = 16.0constant matchestheme::typography::BODYsoLength::em( 2.0 )resolves consistently with the body-text default; a future change can thread a theme-supplied em base through without breaking the resolver shape. The resolver —Length::resolve( viewport: ( f32, f32 ), em_base: f32 ) -> f32— runs at layout time against a viewport supplied by the renderer.Canvas::viewport_logical()is the new helper that exposes that viewport: it divides the canvas's physical size bydpi_scaleand falls back to physical size whendpi_scale <= 0.0, guarding the misconfigured-canvas path so a Vmin call doesn't poison every downstream measurement withNaNorinf. The viewport is in **logical** pixels — matching what every waylandxdg_toplevel.configureevent already hands the client — soLength::vmin( 18.0 )on a 360×720-logical Librem 5 portrait surface resolves to 64.8 px and the same expression on a 1600×900 dev screen resolves to 162 px, automatically. Every widget setter that took anf32size, padding, spacing, max-width, or fixed dimension now takesimpl Into<Length>and stores the value asLength: -widget::text::Text::size( impl Into<Length> ); thesizefield is nowLength.Text::resolved_size( &Canvas )is the internal accessor that every measurement / drawing path routes through, so the field can stayLengthwithout churning the call sites.preferred_sizeanddrawnow readnew_line_size = ascent - descent + line_gapfrom fontdue'sLineMetrics(the fix for the original bug) — the baseline placement is unchanged, only the row height grows by the font's declared leading, which is what every stacked layout was implicitly relying on. -layout::Spacer::height( impl Into<Length> )/.width( impl Into<Length> );fixed_height/fixed_widthare nowOption<Length>. Newresolved_height( &Canvas )/resolved_width( &Canvas )helpers replace the directs.fixed_height.unwrap_or( 0.0 )reads inlayout::column,layout::rowandlayout::stack.Spacer::preferred_sizegrows a&Canvasparameter for the same reason;Element::preferred_sizepasses the canvas through. -layout::Column::spacing/.padding/.max_width,layout::Row::spacing/.padding— all takeimpl Into<Length>and storeLength. Internalresolved_spacing( &Canvas ),resolved_padding( &Canvas ),resolved_max_width( &Canvas )helpers funnel every read, so the layout code paths stay readable. The column'sinner_wprivate helper picks up a&Canvasargument; the test that used it directly is updated.theme::typographykeeps its historicf32constants (H0…BODY_XS, plusLINE_HEIGHT) so the migration is gradual, and adds a parallel responsive scale exposed as functions returningLength:h0(),h1(),h2(),h3(),body(),body_s(),body_xs(). Each is aLength::vmin( pct ).clamp( min_px, max_px )whose percentage is calibrated against a 1000-px smaller side reproducing the legacy px constant exactly, and whose px clamps protect both ends of the spectrum — a 360-px Pinephone hits the lower clamp on the larger headings, a 4K desktop hits the upper one. The tests intheme::typographyexercise all three regimes (narrow phone, calibration point, large display) so future drift in the percentages or clamps is caught immediately.Canvas::viewport_logicalis the only render-surface API touched. None of the existing per-frame paths (draw_text,measure_text,font_line_metrics) change shape, so backends and external embedders aren't disturbed. Thedpi_scaleaccessor already existed; this commit only adds the convenience that ratios it against the surface size to return the unit layout actually wants. Test coverage rounds out the addition rather than just smoke-testing the happy path: 22 new tests, broken down astypes::length_tests(7 — every variant, clamp with relative value, clamp with swapped bounds,From<f32>),render::viewport_tests(3 — scale 1, scale 2, scale 0 fallback),theme::typography::tests(3 — phone-clamped, calibrated, 4K-clamped),layout::spacer::tests(4 — px height, vmin height, vw width, flex spacer reportsNone),layout::column::tests(3 new — vmin spacing accumulates, vmin padding, vmin max-width caps inner-w),layout::row::tests(2 new — vmin padding, vmin spacing produces correct visible gap between non-flex children regardless of the row's centering anchor), andwidget::text::tests(3 updated/new — defaults compare againstLength::px(16.0),.size( f32 )and.size( Length )both verified). The existing integration test intests/layout_stack_spacer.rsis updated to callSpacer::preferred_size( &canvas )and comparefixed_height/fixed_widthagainstSome( Length::px( n ) ). Documentation is updated end-to-end so the new API is discoverable fromcargo docwithout grepping the source:lib.rsgets a new entry forLengthunder the **Types** section and a new **Designing for multiple resolutions** section that lists the three patterns (relativeLengthfor sizing, responsive typography for hierarchy,view()-level branching on surface dimensions only when the structure itself must change).Canvas::viewport_logicalships with a runnableassert_eq!example covering the scale-2 case. The module-level docstrings forSpacer,ColumnandRownow show both anf32example (legacy, still valid) and aLength::vmin( ... ).clamp( ... )example for the responsive variant —cargo docrenders both side by side so the upgrade path is obvious. Out of scope for this commit, deliberate:WrapGrid::spacing_x/spacing_y/padding,widget::text_edit::TextEdit::font_size, andwidget::image::Image::sizestill takef32. None of them are on a critical responsive path right now, theFrom<f32>shim means migrating later is a one-line setter signature change per widget, and keeping this commit focused on the widgets the lockscreen actually uses keeps the diff reviewable. The line-gap fix intext::preferred_sizealready benefitsTextEditindirectly because its caret/row math reads from the same metrics helpers.Pedro M. de Echanove Pasquin
2026-05-24 00:12:50 +02:00 -
c553c4df4b
themes/default: drop the inner shadows from the
launcher.svgglyph In boththemes/default/branding/dark/launcher.svgandthemes/default/branding/light/launcher.svgthe launcher glyph is built from nine cells, each with its ownfilterN_diiii_4479_*. Those filters layered four successive inner-shadow passes (effect2..effect5_innerShadow_*) on top of the outer drop shadoweffect1_dropShadow_*: pairs offeColorMatrix in="SourceAlpha"+feOffset+feGaussianBlur+feComposite operator="arithmetic"+feBlend, with offsets(-3.6,-3.6),(1.8,1.8),(0.45,0.45)and(1.8,1.8)and blend modesplus-lighter,overlayandnormalto reproduce the light-above / dark-below bevel and inner halo of the original Figma export. That is twenty-four lines per cell and nine cells per file — 216 lines per SVG, 432 in total. This change removes those four inner-shadow passes in both files; the outer drop shadow (effect1_dropShadow_*), theclipPathwithbgblur, therectelements defining each cell and the rest of the document are kept verbatim. The launcher silhouette and its placement do not change: the icon still occupies the sameviewBoxand produces the same clip; what disappears is the specular highlight and dark inner contour of each cell, leaving flat rectangles on the background with their projected shadow. The diff is pure deletions, with no added lines, and the existing difference between thedarkandlightvariants is preserved (only the filter identifiers differ,_38862versus_38700).Pedro M. de Echanove Pasquin
2026-05-23 00:53:13 +02:00 -
88385e14b2
add Carousel widget and WrapGrid::centre_last_row Forge's app switcher needs two layouts the existing widget set didn't cover. The desktop grid wants a partial last row centred under the rows above (3 tiles → row 1: two, row 2: one centred) so a 7-of-9 leftover band reads balanced rather than left-aligned. The mobile variant wants a horizontal carousel where the focused tile sits centred in the viewport at a configurable fraction of its width and its neighbours peek out on the sides at a fixed gap. Extend
WrapGridwithcentre_last_row( bool ). When set, layout offsets a row that has fewer thancolumnschildren by(missing * (cell_w + spacing)) / 2so it stays centred inside the content rect. Defaults to false; every existing call site continues to land tiles flush-left. Covered by three layout tests (centred partial row, full row no-op, off-by-default). Add theCarouselwidget atsrc/widget/carousel/. It is a pure layout primitive:focused_width_frac(0.05–1.0, clamped),gapandoffsetare owned by the caller, leaving drag / inertia / snap policy to the host so the compositor can plug in its existing touch pipeline. Each child gets a rect atbase_x + idx * (child_w + gap)and the full viewport height;snap_offset( viewport_w, idx )translates index to centring offset andfocused_index( viewport_w )rounds the current offset back to the nearest tile. Plumbed intoElement::Carouselwith the matching arms inwidget/element.rsand walker indraw/layout.rs; re-exported asltk::{ Carousel, carousel }. Covered by nine unit tests (layout, offset shift, snap / focus round-trip, frac clamp, child height) plus acargo run --example carouseldemo with Prev / Next / arrow-key navigation against an external offset state. The example is wired into theexamplesMakefile target. Updates the widget catalogue and thewidget/mod.rslanding comment to list the carousel under "Clipping wrappers" and to mentioncentre_last_rowin the grid section.Pedro M. de Echanove Pasquin
2026-05-22 19:38:48 +02:00 -
0e52274053
app, event_loop: first-frame-committed hook and foreign_toplevel app_id only Two independent changes, both blockers for a working desktop session through the loginmanager-daemon handoff and the dock's running-app icons.
App::on_first_frame_committedis a new trait hook fired exactly once, immediately after the very firstwl_surface.commitof a rendered buffer on the main surface.AppDatagrows afirst_frame_committed: bool,draw_framenow returns whether this call performed that first commit, andtry_runinvokes the hook after the borrows held during the draw are released. Used by the loginmanager-daemon-aware crustace path to signal "ready to be presented" back to the daemon as soon as the GPU has the first frame — the actual present can still be deferred under VT switching (no DRM master yet), but the client-side commit is the right edge for handoff.toplevel_display_idin the foreign-toplevel-list handler no longer falls back toinfo.titleorinfo.identifierwheninfo.app_idis empty. Smithay creates eachext-foreign-toplevel-list-v1handle withapp_id = ""andinit_new_instanceflushes adoneimmediately, so subscribers used to see the protocol-level identifier (a 32-charAlphanumericrandom token) as the "app id" of every new toplevel — and remained stuck on it whenever the client's realset_app_idarrived between done events but the subscribing app's matcher couldn't resolve it to a.desktopentry. Returning the rawapp_id(empty or not) makes that first transientdoneignorable by the consumer's own empty-string guard; the seconddone, carrying the real app id, is processed normally.Pedro M. de Echanove Pasquin
2026-05-21 01:51:19 +02:00 -
78a7ae151c
layout/stack: opt-in
Stack::fit_content()so a wrapping container can adopt the stack's intrinsic sizeStack::preferred_sizeunconditionally reported(max_width, max_h)— every stack claimed the full width its parent offered, regardless of what its children actually needed. That is the right default for a FrameLayout-style overlay (the existing callers all rely on it) but it makes the natural "pin a stack to a fixed-size child (e.g. a spacer ofcard_w × card_h) and centre other children on top of it" pattern impossible: a container wrapping such a stack always inherited the full parent width and ignored the spacer's footprint. The newStack::fit_content()builder mirrors theColumn::fit_contentflag — when set,preferred_sizereturns the max of children's intrinsic widths and heights instead of claiming the parent'smax_width, with the same "skip filler widgets that themselves claimmax_width" exclusion list (Spacerwith nofixed_width,Separator,Scroll,ProgressBar,Slider,TextEditwithoutfixed_width) so the flag is not defeated by a flexible child slipping into the stack. Default behaviour is unchanged.Pedro M. de Echanove Pasquin
2026-05-20 15:26:41 +02:00 -
757063694e
layout/stack: opt-in
Stack::fit_content()so a wrapping container can adopt the stack's intrinsic sizeStack::preferred_sizeunconditionally reported(max_width, max_h)— every stack claimed the full width its parent offered, regardless of what its children actually needed. That is the right default for a FrameLayout-style overlay (the existing callers all rely on it) but it makes the natural "pin a stack to a fixed-size child (e.g. a spacer ofcard_w × card_h) and centre other children on top of it" pattern impossible: a container wrapping such a stack always inherited the full parent width and ignored the spacer's footprint. The newStack::fit_content()builder mirrors theColumn::fit_contentflag — when set,preferred_sizereturns the max of children's intrinsic widths and heights instead of claiming the parent'smax_width, with the same "skip filler widgets that themselves claimmax_width" exclusion list (Spacerwith nofixed_width,Separator,Scroll,ProgressBar,Slider,TextEditwithoutfixed_width) so the flag is not defeated by a flexible child slipping into the stack. Default behaviour is unchanged.Pedro M. de Echanove Pasquin
2026-05-20 15:24:34 +02:00 -
640db23de2
doc: silence rustdoc intra-doc warnings (private-item links and palette/surface ambiguities)
cargo doc --no-depswas emitting eight warnings about intra-doc links resolving to private items or ambiguous fn/module names. None reflect a behaviour bug; they were just noise that cluttered the docs build output. Five of the warnings came from doc comments that cross-referenced items not in the rendered public API.widget::scroll::Scroll(struct),Scroll::horizontal,Scroll::both,event_loop::text_editing(module) andtext_shaping(module) are allpubin their own modules, but thewidgetandevent_loopparents are private to the crate root, so rustdoc treats them as private when resolving links from items that ARE on the public surface (ScrollAxis, thescrollconstructor,font_bytes, thetext_editmodule docs). The fix is to drop the link syntax for those references: keep the identifier in backticks (so it still renders as code) but remove the surrounding[...]so rustdoc doesn't try to resolve it. Where the cross-reference had no semantic load beyond "see this module", the prose is rephrased to name the module without trying to link to it (e.g. "theevent_loop::text_editingprivate module", "see thetext_shapingprivate module"). The remaining three warnings werepalette/surfacelinking ambiguously between a module of that name and a function of the same name withincrate::theme. Adding()after the identifier inside the brackets (palette→palette(),surface→surface()) disambiguates to the function, which is the intent in all three sites — the surrounding text talks about "per-slot shorthand accessors", which is what the functions are. After thiscargo doc --no-depsruns clean with no warnings.Pedro M. de Echanove Pasquin
2026-05-19 22:41:42 +02:00 -
a8bbd1e35c
input/keyboard: fall through to App::on_key when the Enter dispatch target widget has no submit/press message
handle_key_returnpreviously routedReturnto either the focused or hovered widget's submit/press message and, only when neither index existed, fell through toapp.on_key_with_modifiers. If a stalehovered_idx(left over from a prior screen) pointed at a widget that exists in the newwidget_rectsbut exposes no submit or press handler,target.is_some()was true and the message-less dispatch silently swallowed the key — theelsebranch never ran andApp::on_keynever saw the keysym. This manifested in the eydos-loginmanager greeter asEnteron the Lock screen failing to fireMessage::Unlockafter a pause/resume cycle, until the user clicked something to refreshhovered_idx. Track whether the widget path actually pushed a message and, when it didn't, fall through to the app-level handler so theReturnkeysym still gets a chance to be interpreted by the application'son_key. No behaviour change when the focused/hovered widget does provide asubmit_msgorpress_msg.Pedro M. de Echanove Pasquin
2026-05-19 21:40:20 +02:00 -
9cc65e70ea
fix(event_loop): walk error source chain for BrokenPipe in OtherError
yamabush1
2026-05-18 20:48:35 +02:00 -
750eae7a93
fix(event_loop): exit cleanly on OtherError(BrokenPipe) from compositor
yamabush1
2026-05-17 18:28:50 +02:00 -
4a80165428
event_loop, a11y, text_shaping: AccessKit AT-SPI2 bridge, cross-app clipboard, xdg-activation, HarfBuzz shaping, multi-touch hooks Five orthogonal capabilities land together because they share the same
try_runplumbing: an optional global is bound at startup, a piece of state is added toAppData, the run-loop iteration drains an inbox / pushes a frame snapshot, and the public surface gains a small set of opt-inApphooks. Nothing here breaks an existing app — every new path degrades to a no-op when the compositor does not advertise the relevant global or when the platform adapter cannot start. AT-SPI2 accessibility via AccessKit. A newsrc/a11y/module owns the platform adapter and the inboundActionRequestchannel.A11yState::try_newconstructs anaccesskit_unix::Adapter; when the AT-SPI2 daemon is not on the session bus (headless CI, locked-down compositors) the constructor returnsNoneand the rest of the pipeline runs unchanged. After every successfuldraw_frame, the run loop builds a freshaccesskit::TreeUpdatefromwidget_rectsand pushes it through the adapter — main surface plus every visible overlay, each translated to global coordinates viasurface_offset_forso screen readers report positions in the same frame the user sees. Buttons / toggles / checkboxes / radios / list items / sliders / text edits map to the matchingRoles;ClickandFocusactions are advertised on every interactive node; inbound action requests are drained at the top of each iteration and translated into a synthetic press / focus on the matching widget. The integration is documented as best-effort indocs/architecture.mdunder "Known gaps and non-goals": hierarchical nesting, per-widget accessible names, live regions andAction::SetValueare listed as the natural follow-ups that the foundation now supports but does not yet wire. Cross-application clipboard viawl_data_device_manager. A newsrc/event_loop/data_device.rsbridges the existing process-localclipboard: Stringto the Wayland selection. Outbound (Ctrl+C / Cut): after the local clipboard is populated,publish_clipboard_selectioncreates aCopyPasteSourceofferingtext/plain;charset=utf-8and installs it as the seat's selection;DataSourceHandler::sendwrites the cached string into the fd the peer hands us. Inbound (Ctrl+V from another app):DataDeviceHandler::selectionasks for the offered text viaWlDataOffer::receive, spawns a tiny worker thread to drain the read pipe with a 16 MiB cap to prevent paste-bomb DoS, and posts the result back through anmpsc::Senderthat the run loop drains each iteration intodata.clipboard. Theclipboard:field's doc-comment is updated to reflect the new behaviour: process-local when the compositor does not advertise the global, synchronised with the seat selection otherwise. External drag-and-drop reception. The samedata_devicemodule handlesDragOfferenter / motion / leave / drop_performed:on_drop_motion( x, y )fires while the drag hovers over the surface,on_drop_leave()when it withdraws without dropping, andon_drop_received( x, y, mime, text )when an external payload (text/uri-list,text/plain, …) is released on top of an ltk window. The receive path reuses the same worker-thread / channel pattern as the clipboard so the run loop never blocks on the read fd. Three newApphooks expose the events with no-op defaults; apps that ignore them get the previous behaviour.xdg-activation-v1. The global is bound optionally; when it is present,try_runreads$XDG_ACTIVATION_TOKENfrom the environment, removes it immediately (single-use; preventing leaks into child processes) and stashes it onAppData::activation_token_pending. After the first successful configure of the main surface — the earliest point at whichxdg_activation_v1.activateis meaningful — the token is consumed once and the surface raised to focus. Compositors without the global leaveactivation_stateasNoneand the inbound path silently degrades. AnApp::request_activation_tokenoutbound path is reserved on the trait but not yet exercised here. HarfBuzz shaping. A newsrc/text_shaping.rs::shape_linedrives both renderers: the logical-order string is run throughunicode-bidi, split into per-font sub-runs, and shaped throughrustybuzz. EachPositionedGlyphcarries the per-fontglyph_id, the visual advance and the ink offsets — exactly whatfontdue::Font::rasterize_indexedneeds to render Arabic connected forms, Devanagari clusters and CJK shaped glyphs correctly. The GLES atlas is re-keyed on(glyph_id, size_bits, font_id)so glyphs from different fonts at the same size no longer collide, and the atlas format is selected per ES profile (GL_R8/GL_REDon ES3,GL_LUMINANCEon ES2) — the fragment shader samples.rfor both, sinceGL_LUMINANCEreplicates the coverage byte into.r=.g=.b. Software path follows the same key. NewCargo.tomldeps:unicode-bidi = "0.3",rustybuzz = "0.14". Multi-touch hooks.App::on_touch_down / on_touch_move / on_touch_up( id, x, y )expose the rawwl_touch.idof every secondary finger. The first finger to land remains the *primary slot* and is fed through the regular gesture machine (on_pointer_*, swipe, scroll, long-press, drag-and-drop). Every additional finger fires the new callbacks instead, leaving the existing single-slot behaviour untouched for apps that do not override them. This is the substrate for app-defined pinch-zoom / two-finger pan; the toolkit itself does not yet ship a built-in pinch gesture (called out in the same "Known gaps" doc section).event_loop::frameextracted fromdraw/mod.rs. Thedraw_frameorchestrator and its per-format SHM helper (pick_shm_format) move intosrc/event_loop/frame.rs, leavingdraw/strictly responsible for per-surface paint primitives. The import inevent_loop/run.rsis rewritten accordingly;draw/mod.rsshrinks from 192-line orchestrator to a thin module index. Overlay teardown safety.AppData::discard_overlay( id )synchronously removes a destroyed overlay from the map and rewrites every per-device focus that pointed at it (pointer, keyboard, every touch slot), migrating an in-flight long-press drag to the main surface the same wayreconcile_overlaysdoes. Used by the compositor-driven destruction paths (PopupHandler::done,LayerShellHandler::closed) where waiting for the next reconcile would leave a window in whichsurface()/surface_mut()panic. The non-panicking siblingstry_surface/try_surface_mutare added for callers on async dispatch paths (IMEDone, tooltip arm) that may race a teardown. Miscellaneous. CI:master→mainto match the actual default branch.Makefileaddscargo run --example dialogto the examples target.src/lib.rsre-exportswidget::scroll::ScrollAxisso apps can configure ascroll()axis without reaching into apub(crate)module.Cargo.tomladdsaccesskit = "0.17"andaccesskit_unix = "0.13".docs/architecture.mdgains the "Known gaps and non-goals" section that enumerates the new capabilities, what still ships flat, and what is deferred (per-widget a11y labels, primary selection, intra-process multi-touch gestures,wp_fractional_scale_v1).Pedro M. de Echanove Pasquin
2026-05-16 22:09:59 +02:00 -
4aa3480b64
refactor: split every monolithic module into focused submodules Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules.
src/event_loop/mod.rs(878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves. image bumped from 0.25.2 to 0.25.9.Pedro M. de Echanove Pasquin
2026-05-15 23:46:56 +02:00 -
3d237039c6
input/keyboard: let the app intercept arrow / Tab keys before the default text-edit and focus-shift behaviours The keysym dispatcher used to give the focused widget first refusal on Left / Right / Up / Down / Tab. A
text_editswallowed the four arrows for cursor movement, and Tab walked the keyboard-focus ring throughnext_focusable_index. Apps only goton_key_with_modifiersfor those keys when no text input was focused (Tab never), so a search field with autocomplete couldn't drive a selection cursor through arrows or Tab without putting focus on something else first — which is exactly the wrong UX for a search-as-you-type list. Three arms inAppData::handle_key_pressnow queryself.app.on_key_with_modifiers( keysym, ctrl, shift )*first* and only fall back to the default behaviour if the app returnsNone: -Left | Right: previously branched onis_text_inputand routed the keypress tohandle_cursor_left/rightfor text fields, otherwise asked the app. Now: app first; if app saysNone, the existing text-cursor path runs for text inputs and non-text widgets get nothing (their previous fallback already produced no useful action without an app handler). -Up | Down: the previous logic was a four-way decision that tried text-cursor movement, thenmove_keyboard_hoverfor combo / list widgets, then the app handler. Now the app gets first refusal too. When it declines, the original cascade (text-cursor → hover navigation) still runs, so multilinetext_editcursor walking and combo / scrollable-list keyboard nav are unchanged for any app that doesn't intercept these keys. -Tab | ISO_Left_Tab: app first; onNonethe focus-shift path (next_focusable_index+set_focus) runs. Apps that want Tab as a navigation message between custom UI states (a search field cycling through results, a wizard advancing pages) finally have a hook; apps that don't get the standard tab-through-focusable behaviour by leavingon_key_with_modifiersreturningNonefor Tab, which is the trait's default. The interception is keysym-only — modifier state is forwarded so an app can distinguishTabfromShift+Tab,RightfromCtrl+Right. The text-input check is preserved as a local before the dispatch so the fallback doesn't lose the "is this even a text edit?" question; it just runs *after* the app instead of before. Net effect: a list / autocomplete that needs arrow / Tab navigation can now be implemented purely by overridingApp::on_key_with_modifiers, with no changes totext_edititself and no risk of breaking apps that don't need it. The Up / Down branch's old comment about the "fall through to list hover-navigation" rationale is dropped — the cascade still exists, but the app-first ordering is the new contract and the comment was about the previous one.Pedro M. de Echanove Pasquin
2026-05-15 00:40:01 +02:00 -
5ff4fa7e59
Fixed default dark theme
Pedro M. de Echanove Pasquin
2026-05-14 23:49:08 +02:00 -
bfe27b6fef
event_loop, widget, input: pointer-dwell tooltips, global drag coords, foreign-toplevel name cascade
Button::tooltip( text )registers a hint string that fires after a 600 ms pointer dwell.LaidOutWidgetgains atooltip: Option<String>field,Element::tooltip()exposes it to the input layer, and the existing pointer-hover path now callsarm_tooltipon hover-enter andcancel_tooltipon hover-leave / touch. The deadline is polled alongsidenext_long_press_wakeupintry_runso an idle pointer still gets a wake-up at the firing instant; on fire,tooltip_overlay()synthesises anOverlaySpec— a roundedtext_primary @ 95%pill drawn withbgtext — anchored above the hovered widget, flipping below or clamping inside the screen if it would clip, and pushed alongside the app's own overlays both in the redraw path and inreconcile_overlaysso the layer surface is created the same frame the tooltip becomes visible. Pointer-only by design: touch events explicitly cancel because a tap-and-release should never linger into a hint. Theshowcaseexample wires.tooltip(..)on the three button variants as a smoke test. The drag pipeline now reports positions in main-surface (global) coordinates instead of per-surface.surface_offset_for( focus )derives the top-left of an overlay surface fromSurfaceState::layer_anchor— newly stored atreconcile_overlaystime fromOverlaySpec::anchor— combined with the main surface's dimensions;on_drag_move,on_drop, the synthetic move emitted on drag-promotion inpointer.rs, and thepending_drag_initspush site ingesture::start_dragall translate before handing coordinates to the app. The motion and release paths additionallyrequest_redraw()every overlay so a dock-style drop target painted on anAnchor::Bottomlayer surface gets repainted as the drag moves — without that, the visible drop indicator only updates when the cursor re-enters the main surface. Drops still target whichever surface fired the release; only the coordinates are unified.ForeignToplevelListHandlerpreviously readapp_iddirectly viaForeignToplevelList::info(). Clients that never setapp_id(some winit-windowed compositors, simple test clients) were silently invisible to crustace-style docks because the empty string fell throughunwrap_or_default()and the dock then keyed entries off"".toplevel_display_id()cascades: preferapp_idfor desktop-entry matching, fall back totitlefor human-readable identification, and finally to the protocol-issuedidentifierwhich is always present and unique per handle. Applied to bothnew_toplevelandupdate_toplevel.theme::system_fontdb()lazily loads the system font database once viaOnceLockand reuses theArcfor everydecode_svg_bytescall. resvg's defaultOptions::fontdbis empty, so any SVG containing<text>rendered with the built-in fallback font or no font at all; with the system DB attached, icons and decorative SVGs with embedded labels now resolve glyphs correctly. Cached becauseload_system_fonts()walks every font path on the system and is comfortably tens of milliseconds on a cold cache — not something to repeat per icon decode.themes/default/theme.jsontweaks one variant's slot palette:surface-altfrom@indigo/D9to@white/D9andtext-primaryfrom@whiteto@navy, plus a cosmetic re-alignment of the"value"columns in the same slots block.Pedro M. de Echanove Pasquin
2026-05-14 22:36:17 +02:00 -
821037f509
container, text, date_picker: width-aware sizing pass
Container::max_width(px)mirrors the same flag onColumn/Row— the container reportsmin( offered, px )upwards and the draw pass capsrect.widthtopx, so a decorated child wrapped incontainer().max_width(260)no longer needs acolumn()-of-one shell just to access the cap. Propagated throughmap_msg, covered by a test, and documented under thecontainersection ofdocs/widgets.md.Text::no_truncate()opts out of the default ellipsis behaviour: whentruncate = falsethe draw pass paints the full string even ifmeasure(text) > rect.width. Useful for very short labels (calendar days "1"–"31", day-of-week stubs "Lu" / "Mi" / "Sá") inside grid slots whose width is dictated by the parent — a couple of pixels of overflow centred in the rect is invisible, while "..." in place of a single-digit number is loud.DatePicker::width(px)is a layout hint: when set,build()derivesheader_fs,dow_fsandday_fsfrom the slot width so the worst-case label in each row ("September 2026" in the header, "Mié" / "Sáb" in the DOW row, "30" in the day cell) fits at a sensible size; the design defaults stay as upper bounds. Day and DOW cells also flip onText::no_truncate()as a safety net — heuristic font sizing can't perfectly predict glyph widths across families, and overflowing one pixel beats truncating to "...".Pedro M. de Echanove Pasquin
2026-05-13 19:38:41 +02:00 -
96f437544a
event_loop: route
take_focus_requestto widgets on overlay surfaces Thetake_focus_requestblock intry_runonly searcheddata.main.widget_rectsfor the requestedWidgetId, so an app that wanted to focus a widget living on an overlay surface (a search field on a launcher overlay, a text edit on a dialog modal, a password field on a popup) silently no-op'd: the widget existed and had been laid out, but the lookup never found it because it was scanning the wrongwidget_rects. Crustace hit this trying to put cursor focus on the launcher search field when the launcher slides up — the field rendered with a visible caret but typing went to whoever the keyboard had been focused on before. The lookup now falls through todata.overlays.iter()if the main scan misses, returning the first(SurfaceFocus::Overlay(id), flat_idx)whose surface carries a widget with that id.data.set_focusalready accepts theSurfaceFocusdiscriminant, so the call site just forwards what the lookup found and requests a redraw on the matching surface instead of unconditionally on main. No effect on apps that target main-surface widgets — the main scan still runs first and short-circuits the overlay walk.Pedro M. de Echanove Pasquin
2026-05-13 13:47:01 +02:00 -
c3839060cc
theme, event_loop: window_controls.bar_bg slot + graceful exit on compositor disconnect
WindowControlsSpecgainsbar_bg, the background fill of the SSD title-bar strip the close / maximize / minimize controls sit on. Forge used to paint that strip withpalette.surfaceclamped to alpha 1.0 — a hack on a translucent panel token. With a dedicated slot the theme decides directly:@off-whitein light and a new@window-bar-darkshade in dark. Schema gets the matchingOption<String>field (defaulting topalette.surfaceto keep existing themes rendering the same) and the fallbackWindowControlsSpecseedsColor::WHITEso a missing theme still draws something readable.try_run's dispatch loop previously.expect("dispatch")-ed every calloop error. When the compositor closes the wayland socket —wl_display.error, forge crashing, the user logging out — the loop saw aBrokenPipe/ConnectionResetand panicked, which polluted the user's stderr with a backtrace for what is just "the session ended". The match now treats those twoIoErrorkinds as a clean shutdown: it printsConnection::protocol_error()(the typedwl_display.errorif the server sent one), setsexit_requested = true, and lets the loop drain out normally. Any other dispatch error keeps panicking — those are still genuine bugs.Pedro M. de Echanove Pasquin
2026-05-13 10:17:36 +02:00 -
dc781fb78d
event_loop: client binding for ext-foreign-toplevel-list-v1 Adds an
Appcallback that delivers the live list of open toplevels from the compositor — the data source a shell needs for dock running-app indicators, taskbar tiles, alt-tab and any other "what is currently running" UI. Hand-wiring the protocol binding from every shell that wants it is the kind of boilerplate ltk should absorb once: this is that move.Cargo.tomladds"staging"towayland-protocols' feature list. SCTK 0.20 already pulls staging in transitively (it carriesforeign_toplevel_list.rsand its own dispatch helper), so this is belt-and-braces against a future ltk tree that swaps SCTK for a different client toolkit — it keeps the protocol available crate-wide even then.src/app.rsintroducesToplevelEvent { Opened { id: u32, app_id: String }, Closed { id: u32 } }andApp::on_toplevel_event( &self, ToplevelEvent ) -> Option<Self::Message>with the default returningNoneso apps that do not care pay nothing (no allocation, no dispatch).idis the Wayland protocol id of the handle proxy — unique per session, stable for the handle's lifetime, the same value paired acrossOpenedandClosed.src/lib.rsre-exportsToplevelEventfrom the public prelude.src/event_loop/app_data.rsgrows apub foreign_toplevel_list: ForeignToplevelListfield.src/event_loop/mod.rsconstructs it viaForeignToplevelList::new( &globals, &qh )and stores it onAppData. If the compositor does not advertise the global the innerGlobalProxyjust resolves to "absent" and the list yields no toplevels — no error path needed at construction.src/event_loop/handlers.rsadds theProxyimport,delegate_foreign_toplevel_list!( @<A: App> AppData<A> )next to the rest, and implementsForeignToplevelListHandlerforAppData<A>. The three SCTK callbacks (new_toplevel,update_toplevel,toplevel_closed) each pull the handle's protocol id and its currently-cachedapp_idfrom the list's info cache, callself.app.on_toplevel_event( … ), and push the returned message ontopending_msgsso it flows through the normalupdatecycle with the regularinvalidate_afterscoping path.update_toplevelre-emitsOpenedwith the latest info — compositors fire this on title changes too, not justapp_idchanges, but apps whose state is keyed on(id, app_id)can absorb the repeat idempotently and apps that need title-change granularity can scope viainvalidate_after. The wire-up is generic: a shell that wants finer behaviour (focus follow, per-title indicators, multi-window grouping) can layer on top by translating the event into more specific app messages. The default app pays zero, the shell that opts in gets a real event stream without touchingsmithay-client-toolkitdirectly.Pedro M. de Echanove Pasquin
2026-05-11 22:30:29 +02:00 -
39fbafec24
container: generalise
backgroundfromColortoPaintContainer::backgroundnow storesOption<Paint>instead ofOption<Color>, and the builder acceptsimpl Into<Paint>so callers can pass a plainColor(auto-wrapped inPaint::Solidvia the trait impl) or an explicitLinearGradient/RadialGradient.layout_and_drawswitches fromcanvas.fill_rect( rect, bg, corners )tocanvas.fill_paint_rect( rect, &bg, corners )to consume the wider type. No behaviour change for existing call sites — solid-colour containers keep working unchanged thanks to theInto<Paint>forColor.Pedro M. de Echanove Pasquin
2026-05-11 12:20:28 +02:00 -
703a1ed228
event_loop: honour
App::window_size_hint()on the first configure Previously onlyset_min_size()was called with the requested size, on the assumption that the compositor would adopt it as the initial dimension. In practice that doesn't hold: xdg-shell has no "preferred initial size" primitive and compositors are free to pick any size within[min, max]on the first configure. The window ended up opening at the 800x600 fallback instead of the size the application had asked for. The fix pinsmin == maxbefore the first commit, which forces the compositor to honour the requested size in its first configure, and then releasesmax_sizefrom the configure handler via thepending_size_hint_unpinlatch so the surface remains user-resizable afterwards. The 800x600 fallback now only applies when the application does not provide a hint.Pedro M. de Echanove Pasquin
2026-05-10 23:16:17 +02:00 -
f3621b72c9
Added examples in README.md
Pedro M. de Echanove Pasquin
2026-05-10 15:23:22 +02:00 -
bbab5e238d
First commit. Version 0.1.0
Pedro M. de Echanove Pasquin
2026-05-10 09:58:23 +02:00 -
af105b7f7d
Initial commit
admin
2026-04-23 11:08:43 +02:00