Add flex layouts, modal overlays, and input elements

- Add visible-subs in ansi.clj for ANSI-aware substring operations
- Enhance render-row with multi-line support and :widths flex sizing
- Enhance render-col with :heights flex sizing
- Add calculate-flex-sizes for distributing space among flex items
- Enhance render-box with :fill width/height to use available space
- Add :input element for text input with cursor
- Add :modal element for centered overlay on background
- Pass terminal size to view functions and render context

These changes enable responsive terminal UIs that adapt to window size.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 20:56:40 -05:00
parent d069317b22
commit 95b53f7533
3 changed files with 268 additions and 23 deletions
+64
View File
@@ -155,3 +155,67 @@
(if (<= (visible-length s) max-width)
s
(str (subs s 0 (max 0 (- max-width 1))) "…")))
(defn visible-subs
"Substring based on visible character positions (ignoring ANSI escape codes).
Returns substring from visible position start to end (or end of string).
Preserves ANSI escape sequences that affect the visible portion."
([s start] (visible-subs s start nil))
([s start end]
(when s
(let [ansi-pattern #"\u001b\[[0-9;]*m"
;; Split string into segments: ANSI codes and regular text
segments (loop [remaining s
result []]
(if (empty? remaining)
result
(if-let [match (re-find ansi-pattern remaining)]
(let [idx (.indexOf remaining match)]
(if (zero? idx)
;; Starts with ANSI code
(recur (subs remaining (count match))
(conj result {:type :ansi :text match}))
;; Has text before ANSI code
(recur (subs remaining idx)
(conj result {:type :text :text (subs remaining 0 idx)}))))
;; No more ANSI codes, rest is text
(conj result {:type :text :text remaining}))))
;; Build result by tracking visible position
result (loop [segs segments
visible-pos 0
output []
in-range? false]
(if (empty? segs)
output
(let [{:keys [type text]} (first segs)]
(if (= type :ansi)
;; Always include ANSI codes that appear in or after range start
(recur (rest segs)
visible-pos
(if (or in-range? (>= visible-pos start))
(conj output text)
output)
in-range?)
;; Text segment
(let [seg-len (count text)
seg-end (+ visible-pos seg-len)
effective-end (or end Integer/MAX_VALUE)]
(cond
;; Segment entirely before range - skip
(<= seg-end start)
(recur (rest segs) seg-end output false)
;; Segment entirely within or after range end - take partial or stop
(>= visible-pos effective-end)
output
;; Segment overlaps range
:else
(let [take-start (max 0 (- start visible-pos))
take-end (min seg-len (- effective-end visible-pos))
portion (subs text take-start take-end)]
(recur (rest segs)
seg-end
(conj output portion)
true))))))))]
(apply str result)))))
+193 -18
View File
@@ -47,6 +47,7 @@
;; === Layout Primitives ===
(declare render-element)
(declare calculate-flex-sizes)
(defn- render-children
"Render all children and return list of rendered strings."
@@ -54,26 +55,126 @@
(mapv #(render-element % ctx) children))
(defn- render-row
"Render :row - horizontal layout."
[{:keys [gap justify align] :or {gap 0}} children ctx]
(let [rendered (render-children children ctx)
separator (apply str (repeat gap " "))]
(str/join separator rendered)))
"Render :row - horizontal layout with proper multi-line support.
Each child is rendered and placed side-by-side, with lines aligned.
Supports :gap for spacing and :widths for column widths.
:widths can be numbers (fixed), :flex (share remaining), or nil (auto).
Example: [:row {:widths [20 :flex :flex]} child1 child2 child3]"
[{:keys [gap widths] :or {gap 0}} children ctx]
(let [available-width (or (:available-width ctx) 120)
available-height (or (:available-height ctx) 100)
;; Calculate flex widths if :flex is used
calculated-widths (when (and widths (some #(= % :flex) widths))
(calculate-flex-sizes widths children available-width gap))
;; Render each child with its allocated width in context
rendered (map-indexed
(fn [idx child]
(let [child-width (when calculated-widths
(nth calculated-widths idx nil))
child-ctx (cond-> ctx
child-width (assoc :available-width child-width))]
(render-element child child-ctx)))
children)
;; Split each rendered child into lines
child-lines (mapv #(str/split % #"\n" -1) rendered)
;; Calculate width of each child
child-widths (cond
;; Use calculated flex widths
calculated-widths calculated-widths
;; Use provided fixed widths
widths widths
;; Auto: max visible length of lines
:else (mapv (fn [lines]
(apply max 0 (map ansi/visible-length lines)))
child-lines))
;; Find max height (most lines) - rows should only be as tall as content
max-height (apply max 1 (map count child-lines))
;; Gap separator
separator (apply str (repeat gap " "))
;; Build combined lines
combined-lines
(for [line-idx (range max-height)]
(str/join separator
(map-indexed
(fn [child-idx lines]
(let [line (get lines line-idx "")
width (get child-widths child-idx 0)]
(ansi/pad-right line width)))
child-lines)))]
(str/join "\n" combined-lines)))
(defn- calculate-flex-sizes
"Calculate sizes for children given a spec.
Sizes can be: numbers (fixed), :flex (share remaining), or nil (auto).
Returns vector of calculated sizes."
[sizes children available-size gap]
(let [num-children (count children)
sizes-vec (if sizes
(vec (take num-children (concat sizes (repeat nil))))
(vec (repeat num-children nil)))
;; Total gap space
total-gap (* gap (max 0 (dec num-children)))
usable-size (- available-size total-gap)
;; Count fixed sizes and flex items
fixed-total (reduce + 0 (filter number? sizes-vec))
flex-count (count (filter #(= % :flex) sizes-vec))
;; Calculate size per flex item
remaining (- usable-size fixed-total)
flex-size (if (pos? flex-count)
(max 1 (quot remaining flex-count))
0)]
;; Return calculated sizes
(mapv (fn [s]
(cond
(number? s) s
(= s :flex) flex-size
:else nil)) ; nil means auto-size
sizes-vec)))
(defn- render-col
"Render :col - vertical layout."
[{:keys [gap] :or {gap 0}} children ctx]
(let [rendered (render-children children ctx)
"Render :col - vertical layout.
Supports :heights for distributing vertical space.
Heights can be numbers (fixed) or :flex (share remaining space).
Example: [:col {:heights [3 :flex :flex 4]} child1 child2 child3 child4]"
[{:keys [gap heights width height] :or {gap 0}} children ctx]
(let [;; Use explicit width/height if provided, otherwise from context
available-width (or width (:available-width ctx) 120)
available-height (or height (:available-height ctx) 100)
calculated-heights (when heights
(calculate-flex-sizes heights children available-height gap))
;; Render each child with its allocated height in context
rendered (map-indexed
(fn [idx child]
(let [child-height (when calculated-heights
(nth calculated-heights idx nil))
child-ctx (cond-> (assoc ctx :available-width available-width)
child-height (assoc :available-height child-height))]
(render-element child child-ctx)))
children)
separator (str/join (repeat gap "\n"))]
(str/join (str "\n" separator) rendered)))
(defn- render-box
"Render :box - bordered container."
[{:keys [border title padding width]
"Render :box - bordered container.
Supports :width/:height as number or :fill (uses available size from ctx)."
[{:keys [border title padding width height]
:or {border :rounded padding 0}}
children ctx]
(let [chars (get ansi/box-chars border (:rounded ansi/box-chars))
content (str/join "\n" (render-children children ctx))
(let [;; Calculate target dimensions first (for passing to children)
target-width (cond
(number? width) width
(= width :fill) (or (:available-width ctx) 80)
:else nil)
target-height (cond
(number? height) height
(= height :fill) (or (:available-height ctx) 24)
:else nil)
;; Pass constrained dimensions to children
child-ctx (cond-> ctx
target-width (assoc :available-width (- target-width 2))
target-height (assoc :available-height (- target-height 2)))
chars (get ansi/box-chars border (:rounded ansi/box-chars))
content (str/join "\n" (render-children children child-ctx))
lines (str/split content #"\n" -1)
;; Calculate padding
@@ -93,7 +194,7 @@
inner-width (+ max-content-width pad-left pad-right)
;; Title needs: "─ title " = title-length + 3
title-width (if title (+ (count title) 3) 0)
box-width (or width (+ (max inner-width title-width) 2))
box-width (or target-width (+ (max inner-width title-width) 2))
content-width (- box-width 2)
;; Pad lines
@@ -104,16 +205,27 @@
;; Add vertical padding
empty-line (apply str (repeat content-width " "))
all-lines (concat
(repeat pad-top empty-line)
padded-lines
(repeat pad-bottom empty-line))
base-lines (concat
(repeat pad-top empty-line)
padded-lines
(repeat pad-bottom empty-line))
;; Calculate target inner height and clip/fill as needed
inner-height (when target-height (- target-height 2))
all-lines (cond
;; No target height - use all content
(nil? inner-height) base-lines
;; Content shorter than target - fill with empty lines
(> inner-height (count base-lines))
(concat base-lines (repeat (- inner-height (count base-lines)) empty-line))
;; Content taller than target - truncate
:else (take inner-height base-lines))
;; Build box
top-line (str (:tl chars)
(if title
(str (:h chars) " " title " "
(apply str (repeat (- content-width (count title) 4) (:h chars))))
(apply str (repeat (max 0 (- content-width (count title) 3)) (:h chars))))
(apply str (repeat content-width (:h chars))))
(:tr chars))
bottom-line (str (:bl chars)
@@ -131,6 +243,67 @@
(let [line (apply str (repeat width " "))]
(str/join "\n" (repeat height line))))
(defn- render-input
"Render :input - text input with cursor.
Shows value with a cursor character at the end.
Supports :placeholder for empty state."
[{:keys [value placeholder cursor-char] :or {value "" cursor-char "█"}} _ _]
(if (empty? value)
(if placeholder
(str placeholder cursor-char)
cursor-char)
(str value cursor-char)))
(defn- overlay-lines
"Overlay modal lines on top of background lines, centered."
[bg-lines modal-lines bg-width bg-height]
(let [modal-height (count modal-lines)
modal-width (apply max 0 (map ansi/visible-length modal-lines))
;; Calculate top-left position to center modal
top (max 0 (quot (- bg-height modal-height) 2))
left (max 0 (quot (- bg-width modal-width) 2))
;; Ensure we have enough background lines
padded-bg (vec (take bg-height
(concat bg-lines
(repeat (apply str (repeat bg-width " "))))))]
;; Overlay modal lines onto background
(for [row (range bg-height)]
(let [bg-line (get padded-bg row "")
modal-row (- row top)]
(if (and (>= modal-row 0) (< modal-row modal-height))
;; This row has modal content
(let [modal-line (nth modal-lines modal-row)
modal-visible-len (ansi/visible-length modal-line)
;; Build: bg-left + modal + bg-right
;; Use ANSI-aware visible-subs to handle styled backgrounds
padded-bg-line (ansi/pad-right bg-line bg-width)
bg-before (ansi/visible-subs padded-bg-line 0 (min left bg-width))
bg-after-start (+ left modal-visible-len)
bg-after (if (< bg-after-start bg-width)
(ansi/visible-subs padded-bg-line bg-after-start)
"")]
(str bg-before modal-line bg-after))
;; No modal on this row, just background
(ansi/pad-right bg-line bg-width))))))
(defn- render-modal
"Render :modal - overlay content centered on background.
First child is background, second child is the modal content.
Example: [:modal {} background-view modal-view]"
[attrs children ctx]
(let [available-width (or (:available-width ctx) 120)
available-height (or (:available-height ctx) 30)
[bg-child modal-child] children
;; Render background
bg-rendered (render-element bg-child ctx)
bg-lines (str/split bg-rendered #"\n" -1)
;; Render modal
modal-rendered (render-element modal-child ctx)
modal-lines (str/split modal-rendered #"\n" -1)
;; Overlay
result-lines (overlay-lines bg-lines modal-lines available-width available-height)]
(str/join "\n" result-lines)))
;; === Main Render Function ===
(defn render-element
"Render a hiccup element to ANSI string."
@@ -154,6 +327,8 @@
:col (render-col attrs children ctx)
:box (render-box attrs children ctx)
:space (render-space attrs children ctx)
:input (render-input attrs children ctx)
:modal (render-modal attrs children ctx)
;; Default: just render children
(apply str (render-children children ctx))))
+11 -5
View File
@@ -27,7 +27,7 @@
Options:
- :init - Initial model (required)
- :update - (fn [model msg] [new-model cmd]) (required)
- :view - (fn [model] hiccup) (required)
- :view - (fn [model size] hiccup) where size is {:width w :height h} (required)
- :alt-screen - Use alternate screen buffer (default true)
Returns the final model."
@@ -42,14 +42,20 @@
(try
;; Initial render
(term/render! (render/render (view init)))
(let [size (term/get-terminal-size)
ctx {:available-height (:height size)
:available-width (:width size)}]
(term/render! (render/render (view init size) ctx)))
;; Main loop - simple synchronous
(loop [model init]
(if-let [key-msg (input/read-key)]
(let [[new-model cmd] (update model key-msg)]
;; Render
(term/render! (render/render (view new-model)))
(let [[new-model cmd] (update model key-msg)
size (term/get-terminal-size)
ctx {:available-height (:height size)
:available-width (:width size)}]
;; Render with context for flex layouts
(term/render! (render/render (view new-model size) ctx))
;; Check for quit
(if (= cmd [:quit])