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)
|
||||
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
@@ -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
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user