Commit Graph
372 Commits
Author SHA1 Message Date
phranck 93958e0308 Feat: Modal/Alert as focus sections — replace focusManager.clear() with section activation
ModalPresentationModifier and AlertPresentationModifier now register
dedicated focus sections (__modal__/__alert__) and activate them instead
of clearing the entire FocusManager. Modal buttons register in the modal
section. When the modal closes, the section is not re-registered and
endRenderPass() falls back to the previous active section automatically.
2026-02-03 16:59:57 +01:00
phranck b078661e7e Feat: StatusBar cascading — .statusBarItems(.merge/.replace) composition API
New declarative StatusBar item composition system:
- StatusBarItemComposition enum (.merge/.replace)
- .statusBarItems(.replace) { } for cascade barriers (modals)
- .statusBarItems { } defaults to .merge (combines with parent items)
- StatusBarState resolves items per active focus section:
  .replace = only section items, .merge = section + global (section wins)
- Section items rebuilt each frame via clearSectionItems()/registerSectionItems()
- Legacy context: push/pop API preserved for backward compatibility
2026-02-03 16:58:33 +01:00
phranck 818fc5d198 Feat: Breathing focus indicator — pulsing ● in active section border
New components:
- PulseTimer: GCD-based timer driving a sine-wave phase (0→1→0, 3s cycle)
- Color.lerp(): RGB linear interpolation between two colors
- BorderRenderer: standardTopBorder accepts focusIndicatorColor for ● rendering

Integration:
- FocusSectionModifier computes interpolated accent color when section is active
- RenderContext carries pulsePhase (from PulseTimer) and focusIndicatorColor
- BorderedView and ContainerView consume the indicator color on first border,
  then nil it out so nested borders don't duplicate the indicator
- AppRunner owns PulseTimer, passes phase to RenderLoop each frame
2026-02-03 16:52:50 +01:00
phranck b7f51e1b85 Fix: Preserve focus state across render passes
RenderLoop called focusManager.clear() every frame, which reset the
active section and focused element. Replaced with beginRenderPass()
(clears sections/focusables for re-registration) and endRenderPass()
(validates preserved activeSectionID/focusedID against re-registered
sections, falls back to first available if removed from tree).
2026-02-03 16:47:20 +01:00
phranck 8ebda6371e Feat: Section-aware navigation — Up/Down cycles within active section
FocusManager.dispatchKeyEvent now intercepts Up/Down arrows to navigate
between focusable elements within the active section. Tab/Shift+Tab
continues to cycle between sections. Views needing custom Up/Down
handling (e.g. Menu) register via KeyEventDispatcher (Layer 2), which
takes priority over the FocusManager (Layer 3).
2026-02-03 16:43:34 +01:00
phranck 2a3c96e782 Feat: Add FocusSection registration — section-aware FocusManager and .focusSection() modifier
FocusManager reorganized from flat focusable list to section-based architecture.
Each section groups its own focusable elements. Tab cycles sections when multiple
exist, falls back to within-section cycling for single section (backward compat).

New types: FocusSection, FocusSectionModifier
New API: .focusSection("id"), register(_:inSection:), activateNextSection()
RenderContext carries activeFocusSectionID for child registration routing.
2026-02-03 16:41:37 +01:00
phranck 384ebeb7f7 Merge pull request #67 from phranck/feat/overlays-demo-redesign
Feat: Redesign OverlaysPage with interactive demo menu
2026-02-03 13:46:57 +01:00
phranck 5975820ed4 Test: Fix flaky state tests by eliminating global RenderNotifier mutation
AppStateTests: Tests AppState as isolated instances without touching
RenderNotifier.current. Removed .serialized (no shared state).

StateStorageIdentityTests: Replaced testEnvironment() with testStorage()
that only creates a StateStorage. No longer sets RenderNotifier.current.
StateBox.didSet harmlessly calls setNeedsRender() on the default global.

StatePropertyTests: The one test that needs RenderNotifier.current
(stateTriggerRender) now swaps it atomically — set, mutate, check,
restore in a single synchronous sequence with no yield points.

