Skip to content

UI elements: progress, scroll, controls, images, menus, windows from code and drops - #16

Merged
iStorry merged 13 commits into
mainfrom
feat/ui-elements
Oct 20, 2026
Merged

iStorry merged 13 commits into
mainfrom
feat/ui-elements

Conversation

@iStorry

@iStorry iStorry commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Adds the UI elements a full app like a download manager needs. Each one reaches the checker, interpreter, compiled apps, both backends, FakeBackend, the LSP, the four highlighters, the reference docs, fixtures and the roadmap. Syntax decisions are recorded as rows 12–22 in language.md, and row 4 (named windows) is updated.

What's in it

# Feature Syntax Commit
1 Progress bars progress(value:); no value means busy 99d71bc
2 Scroll areas scroll { … } 0aa3612
3 Switch, slider, picker switch(on: $b, title:), slider($n, min:, max:), picker($s, options:) 212c9ce
4 Picking one of a few choices button(…, selected: cond) 0fd8bf0
5 Multi-line text fields text_field($t, lines: 3) 52b34e7
6 Images from files image(path, width:, height:); the path must be absolute or start with ~ 663bd58
7 Text field action text_field($q) { … }, run on Return 5a06a96
8 Menus and keyboard shortcuts menu("File") { menu_item("New", shortcut: "shift+n") { … } divider() menu("Sub") { … } } b17d457
9 Context menus context_menu() { … } as a child of a stack; the innermost stack's menu wins fddb21f
10 Windows from code window settings("Settings", open: false) { … }, open_window(settings), close_window(settings) 7ae831c
11 Dropping files and links drop_target($dropped) { … }: writes a [string], then runs the block 4e62fd7

Design decisions to review

  • Window names: the parser accepts ident ident(…) at the top level. Only window takes a name; any other element given one is an error.
  • When window changes happen: open and close requests apply after the event finishes. For each window the last request wins, and windows open before others close.
  • Quitting: the app quits when its last window closes, including when code closes it.
  • nf dev reload: open windows stay open and closed ones stay closed.
  • Drops: a drop replaces the bound list rather than appending to it. A text field under the pointer takes the drop as text.
  • Windows dependencies: nifty-windows now depends on windows-core directly, because the implement macro needs it.
  • Known limits, documented:
    • Sliders take whole numbers only.
    • Images that ship with an app aren't supported.
    • scroll takes the space left in its stack.
    • The Windows picker is a button that opens a popup menu.
    • On Windows the menu bar stays light in dark mode.
    • Multi-line fields have no placeholder.

Checked

  • make check passes: fmt, clippy (-D warnings) on macOS and with --target x86_64-pc-windows-msvc, build, cargo test --workspace, the tree-sitter corpus (28/28) and the VS Code grammar tests.
  • By hand on macOS, with a scratch harness:
    • progress, scrolling, switch, picker, slider and selected buttons
    • typing in multi-line fields; image sizes and missing images
    • Return running a field's action
    • menu bar layout and key equivalents
    • context menu routing, simulated through menuForEvent:
    • opening, raising and closing windows, and quitting after the last one
    • drops, simulated with a fake drag

Not yet checked by hand

macOS:

  • A real Control-click or right-click on a context menu.
  • A real drag from Finder and from a browser.
  • A text field action stopping Return from also pressing the window's .primary button.

Windows (nothing has run yet, only been linted):

  • Features 1–8:
    • progress, scroll and switch/slider/picker drawing
    • selected buttons and multi-line fields
    • images
    • the Return subclass
    • the menu bar and accelerators
    • the picker popup
  • Context menus with right-click, Shift+F10 and the Menu key (WM_CONTEXTMENU).
  • Closing with the close button and Alt+F4 (WM_CLOSE), and a Done button that closes its own window during its WM_COMMAND.
  • SetForegroundWindow bringing an open window to the front.
  • Drops from File Explorer, Edge, Chrome and Firefox (OLE IDropTarget).
  • File dialogs and WIC images, re-checked now that the backend calls OleInitialize instead of CoInitializeEx.

Follow-ups

  • Bump the Zed rev in editors/zed/extension.toml after this lands. The windows and drop commits change the Tree-sitter grammar.
  • Untested: a drop or menu action that destroys the host it came from, such as closing a window from its own context menu.

