diff --git a/src/tui/ansi.clj b/src/tui/ansi.clj index 5bbbfb6..aa5ca02 100644 --- a/src/tui/ansi.clj +++ b/src/tui/ansi.clj @@ -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))))) diff --git a/src/tui/render.clj b/src/tui/render.clj index 6e7a8a7..38ec0cf 100644 --- a/src/tui/render.clj +++ b/src/tui/render.clj @@ -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)))) diff --git a/src/tui/simple.clj b/src/tui/simple.clj index 6dcfdfe..613265d 100644 --- a/src/tui/simple.clj +++ b/src/tui/simple.clj @@ -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])