Root cause: All three suites ran .serialized internally but parallel
to each other. Since RenderNotifier.current is a process-global static
variable, concurrent writes caused one suite to overwrite another's
AppState, making needsRender checks fail intermittently.
2026-02-03 13:45:08 +01:00
phranck 186e444fcc Fix: ESC closes modal via StatusBar context switching
OverlaysPage now manages its own StatusBar items based on modal state:
- Modal open: ESC → close modal, Enter → dismiss
- Modal closed: ESC → back to menu, arrows → nav, Enter → show

Removes redundant ESC handlers from Modal/AlertPresentationModifier.
The correct approach is context-aware StatusBar items, not multiple
competing ESC handlers.
2026-02-03 12:35:07 +01:00
phranck ca9a0cf49c Fix: Modal focus isolation, ESC dismiss, footer layout, palette colors
- Wire FocusManager.dispatchKeyEvent() into InputHandler as Layer 3
  (Tab/Shift+Tab navigation, Enter/Space button activation)
- Modal/Alert presenters isolate base content from focus/key systems
  using RenderContext.isolatedForBackground() so only modal buttons
  receive focus and key events
- ESC automatically dismisses any modal or alert (framework-level)
- Fix ContainerView footer layout: Spacer() now fills correctly by
  constraining footer context to actual inner width
- Remove body background color from standard style containers
  (only block style uses distinct section backgrounds)
- Replace all hardcoded ANSI colors with palette semantic colors
  (.palette.warning/error/info/success) in Alert presets and example app
2026-02-03 12:28:39 +01:00
phranck 7e69b9e924 Fix: Right-align dismiss buttons in all overlay demo variants
Changed dismissButton to use HStack { Spacer(); Button } pattern for
consistent right-alignment across all alerts, dialogs, and modals.

Also simplified Alert preset usage — replaced explicit Alert<Button>.warning()
with standard Alert() initializer to avoid generic type inference issues.
2026-02-03 02:22:46 +01:00
phranck 44895b59cc Feat: Redesign OverlaysPage with interactive demo menu
Replace the static single-alert demo with an interactive page featuring:
- Menu with 8 overlay variants (5 Alert presets, 2 Dialog styles, 1 custom modal)
- Description panel showing details and API usage for the selected variant
- Enter key triggers the selected overlay with dimmed background
- Dismiss button closes any overlay

Variants demonstrated:
1. Alert (Standard) — default theme colors
2. Alert (Warning) — yellow preset
3. Alert (Error) — red preset
4. Alert (Info) — cyan preset
5. Alert (Success) — green preset
6. Dialog — content-only, no footer separator
7. Dialog with Footer — footer section with separator line
8. Modal (Custom) — arbitrary view content with .modal(isPresented:)
2026-02-03 02:19:12 +01:00
phranck 8f8d823ffc Chore: Update test count badge to 456 tests [skip ci] 2026-02-03 01:08:21 +00:00
phranck c1d83ef915 Merge pull request #66 from phranck/chore/dead-code-cleanup
Chore: Remove 19 dead production symbols and 12 dead tests
2026-02-03 02:06:59 +01:00
phranck d26dc795ce Merge pull request #65 from phranck/feat/swiftui-presentation-api
Feat: SwiftUI-style presentation API (.alert, .modal)
2026-02-03 02:06:29 +01:00
phranck 82c8eaf4b2 Fix: Enforce absolute SwiftUI API parity for .alert() modifier
Changed .alert() signature to match SwiftUI exactly:
- message parameter is now @ViewBuilder () -> Message (was String)
- Parameter order matches SwiftUI: title, isPresented, actions, message
- Message content is rendered to string internally for Alert view

Updated:
- AlertPresentationModifier generic signature (added Message type parameter)
- View+Presentation.swift API signatures
- OverlaysPage demo to use ViewBuilder message
- All AlertPresentationModifierTests to use ViewBuilder message

Added permanent rule to .claude/CLAUDE.md:
- ABSOLUTE SwiftUI API Parity is non-negotiable
- Must research exact SwiftUI signatures before implementing
- Only deviate when terminal constraints require it