iStorry and others added 13 commits October 20, 2026 14:53
`progress(value:)` fills to an int percentage that stays up to date, kept
between 0 and 100; `progress()` animates to show work under way. It is
NSProgressIndicator's bar on macOS and the system's msctls_progress32 on
Windows, smooth with a value and a marquee without one.

Logic's `Text` becomes `Value`, since UI now shows numbers as well as
text, and gains `number`. Compiled apps read the percentage through the
template's `value` function; the runtime ABI goes to version 2. The
downloader fixture shows a bar per download, and the gallery shows both.
`scroll(...)` stacks its children top to bottom, like `column` without
`grow`, in the space its parent has left, and scrolls them up and down
when they're too tall. It adds nothing to its parent's size, as spacer
doesn't, so it belongs straight in a window or in a growing stack.

Widgets are now created inside their parent: Ui's constructors take the
Position they go at, and backends receive a Parent, the window or a
scroll area's content, so native views nest. Layout gives a scroll node
overflow and the scroll bar's width, reports frames relative to the scroll
content, and sizes that content. The width is asked at every layout, since
it changes with the user's settings; macOS lays windows out again when the
preferred scroller style changes.

macOS uses NSScrollView with a flipped document view. Windows gets a
NiftyScroll child window with a vertical scroll bar that always shows,
which hosts its controls, paints its own surfaces, moves its children with
ScrollWindowEx and passes their notifications on to the window. Surfaces
move to a Surfaces type both share. The gallery and the downloader's list
now scroll.
`switch(on: $value, title:)` turns a bool on and off with the switch at
the end of its title; it shares the checkbox's node and widget, told
apart by a ToggleStyle. `slider($value, min:, max:)` picks a whole number
in a range written literally, 0 to 100 by default. `picker($selection,
options:)` picks a string from a list that stays up to date.

Logic gains read_number, write_number and texts; the engine's input
handlers now share one dispatch. Binding errors name the parameter they
expect, such as `on: $name`. Compiled templates gain min and max, and
kinds for each control.

macOS uses NSSwitch beside a label, NSSlider and NSPopUpButton, whose
items go through its menu so equal titles stay. Windows draws Fluent's
toggle switch on a checkbox, a trackbar's track and thumb through custom
draw, and a picker as a button that opens a Win32 pop-up menu. The
syntax decisions are recorded in the language reference.
`button(title, selected: condition)` shows the button as selected while
the condition holds, so a row of buttons whose actions set one state picks
one of a few choices. Clicking never selects a button by itself; it shows
what `selected` says once the action has run. A `.primary` button can't
take it, being the window's default action. Logic gains `flag`.

This fits both platforms better than a segmented control, which Win32
doesn't have: macOS uses on-off push buttons, put back to the selection
after AppKit flips them on click, and Windows push-like checkboxes, which
screen readers announce as pressed, drawn as Fluent's toggle buttons. The
reasoning is a new row in the syntax decisions.

Icon-only buttons on macOS no longer keep their title in the button,
since setting it once shown drew it over the icon; it stays their tooltip
and accessibility label.
`text_field($value, lines: n)` makes a text field n lines tall, from 1,
the default, to 100. With more than one line it wraps, scrolls, and takes
Return as a new line; bound text ends its lines in \n on both platforms.
Neither platform's multi-line text box has a placeholder, so the checker
keeps `placeholder` to one-line fields.

macOS uses a text view in a bezel-bordered scroll view, sized from its
font's line height; Windows a multi-line edit control with a scroll bar,
whose \r\n line ends are turned into \n as the user types. Compiled
templates carry `lines`.
`image(path, width:, height:)` shows the image in a file as large as fits
its frame, keeping its shape; fixing one side sizes the other from the
image, and with neither it shows at its own size. The path is text that
stays up to date, so an image can show a file the app downloaded or the
user picked; `~` stands for the home folder. A file that can't be read
shows nothing, at the size given.

Paths must be full or start with `~`: bundling images with an app needs
`nf build` to carry them and a rule for finding them, which is left for
later, so a relative path written directly is an error. ImageSize, shared
by both backends, works out the frame and the fit.

