docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md
This document proposes a minimalistic, incremental solution to unify UI patterns across the editor core and plugins by extracting and generalizing the existing controls library.
The codebase already has well-designed patterns that should become the unified framework:
src/view/controls/)A mature control library with consistent patterns:
controls/
├── mod.rs # Exports, FocusState, ControlColors
├── button/ # ButtonState, ButtonLayout, ButtonColors, render_button
├── dropdown/ # DropdownState, DropdownLayout, DropdownColors, render_dropdown
├── number_input/ # NumberInputState, NumberInputLayout, ...
├── text_input/ # TextInputState, TextInputLayout, ...
├── text_list/ # TextListState, TextListLayout, ...
├── map_input/ # MapState, MapLayout, ...
├── toggle/ # ToggleState, ToggleLayout, ...
└── keybinding_list/ # KeybindingListState, KeybindingListLayout, ...
Each control follows the pattern:
*State - Data and behavior (selection, open/closed, value)*Colors - Theme-derived colors with from_theme()*Layout - Hit testing info with contains(), is_*(), *_at() methodsrender_*() - Returns *Layout for hit testingShared types:
FocusState enum: Normal, Focused, Hovered, DisabledControlColors - Common color schemesrc/view/settings/)Uses the controls library with additional orchestration:
SettingsLayout - Aggregates all control layouts + button areasControlLayoutInfo - Maps control types to their specific layout structsSettingsHit enum - Unified hit test result typeScrollablePanel - Generic scrollable container with ScrollItem traitsrc/view/ui/scroll_panel.rs)Reusable scrolling abstraction:
ScrollItem trait with height() and focus_regions()FocusRegion for sub-focus within itemsScrollState for scroll positionScrollablePanelLayout<L> with item layouts| Component | Uses Controls? | Uses Generalized Layout? | Problem |
|---|---|---|---|
| Settings UI | Yes | No - has its own SettingsLayout, ControlLayoutInfo | Good patterns but not reusable |
| Menu | No | No - manual hit calc | Duplicates width calculation |
| Tabs | No | Partial - returns Vec | Different return type |
| Status Bar | No | Partial | Ad-hoc layout |
| Plugin (pkg.ts) | No | No | Manual byte offset calculation |
The goal:
*Layout patternExtract patterns from Settings into shared modules, then all components use them:
┌─────────────────────────────────────────────────────────────────────┐
│ src/view/ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ ui/ (SHARED LIBRARY) │ │
│ │ │ │
│ │ layout.rs - point_in_rect() helper │ │
│ │ focus.rs - FocusManager<T> │ │
│ │ menu.rs - MenuLayout, MenuHit, MenuRenderer │ │
│ │ tabs.rs - TabLayout, TabHit, TabsRenderer │ │
│ │ scroll_panel.rs - ScrollItem, ScrollablePanel │ │
│ │ scrollbar.rs - ScrollbarState │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ controls/ (EXISTING) │ │
│ │ Button, Dropdown, Toggle, TextInput, NumberInput, ... │ │
│ │ Pattern: *State + *Layout + *Colors + render_*() │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────┼──────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ settings │ │ app/ (mouse │ │ Plugin TypeScript API │ │
│ │ (USES) │ │ handlers) │ │ (FUTURE) │ │
│ └──────────┘ └──────────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Key pattern: Each UI component (Menu, Tabs, Settings) has its own *Layout struct returned by render, with a hit_test() method returning a typed *Hit enum. Mouse handlers use these concrete types directly - no trait abstraction needed.
*Layout Pattern (Already Exists)Each control already returns a layout struct. Example from DropdownLayout:
// EXISTING in controls/dropdown/mod.rs
pub struct DropdownLayout {
pub button_area: Rect,
pub option_areas: Vec<Rect>,
pub full_area: Rect,
pub scroll_offset: usize,
}
impl DropdownLayout {
pub fn is_button(&self, x: u16, y: u16) -> bool { ... }
pub fn option_at(&self, x: u16, y: u16) -> Option<usize> { ... }
pub fn contains(&self, x: u16, y: u16) -> bool { ... }
}
This pattern is already correct. No changes needed to individual controls.
Create a trait that all layout structs can implement, allowing uniform hit testing:
// crates/fresh-editor/src/view/ui/layout.rs (NEW FILE)
use ratatui::layout::Rect;
/// Common hit testing interface for all layout structs
///
/// This trait allows EditorState to perform hit testing uniformly
/// across different UI components (Menu, Tabs, Settings controls).
pub trait HitTest {
/// The hit result type for this component
type Hit;
/// Test if point (x,y) hits any element, returning the hit info
fn hit_test(&self, x: u16, y: u16) -> Option<Self::Hit>;
/// Check if point is within the component's bounds at all
fn contains(&self, x: u16, y: u16) -> bool;
}
/// Helper to check point in rect (used by all Layout impls)
#[inline]
pub fn point_in_rect(rect: Rect, x: u16, y: u16) -> bool {
x >= rect.x && x < rect.x + rect.width &&
y >= rect.y && y < rect.y + rect.height
}
Add trait implementations to existing layout structs:
// In controls/dropdown/mod.rs - add impl
impl HitTest for DropdownLayout {
type Hit = DropdownHit;
fn hit_test(&self, x: u16, y: u16) -> Option<DropdownHit> {
if self.is_button(x, y) {
return Some(DropdownHit::Button);
}
if let Some(idx) = self.option_at(x, y) {
return Some(DropdownHit::Option(idx));
}
if self.contains(x, y) {
return Some(DropdownHit::Background);
}
None
}
fn contains(&self, x: u16, y: u16) -> bool {
point_in_rect(self.full_area, x, y)
}
}
pub enum DropdownHit {
Button,
Option(usize),
Background,
}
SettingsLayout already aggregates multiple control layouts. Generalize this pattern:
// crates/fresh-editor/src/view/ui/layout.rs
/// A layout that aggregates multiple component layouts
///
/// This is a generalization of SettingsLayout - any complex UI
/// can use this to track hit areas for its children.
#[derive(Debug, Default)]
pub struct CompositeLayout<H> {
/// Bounds of the entire component
pub bounds: Rect,
/// Named regions with their hit result
regions: Vec<(Rect, H)>,
}
impl<H: Clone> CompositeLayout<H> {
pub fn new(bounds: Rect) -> Self {
Self { bounds, regions: Vec::new() }
}
/// Add a hit region
pub fn add(&mut self, area: Rect, hit: H) {
self.regions.push((area, hit));
}
/// Hit test - returns first matching region (last added wins for overlaps)
pub fn hit_test(&self, x: u16, y: u16) -> Option<H> {
// Check in reverse order (last added = on top)
for (rect, hit) in self.regions.iter().rev() {
if point_in_rect(*rect, x, y) {
return Some(hit.clone());
}
}
None
}
}
Settings already has focus panel navigation. Extract it:
// crates/fresh-editor/src/view/ui/focus.rs (NEW FILE)
/// Manages Tab-order focus navigation
///
/// Extracted from SettingsState focus panel logic.
/// Can be used by Menu, plugin UIs, or any component with Tab navigation.
#[derive(Debug, Clone)]
pub struct FocusManager<T: Copy + Eq> {
/// Ordered list of focusable elements
elements: Vec<T>,
/// Current focus index
current: usize,
}
impl<T: Copy + Eq> FocusManager<T> {
pub fn new(elements: Vec<T>) -> Self {
Self { elements, current: 0 }
}
pub fn current(&self) -> Option<T> {
self.elements.get(self.current).copied()
}
pub fn focus_next(&mut self) -> Option<T> {
if self.elements.is_empty() { return None; }
self.current = (self.current + 1) % self.elements.len();
self.current()
}
pub fn focus_prev(&mut self) -> Option<T> {
if self.elements.is_empty() { return None; }
self.current = (self.current + self.elements.len() - 1) % self.elements.len();
self.current()
}
pub fn focus(&mut self, element: T) -> bool {
if let Some(idx) = self.elements.iter().position(|&e| e == element) {
self.current = idx;
true
} else {
false
}
}
pub fn is_focused(&self, element: T) -> bool {
self.current().map_or(false, |e| e == element)
}
}
// SettingsState.focus_panel can become FocusManager<FocusPanel>
// MenuState can use FocusManager<usize> for menu items
// pkg.ts can use FocusManager equivalent in TypeScript
The key insight: plugins like pkg.ts need the same controls that Settings UI uses, but accessible from TypeScript. Rather than build a parallel widget system, we expose the existing control patterns.
// Current pkg.ts - 400+ lines of manual UI construction
function buildListViewEntries(): TextPropertyEntry[] {
const entries: TextPropertyEntry[] = [];
// Manual button rendering with bracket logic
const focused = isButtonFocused("filter", i);
const leftBracket = focused ? "[" : " ";
const rightBracket = focused ? "]" : " ";
entries.push({ text: `${leftBracket} ${label} ${rightBracket}`, properties: {...} });
// Manual list rendering with selection prefix
const prefix = isSelected ? "▸" : " ";
const line = `${prefix} ${name.padEnd(18)} ${ver.padEnd(7)} ${status}`;
// Manual byte offset calculation for overlays
const len = utf8ByteLength(entry.text);
editor.addOverlay(bufferId, "pkg", byteOffset, byteOffset + len, options);
byteOffset += len;
// ... 400+ more lines
}
Provide TypeScript classes that mirror the Rust control patterns:
// crates/fresh-editor/plugins/lib/controls.ts
import { FocusState } from "./focus";
/**
* Button control - mirrors controls/button in Rust
*
* Pattern: State + render() -> Layout with hit areas
*/
export class ButtonControl {
constructor(
public label: string,
public focus: FocusState = FocusState.Normal
) {}
/** Render button text with focus indicators */
render(): { text: string; styles: StyleRange[] } {
const focused = this.focus === FocusState.Focused;
const left = focused ? "[" : " ";
const right = focused ? "]" : " ";
const text = `${left} ${this.label} ${right}`;
return {
text,
styles: focused ? [{ start: 0, end: text.length, bg: "syntax.keyword" }] : [],
};
}
}
/**
* Selectable list control - mirrors Settings item list behavior
*/
export class ListControl<T> {
public selectedIndex: number = 0;
public scrollOffset: number = 0;
constructor(
public items: T[],
public renderItem: (item: T, selected: boolean) => string,
public maxVisible: number = 10
) {}
selectNext(): void {
if (this.items.length === 0) return;
this.selectedIndex = Math.min(this.selectedIndex + 1, this.items.length - 1);
this.ensureVisible();
}
selectPrev(): void {
if (this.items.length === 0) return;
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.ensureVisible();
}
private ensureVisible(): void {
if (this.selectedIndex < this.scrollOffset) {
this.scrollOffset = this.selectedIndex;
} else if (this.selectedIndex >= this.scrollOffset + this.maxVisible) {
this.scrollOffset = this.selectedIndex - this.maxVisible + 1;
}
}
render(): { text: string; styles: StyleRange[]; selectedLine: number } {
const lines: string[] = [];
const styles: StyleRange[] = [];
let charOffset = 0;
const visibleItems = this.items.slice(
this.scrollOffset,
this.scrollOffset + this.maxVisible
);
for (let i = 0; i < visibleItems.length; i++) {
const actualIndex = this.scrollOffset + i;
const selected = actualIndex === this.selectedIndex;
const prefix = selected ? "▸ " : " ";
const line = prefix + this.renderItem(visibleItems[i], selected);
lines.push(line);
if (selected) {
styles.push({
start: charOffset,
end: charOffset + line.length,
fg: "ui.menu_active_fg",
bg: "ui.menu_active_bg",
});
}
charOffset += line.length + 1; // +1 for \n
}
return {
text: lines.join("\n"),
styles,
selectedLine: this.selectedIndex - this.scrollOffset
};
}
}
/**
* Focus manager - mirrors FocusManager<T> from Rust
*/
export class FocusManager<T> {
private currentIndex: number = 0;
constructor(public elements: T[]) {}
current(): T | undefined {
return this.elements[this.currentIndex];
}
focusNext(): T | undefined {
if (this.elements.length === 0) return undefined;
this.currentIndex = (this.currentIndex + 1) % this.elements.length;
return this.current();
}
focusPrev(): T | undefined {
if (this.elements.length === 0) return undefined;
this.currentIndex = (this.currentIndex + this.elements.length - 1) % this.elements.length;
return this.current();
}
isFocused(element: T): boolean {
return this.elements[this.currentIndex] === element;
}
focus(element: T): boolean {
const idx = this.elements.indexOf(element);
if (idx >= 0) {
this.currentIndex = idx;
return true;
}
return false;
}
}
export enum FocusState {
Normal = "normal",
Focused = "focused",
Hovered = "hovered",
Disabled = "disabled",
}
export interface StyleRange {
start: number; // Character offset
end: number;
fg?: string | [number, number, number];
bg?: string | [number, number, number];
bold?: boolean;
}
A builder that handles byte offset calculation automatically:
// crates/fresh-editor/plugins/lib/vbuffer.ts
/**
* Builds virtual buffer content with automatic style offset tracking.
*
* Eliminates manual utf8ByteLength() calls and offset tracking.
*/
export class VirtualBufferBuilder {
private entries: Array<{ text: string; styles: StyleRange[] }> = [];
private namespace: string;
constructor(
private bufferId: number,
namespace: string = "ui"
) {
this.namespace = namespace;
}
/** Add text with optional styles */
text(content: string, styles?: StyleRange[]): this {
this.entries.push({ text: content, styles: styles ?? [] });
return this;
}
/** Add a newline */
newline(): this {
return this.text("\n");
}
/** Add a horizontal separator */
separator(width: number, char: string = "─"): this {
return this.text(char.repeat(width) + "\n");
}
/** Add a control's output */
control(output: { text: string; styles: StyleRange[] }): this {
this.entries.push(output);
return this;
}
/** Add a row of controls with spacing */
row(...controls: Array<{ text: string; styles: StyleRange[] }>): this {
let combined = "";
const allStyles: StyleRange[] = [];
let offset = 0;
for (const ctrl of controls) {
// Shift styles by current offset
for (const style of ctrl.styles) {
allStyles.push({
...style,
start: style.start + offset,
end: style.end + offset,
});
}
combined += ctrl.text;
offset += ctrl.text.length;
}
this.entries.push({ text: combined, styles: allStyles });
return this;
}
/** Build and apply to the virtual buffer */
build(): void {
// Combine all text
let fullText = "";
const allStyles: StyleRange[] = [];
let charOffset = 0;
for (const entry of this.entries) {
for (const style of entry.styles) {
allStyles.push({
...style,
start: style.start + charOffset,
end: style.end + charOffset,
});
}
fullText += entry.text;
charOffset += entry.text.length;
}
// Convert to TextPropertyEntry format
const textEntries = [{ text: fullText, properties: {} }];
editor.setVirtualBufferContent(this.bufferId, textEntries);
// Apply overlays (handles UTF-8 byte conversion internally)
editor.clearNamespace(this.bufferId, this.namespace);
for (const style of allStyles) {
const byteStart = this.charToByteOffset(fullText, style.start);
const byteEnd = this.charToByteOffset(fullText, style.end);
editor.addOverlay(this.bufferId, this.namespace, byteStart, byteEnd, {
fg: style.fg,
bg: style.bg,
bold: style.bold,
});
}
}
private charToByteOffset(text: string, charOffset: number): number {
// Use TextEncoder for accurate UTF-8 byte counting
const encoder = new TextEncoder();
const prefix = text.slice(0, charOffset);
return encoder.encode(prefix).length;
}
}
With the new library, pkg.ts becomes:
// Simplified pkg.ts using controls library
import { ButtonControl, ListControl, FocusManager, FocusState } from "./lib/controls";
import { VirtualBufferBuilder } from "./lib/vbuffer";
// State uses FocusManager instead of manual focus tracking
const focusManager = new FocusManager([
"search", "filter-all", "filter-installed", "filter-plugins", "filter-themes",
"sync", "list", "action-0", "action-1"
]);
const packageList = new ListControl(
getFilteredItems(),
(item, selected) => formatPackageRow(item),
20 // max visible
);
function updatePkgManagerView(): void {
const builder = new VirtualBufferBuilder(pkgState.bufferId!, "pkg");
// Header
builder.text(" Packages\n", [{ start: 0, end: 10, fg: "syntax.keyword" }]);
builder.newline();
// Filter buttons row
builder.row(
new ButtonControl("All", focusManager.isFocused("filter-all") ? FocusState.Focused : FocusState.Normal).render(),
{ text: " ", styles: [] },
new ButtonControl("Installed", focusManager.isFocused("filter-installed") ? FocusState.Focused : FocusState.Normal).render(),
{ text: " ", styles: [] },
new ButtonControl("Sync", focusManager.isFocused("sync") ? FocusState.Focused : FocusState.Normal).render(),
);
builder.newline();
// Separator
builder.separator(80);
// Package list
builder.control(packageList.render());
// Build and apply
builder.build();
}
// Navigation now uses shared controls
globalThis.pkg_nav_down = () => {
packageList.selectNext();
updatePkgManagerView();
};
globalThis.pkg_next_button = () => {
focusManager.focusNext();
updatePkgManagerView();
};
Reduction: ~400 lines of manual UI → ~50 lines using controls.
ui/layout.rs with HitTest trait and point_in_rect() helperui/focus.rs with generic FocusManager<T>plugins/lib/controls.ts with TypeScript control classesplugins/lib/vbuffer.ts with VirtualBufferBuilderNo changes to existing code - all additive.
Refactor pkg.ts to use the new TypeScript controls:
Before: 2300 lines, ~400 for UI rendering
After: 1500 lines, ~50 for UI rendering (using controls library)
This validates the approach without touching Rust code.
Current problem in menu.rs:
// MenuState has manual hit calculation that duplicates rendering logic
impl MenuState {
pub fn get_menu_at_position(&self, menus: &[Menu], x: u16) -> Option<usize> {
let mut pos = 2;
for (i, menu) in menus.iter().enumerate() {
let label_width = str_width(&menu.label) + 4; // Duplicates render logic!
// ...
}
}
}
Migration:
MenuLayout struct (similar to DropdownLayout):pub struct MenuLayout {
pub menu_areas: Vec<Rect>, // Top-level menu labels
pub item_areas: Vec<Rect>, // Dropdown items
pub full_area: Rect,
}
impl HitTest for MenuLayout {
type Hit = MenuHit;
fn hit_test(&self, x: u16, y: u16) -> Option<MenuHit> { ... }
}
pub enum MenuHit {
MenuLabel(usize),
MenuItem(usize),
Background,
}
render_menu_bar() returns MenuLayout instead of nothingget_menu_at_position() - use MenuLayout::hit_test() insteadFocusManager<usize> for item navigationCurrent: render_for_split() returns Vec<(BufferId, u16, u16, u16)> - ad-hoc tuple.
Migration:
TabLayout struct:pub struct TabLayout {
pub tabs: Vec<TabHitArea>,
pub full_area: Rect,
}
pub struct TabHitArea {
pub buffer_id: BufferId,
pub label_area: Rect,
pub close_button_area: Rect,
}
impl HitTest for TabLayout {
type Hit = TabHit;
// ...
}
pub enum TabHit {
Tab(BufferId),
CloseButton(BufferId),
Background,
}
TabsRenderer::render_for_split() returns TabLayoutTabLayout::hit_test()Settings UI should use the generalized library so there's one unified system:
SettingsLayout with CompositeLayout<SettingsHit>:// Current: custom SettingsLayout struct
pub struct SettingsLayout {
pub modal_area: Rect,
pub categories: Vec<(usize, Rect)>,
pub items: Vec<ItemLayout>,
pub save_button: Option<Rect>,
// ... many fields
}
// After: use CompositeLayout from ui/layout.rs
pub type SettingsLayoutMap = CompositeLayout<SettingsHit>;
// render_settings returns the shared type
pub fn render_settings(...) -> SettingsLayoutMap {
let mut layout = CompositeLayout::new(modal_area);
layout.add(save_button_rect, SettingsHit::SaveButton);
layout.add(category_rect, SettingsHit::Category(idx));
// ...
layout
}
ControlLayoutInfo enum - controls already return their own Layout types:// Current: ControlLayoutInfo duplicates what *Layout already provides
pub enum ControlLayoutInfo {
Toggle(Rect),
Dropdown { button_area: Rect, option_areas: Vec<Rect>, ... },
// ...
}
// After: store the actual control layout, use HitTest trait
pub struct ItemLayout {
pub index: usize,
pub path: String,
pub area: Rect,
// Store actual layout type, not a copy of its fields
pub control_layout: ControlLayout,
}
pub enum ControlLayout {
Toggle(ToggleLayout),
Dropdown(DropdownLayout),
Number(NumberInputLayout),
// ...
}
SettingsState uses FocusManager<FocusPanel>:// Current: manual focus cycling
pub focus_panel: FocusPanel,
pub fn next_panel(&mut self) {
self.focus_panel = match self.focus_panel {
FocusPanel::Categories => FocusPanel::Settings,
FocusPanel::Settings => FocusPanel::Footer,
FocusPanel::Footer => FocusPanel::Categories,
};
}
// After: use FocusManager
pub focus: FocusManager<FocusPanel>,
pub fn next_panel(&mut self) {
self.focus.focus_next();
}
settings/layout.rs - functionality moves to ui/layout.rssplit_rendering.rs is 5.5k lines mixing many concerns. Future work could:
BufferRenderer - single buffer renderingSplitLayout - pane sizing and compositionInlineDiff - character-level diff logicScrollablePanel for buffer content (it's already used in Settings)This is the largest change and should happen after the control/layout unification proves successful.
Before (current menu.rs):
// MenuState has hit testing that duplicates render logic
impl MenuState {
pub fn get_menu_at_position(&self, menus: &[Menu], x: u16) -> Option<usize> {
let mut pos = 2;
for (i, menu) in menus.iter().enumerate() {
let label_width = str_width(&menu.label) + 4; // DUPLICATES RENDERING!
if x >= pos && x < pos + label_width as u16 {
return Some(i);
}
pos += label_width as u16;
}
None
}
}
After (using MenuLayout):
// menu.rs - add MenuLayout following the controls pattern
/// Layout info for menu bar - mirrors DropdownLayout pattern
pub struct MenuLayout {
pub menu_labels: Vec<(usize, Rect)>, // (menu_index, area)
pub dropdown_items: Vec<(usize, Rect)>, // (item_index, area)
pub full_area: Rect,
}
impl MenuLayout {
pub fn menu_at(&self, x: u16, y: u16) -> Option<usize> {
for (idx, area) in &self.menu_labels {
if point_in_rect(*area, x, y) {
return Some(*idx);
}
}
None
}
pub fn item_at(&self, x: u16, y: u16) -> Option<usize> {
for (idx, area) in &self.dropdown_items {
if point_in_rect(*area, x, y) {
return Some(*idx);
}
}
None
}
}
// MenuRenderer now returns MenuLayout
pub fn render_menu_bar(
frame: &mut Frame,
area: Rect,
menus: &[Menu],
state: &MenuState,
theme: &Theme,
) -> MenuLayout {
let mut layout = MenuLayout { menu_labels: vec![], dropdown_items: vec![], full_area: area };
let mut x = area.x + 2;
for (i, menu) in menus.iter().enumerate() {
let width = str_width(&menu.label) as u16 + 4;
let label_area = Rect::new(x, area.y, width, 1);
// Render the label...
render_menu_label(frame, label_area, &menu.label, state.active_menu == Some(i), theme);
// Record in layout (single source of truth!)
layout.menu_labels.push((i, label_area));
x += width;
}
layout
}
// Mouse handling uses layout, no duplicate calculation
fn handle_menu_mouse(layout: &MenuLayout, x: u16, y: u16) -> Option<MenuAction> {
if let Some(menu_idx) = layout.menu_at(x, y) {
return Some(MenuAction::OpenMenu(menu_idx));
}
if let Some(item_idx) = layout.item_at(x, y) {
return Some(MenuAction::SelectItem(item_idx));
}
None
}
// DELETE get_menu_at_position - no longer needed!
Before (current - manual everything):
// 260+ lines in buildListViewEntries()
function buildListViewEntries(): TextPropertyEntry[] {
const entries: TextPropertyEntry[] = [];
// Manual button with bracket logic (repeated 6x for each button)
const syncFocused = isButtonFocused("sync");
const syncLeft = syncFocused ? "[" : " ";
const syncRight = syncFocused ? "]" : " ";
entries.push({ text: `${syncLeft} Sync ${syncRight}`, properties: { type: "sync-btn", focused: syncFocused } });
// Manual list with selection indicator
for (let i = 0; i < items.length; i++) {
const isSelected = i === pkgState.selectedIndex;
const prefix = isSelected ? "▸ " : " ";
// ... padding, formatting ...
}
return entries;
}
// Separate function to apply overlays with manual byte offset tracking
function applyPkgManagerHighlighting(): void {
let byteOffset = 0;
for (const entry of entries) {
const len = utf8ByteLength(entry.text); // Manual byte counting!
// ... determine style based on entry type ...
editor.addOverlay(pkgState.bufferId, "pkg", byteOffset, byteOffset + len, options);
byteOffset += len; // Manual offset tracking!
}
}
After (using controls library):
import { ButtonControl, ListControl, FocusState } from "./lib/controls";
import { VirtualBufferBuilder } from "./lib/vbuffer";
function updatePkgManagerView(): void {
const builder = new VirtualBufferBuilder(pkgState.bufferId!, "pkg");
// Buttons use ButtonControl (handles brackets, focus styling)
const filterButtons = ["All", "Installed", "Plugins", "Themes"].map((label, i) =>
new ButtonControl(label, pkgState.focus.type === "filter" && pkgState.focus.index === i
? FocusState.Focused : FocusState.Normal)
);
builder
.text(" Packages\n", [{ start: 0, end: 10, fg: "syntax.keyword" }])
.newline()
.row(...filterButtons.map(b => b.render()), { text: " ", styles: [] },
new ButtonControl("Sync", pkgState.focus.type === "sync" ? FocusState.Focused : FocusState.Normal).render())
.newline()
.separator(80)
.control(packageList.render()) // ListControl handles selection, scrolling
.build(); // Handles byte offset conversion automatically
}
Line count comparison:
buildListViewEntries(): 260 lines → ~20 linesapplyPkgManagerHighlighting(): 130 lines → 0 lines (handled by builder)FocusManagerSettings UI has good patterns that become the shared library. Then Settings itself uses that library:
| Pattern | Current (Settings-specific) | After (Shared Library) |
|---|---|---|
| Hit Testing | SettingsLayout in settings/layout.rs | CompositeLayout<H> in ui/layout.rs |
| Focus Nav | Manual focus_panel cycling | FocusManager<FocusPanel> from ui/focus.rs |
| Control Layout | ControlLayoutInfo enum duplicates fields | Store actual *Layout types, use HitTest trait |
| Control State | DropdownState, etc. in controls/ | Unchanged - already good |
| Scrolling | ScrollablePanel | Unchanged - already reusable |
| Colors | *Colors::from_theme() | Unchanged - already good |
Before: Settings has its own layout system that others can't use.
After: Everyone (Settings, Menu, Tabs, plugins) uses the same ui/layout.rs and ui/focus.rs.
These already exist and remain the foundation:
// controls/mod.rs - EXISTING, UNCHANGED
pub enum FocusState { Normal, Focused, Hovered, Disabled }
pub struct ControlColors { bg, fg, border, accent, disabled }
// controls/dropdown/mod.rs - EXISTING (example of the pattern)
pub struct DropdownState { selected, options, values, open, focus, ... }
pub struct DropdownLayout { button_area: Rect, option_areas: Vec<Rect>, ... }
pub struct DropdownColors { label, selected, border, ... }
pub fn render_dropdown(frame, area, state, colors) -> DropdownLayout;
// Same pattern for: Toggle, NumberInput, TextInput, TextList, MapInput, Button
// ui/scroll_panel.rs - EXISTING, UNCHANGED
pub trait ScrollItem { fn height(&self) -> u16; fn focus_regions(&self) -> Vec<FocusRegion>; }
pub struct ScrollState { offset, viewport, content_height }
pub struct ScrollablePanel { scroll: ScrollState }
// ui/layout.rs - NEW (generalized from settings/layout.rs)
pub trait HitTest {
type Hit;
fn hit_test(&self, x: u16, y: u16) -> Option<Self::Hit>;
fn contains(&self, x: u16, y: u16) -> bool;
}
/// Generic composite layout - replaces SettingsLayout
pub struct CompositeLayout<H> {
pub bounds: Rect,
regions: Vec<(Rect, H)>,
}
impl<H: Clone> CompositeLayout<H> {
fn new(bounds: Rect) -> Self;
fn add(&mut self, area: Rect, hit: H);
fn hit_test(&self, x: u16, y: u16) -> Option<H>;
}
pub fn point_in_rect(rect: Rect, x: u16, y: u16) -> bool;
// ui/focus.rs - NEW (extracted from SettingsState)
pub struct FocusManager<T: Copy + Eq> {
elements: Vec<T>,
current: usize,
}
impl<T> FocusManager<T> {
fn new(elements: Vec<T>) -> Self;
fn current(&self) -> Option<T>;
fn focus_next(&mut self) -> Option<T>;
fn focus_prev(&mut self) -> Option<T>;
fn focus(&mut self, element: T) -> bool;
fn is_focused(&self, element: T) -> bool;
}
// settings/render.rs - MODIFIED to use shared library
pub fn render_settings(...) -> CompositeLayout<SettingsHit> { ... }
// settings/state.rs - MODIFIED to use FocusManager
pub struct SettingsState {
pub focus: FocusManager<FocusPanel>, // was: focus_panel: FocusPanel
// ...
}
// ui/menu.rs - MODIFIED (add MenuLayout using HitTest)
pub struct MenuLayout { menu_labels: Vec<(usize, Rect)>, ... }
impl HitTest for MenuLayout { type Hit = MenuHit; ... }
// ui/tabs.rs - MODIFIED (add TabLayout using HitTest)
pub struct TabLayout { tabs: Vec<TabHitArea>, ... }
impl HitTest for TabLayout { type Hit = TabHit; ... }
// controls/dropdown/mod.rs - ADD HitTest impl
impl HitTest for DropdownLayout { type Hit = DropdownHit; ... }
// plugins/lib/controls.ts - NEW (mirrors Rust controls)
export enum FocusState { Normal, Focused, Hovered, Disabled }
export class ButtonControl { label, focus; render(): { text, styles } }
export class ListControl<T> { items, selectedIndex; selectNext(); selectPrev(); render() }
export class FocusManager<T> { elements; current(); focusNext(); focusPrev(); isFocused(t) }
// plugins/lib/vbuffer.ts - NEW (eliminates manual byte offset tracking)
export class VirtualBufferBuilder {
constructor(bufferId: number, namespace?: string);
text(content: string, styles?: StyleRange[]): this;
newline(): this;
separator(width: number): this;
control(output: { text, styles }): this;
row(...controls): this;
build(): void; // Handles UTF-8 conversion automatically
}
export interface StyleRange {
start: number; // Character offset (not bytes!)
end: number;
fg?: string | [number, number, number];
bg?: string | [number, number, number];
bold?: boolean;
}
The immediate goal is minimal changes that leverage existing code:
*State structs) is clearerThe Layout DSL (Part 5) is a future direction that adds compositional UI building, but it builds on the foundation established in the current phase rather than replacing it.
ui/layout.rs and ui/focus.rssettings/layout.rs deleted, ControlLayoutInfo simplified| File | Purpose | Lines (est.) |
|---|---|---|
src/view/ui/layout.rs | point_in_rect() helper | ~30 |
src/view/ui/focus.rs | FocusManager<T> | ~60 |
plugins/lib/controls.ts | ButtonControl, ListControl, FocusManager | ~200 |
plugins/lib/vbuffer.ts | VirtualBufferBuilder | ~100 |
Total new code: ~390 lines (mostly TypeScript for plugins)
| File | Change | Complexity |
|---|---|---|
src/view/settings/state.rs | Use FocusManager<FocusPanel> | Low |
src/view/settings/layout.rs | Use shared point_in_rect() | Low |
src/view/ui/menu.rs | Add MenuLayout, delete get_menu_at_position() | Medium |
src/view/ui/tabs.rs | Return TabLayout instead of tuple Vec | Low |
plugins/pkg.ts | Use controls library (future) | Medium (but simplifies) |
| File | Why |
|---|---|
src/view/controls/ (state/render) | Already well-designed |
src/view/ui/scroll_panel.rs | Already reusable |
src/input/handler.rs | Input handling is orthogonal to layout |
FocusManager replace FocusPanel?FocusManager<FocusPanel> with a focus_panel() helper methodHitTest trait?MenuLayout, TabLayout, SettingsLayout) has its own hit_test() method returning a typed enum. A trait would only be useful if we had generic code that needed to work with unknown layout types polymorphically - we don't have that use case.The key principle: extract existing code into shared modules first, then have the original code use the extraction. This validates the extraction works before anyone else adopts it.
| Step | Description | Status |
|---|---|---|
| 1 | Extract point_in_rect() to ui/layout.rs | ✅ Done |
| 2 | Extract FocusManager to ui/focus.rs | ✅ Done |
| 3 | Update ui/mod.rs exports | ✅ Done |
| 4 | Migrate settings/layout.rs to use point_in_rect | ✅ Done |
| 5 | Migrate settings/state.rs to use FocusManager | ✅ Done |
| 6 | Add MenuLayout + MenuHit to menu.rs | ✅ Done |
| 7 | Add TabLayout + TabHit to tabs.rs | ✅ Done |
point_in_rect() to ui/layout.rsWhat: Move the contains() helper from settings/layout.rs:257 to a new shared module.
Source code (settings/layout.rs:257-259):
fn contains(&self, rect: Rect, x: u16, y: u16) -> bool {
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
}
Target: Create src/view/ui/layout.rs with:
//! Layout utilities for hit testing
use ratatui::layout::Rect;
/// Check if a point is within a rectangle
#[inline]
pub fn point_in_rect(rect: Rect, x: u16, y: u16) -> bool {
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
}
Then: Update settings/layout.rs to use it:
use crate::view::ui::layout::point_in_rect;
// Change: self.contains(*area, x, y)
// To: point_in_rect(*area, x, y)
Validation: All settings hit testing continues to work identically.
HitTest trait to ui/layout.rsWhat: Define a trait that layout structs can implement. This is new code, but it's a small addition to the file created in Step 1.
/// Trait for layout structs that support hit testing
pub trait HitTest {
/// The hit result type (e.g., DropdownHit, MenuHit)
type Hit;
/// Test if point hits any element, returning hit info
fn hit_test(&self, x: u16, y: u16) -> Option<Self::Hit>;
/// Check if point is within component bounds
fn contains(&self, x: u16, y: u16) -> bool;
}
No migration yet - this is just adding the trait definition. Existing code continues to work.
FocusManager to ui/focus.rsWhat: Generalize the focus cycling pattern from settings/state.rs.
Source pattern (settings/state.rs:380-386):
pub fn toggle_focus(&mut self) {
self.focus_panel = match self.focus_panel {
FocusPanel::Categories => FocusPanel::Settings,
FocusPanel::Settings => FocusPanel::Footer,
FocusPanel::Footer => FocusPanel::Categories,
};
// ... side effects follow
}
Target: Create src/view/ui/focus.rs with generic cycling:
//! Focus management utilities
/// Manages focus cycling through a list of elements
#[derive(Debug, Clone)]
pub struct FocusManager<T> {
elements: Vec<T>,
current: usize,
}
impl<T: Copy + Eq> FocusManager<T> {
pub fn new(elements: Vec<T>) -> Self {
Self { elements, current: 0 }
}
pub fn current(&self) -> Option<T> {
self.elements.get(self.current).copied()
}
pub fn focus_next(&mut self) -> Option<T> {
if self.elements.is_empty() { return None; }
self.current = (self.current + 1) % self.elements.len();
self.current()
}
pub fn focus_prev(&mut self) -> Option<T> {
if self.elements.is_empty() { return None; }
self.current = (self.current + self.elements.len() - 1) % self.elements.len();
self.current()
}
pub fn set(&mut self, element: T) -> bool {
if let Some(idx) = self.elements.iter().position(|&e| e == element) {
self.current = idx;
true
} else {
false
}
}
pub fn is_current(&self, element: T) -> bool {
self.current() == Some(element)
}
}
Note: Settings keeps its side effects (update_control_focus, ensure_visible, etc.) - only the pure cycling logic is extracted.
ui/mod.rs exports// Add to WASM-compatible modules section:
pub mod focus;
pub mod layout;
// Add to re-exports:
pub use focus::FocusManager;
pub use layout::{point_in_rect, HitTest};
settings/layout.rs to use point_in_rectWhat: Replace all self.contains(rect, x, y) calls with point_in_rect(rect, x, y).
Changes:
use crate::view::ui::layout::point_in_rect;contains method from SettingsLayoutself.contains(...) with point_in_rect(...)Validation: Run settings tests, verify mouse clicks still work.
settings/state.rs to use FocusManagerWhat: Replace manual focus_panel field with FocusManager<FocusPanel>.
Before:
pub struct SettingsState {
pub focus_panel: FocusPanel,
// ...
}
pub fn toggle_focus(&mut self) {
let old_panel = self.focus_panel;
self.focus_panel = match self.focus_panel {
FocusPanel::Categories => FocusPanel::Settings,
// ...
};
// side effects...
}
After:
use crate::view::ui::FocusManager;
pub struct SettingsState {
pub focus: FocusManager<FocusPanel>,
// ...
}
pub fn toggle_focus(&mut self) {
let old_panel = self.focus.current().unwrap_or_default();
self.focus.focus_next();
// side effects remain the same...
}
Note: This requires updating all reads of self.focus_panel to self.focus.current().unwrap_or(FocusPanel::Categories). Consider adding a helper method.
MenuLayout to menu.rsWhat: Make render_menu_bar() return layout info instead of computing positions twice.
Current problem (menu.rs):
// Rendering calculates positions:
let mut x = area.x;
for menu in menus {
let width = str_width(&menu.label) + 2;
// render at x...
x += width + 1;
}
// Hit testing recalculates the same positions:
pub fn get_menu_at_position(&self, menus: &[Menu], x: u16) -> Option<usize> {
let mut current_x = 0;
for (idx, menu) in menus.iter().enumerate() {
let label_width = str_width(&menu.label) + 2; // DUPLICATE!
// ...
}
}
Solution: Add MenuLayout struct, return from render:
pub struct MenuLayout {
pub menu_areas: Vec<(usize, Rect)>,
pub item_areas: Vec<(usize, Rect)>,
}
impl MenuLayout {
pub fn menu_at(&self, x: u16, y: u16) -> Option<usize> {
for (idx, area) in &self.menu_areas {
if point_in_rect(*area, x, y) {
return Some(*idx);
}
}
None
}
}
// render_menu_bar now returns MenuLayout
pub fn render_menu_bar(...) -> MenuLayout {
let mut layout = MenuLayout::default();
let mut x = area.x;
for (idx, menu) in menus.iter().enumerate() {
let width = str_width(&menu.label) as u16 + 2;
let menu_area = Rect::new(x, area.y, width, 1);
layout.menu_areas.push((idx, menu_area));
// render...
x += width + 1;
}
layout
}
Then: Delete get_menu_at_position() and get_item_at_position() - use MenuLayout instead.
TabLayout to tabs.rsWhat: Replace Vec<(BufferId, u16, u16, u16)> with a proper struct.
Current (tabs.rs):
pub fn render_for_split(...) -> Vec<(BufferId, u16, u16, u16)>
// Returns: (buffer_id, tab_start, tab_end, close_start)
After:
pub struct TabHitArea {
pub buffer_id: BufferId,
pub tab_area: Rect,
pub close_area: Rect,
}
pub struct TabLayout {
pub tabs: Vec<TabHitArea>,
pub scroll_left_area: Option<Rect>,
pub scroll_right_area: Option<Rect>,
}
pub fn render_for_split(...) -> TabLayout
| Step | Action | Type | Status |
|---|---|---|---|
| 1 | Create ui/layout.rs with point_in_rect() | Extract | ✅ Done |
| 2 | Add HitTest trait | New code | ✅ Done |
| 3 | Create ui/focus.rs with FocusManager<T> | Extract pattern | ✅ Done |
| 4 | Update ui/mod.rs exports | Wire up | ✅ Done |
| 5 | Migrate settings/layout.rs to use point_in_rect | Adopt | ✅ Done |
| 6 | Migrate settings/state.rs to use FocusManager | Adopt | ✅ Done |
| 7 | Add MenuLayout, delete duplicate hit methods | Refactor | Pending |
| 8 | Add TabLayout, replace tuple Vec | Refactor | Pending |
Steps 1-6 complete. Settings now uses the shared point_in_rect() and FocusManager<T>.
Steps 7-8 are independent refactors that benefit from the shared utilities.
The current plan extracts utilities and improves hit-testing. The next evolution is a typed DSL for composing UI hierarchies that unifies Settings UI, Menu, Tabs, and plugin UIs.
The DSL is intentionally minimal:
// Two containers
Column // Children stacked vertically
Row // Children laid out horizontally
// Two sizing modes
Fixed(n) // Exact n cells
Fill // Take remaining space
// Overlays
Stack // Children layered, last on top
// Escape hatch
Custom // Manual rendering with full control
// Settings-like layout
Column::new()
.child(header.height(Fixed(1)))
.child(
Row::new()
.child(sidebar.width(Fixed(30)))
.child(content) // Defaults to Fill
)
.child(footer.height(Fixed(1)))
// Menu bar
Row::new()
.child(menu_item("File"))
.child(menu_item("Edit"))
.child(menu_item("View"))
.child(spacer().width(Fill)) // Push help to right
.child(menu_item("Help"))
Stack::new()
.child(editor_content) // Base layer
.child(popup.at(cursor_x, cursor_y + 1)) // Absolute position
.child(modal.centered()) // Centered in parent
Buffer content, syntax highlighting, and other complex rendering stays manual:
Column::new()
.child(tabs.height(Fixed(1)))
.child(Custom::new(|frame, area, ctx| {
// Full manual control - receives computed Rect
render_buffer_content(frame, area, &ctx.buffer);
// Return layout for hit-testing
BufferLayout { line_rects: ... }
}))
.child(status.height(Fixed(1)))
Column, Row, Stack, controlsRect to each nodeRect(x, y), get hit resultThe tree structure IS the layout - no separate layout calculation that can drift.
// Same structure, same API
Column()
.child(Label("Packages").height(Fixed(1)))
.child(
Row()
.child(PackageList(items).width(Fixed(40)))
.child(PackageDetails(selected))
)
.child(StatusBar().height(Fixed(1)))
TypeScript DSL either:
The DSL builds on top of existing controls, not replacing them:
// Button becomes an Element
impl Element for Button {
fn render(&self, frame: &mut Frame, area: Rect) -> ButtonLayout {
render_button(frame, area, &self.state, &self.colors) // Existing fn
}
}
// Column composes Elements
impl Element for Column {
fn render(&self, frame: &mut Frame, area: Rect) -> ColumnLayout {
let mut y = area.y;
let mut layouts = vec![];
for child in &self.children {
let child_height = child.height.resolve(area.height, ...);
let child_area = Rect::new(area.x, y, area.width, child_height);
layouts.push(child.render(frame, child_area));
y += child_height;
}
ColumnLayout { children: layouts, area }
}
}
| Capability | Manual Rects | With DSL |
|---|---|---|
| Express hierarchy | Implicit in render order | Explicit tree structure |
| Resize handling | Manual recalculation | Automatic constraint solving |
| Hit testing | Separate code path | Derived from same tree |
| Plugin UIs | Manual string building | Same DSL as Rust UI |
| Composition | Copy-paste patterns | Reusable components |
| Phase | Scope | Validates |
|---|---|---|
| Current (1-8) | Extract utilities, improve hit-testing | Patterns work |
| DSL Phase 1 | Add Column, Row to Rust | Basic composition |
| DSL Phase 2 | Migrate Settings UI to DSL | Handles real complexity |
| DSL Phase 3 | TypeScript DSL for plugins | Cross-language parity |
| DSL Phase 4 | Stack for overlays, modals | Full UI capability |
Once the Rust patterns are validated:
plugins/lib/controls.ts)Column, Row, Stack, Custom) as described in Part 5These were considered but not needed:
HitTest trait: A generic trait for hit testing was considered but removed. Each layout type (MenuLayout, TabLayout, SettingsLayout) has its own concrete hit_test() method returning a typed enum (MenuHit, TabHit, SettingsHit). A trait would only add value if we needed polymorphic code like fn handle<L: HitTest>(layout: &L) - we don't have that use case since each mouse handler knows exactly which layout type it's dealing with.
CompositeLayout<H>: A generic layout aggregator was considered but isn't needed. Settings already has SettingsLayout which works fine. Menu and Tabs have their own specialized layouts. Generalizing would add abstraction without reducing code.
src/view/settings/layout.rs:257-259 - contains() → point_in_rect()src/view/settings/state.rs:380-386 - Focus cycling → FocusManager<T>src/view/controls/dropdown/mod.rs - Reference implementation of State/Layout/Colors patternsrc/view/ui/scroll_panel.rs - Reusable scrolling abstraction (already shared)INPUT_LAYOUT_RENDERING_SUMMARY.md - Current architecture documentationTUI Architecture Deep Dive.pdf - Analysis of TUI framework patternsTUI Frameworks vs. Web and Game Engines.pdf - Comparison with other UI systems