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:
@@ -155,3 +155,67 @@
|
|||||||
(if (<= (visible-length s) max-width)
|
(if (<= (visible-length s) max-width)
|
||||||
s
|
s
|
||||||
(str (subs s 0 (max 0 (- max-width 1))) "…")))
|
(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
@@ -47,6 +47,7 @@
|
|||||||
|
|
||||||
;; === Layout Primitives ===
|
;; === Layout Primitives ===
|
||||||
(declare render-element)
|
(declare render-element)
|
||||||
|
(declare calculate-flex-sizes)
|
||||||
|
|
||||||
(defn- render-children
|
(defn- render-children
|
||||||
"Render all children and return list of rendered strings."
|
"Render all children and return list of rendered strings."
|
||||||
@@ -54,26 +55,126 @@
|
|||||||
(mapv #(render-element % ctx) children))
|
(mapv #(render-element % ctx) children))
|
||||||
|
|
||||||
(defn- render-row
|
(defn- render-row
|
||||||
"Render :row - horizontal layout."
|
"Render :row - horizontal layout with proper multi-line support.
|
||||||
[{:keys [gap justify align] :or {gap 0}} children ctx]
|
Each child is rendered and placed side-by-side, with lines aligned.
|
||||||
(let [rendered (render-children children ctx)
|
Supports :gap for spacing and :widths for column widths.
|
||||||
separator (apply str (repeat gap " "))]
|
:widths can be numbers (fixed), :flex (share remaining), or nil (auto).
|
||||||
(str/join separator rendered)))
|
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
|
(defn- render-col
|
||||||
"Render :col - vertical layout."
|
"Render :col - vertical layout.
|
||||||
[{:keys [gap] :or {gap 0}} children ctx]
|
Supports :heights for distributing vertical space.
|
||||||
(let [rendered (render-children children ctx)
|
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"))]
|
separator (str/join (repeat gap "\n"))]
|
||||||
(str/join (str "\n" separator) rendered)))
|
(str/join (str "\n" separator) rendered)))
|
||||||
|
|
||||||
(defn- render-box
|
(defn- render-box
|
||||||
"Render :box - bordered container."
|
"Render :box - bordered container.
|
||||||
[{:keys [border title padding width]
|
Supports :width/:height as number or :fill (uses available size from ctx)."
|
||||||
|
[{:keys [border title padding width height]
|
||||||
:or {border :rounded padding 0}}
|
:or {border :rounded padding 0}}
|
||||||
children ctx]
|
children ctx]
|
||||||
(let [chars (get ansi/box-chars border (:rounded ansi/box-chars))
|
(let [;; Calculate target dimensions first (for passing to children)
|
||||||
content (str/join "\n" (render-children children ctx))
|
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)
|
lines (str/split content #"\n" -1)
|
||||||
|
|
||||||
;; Calculate padding
|
;; Calculate padding
|
||||||
@@ -93,7 +194,7 @@
|
|||||||
inner-width (+ max-content-width pad-left pad-right)
|
inner-width (+ max-content-width pad-left pad-right)
|
||||||
;; Title needs: "─ title " = title-length + 3
|
;; Title needs: "─ title " = title-length + 3
|
||||||
title-width (if title (+ (count title) 3) 0)
|
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)
|
content-width (- box-width 2)
|
||||||
|
|
||||||
;; Pad lines
|
;; Pad lines
|
||||||
@@ -104,16 +205,27 @@
|
|||||||
|
|
||||||
;; Add vertical padding
|
;; Add vertical padding
|
||||||
empty-line (apply str (repeat content-width " "))
|
empty-line (apply str (repeat content-width " "))
|
||||||
all-lines (concat
|
base-lines (concat
|
||||||
(repeat pad-top empty-line)
|
(repeat pad-top empty-line)
|
||||||
padded-lines
|
padded-lines
|
||||||
(repeat pad-bottom empty-line))
|
(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
|
;; Build box
|
||||||
top-line (str (:tl chars)
|
top-line (str (:tl chars)
|
||||||
(if title
|
(if title
|
||||||
(str (:h chars) " " 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))))
|
(apply str (repeat content-width (:h chars))))
|
||||||
(:tr chars))
|
(:tr chars))
|
||||||
bottom-line (str (:bl chars)
|
bottom-line (str (:bl chars)
|
||||||
@@ -131,6 +243,67 @@
|
|||||||
(let [line (apply str (repeat width " "))]
|
(let [line (apply str (repeat width " "))]
|
||||||
(str/join "\n" (repeat height line))))
|
(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 ===
|
;; === Main Render Function ===
|
||||||
(defn render-element
|
(defn render-element
|
||||||
"Render a hiccup element to ANSI string."
|
"Render a hiccup element to ANSI string."
|
||||||
@@ -154,6 +327,8 @@
|
|||||||
:col (render-col attrs children ctx)
|
:col (render-col attrs children ctx)
|
||||||
:box (render-box attrs children ctx)
|
:box (render-box attrs children ctx)
|
||||||
:space (render-space 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
|
;; Default: just render children
|
||||||
(apply str (render-children children ctx))))
|
(apply str (render-children children ctx))))
|
||||||
|
|
||||||
|
|||||||
+11
-5
@@ -27,7 +27,7 @@
|
|||||||
Options:
|
Options:
|
||||||
- :init - Initial model (required)
|
- :init - Initial model (required)
|
||||||
- :update - (fn [model msg] [new-model cmd]) (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)
|
- :alt-screen - Use alternate screen buffer (default true)
|
||||||
|
|
||||||
Returns the final model."
|
Returns the final model."
|
||||||
@@ -42,14 +42,20 @@
|
|||||||
|
|
||||||
(try
|
(try
|
||||||
;; Initial render
|
;; 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
|
;; Main loop - simple synchronous
|
||||||
(loop [model init]
|
(loop [model init]
|
||||||
(if-let [key-msg (input/read-key)]
|
(if-let [key-msg (input/read-key)]
|
||||||
(let [[new-model cmd] (update model key-msg)]
|
(let [[new-model cmd] (update model key-msg)
|
||||||
;; Render
|
size (term/get-terminal-size)
|
||||||
(term/render! (render/render (view new-model)))
|
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
|
;; Check for quit
|
||||||
(if (= cmd [:quit])
|
(if (= cmd [:quit])
|
||||||
|
|||||||
Reference in New Issue
Block a user