This ensures TUIKit provides a familiar API for SwiftUI developers.
2026-02-03 02:03:39 +01:00
phranck d25705f219 Fix: Remove redundant content rendering when isPresented is false
Both AlertPresentationModifier and ModalPresentationModifier were rendering
the base content twice when isPresented == true:
1. Once at the start (unused)
2. Again via DimmedModifier

Changed to only render content once per code path:
- If not presented: render content directly
- If presented: render content via DimmedModifier only

This eliminates unnecessary render work and improves performance.
2026-02-03 01:58:07 +01:00
phranck cece3aab68 Fix: Replace Button with Text in presentation modifier tests to avoid FocusManager pollution
The FocusManager has a singleton defaultValue that persists across tests,
causing test-order dependencies. Button instances register with the global
FocusManager on render, polluting later tests that expect a fresh manager.

Changed tests to use Text instead of Button for action content, eliminating
the focus registration side-effect while still testing the presentation API.

This fixes the CI failure on macOS where 'Default FocusManager is provided
if not set' failed due to a previous test rendering buttons.
2026-02-03 01:44:18 +01:00
phranck f9488e839c Chore: Remove 19 dead production symbols and 12 dead tests
Removed dead code that is no longer used after render pipeline optimization:

Terminal (7 dead methods/properties):
- width, height (replaced by getSize())
- writeLine(), clear(), fillBackground()
- readChar(), readLine()

ANSIRenderer (12 dead symbols):
- cursorUp(), cursorDown(), cursorForward(), cursorBack()
- saveCursor, restoreCursor
- clearScreen, clearToEnd, clearToBeginning
- clearLine, clearLineToEnd, clearLineToBeginning

Tests (12 dead tests + 1 empty suite):
- Removed 12 tests for deleted ANSIRenderer cursor/screen methods
- Removed entire ANSIRendererScreenTests suite (now empty)

Consistency fixes (3 files):
- RenderLoop.swift: buffer.lines.count → buffer.height
- FrameModifier.swift: buffer.lines.count → buffer.height
- FrameDiffWriter.swift: buffer.lines.count → buffer.height

Result: 443 tests in 78 suites (down from 455 tests in 80 suites)
2026-02-03 01:40:35 +01:00
phranck 45503c0d80 Feat: Add SwiftUI-style presentation API with .alert() and .modal(isPresented:)
- Add AlertPresentationModifier with Binding<Bool> support
- Add ModalPresentationModifier for custom modal content
- Add View+Presentation.swift with .alert() and .modal(isPresented:) extensions
- Update OverlaysPage to demonstrate new declarative presentation API
- Add comprehensive tests for both presentation modifiers (13 tests)
- Follows SwiftUI API parity rule: same naming, parameter order, and behavior

The new API eliminates manual if/else branching for modals:

Before:
  if showModal {
    content.dimmed().overlay { AlertView() }
  } else {
    content
  }

After:
  content.alert("Title", isPresented: $showModal) {
    Button("OK") { showModal = false }
  }

This mirrors SwiftUI's .alert(isPresented:) and .sheet(isPresented:) patterns.
2026-02-03 01:34:30 +01:00
phranck 3f73327ca2 Refactor: Self-review fixes — early return, test correctness, doc completeness
- FrameBuffer.appendVertically: Early return for empty other buffer,
  use repeatElement instead of Array(repeating:) for spacing
- TerminalOutputBufferTests: Fix multipleFrames test to use sequential
  frames on the same Terminal instance (was testing separate instances)
- ChildInfoProvider.childInfos: Add missing parameter/return docs
2026-02-03 01:11:09 +01:00
phranck f07feb1ed6 Merge pull request #64 from phranck/refactor/render-pipeline-phase4
Refactor: Render Pipeline Phase 4 — Architecture Cleanup
2026-02-03 01:06:47 +01:00
phranck 1cb2d35696 Merge pull request #63 from phranck/refactor/render-pipeline-phase3
Refactor: Render Pipeline Phase 3 — Caching
2026-02-03 01:02:40 +01:00
phranck ed797ac054 Chore: Update test count badge to 455 tests [skip ci] 2026-02-03 00:00:41 +00:00
phranck 8c2e545fed Merge pull request #62 from phranck/refactor/render-pipeline-phase1
Refactor: Line-level diff rendering (Render Pipeline Phase 1)
2026-02-03 00:58:55 +01:00
phranck 71ec79d34a Refactor: Render Pipeline Phase 4 — remove terminal from RenderContext, update docs
Architecture cleanup:
- RenderContext no longer holds a Terminal reference (was never read
  after construction). availableWidth/availableHeight are now required.