macOS uses NSImageView, scaling proportionally. Windows reads images with
the Windows Imaging Component into memory and draws them into an
owner-drawn static control, scaled once per size and alpha-blended over
the surface under it. The gallery picks an image to show.
A one-line `text_field` takes an optional trailing block, run when the
user presses Return in it, in place of the window's primary button; a
field without one leaves Return to the primary button as before. A field
with `lines:` takes Return as a new line, so it can't have one. The
element table marks the block as an optional action, so the language
server completes the field without a block and treats one as an action.

macOS's text field delegate answers insertNewline: with the action and
reports it handled. On Windows the edit control gets a subclass that asks
the dialog manager for Return, runs the action on the key and swallows the
character that follows. The downloader starts a download from its address
field.
A top-level `menu(title) { … }` is a menu of the app's menu bar, holding
`menu_item(title, shortcut:) { action }`s, `divider()`s and nested menus.
A shortcut is written as a key after `shift+` or `alt+`, and always uses
the platform's command key: "n" is Cmd+N on macOS and Ctrl+N on Windows.
The checker refuses the system's own shortcuts (Quit and the editing
commands), and two items sharing one.

Elements now say where they may be written, so menu elements are refused
in windows and the language server offers each place only what fits it.
The runtime owns Key, Shortcut, SystemCommand and the generic Menu type,
which the checker uses for its menus too. Engine::start and reload take an
AppTemplate of windows and a menu bar, whose item actions share a frame.
Compiled programs describe their menus in the descriptor.

macOS builds the main menu: the app menu with Quit, the app's first menu,
the Edit menu from the same system commands, then the rest, with key
equivalents and a target that runs items by tag. Windows gives every
window a menu bar, keeping its content size, routes menu commands that
come with no control, and turns shortcuts into commands with an
accelerator table before the dialog manager sees the keys. The gallery
has a menu.
A context_menu() { … } child of a stack holds the same items, dividers and submenus as a menu bar menu, without shortcuts. It takes no room in layout and covers its whole stack; the innermost stack's menu shows. Its items run with the names around them, so each row of a list gets a menu for its own item.

macOS: the views widgets sit in answer menuForEvent: with the smallest hidden menu area under the click. Windows: hosts answer WM_CONTEXTMENU from their areas, from the mouse or the keyboard, with TrackPopupMenuEx; surfaces and menu areas share one Areas list.
A window takes a name between `window` and its title, as in `window settings("Settings", open: false) { … }`, and `open_window(settings)` and `close_window(settings)` open and close it once the event finishes; `open: false` keeps a window closed until code opens it. Opening an open window brings it to the front, the last request for a window decides, and windows open before others close.

The engine keeps a slot per window template and builds a window's content each time it opens. A user's close request now reaches the app through the backend (windowShouldClose: on macOS, WM_CLOSE on Windows), which closes the window the same way code does, so its widgets go with it. Reloading keeps open windows open and closed ones closed.

The downloader moves its folder into a settings window, opened from a button and a Settings… menu item.
A drop_target($dropped) { … } child of a stack takes files and links dragged onto it from other apps: a drop writes a path for each file and an address for each link into the bound [string], then runs the optional block. Like context_menu it covers its stack without taking room, the innermost stack takes the drop, and a stack has one.

macOS: the views widgets sit in are dragging destinations for file URLs and URLs once they hold a drop area, and find the innermost area under the drag, there or in the views around; context menus and drop areas share one lookup. Windows: hosts register an OLE IDropTarget, which reads CF_HDROP files and UniformResourceLocatorW links; the backend now initializes OLE rather than plain COM, and surfaces, context menus and drop areas share one Areas list.

The gallery takes a dropped image, and the downloader downloads dropped links.
Brings in loop indexes and ranges (#14). The loop checker, codegen and lowering now take
the shared loop head; UI syntax decisions are renumbered 13–23 after main's row 12; and
the roadmap lists #14 as supported and this PR as in progress.

Co-Authored-By: Claude <noreply@anthropic.com>
On Apple Silicon a direct call reaches only ±128 MiB. The JIT allocated each function
separately, so a call could land out of range and trip Cranelift's relocation assert,
failing failures_match_the_interpreter about one run in six. One reserved arena per
program keeps every call in range.

Co-Authored-By: Claude <noreply@anthropic.com>
@iStorry
iStorry merged commit 7f40a5b into main Oct 20, 2026
5 checks passed
@iStorry
iStorry deleted the feat/ui-elements branch October 20, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant