EmuSen.LunaP 0.10.0

dotnet add package EmuSen.LunaP --version 0.10.0
                    
NuGet\Install-Package EmuSen.LunaP -Version 0.10.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="EmuSen.LunaP" Version="0.10.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="EmuSen.LunaP" Version="0.10.0" />
                    
Directory.Packages.props
<PackageReference Include="EmuSen.LunaP" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add EmuSen.LunaP --version 0.10.0
                    
#r "nuget: EmuSen.LunaP, 0.10.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package EmuSen.LunaP@0.10.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=EmuSen.LunaP&version=0.10.0
                    
Install as a Cake Addin
#tool nuget:?package=EmuSen.LunaP&version=0.10.0
                    
Install as a Cake Tool

LunaP

A small Avalonia toolkit: a theme, a control kit, an application shell, window scaffolding that remembers where it was, and a fluent layout surface. It is the chrome around whatever your application actually does.

Named for Luna-P, Chibiusa's floating gadget ball, which becomes whichever tool is needed.

What it requires

Target framework net10.0
Avalonia 12.1.0, across the toolkit, the harness and the test project
Platforms Linux, Windows and macOS — the suite builds and runs on all three in CI
Licence MIT, as of 0.6.0
Dependencies Avalonia only (Avalonia, .Desktop, .Themes.Fluent, .Fonts.Inter, .X11, .Markup.Xaml.Loader)

net10.0 only, and that is a decision rather than an oversight. Avalonia 12.1.0 also ships net8.0, so LunaP is stricter than its own dependency. .NET 8 leaves support around 2026-11-10 and .NET 9 already has, so multi-targeting buys a dying LTS. docs/LunaP.md §34.2.

The rule it is built on

LunaP references Avalonia and nothing else.

That is not modesty, it is the thing that makes it usable. Every control takes plain data or a delegate — a meter row takes (string, double, string), a console pane takes a Func<string, string> — so nothing here can drag your domain model into a window, and nothing here needs to know what your program is for. Anything that would otherwise need a dependency arrives through a seam you fill in; ISettingsStore is the only one so far.

It was written inside an emulator project, where three applications consume it, and it left once that sentence became true. docs/LunaP.md §19 records what had to move for it to be true and §20 records the move. A test asserts it in both directions.

Installing

dotnet add package EmuSen.LunaP

The package id keeps the EmuSen. prefix from where it was written. It carries no dependency on anything of EmuSen's.

The test harness is a second package, referenced from your test project only, so that the rule above does not have to bend to accommodate xunit:

dotnet add package EmuSen.LunaP.Testing

Both ship from the same tag at the same version number, because the harness asserts about the toolkit's own controls and pairing two versions of them is a question nobody wants to answer.

The bootstrap

LunaApp.Configure<App>() replaces the AppBuilder chain a Program.cs usually spells out:

[STAThread]
public static void Main(string[] args) =>
    LunaApp.Configure<App>().StartWithClassicDesktopLifetime(args);

It applies the saved theme and picks X11 on Linux. That last part is not cosmetic: UsePlatformDetect does not choose X11 on a Wayland session, and a hand-rolled bootstrap that reproduces three quarters of this one is how that gets dropped silently. docs/LunaP.md §3.

The theme, if you are not using LunaApp:

public override void Initialize()
{
    var theme = new StyleInclude(new Uri("avares://YourApp/"))
    {
        Source = new Uri("avares://EmuSen.LunaP/Theme/LunaTheme.axaml"),
    };
    Styles.Add(theme);
}

Load it in your tests too. A headless pass that misses it asserts over untemplated controls and passes green, which is how a window that rendered nothing once shipped — §11 of the design record is the incident.

The control kit

Twenty-seven controls, every one of them in GalleryWindow and every one of them in the automation tree.

Text SectionHeader, HintText, MonoText, ErrorText — the four idioms the theme knows about
Fields FieldRow (label, hint, error), PathPickerRow, LunaSwitch, Dropdown, Tabs
Data LunaList<T>, LunaTable<T>
Readouts MeterRow, MeterList, StatusBar, EmptyState, RgbaImageView
Surfaces Card, SplitPane, SidePanel, ConsolePane, FilterBar
Commands MenuBar, ToolBar, ButtonBar, ActionButton, ActionToggle, ActionMenuItem

Stock Avalonia controls are themed too, as of 0.8.0. A TextBox, CheckBox, RadioButton, Slider, NumericUpDown, ComboBox, ProgressBar, ToggleSwitch or CalendarDatePicker you create yourself paints in LunaP's palette rather than FluentTheme's — you write new TextBox() and it fits. This is done by handing LunaP's colours to 51 of FluentTheme's own resource keys, so the templates, keyboard handling and accessibility behaviour are Avalonia's untouched, and a control added to Avalonia next year inherits it. A test shows every one of them in a live window and requires the colours it actually resolves to come from LunaPalette. docs/LunaP.md §48.

If you had restyled these controls yourself, your own styles still win: this changes resources, not templates.

Layout without a XAML file

Ui.Stack, Ui.Row, Ui.Dock, Ui.Cols, Ui.Rows, Ui.Scroll, Ui.Section, Ui.Button, Ui.Buttons, Ui.Header, Ui.Hint, Ui.Mono, Ui.Text.

Fluent setters, each returning what you gave it: .Wrap(), .Bold(), .FontSize(), .Width(), .Height(), .MaxHeight(), .MinSize(), .Margin(), .Spacing(), .Left(), .Right(), .Center(), .Grow(), .Dock(), .AtRow(), .AtColumn(), .Name(), .Visible().

Ui.Cols and Ui.Rows place each child in the next cell by position unless it already carries an explicit one, which is how a child spans while the rest fall where they are written. Ui.Dock follows DockPanel: the last child takes the remaining space, which is the usual source of surprise.

A field can be wrong and say so

FieldRow.Error shows a message under the field; empty means valid, and there is no separate IsValid to disagree with it:

new FieldRow
{
    Label = "Save State Folder",
    Hint  = "Where save states are written.",
    Error = Directory.Exists(path) ? "" : "That folder does not exist.",
    Content = new TextBox { Text = path },
}

FieldRow lends its label to whatever you put inside it, so the TextBox in a settings field is announced by the field's name without you doing anything.

Lists

LunaList<T> keeps hold of the type you gave it — you get the model back on selection, not a row index into a parallel array — and Refresh puts the selection back afterwards:

var peers = new LunaList<Peer> { Label = p => p.Handle, Key = p => p.Handle };
peers.Chose += peer => Open(peer);
peers.Refresh(await roster.All());   // selection survives the rebuild

Key defaults to reference identity, which is right for a cached model and wrong for rows rebuilt on every poll. Give it a key when your models are replaced rather than mutated.

Setting a value is not the user doing something

Every control in the kit that raises a "the user chose this" event holds to one rule: writing the value from code does not raise it. Dropdown.Chose is not raised by Fill, LunaList.Chose and LunaTable.Chose are not raised by Refresh or Select, PathPickerRow.PathPicked is not raised by setting Path, and FilterBar.Changed is not raised by setting SearchText.

This is what lets you restore saved state without it looking like input — a window that reopens with the last filter, sort and selection in place does not re-run the query that produced them. The one deliberate exception is documented where it lives: writing LunaList.SelectedIndex directly does raise Chose, because a direct index write is not a restore.

FilterBar.Changed only started honouring this in 0.10.0; before that, assigning SearchText raised it synchronously. If you have code that leant on the raise, call your handler yourself after setting the value. docs/LunaP.md §80.1.

Tables

LunaTable<T> is the same idea with columns — each one a header and a projection, so your model needs no attributes and no base class:

var fields = new LunaTable<Field> { Key = f => f.Name };
fields.Column("name", f => f.Name, "2*")
      .Column("type", f => f.Type)
      .Column("pg", f => f.Page.ToString(), "40");
fields.Refresh(detected);            // selection survives the rebuild

Everything below is additive and off by default. A table with no Children, no SelectionMode, no Commit, no FrozenColumns, no CanReorderRows and no VirtualizeColumns behaves exactly as it did in 0.7.0.

Sorting, alignment and editing

fields.Column(new LunaColumn<Field>("name", f => f.Name)
{
    Width    = "2*",
    Sort     = (a, b) => string.Compare(a.Name, b.Name, StringComparison.CurrentCulture),
    Commit   = (f, text) => f.Name = text.Trim(),
    Validate = (_, text) => string.IsNullOrWhiteSpace(text) ? "A field needs a name." : null,
});

fields.TableKey = "fields";          // remember widths and sort order
fields.Column(new LunaColumn<Field>("pg", f => f.Page.ToString())
{
    Width             = "40",
    Alignment         = HorizontalAlignment.Right,  // null - the default - changes nothing
    VerticalAlignment = VerticalAlignment.Center,
    Sort              = (a, b) => a.Page.CompareTo(b.Page),
});

fields.SortBy(2, descending: true);  // as though the heading had been clicked
fields.ClearSort();                  // back to the order you gave

The heading follows the column, so a right-aligned column of numbers does not sit under a left-aligned word. SortBy refuses a column with no Sort rather than falling back to sorting the displayed text, and a remembered layout still wins over a sort you set in code — what your user clicked last time outranks what your application declared this time. SortedColumn and SortedDescending read it back.

Sort compares the models, not the projected text, because "10" sorts before "9" otherwise. Commit null — the default — means the column is read-only. Validate returns the problem rather than a bool, and the message appears under the table; a rejected edit keeps the caret rather than throwing away what was typed. Double-click or F2 opens an editor, Enter commits, Escape cancels. Edit(item, column) opens one from code, and IsEditing says whether one is open.