- ViewRenderer queries terminal size directly, documented as
  convenience-only (not part of main pipeline).
- ChildInfo/ChildInfoProvider extracted from ViewRenderer.swift into
  own file.

Documentation updates across all 4 phases:
- RenderCycle.md: Rewrote for 12-step pipeline with diffing, buffering,
  and width caching. Replaced 'Why No Double Buffer' with 'Output
  Optimization' section.
- AppLifecycle.md: Updated pipeline from 8 to 12 steps.
- StateManagement.md: Clarified 'no diffing' applies to view tree only,
  not terminal output.

455 tests in 79 suites passing.
2026-02-02 22:30:20 +01:00
phranck 176cfc0db3 Refactor: Cache FrameBuffer.width, eliminate redundant regex and ioctl calls
Three caching optimizations for the render pipeline:

1. FrameBuffer.width is now a stored property, recomputed only when
   lines mutates (didSet). Eliminates ~125 redundant ANSI-stripping
   regex runs per frame from repeated .width accesses.

2. strippedLength counts visible characters by subtracting ANSI match
   lengths instead of allocating an intermediate stripped string.

3. RenderLoop.render() calls terminal.getSize() once per frame instead
   of terminal.width + terminal.height (2 ioctl syscalls → 1).

All 455 tests passing.
2026-02-02 22:10:37 +01:00
phranck 0d6f4a7bf3 Fix: CI concurrency group was static — runs cancelled each other
The concurrency group was hardcoded to "pages", causing every CI run
(any branch) to cancel all other running workflows. Now uses per-ref
groups (ci-$ref) with cancel-in-progress only for pull requests.
2026-02-02 21:55:39 +01:00
phranck a26582bfd8 Refactor: Frame-buffered output — collect all writes per frame, flush in one syscall
Terminal.beginFrame()/endFrame() collects all write() calls in a [UInt8]
buffer (16 KB pre-allocated) and flushes them as a single POSIX write()
syscall. RenderLoop wraps content diff + status bar diff in one frame.

Reduces per-frame syscalls from ~40+ to exactly 1. Combined with Phase 1
line-level diffing, only changed lines are buffered and flushed.

6 new tests with pipe()-based stdout capturing (455 tests in 79 suites).
2026-02-02 21:52:32 +01:00
phranck 5a2c8109c9 Refactor: Self-review fixes — deduplicate diff logic, fix redundant buildEnvironment call 2026-02-02 21:38:18 +01:00
phranck dc34b0fa55 Refactor: Line-level diff rendering — only write changed terminal lines
Introduce FrameDiffWriter that stores the previous frame's output and
compares line-by-line on each render. Only lines that actually differ
are written to the terminal, reducing I/O by ~94% for mostly-static UI.

- Extract output line building from WindowGroup.renderScene into
  FrameDiffWriter.buildOutputLines() pure function
- WindowGroup.renderScene now returns FrameBuffer instead of writing
  directly to terminal
