Context
Pencuil's immediate-mode locality is useful, but its current API leaves common control, layout, identity, presentation, and invalidation work to each application.
The gameplay settings editor in Babka and the Pixely Hotbar tutorial expose the recurring friction:
Pencil.Button() registers interaction without drawing the button or advancing layout, so applications build buttons from Panel(), text measurement, and manual centering.Panel() always registers hover and click tests, even when used only as a background or decorative rectangle.- Layout frequently requires absolute
MoveTo() calls, manual text measurement, coordinate arithmetic, and saving/restoring Pencil cursor state around composed content. - Text and number fields accept raw integer IDs from a namespace shared by all views in a Pencuil root.
- Every
IPencuilViewModel exposes mutable IsDirty, and every state mutation must remember to set it.
Improve Pencuil itself while preserving immediate-mode authoring and rebuild-only-when-needed rendering.
Proposed improvements
Complete standard controls
Make Button() a complete control: draw its background, border, and text; expose its interaction state; support enabled state and fixed or content-derived sizing; and contribute its final bounds to the active layout.
Separate visuals from interaction
Use distinct operations for distinct intent:
Rectangle() or Fill() for non-interactive visuals;Button() for ordinary button behavior;HitArea() or an equivalent primitive for custom interactive regions such as hotbar slots.
Drawing a background must not silently register a full-window interactive area.
Add composable immediate-mode layout
Add layout scopes such as Row, Column, Padding, Align, and Overlay. A nested scope must correctly contribute its resulting bounds to its parent, so composing content does not require applications to preserve and restore CurrentPosition and CurrentSize.
The API should cover common fixed-size, content-size, stretch, gap, and alignment cases without introducing a retained element tree.
Scope control identity
Control IDs should:
- be scoped automatically by the current view;
- accept semantic keys such as enums without casts;
- support nested scopes for repeated controls;
- prevent collisions between independently composed views.
Add presentation conveniences
Directly support common operations that currently require measurement and coordinate plumbing:
- horizontal and vertical text alignment;
- wrapped text constrained to a maximum width;
- centering inside the current layout region;
- clipping and scrolling;
- enabled, hovered, pressed, and selected visual states.
Applications should rarely call MeasureText() merely to position content.
Encapsulate dirty tracking
Keep conditional rebuilding, but remove IsDirty bookkeeping from application state. Provide a value-based wrapper along these lines:
publicreadonlyrecordstructGameplaySettingsViewState(floatCharacterSpeed,floatSowingDuration);ViewModel<GameplaySettingsViewState>viewModel=new(initialState);viewModel.Value=viewModel.Valuewith{CharacterSpeed=editedSpeed};ViewModel<TValue> should require TValue : struct. Assigning Value should invalidate observers, backed by a monotonically increasing version rather than a consumable boolean. Each view tracks its own observed version, allowing multiple views to observe the same model safely.
Keep Value by value initially for assignment ergonomics. A ref readonly accessor can be considered later only if profiling demonstrates meaningful copies.
Actions and injected services remain separate from the state value; this is invalidation support, not mandatory MVVM or data binding.
Acceptance criteria
- The Babka gameplay settings editor no longer needs its custom button implementation, integer ID casts, manual text centering, or most absolute coordinate arithmetic.
- The Hotbar tutorial can compose an image inside each slot without saving and restoring Pencil cursor state.
- Decorative backgrounds do not participate in hit testing.
- Independently authored views and repeated control groups cannot collide through control IDs.
- A normal view model does not expose or manually maintain
IsDirty; whole-value assignment triggers rebuilding. - Two views observing the same value-based view model both see an update.
- Existing low-level drawing and custom hit-area composition remain possible.
Non-goals
- A retained-mode element tree.
- Widget reconciliation.
- Mandatory data binding.
- A comprehensive theme system before the standard control API is established.
These improvements can be implemented incrementally. Completing Button(), separating visual and interactive rectangles, and introducing scoped control identity are useful independently of the larger layout work.
Context
Pencuil's immediate-mode locality is useful, but its current API leaves common control, layout, identity, presentation, and invalidation work to each application.
The gameplay settings editor in Babka and the Pixely Hotbar tutorial expose the recurring friction:
Pencil.Button()registers interaction without drawing the button or advancing layout, so applications build buttons fromPanel(), text measurement, and manual centering.Panel()always registers hover and click tests, even when used only as a background or decorative rectangle.MoveTo()calls, manual text measurement, coordinate arithmetic, and saving/restoring Pencil cursor state around composed content.IPencuilViewModelexposes mutableIsDirty, and every state mutation must remember to set it.Improve Pencuil itself while preserving immediate-mode authoring and rebuild-only-when-needed rendering.
Proposed improvements
Complete standard controls
Make
Button()a complete control: draw its background, border, and text; expose its interaction state; support enabled state and fixed or content-derived sizing; and contribute its final bounds to the active layout.Separate visuals from interaction
Use distinct operations for distinct intent:
Rectangle()orFill()for non-interactive visuals;Button()for ordinary button behavior;HitArea()or an equivalent primitive for custom interactive regions such as hotbar slots.Drawing a background must not silently register a full-window interactive area.
Add composable immediate-mode layout
Add layout scopes such as
Row,Column,Padding,Align, andOverlay. A nested scope must correctly contribute its resulting bounds to its parent, so composing content does not require applications to preserve and restoreCurrentPositionandCurrentSize.The API should cover common fixed-size, content-size, stretch, gap, and alignment cases without introducing a retained element tree.
Scope control identity
Control IDs should:
Add presentation conveniences
Directly support common operations that currently require measurement and coordinate plumbing:
Applications should rarely call
MeasureText()merely to position content.Encapsulate dirty tracking
Keep conditional rebuilding, but remove
IsDirtybookkeeping from application state. Provide a value-based wrapper along these lines:ViewModel<TValue>should requireTValue : struct. AssigningValueshould invalidate observers, backed by a monotonically increasing version rather than a consumable boolean. Each view tracks its own observed version, allowing multiple views to observe the same model safely.Keep
Valueby value initially for assignment ergonomics. Aref readonlyaccessor can be considered later only if profiling demonstrates meaningful copies.Actions and injected services remain separate from the state value; this is invalidation support, not mandatory MVVM or data binding.
Acceptance criteria
IsDirty; whole-value assignment triggers rebuilding.Non-goals
These improvements can be implemented incrementally. Completing
Button(), separating visual and interactive rectangles, and introducing scoped control identity are useful independently of the larger layout work.