A layout under TableKey is written on every change and flushed when the table leaves the visual tree; SaveNow() forces it.

Widths and visibility

fields.Column(new LunaColumn<Field>("type", f => f.Type)
{
    MinWidth  = 60,          // null - the default - is the Grid's own 0 and infinity
    MaxWidth  = 200,
    IsVisible = showTypes,   // hides it WITHOUT moving any index
});

A hidden column keeps its place, so a remembered layout, a sort and Edit(item, 2) all still mean what they meant.

Rows, gestures and lifecycle

fields.SelectionMode = LunaSelectionMode.Multiple;  // None, Single (default), Multiple
fields.GridLines = LunaGridLines.All;               // None (default), Horizontal, Vertical, All
fields.EditGestures = LunaEditGestures.F2 | LunaEditGestures.DoubleTap;

fields.RowPrepared += (row, container) => { };      // realised
fields.RowClearing += (row, container) => { };      // recycled
fields.CellValueChanged += (row, column) => { };    // a commit or a toggle wrote

fields.BringRowIntoView(row);
fields.TryGetRow(row, out Control? visual);
fields.TryGetCell(row, 2, out Control? cell);

EditGestures is a set rather than a mode because the gestures compose. The two TryGet methods answer false for a row that is not currently realised rather than forcing one into existence — which is why BringRowIntoView exists. There is deliberately no CellPrepared/CellClearing: it would fire per cell per row per realization, and the two things it is wanted for are already a template column and a projection.

A gutter down the left

fields.RowHeader = (_, i) => (i + 1).ToString();   // or (row, _) => row.Address.ToString("X4")
fields.RowHeaderCaption = "#";
fields.RowHeaderWidth = "48";

RowHeader takes the row and its displayed index. The gutter stays put when the table scrolls sideways, whatever FrozenColumns says, because a row label that scrolls away leaves your user reading a line of values with nothing to say which row it belongs to.

Scrolling sideways, and frozen columns

Columns wider than the table scroll sideways, and the header follows. This is a fix rather than a feature: before 0.8.0 the columns past the right edge were resolved, clipped and unreachable by scrollbar, wheel or keyboard. Star-width columns — the default — fit by definition and never scroll.

fields.FrozenColumns = 1;            // the gutter is always pinned; this counts your columns

Counted in the columns you declared, in the order you declared them, so a hidden column takes one of the places. A band that would not leave room for the columns behind it pins nothing rather than making them unreachable, and comes back by itself when the window is widened — so a table that suddenly stops pinning is a table that is too narrow, not a bug. There is a line where the pinning stops; it takes LunaBorder, and Border.frozen-edge restyles it.

FrozenColumns is deliberately not remembered with the widths and the sort order: those are what your user did, and this is what you declared. If you offer it as a "Freeze first column" menu item, remember it in your own settings.

Building only the columns in view

fields.VirtualizeColumns = true;     // off by default

Worth it when a table scrolls sideways past many columns, and worth leaving off otherwise. Measured on 120 columns of 120 pixels in an 800-wide viewport, where 6.7 of them are visible: a refresh went from 42.7ms to 6.0ms, and one row held eight cells instead of 120 (§72.1).

Two things to know before you turn it on. Only fixed-width columns are ever left out — an Auto or star column takes its width from its content, so dropping its cells would change how wide it is, and frozen columns are on screen at every offset by definition. A table of star columns therefore gains nothing, which is the same table that never scrolls sideways anyway. And a column that is not built has no cell, so TryGetCell answers false for it and a screen reader walking cells does not reach it — the same trade row virtualization has always made for a row scrolled away. Editing and the arrow keys are unaffected: both bring a column back before they go looking for it.

What does not change is the sentence a screen reader hears for the row, which is built from your columns rather than from the cells that happen to exist.

Dragging rows into a new order

The table changes nothing itself — it tells you where the drop landed and you move your own rows:

fields.CanReorderRows = true;
fields.RowDropped += drop =>
{
    foreach (Field moved in drop.Rows) schema.Remove(moved);

    int at = drop.Target is null ? schema.Count : schema.IndexOf(drop.Target);
    if (drop.Position == LunaDropPosition.After) at++;

    schema.InsertRange(at, drop.Rows);
    fields.Refresh(schema);
};

Alt+Up/Alt+Down moves the selected row without a pointer. CanDrop refuses a drop before the indicator promises it. In a tree, dropping into the middle of a row reports Inside — a reparent rather than a reorder. Dragging a row inside a multi-selection takes the whole selection.

This is pointer capture rather than the platform's drag-and-drop, so a row can be reordered inside its table but cannot be dragged out of it into another control.

Selecting cells instead of rows

Two properties, because how many and what kind are different questions:

fields.SelectionUnit = LunaSelectionUnit.Cell;      // Row is the default
fields.SelectionMode = LunaSelectionMode.Multiple;  // and this still means how many

fields.CellChosen += cell => Show(cell?.Row, cell?.Column);

Arrow keys walk the columns, Home and End go to the ends, Shift extends a rectangle and Ctrl+click adds one cell at a time. SelectedCell is the current one, SelectedCells is every one in display order, and SelectedItems still answers with the rows those cells are in. IsCellSelected, SelectCell and ClearCellSelection drive it from code. F2 opens the cell you are on rather than the first editable column.

A cell coordinate is (your model, column index) — not two positions — so it survives a Refresh that rebuilds every object, exactly as the row selection does. Changing the unit clears the selection: a row has no column to become.

A cell does not have to be text

A checkbox column takes a boolean projection and, optionally, somewhere to write it back; a template column takes a control and the sentence a screen reader hears in its place, which is required rather than optional because a coloured dot describes itself to nobody:

fields.Column(new LunaColumn<Field>("req", f => f.Required, (f, on) => f.Required = on)
      {
          Width = "40",                // read-only if you leave the writer off
      })
      .Column(new LunaColumn<Field>(
          "kind",
          f => new Ellipse { Width = 8, Height = 8, Fill = ColourFor(f.Type) },
          f => f.Type));                // what a screen reader hears instead

Both are ordinary constructors, so Width, Sort, MinWidth, IsVisible and the rest apply exactly as they do to a text column. A Toggle that declines to write leaves the tick where it was — the table re-reads your model rather than trusting the box. What it cannot do is say why, which a text column's Validate can.

A template cell you gave a width to starts at the column's left edge like every other cell. One you did not still stretches to fill the column, so a progress bar or a background band works as you would expect — and an alignment you write yourself always wins.

A table can be a tree

One projection, and null — the default — is a flat table:

files.Children = node => node.Kids;   // return empty for a leaf; null is a flat table
files.ExpanderColumn = 0;             // which column carries the toggle
files.IndentSize = 16;
files.ExpandAll();                    // Expand, Collapse, CollapseAll, IsExpanded

A projection rather than an interface, so your model needs no base class and no knowledge that LunaP exists — one that keeps its children elsewhere writes n => index[n.Id], which an interface could not express. Sorting applies at every level, so a tree stays a tree rather than becoming an alphabetical list of everything; expansion is keyed by Key, so it survives a Refresh that rebuilds every object; and a Children that returns an ancestor is dropped rather than overflowing the stack.

If you want a full data grid

Avalonia.Controls.TreeDataGrid is the one to reach for — but check docs/LunaP.md §27.1 and §54.5 first, because it requires a paid Avalonia Accelerate licence that fails the build, not the run, in your project. LunaP therefore does not depend on it, and LunaTable<T> closed all thirteen gaps that decision was measured against (§73).

Images and frames

RgbaImageView shows a raw RGBA buffer and reuses its bitmap across frames. It takes them from wherever you already have them, so a frame in native memory is not copied into a managed array just to be copied straight back out:

view.SetFrame(pixels, w, h);                           // byte[]
view.SetFrame(buffer.Slice(offset, w * h * 4), w, h);  // ReadOnlySpan<byte>
view.SetFrame(core.FrameBufferAddress, w, h);          // nint, unchecked - you promise the size

Stretch defaults to Stretch.None, which does not scale at all — one bitmap pixel to one layout pixel, which is what a pixel-accurate view wants. Set IntegerScale = true with a scaling Stretch to enlarge by whole numbers only and centre the result; a fractional factor makes nearest-neighbour duplicate some rows and not others, which shimmers when anything moves. docs/LunaP.md §53.

Commands, menus and a shell

A LunaAction is one command — a label, a shortcut, an enabled state, a handler — and every surface you put it on follows it. Disable the action and the menu entry, the toolbar button and the keystroke all go with it:

var open = new LunaAction("Open ROM...", () => Load())
{
    Shortcut = KeyGesture.Parse("Ctrl+O"),
    HelpText = "Chooses a ROM to load.",
};
var grid = new LunaAction("Grid", self => ShowGrid(self.IsChecked)) { IsCheckable = true };

var window = new AppWindow { Title = "Studio", WindowKey = "main" };
window.SetMenus(new LunaMenu("File", open, LunaAction.Separator(), quit));
window.SetToolBar(open, grid);
window.AddPanel(new SidePanel { Title = "Explorer", Side = PanelSide.Left, PanelKey = "explorer" });
window.Central = editor;
window.Status = "Ready.";

SetMenus and SetToolBar also bind the shortcuts, which is a separate act from showing them: MenuItem.InputGesture draws "Ctrl+O" in the menu and binds nothing at all, so a hand-built menu can advertise a key that does nothing. Claim one key twice and LunaP says so through LunaSettings.Diagnostics rather than letting the second command quietly never fire.

A group of actions can be exclusive. ActionGroup is Qt's QActionGroup: adding an action makes it checkable and ticking one unticks the rest. An action already in another group is refused rather than silently moved.

var speed = new ActionGroup();
var half  = speed.Add("50%",  _ => SetSpeed(0.5));
var full  = speed.Add("100%", _ => SetSpeed(1.0));

speed.Checked = full;    // checks that one, unchecks the rest, runs no handler
speed.Checked = null;    // unchecks everything

Checked both reads and writes, and writing it runs no handler — so a window that shows the current selection cannot apply it just by displaying it. Assigning an action that is not a member throws rather than joining it silently.

A menu can nest. LunaAction.Submenu takes a LunaMenu, so a "Recent Files" entry is an action carrying its own menu rather than a second kind of object.

Menus without an AppWindow. Menus.Context(actions) builds a ContextMenu, Menus.Items(...) builds the controls, and Menus.BindShortcuts(target, ...) / Menus.Unbind(...) bind and release the keystrokes on any InputElement.

A panel's ToggleAction is the View-menu entry for it, and it is the same object as its close button — so the tick and the panel cannot drift apart. AppWindow.PanelToggles() hands you every one of them for a View menu. SplitPane gives you a draggable divider that remembers where it was left, in pixels, under an opt-in PaneKey; Orientation, Fixed, FixedSize, MinFirst, MinSecond, SplitterThickness and DividerLabel shape it.

Windows

ToolWindow is the base: a WindowKey to remember geometry under, ClosesOnEscape, and the theme's restyle hook. PollingWindow refreshes on a cadence and stops while hidden. MessageWindow and Dialogs cover the rest — ConfirmAsync, ErrorAsync, PickFileAsync, PickFolderAsync and SaveFileAsync, all returning paths rather than storage items. WindowSlot<T> holds a one-at-a-time window, with RefreshIfOpen for the case where it is not.

A window that remembers its own geometry:

public class SettingsWindow : ToolWindow
{
    public SettingsWindow()
    {
        Title = "Settings";
        WindowKey = "settings";   // opt in; without a key nothing is remembered
        Content = Ui.Stack(8, Ui.Header("Audio"), Ui.Row(6, volume, mute));
    }
}

A defect fixed in 0.8.0, and it reaches anybody with a WindowKey. A window closed while maximized or full screen, with nothing stored from a previous run, saved the screen's bounds as its own restored size — so it reopened the size of the display with its title bar off the top. It now records the flag and no geometry. The maximized half of that had been present since 0.2.0.

Any window can go full screen, and coming back out returns it to the state it came from rather than always to a normal window:

window.ToggleFullScreen();               // or: window.IsFullScreen = true
window.FullScreenChanged += on => full.IsChecked = on;

IsFullScreen is read from the window rather than stored beside it, so it stays right when the platform's own full-screen affordance is what moved it. That is also why FullScreenChanged exists: a checkable menu item that kept its own tick would say the opposite of the window the first time somebody used a window-manager shortcut. F11 is yours to bind — LunaP does not claim a function key.

A window closed while full screen reopens as an ordinary window at the size it had before, and full screen is deliberately not remembered while maximized is: a window that reopens maximized still has its title bar and its close button, and one that reopens full screen has neither, with the key that would let it out bound to whatever you chose. Set IsFullScreen at startup if you want it back.

The pointer can get out of the way when it has been still for a while, which is what a full-screen anything wants:

_idle = new IdleCursor(this);                            // the whole window, three seconds
_idle = new IdleCursor(screen, TimeSpan.FromSeconds(1)); // or just the framebuffer

It attaches to any control rather than being a flag on the window, because "hidden over the video and visible over the toolbar" is the common case and a window-level switch cannot say it. Hide() and Show() drive it directly — a window entering full screen wants the pointer gone at once rather than in three seconds — and Show() is also the seam for your own idea of activity, a gamepad or a media player leaving playback. IsHidden and HiddenChanged read it back.

Dispose it. The cursor comes back on disposal, and one left hidden by an object nobody unsubscribed is an application whose pointer is gone for good.

Only pointer movement counts as activity: keystrokes deliberately do not, or an application somebody is holding four keys down in would never hide it at all. A child that sets its own cursor keeps it, so the pointer reappears over a sortable table heading.

Files can be dropped onto any control, arriving as local paths:

_drop = new FileDrop(this, paths => Load(paths[0]));
_drop.Accept = paths => paths.Count == 1;      // refuses while the drag is still moving

Avalonia already extracts the files; what this removes is four lines of wiring with two silent failures in them — forgetting AllowDrop, so no drag event is raised at all, and forgetting to set an effect in DragOver, so the platform refuses the drop before your handler is reached. Neither produces an error or a mark on screen. Dispose it, and whatever AllowDrop you had is put back.