- RenderLoop (now final class) owns FrameDiffWriter for state tracking
- Content and status bar are diffed independently
- SIGWINCH invalidates diff cache for full repaint on resize
- SignalManager gains separate consumeResizeFlag() for resize detection
- 13 new tests for diff computation and output line building
2026-02-02 21:33:43 +01:00
phranck 45222115cd Merge pull request #61 from phranck/feat/spinner-view
Feat: Add Spinner view with dots, line, and bouncing styles
2026-02-02 20:59:59 +01:00
phranck 1c3057df54 Fix: Align last trail opacity with inactive dot opacity (0.15) 2026-02-02 20:59:34 +01:00
phranck 020afa75b0 Refactor: Use uniform dot character (●) for bouncing spinner with 6-step fade trail
Replace mixed block characters (▇/■) with a single ● for all track
positions. Visual distinction comes purely from opacity fade. Extends
trail to 6 steps for a smoother motion effect.
2026-02-02 20:53:12 +01:00
phranck 2cdb0dd3db Refactor: Simplify Spinner API — remove speed, track width, and trail length enums
Radically simplify the Spinner public API from 6 parameters to 3.
Remove SpinnerSpeed, BouncingTrackWidth, and BouncingTrailLength enums.
Hardcode calibrated intervals (dots 110ms, line 140ms, bouncing 100ms),
fixed track width (9), and trail opacities with edge overshoot for
smooth fade-in/fade-out at track edges.
2026-02-02 20:25:38 +01:00
phranck c6ee32f02d Fix: Replace CFAbsoluteTimeGetCurrent with Date for CI portability 2026-02-02 20:10:12 +01:00
phranck dec8c7c29f Feat: Add Spinner view with dots, line, and bouncing styles
Auto-animating loading indicator with three visual styles:
- Dots (braille rotation), Line (ASCII rotation), Bouncing (Larson scanner
  with fade trail). Time-based frame calculation ensures each spinner runs
  at its own speed independent of render triggers.

Configurable: SpinnerSpeed (.slow/.regular/.fast), BouncingTrackWidth
(.minimum/.default/.maximum/.fixed(Int)), BouncingTrailLength
(.short/.regular/.long), custom color.

Run loop upgraded from 100ms to 40ms polling (~25 FPS) to support
smooth animations.
2026-02-02 19:57:58 +01:00
phranck 40e78ebdf3 Chore: Update test count badge to 424 tests [skip ci] 2026-02-02 17:46:21 +00:00
phranck 62e83598f9 Merge pull request #60 from phranck/refactor/state-storage-identity
Feat: Structural identity for @State
2026-02-02 18:44:27 +01:00
phranck bb6b23f0da Fix: Rename @State var count to countValue to avoid SwiftLint empty_count false positive 2026-02-02 18:36:59 +01:00
phranck 50825d61da Feat: Structural identity for @State — persistent state across view reconstruction
Implement SwiftUI-style structural identity so @State values survive
full view-tree reconstruction on every render pass. State is keyed by
ViewIdentity (path-based) and property index in a central StateStorage.

- Add ViewIdentity (path-based structural identity for views)
- Add StateStorage with GC and branch invalidation
- Refactor @State to self-hydrate from StateStorage during init
- Set root hydration context in RenderLoop before app.body evaluation
- Extend RenderContext with identity propagation helpers
- Add ConditionalView branch invalidation on switch
- Remove scene cache from RenderLoop (app.body evaluated fresh per frame)
- Enhance Example App pages with @State (ButtonsPage, OverlaysPage, ContainersPage)
- Update DocC articles (StateManagement, RenderCycle)
- Add 12 tests for state storage identity
2026-02-02 18:31:53 +01:00
phranck e2f654423a Fix: Cache scene in RenderLoop to preserve @State across render passes 2026-02-02 17:41:58 +01:00
phranck 6b6d4e8945 Merge pull request #59 from phranck/refactor/eliminate-appstate-singleton
Refactor: Eliminate AppState singleton via RenderNotifier and constructor injection
2026-02-02 17:37:58 +01:00
phranck 1877ae65e6 Refactor: Eliminate AppState singleton via RenderNotifier and constructor injection 2026-02-02 17:31:52 +01:00
phranck 45e00e43db Merge pull request #58 from phranck/fix/flaky-state-test
Fix: Isolate AppState tests to prevent race condition on Linux
2026-02-02 17:12:04 +01:00
phranck baa7912874 Fix: Isolate AppState tests to prevent race condition on Linux 2026-02-02 17:09:58 +01:00
phranck 7884949a17 Merge pull request #57 from phranck/chore/weak-test-rewrites
Test: Strengthen weak ANSI assertions with specific escape codes
2026-02-02 17:01:04 +01:00
phranck 52fe174142 Test: Strengthen weak ANSI assertions with specific escape codes 2026-02-02 16:58:19 +01:00
phranck 88f632b8ed Chore: Use personal git identity for CI badge commits 2026-02-02 16:57:23 +01:00
phranck 63b6513046 Fix: Include workflow_dispatch in update-badge trigger condition 2026-02-02 16:36:28 +01:00