Paths rather than storage items, to match Dialogs. A file with no local path — out of a remote share, or a virtual file from an archive viewer — is not offered, and a drop carrying nothing else is refused rather than delivered empty.

What LunaP deliberately does not wrap, because Avalonia already does it well: Window.Topmost, Window.Icon, TopLevel.Clipboard (with TryGetText and TryGetFiles), and ExtendClientAreaToDecorationsHint for a borderless window. A toolkit may not charge a name for a property that already exists.

Threading

UiThread marshals onto the UI thread — Run inline when it is already there, Post otherwise, IsCurrent to ask. Latest<T> takes a fast producer and presents the newest value through one callback. Suppressor stops a control's own change handler answering back while you write to it, counting rather than flagging so nesting works. Debounce collapses a burst — Poke, Flush, Cancel, IsPending.

All four were things applications kept writing by hand; docs/LunaP.md §22 has the counts, and §22.1 has a bug that turned up while generalising one of them.

Settings

LunaP remembers four things, each opt-in: window geometry in windows.json, split and panel sizes in panes.json, table widths and sort order in tables.json, and the chosen theme in luna.json. Where those go is yours to decide:

LunaSettings.Store = new JsonSettingsStore("/path/to/your/config");
LunaSettings.Diagnostics = message => logger.Warn(message);

Set nothing and it writes indented JSON under ApplicationData/<your entry assembly>. Implement ISettingsStore — three methods — if you keep settings somewhere that is not a directory of JSON files.

Under a test runner it does something different, on purpose. The entry assembly there is testhost, which is the same name for every project on the machine — so settings would land in one shared folder that every other repository's test suite also reads and writes. Instead the store roots itself under your test project's own bin directory and reports where through Diagnostics. A name you pass explicitly is always honoured. See §43.

Diagnostics is where "this file would not load, and why" goes. Loading is best-effort and falls back to defaults either way; the hook only stops it happening in silence.

Light and dark

LunaP is dark by default, and that is a decision rather than the only option: the palette carries a light column too, keyed by theme variant.

LunaTheme.Variant = ThemeVariant.Default;   // follow the desktop
LunaTheme.Variant = ThemeVariant.Light;     // always light

Set it before LunaApp.Configure, which applies it. The default is Dark and stays there on purpose — every consumer of this toolkit has been dark since it existed, and following the desktop by default would mean an application looking different after a version bump its author took for something else.

It matters that the two agree. LunaTheme.axaml includes a bare <FluentTheme/>, which follows the system variant whatever LunaP does; leaving the palette fixed while Avalonia's own controls moved is what put dark text on a dark surface for anybody on a light desktop. docs/LunaP.md §23 has the measurement.

Every light foreground is held to 4.5:1 against the light surface by a test. LunaMuted on the dark surface measures 4.22:1, below that floor; it predates the light column, it is recorded rather than quietly adjusted, and §23.4 says why.

Themes

A theme is a resource dictionary of palette keys, written as .axaml or as CSS, dropped in the directory LunaTheme.Directory points at. LunaTheme.Available() lists them, LunaTheme.Apply(name) applies one, LunaTheme.Current and LunaTheme.Saved read the state back, and the built-in palette is the fallback under everything.

Seventeen colour tokens, each spelled as a brush and a colour (LunaSurface / LunaSurfaceColor, and so on):

Surfaces LunaSurface, LunaInputSurface, LunaVoid, LunaBorder
Text LunaText, LunaMuted, LunaSectionHeader, LunaMeterText
Status LunaError, LunaWarning, LunaSuccess, LunaInfo
Accent LunaAccent, LunaOnAccent
Load ramp LunaNominal, LunaBusy, LunaHot

Plus LunaMonoFont, LunaHeaderFontSize and LunaHintFontSize. The load ramp has a C# side too: LunaPalette.ForLoad(percent) and LevelFor(percent), over thresholds of 60% and 85%.

The palette is spelled twice on purpose — Palette.axaml for XAML, LunaPalette.cs for controls built in C# — and a test resolves every key from the live application and asserts it equals the C# field, so adding a colour to one half and not the other fails immediately.

The CSS form exists because a palette is a list of colours and XAML is a heavy way to write one:

:root { --luna-surface: #1E1E1E; --luna-accent: #007ACC; }
section-header { color: var(--luna-section-header); font-size: 15px; }
console-pane .output { font-family: "JetBrains Mono"; }

The whole vocabulary is enumerable rather than something to guess at: CssTheme.ElementNames gives the 22 element names a rule may target, PartsOf and StatesOf their parts and states, PropertyNames the six property names (color, background, background-color, font-family, font-size, font-weight), and CssTheme.TokenNames the 20 --luna- tokens a :root block may set. Anything outside it — an unknown element, an unknown property, or a misspelled token — is reported through CssThemeResult.Warnings rather than silently ignored. Token names were not checked before 0.10.0, so --luna-surfce used to parse, do nothing, and say nothing. docs/LunaP.md §12.2 is the format and §79.4 is that fix.

One behaviour worth knowing if you write a theme switcher: mutating Application.Styles at runtime strips every already-realized control of its styling, LunaP's own included. LunaTheme.Restyle(root) detaches and reattaches the content, which is what re-runs the style pass. §12.3 is the finding.

Accessibility

Every LunaP control reports itself to the automation layer, and names itself from the property it already had — a MeterRow from Label, an EmptyState from Message, a StatusBar from Status.

Where the toolkit cannot know what a control is about — a MeterList, an RgbaImageView — it says nothing rather than guessing, and that is where you come in:

using EmuSen.LunaP.Fluent;

new RgbaImageView().AccessibleName("Game screen")
new Dropdown().AccessibleName("Console")
new Button { Content = "Prune" }.HelpText("Deletes every cheat for the selected system")
new TextBox().LabeledBy(theLabelYouAlreadyDrew)
new Border().Decorative()                       // out of the control view entirely
new TextBlock().LiveRegion()                    // announce when it changes

Anything you set wins over the control's own name, so a toolkit default never overrides your decision. StatusBar is a polite live region by default — set AutomationProperties.LiveSetting to Off if yours updates continuously.

LunaTable<T> goes further, because a table is where a reader most needs it. It reports itself as a data grid with a selection and a scroll pattern behind the claim, so a reader can ask what is selected and move a table bigger than the window. Each row announces as a sentence built from its own cells — "name: Site, type: text, pg: 1" — and each cell is named for its column, with its value coming from the pattern it carries. Rows expose ISelectionItemProvider and editable cells expose IValueProvider, so a reader can select a row and set a cell — going through your Validate first, exactly as typing does. A template column's spoken sentence is what its cell says, which is why that argument is required rather than optional.

LunaAutomationPeer is public, so a control of your own can join the tree the same way.

Testing your own windows

EmuSen.LunaP.Testing is the harness this project tests itself with, as a separate package so the toolkit itself keeps referencing Avalonia and nothing else:

dotnet add package EmuSen.LunaP.Testing
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
[assembly: CollectionBehavior(DisableTestParallelization = true)]

public class TestAppBuilder
{
    public static AppBuilder BuildAvaloniaApp() => LunaHeadless.BuildApp();
}

[Fact]
public Task The_settings_window_lays_out() => UiTest.Run(() =>
{
    var window = new SettingsWindow();
    window.Show();
    UiTest.AssertLaidOut(window, "settings");
});

AssertLaidOut is the one that earns its keep: a window that failed to lay out, or whose controls have no template, renders as one flat colour, and counting distinct colours catches that where walking the logical tree does not.

DisableTestParallelization is required, and the harness refuses to start without it. Every test shares one headless application and several statics around it are process-global, so running test classes concurrently lets one class's constructor overwrite another's state mid-assertion — which presents as a suite that is green on your machine and red on CI. docs/LunaP.md §20.2 is the failure that taught us, §22.8 is why the refusal is loud rather than documented.

The harness has its own README, shipped in its own package.

Limitations

Collected here rather than left to be discovered. Each is a decision or a measured gap, and each is argued where the work happened.

The shell (§26.12). No floating or re-dockable panels, no tabbed dock groups, no MDI — one panel per side, and a second on the same side replaces the first. No icons anywhere, so a toolbar is a row of words; this needs an icon system rather than a property. No vertical toolbar, no split buttons, and no overflow chevron when a toolbar is wider than its window — it clips. No tooltip on a menu item, only on toolbar items. No native macOS menu bar: Avalonia has NativeMenu and MenuBar does not use it, so a macOS application gets an in-window menu strip where the platform expects one at the top of the screen.

The table (§73.2). No CellPrepared/CellClearing. A row cannot be dragged out of its table. A tree row exposes no IExpandCollapseProvider — its expander is a real focusable button named "Expand <row>", which is the capability without the pattern. FrozenColumns is not remembered with the widths and the sort.

Accessibility (§24.4). Everything above is measured against Avalonia's automation tree, not against a running screen reader — no Orca, NVDA or VoiceOver has been run against any of it. ConsolePane cannot announce line by line: its output is one text block, so a live region there would re-read the whole buffer on every append. FieldRow.Error reaches a reader that visits the field, but nothing interrupts a reader who has moved on (§49.3).

Themes. The CSS vocabulary covers 22 elements, and LunaTable<T> and LunaList<T> are not among them — a CSS theme reaches the rest of the kit and not those two. A theme rule that parses cleanly but matches nothing at runtime is silent: the parse cannot know what is on screen, and the sweep that catches it runs at test time in this repository, not in a host loading a bad theme (§30.5).

Contrast. LunaMuted on the dark surface measures 4.22:1, under the 4.5:1 the light column is held to. Recorded rather than quietly adjusted (§23.4).

Trimming and AOT (§36). Measured, not fixed. Trim-safe is reachable; AOT-safe is not while the default settings store is reflection JSON and themes are loaded from .axaml at runtime.

Process-global statics. LunaSettings.Store, Diagnostics and the applied theme's resource dictionary are static, so every consumer test suite inherits the parallelisation hazard the harness refuses to start under. That is structural, not a bug in your suite (§21.3).

Not built, with reasons (§77.3): keeping the display awake, single-instance, and a custom title-bar control. The first is the one with real value here and it needs three untestable platform paths; the second is process coordination wearing a window service's clothes, and the half of it that is easy is the half nobody wants.

Unexercised. RgbaImageView.Blit reads RowBytes and copies row by row when a framebuffer's stride is padded, but no backend measured here pads — the loop's arithmetic is verified by forcing the branch; the padded case itself is not. First place to look if an image comes out sheared (§53.2).

Building and testing

dotnet build
dotnet test

880 tests, all headless — no window is ever put on a screen, including for the render tests, which drive a real Avalonia control tree through a real Skia pass. That figure is checked by the suite itself, because a hand-written count of a thing the runner knows is a number that rots: this one said 207 for four releases, and its replacement went stale within the hour (§79.7). The suite runs serially on purpose; docs/LunaP.md §20.2 is the race that taught us why. CI runs the same suite on Linux, Windows and macOS, and packs both packages on every push so a missing README is found before a tag rather than after.

The assertion that earns its keep is AssertLaidOut: a window that failed to lay out, or whose controls have no template, renders as one flat colour, and counting distinct colours catches that where walking the logical tree does not. Set EMUSEN_UI_DUMP to a directory to get a PNG of every capture in the run.

Pixel-exact baselines are opt-in behind EMUSEN_UI_BASELINE, because they are an artefact of one machine's font rendering. docs/LunaP.md §10.2 explains what AssertStable is for and the trap it encodes.

The public API surface is written down in tests/EmuSen.LunaP.Tests/ApiSurface/, and a test fails the build when it changes. Regenerate with EMUSEN_API_APPROVE=1 dotnet test and commit the baseline — the diff on that file is the review. §32.

Releasing

Maintainers only; a consumer needs nothing from this section.

Tag it, and the workflow does the rest:

git tag v0.8.0
git push origin v0.8.0

The published version comes from the tag, not from the .csproj. A version written in two places will eventually disagree with itself, and the failure mode here is one this project has already been bitten by: NuGet caches by package id and version, so a package published under a version somebody has already restored is a package nobody receives. The <Version> in each csproj stays as the default for a local dotnet pack and nothing more — which also means it is not evidence that anything shipped. 0.7.1 was prepared, written into both csproj files and given a changelog entry, and never tagged; its fix has never reached a consumer.

There is no API key and no repository secret. Publishing uses NuGet Trusted Publishing: the job asks GitHub for a short-lived token proving which repository and which workflow file is running, and nuget.org exchanges it for a key valid for minutes. Nothing long-lived is stored, so there is nothing to leak or rotate.

The trust policy lives on nuget.org under Account → Trusted Publishing and names four things that must match publish.yml exactly — publisher (GitHub Actions), repository owner (RedQuE3n, the GitHub login), repository (EmuSen.LunaP), and workflow file (publish.yml). Renaming that file breaks publishing, which is the point: the file name is part of what is being trusted.

The workflow runs the suite before it packs. A package that was never tested is a package whose first user is testing it for you.

Documentation

CHANGELOG.md is the consumer's account of what a version bump means to somebody who cannot patch it.

docs/LunaP.md is the design record: what each part is, what was tried and rejected, and the findings that cost something to learn. It is kept from the first commit and has not been tidied to look like the toolkit was always general — §1's layering rule is stated three different ways as the question it was answering changed, and that is the useful part. Where it and the code disagree, the code is the truth and the document is the history.

Licence

MIT, as of 0.6.0. Link it into anything, including a closed application. Every dependency of the toolkit is MIT too, so nothing here hands you a term the licence on the tin does not mention.

Versions 0.2.0 through 0.5.0 were published GPL-3.0-or-later, and remain so — a package already on nuget.org cannot have its metadata changed, and a grant already made cannot be withdrawn. If you are on one of those, take 0.6.0 or later: it is the same code under a licence that asks less of you. §25 is the reasoning.

EmuSen.LunaP.Testing is MIT too, and links xunit.assert, which is Apache-2.0. It is referenced from a test project, so nothing your application ships carries it.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on EmuSen.LunaP:

Package Downloads
EmuSen.LunaP.Testing

Headless test harness for EmuSen.LunaP: render capture, layout assertions and visual-tree queries.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.10.0 120 8/19/2026
0.8.0 106 8/16/2026
0.7.0 102 8/13/2026