diff --git a/deps.edn b/deps.edn index 579baa3..ad29984 100644 --- a/deps.edn +++ b/deps.edn @@ -14,7 +14,7 @@ cider/cider-nrepl {:mvn/version "0.58.0"}} :main-opts ["-m" "nrepl.cmdline" "--middleware" "[cider.nrepl/cider-middleware]"]} - :examples {:extra-paths ["doc"]} + :examples {:extra-deps {charm/examples {:local/root "doc/examples"}}} :user {:extra-paths ["user"]} diff --git a/doc/examples/bb.edn b/doc/examples/bb.edn index 8b32028..7f002af 100644 --- a/doc/examples/bb.edn +++ b/doc/examples/bb.edn @@ -1,4 +1,5 @@ -{:paths ["src" "../../src"] +{:paths ["src" "../../src" "resources"] + :deps {djblue/portal {:mvn/version "0.58.5"}} :tasks {cheatsheet {:doc "Run cheatsheet example" :task (exec 'examples.cheatsheet/-main)} @@ -21,6 +22,9 @@ form {:doc "Run form example" :task (exec 'examples.form/-main)} + talk {:doc "Run talk example" + :task (exec 'examples.talk/-main)} + pomodoro {:doc "Run pomodoro example" :task (exec 'examples.pomodoro/-main)} @@ -49,4 +53,15 @@ (shell "vhs vhs/sketch.tape") (shell "vhs vhs/spinner.tape") #_(shell "vhs vhs/timer.tape") - (shell "vhs vhs/todos.tape"))}}} + (shell "vhs vhs/todos.tape"))} + + portal {:doc "Start nREPL server with portal + tap> wired up" + :requires ([babashka.nrepl.server :as nrepl] + [portal.api :as p]) + :task (do + (p/open) + (add-tap p/submit) + (println "Portal opened. nREPL on localhost:1667") + (nrepl/start-server! {:port 1667}) + (deref (promise)))}}} + diff --git a/doc/examples/deps.edn b/doc/examples/deps.edn index 306da2f..26404f2 100644 --- a/doc/examples/deps.edn +++ b/doc/examples/deps.edn @@ -1,4 +1,4 @@ -{:paths ["src"] +{:paths ["src" "resources"] :deps {de.timokramer/charm.clj {:local/root "../../"} com.github.clj-easy/graal-build-time {:mvn/version "1.0.5"}} :aliases diff --git a/doc/examples/resources/talk/slide01.txt b/doc/examples/resources/talk/slide01.txt new file mode 100644 index 0000000..375510f --- /dev/null +++ b/doc/examples/resources/talk/slide01.txt @@ -0,0 +1,13 @@ +__ __ _ _ _ _ +\ \ / / __(_) |_ ___ ___| |__ __ _ _ __ _ __ ___ (_)_ __ __ _ + \ \ /\ / / '__| | __/ _ \ / __| '_ \ / _` | '__| '_ ` _ \| | '_ \ / _` | + \ V V /| | | | || __/ | (__| | | | (_| | | | | | | | | | | | | (_| | + \_/\_/ |_| |_|\__\___| \___|_| |_|\__,_|_| |_| |_| |_|_|_| |_|\__, | + |___/ + _____ _ _ ___ _ _ _ _ _ +|_ _| | | |_ _|___ (_)_ __ | |__ __ _| |__ __ _ ___| |__ | | ____ _ + | | | | | || |/ __| | | '_ \ | '_ \ / _` | '_ \ / _` / __| '_ \| |/ / _` | + | | | |_| || |\__ \ | | | | | | |_) | (_| | |_) | (_| \__ \ | | | < (_| | + |_| \___/|___|___/ |_|_| |_| |_.__/ \__,_|_.__/ \__,_|___/_| |_|_|\_\__,_| + +by Timo Kramer diff --git a/doc/examples/resources/talk/slide02.txt b/doc/examples/resources/talk/slide02.txt new file mode 100644 index 0000000..2c58659 --- /dev/null +++ b/doc/examples/resources/talk/slide02.txt @@ -0,0 +1,6 @@ + _____ _ _ _ _ +|_ _| |__ ___ | |__ ___ __ _(_)_ __ _ __ (_)_ __ __ _ + | | | '_ \ / _ \ | '_ \ / _ \/ _` | | '_ \| '_ \| | '_ \ / _` | + | | | | | | __/ | |_) | __/ (_| | | | | | | | | | | | | (_| | + |_| |_| |_|\___| |_.__/ \___|\__, |_|_| |_|_| |_|_|_| |_|\__, | + |___/ |___/ diff --git a/doc/examples/resources/talk/slide03.txt b/doc/examples/resources/talk/slide03.txt new file mode 100644 index 0000000..e241f49 --- /dev/null +++ b/doc/examples/resources/talk/slide03.txt @@ -0,0 +1,13 @@ + _ _ _ _ + (_) (_)_ __ ___ __ _ _ __ __| | + | | | | '_ \ / _ \ / _` | '_ \ / _` | + | | | | | | | __/ | (_| | | | | (_| | + _/ |_|_|_| |_|\___| \__,_|_| |_|\__,_| +|__/ + _ _ _ _ + ___| |__ __ _ _ __ _ __ ___ | |__ _ __ __ _ ___ ___| | ___| |_ + / __| '_ \ / _` | '__| '_ ` _ \| '_ \| '__/ _` |/ __/ _ \ |/ _ \ __| +| (__| | | | (_| | | | | | | | | |_) | | | (_| | (_| __/ | __/ |_ + \___|_| |_|\__,_|_| |_| |_| |_|_.__/|_| \__,_|\___\___|_|\___|\__| + +😈😊🏳️‍🌈🙂‍↔️👨‍👩‍👧🇳🇱👍🏿 diff --git a/doc/examples/resources/talk/slide04.txt b/doc/examples/resources/talk/slide04.txt new file mode 100644 index 0000000..47aa9af --- /dev/null +++ b/doc/examples/resources/talk/slide04.txt @@ -0,0 +1,12 @@ + ____ _ _ _ +| __ ) __ _| |_| |_ ___ _ __(_) ___ ___ +| _ \ / _` | __| __/ _ \ '__| |/ _ \/ __| +| |_) | (_| | |_| || __/ | | | __/\__ \ +|____/ \__,_|\__|\__\___|_| |_|\___||___/ + + _ _ _ _ +(_)_ __ ___| |_ _ __| | ___ __| | +| | '_ \ / __| | | | |/ _` |/ _ \/ _` | +| | | | | (__| | |_| | (_| | __/ (_| | +|_|_| |_|\___|_|\__,_|\__,_|\___|\__,_| + diff --git a/doc/examples/resources/talk/slide05.txt b/doc/examples/resources/talk/slide05.txt new file mode 100644 index 0000000..9ccb663 --- /dev/null +++ b/doc/examples/resources/talk/slide05.txt @@ -0,0 +1,12 @@ + ____ _____ ____ _ +| _ \| ____| _ \| | +| |_) | _| | |_) | | +| _ <| |___| __/| |___ +|_| \_\_____|_| |_____| + + _ _ _ + __| | _____ _____| | ___ _ __ _ __ ___ ___ _ __ | |_ + / _` |/ _ \ \ / / _ \ |/ _ \| '_ \| '_ ` _ \ / _ \ '_ \| __| +| (_| | __/\ V / __/ | (_) | |_) | | | | | | __/ | | | |_ + \__,_|\___| \_/ \___|_|\___/| .__/|_| |_| |_|\___|_| |_|\__| + |_| diff --git a/doc/examples/resources/talk/slide06.txt b/doc/examples/resources/talk/slide06.txt new file mode 100644 index 0000000..29fbc9d --- /dev/null +++ b/doc/examples/resources/talk/slide06.txt @@ -0,0 +1,7 @@ + ___ _ + / _ \__ _____ _ __| | __ _ _ _ +| | | \ \ / / _ \ '__| |/ _` | | | | +| |_| |\ V / __/ | | | (_| | |_| | + \___/ \_/ \___|_| |_|\__,_|\__, | + |___/ + diff --git a/doc/examples/resources/talk/slide07.txt b/doc/examples/resources/talk/slide07.txt new file mode 100644 index 0000000..3f7b1b9 --- /dev/null +++ b/doc/examples/resources/talk/slide07.txt @@ -0,0 +1,6 @@ + _ _ _ _ + / \ _ __ (_)_ __ ___ __ _| |_(_) ___ _ __ ___ + / _ \ | '_ \| | '_ ` _ \ / _` | __| |/ _ \| '_ \/ __| + / ___ \| | | | | | | | | | (_| | |_| | (_) | | | \__ \ +/_/ \_\_| |_|_|_| |_| |_|\__,_|\__|_|\___/|_| |_|___/ + diff --git a/doc/examples/resources/talk/slide08.txt b/doc/examples/resources/talk/slide08.txt new file mode 100644 index 0000000..2760c5a --- /dev/null +++ b/doc/examples/resources/talk/slide08.txt @@ -0,0 +1,6 @@ + ____ _ _ _ +| _ \ __ _ __ _(_)_ __ __ _| |_(_) ___ _ __ +| |_) / _` |/ _` | | '_ \ / _` | __| |/ _ \| '_ \ +| __/ (_| | (_| | | | | | (_| | |_| | (_) | | | | +|_| \__,_|\__, |_|_| |_|\__,_|\__|_|\___/|_| |_| + |___/ diff --git a/doc/examples/resources/talk/slide09.txt b/doc/examples/resources/talk/slide09.txt new file mode 100644 index 0000000..244be02 --- /dev/null +++ b/doc/examples/resources/talk/slide09.txt @@ -0,0 +1,6 @@ + _____ _ _ +| ____|_ __ ___ ___ (_|_)___ +| _| | '_ ` _ \ / _ \| | / __| +| |___| | | | | | (_) | | \__ \ +|_____|_| |_| |_|\___// |_|___/ + |__/ diff --git a/doc/examples/src/examples/pomodoro.clj b/doc/examples/src/examples/pomodoro.clj index abf842e..d209247 100644 --- a/doc/examples/src/examples/pomodoro.clj +++ b/doc/examples/src/examples/pomodoro.clj @@ -15,8 +15,7 @@ [charm.components.timer :as timer] [charm.message :as msg] [charm.program :as program] - [charm.style.core :as style] - [clojure.string :as str]) + [charm.style.core :as style]) (:gen-class)) ;; --------------------------------------------------------------------------- @@ -96,11 +95,12 @@ (style/render hint-style "↑/↓ to select, Enter to start, q to quit")))) ;; --------------------------------------------------------------------------- -;; View - Timer Running +;; View - Timer ;; --------------------------------------------------------------------------- -(defn view-running [state] - (let [{:keys [phase timer total-ms cycle-count]} state +(defn view-timer [state] + (let [{:keys [phase timer total-ms cycle-count screen]} state + paused? (= screen :paused) remaining (max 0 (timer/timeout timer)) elapsed (- total-ms remaining) p (if (pos? total-ms) (/ elapsed total-ms) 0.0) @@ -112,6 +112,8 @@ :empty-style (style/style :fg (gradient-color p phase true)))] (str (style/render (phase-style phase) phase-label) (style/render hint-style (str " (cycle " cycle-count ")")) + (when paused? " ") + (when paused? (style/render (style/style :fg (style/rgb 255 200 100) :bold true) "PAUSED")) "\n\n" (progress/progress-view bar) " " @@ -119,32 +121,6 @@ "\n\n" (style/render hint-style "p to pause/resume, q to quit")))) -;; --------------------------------------------------------------------------- -;; View - Paused -;; --------------------------------------------------------------------------- - -(defn view-paused [state] - (let [{:keys [phase timer total-ms cycle-count]} state - remaining (max 0 (timer/timeout timer)) - elapsed (- total-ms remaining) - p (if (pos? total-ms) (/ elapsed total-ms) 0.0) - phase-label (if (= phase :work) "WORK" "BREAK") - bar (progress/progress-bar :width 30 - :percent p - :bar-style :thick - :full-style (style/style :fg (gradient-color p phase)) - :empty-style (style/style :fg (gradient-color p phase true)))] - (str (style/render (phase-style phase) phase-label) - (style/render hint-style (str " (cycle " cycle-count ")")) - " " - (style/render (style/style :fg (style/rgb 255 200 100) :bold true) "PAUSED") - "\n\n" - (progress/progress-view bar) - " " - (style/render time-style (format-remaining remaining)) - "\n\n" - (style/render hint-style "p to resume, q to quit")))) - ;; --------------------------------------------------------------------------- ;; Main View ;; --------------------------------------------------------------------------- @@ -152,8 +128,8 @@ (defn view [state] (case (:screen state) :selecting (view-selecting state) - :running (view-running state) - :paused (view-paused state))) + :running (view-timer state) + :paused (view-timer state))) ;; --------------------------------------------------------------------------- ;; Timer Helpers @@ -180,6 +156,7 @@ ;; --------------------------------------------------------------------------- (defn update-fn [state msg] + (tap> state) (let [{:keys [screen]} state] (cond ;; Global quit @@ -269,3 +246,10 @@ :alt-screen false :hide-cursor true}) (println)))) + +(comment + (def app (program/run-async {:init init + :update #'update-fn + :view #'view + :alt-screen false + :hide-cursor true}))) diff --git a/doc/examples/src/examples/talk.clj b/doc/examples/src/examples/talk.clj new file mode 100644 index 0000000..86b825b --- /dev/null +++ b/doc/examples/src/examples/talk.clj @@ -0,0 +1,228 @@ +(ns examples.talk + "10 mins lightning talk at babashka conf 2026" + (:require + [charm.components.help :as help] + [charm.components.paginator :as paginator] + [charm.components.progress :as progress] + [charm.components.timer :as timer] + [charm.message :as msg] + [charm.program :as program] + [charm.style.border :as border] + [charm.style.core :as style] + [clojure.java.io :as io] + [clojure.string :as str])) + +(def items + (->> (io/file (io/resource "talk")) + .listFiles + (filter #(.endsWith (.getName %) ".txt")) + (sort-by #(.getName %)) + (mapv #(str/trimr (slurp %))))) + +(def title-style + (style/style :fg style/red :bold true)) + +(def item-style + (style/style :fg style/black)) + +(def pag-style + (style/style :fg nil)) + +(def hint-style + (style/style :faint true)) + +(def active-dot-style + (style/style :fg style/cyan :bold true)) + +(def inactive-dot-style + (style/style :fg 240)) + +(def paused-style + (style/style :fg (style/rgb 255 150 100) :bold true)) + +(def footer-style + (style/style :fg style/cyan :faint true)) + +(def footer-text "github.com/timokramer/charm.clj") + +;; Reserved rows: title (1) + paginator (1) + progress (1) + hint (1) + spacers (2) = 6 +(def chrome-rows 6) + +(defn gradient-color + "Smooth green -> yellow -> red ramp based on progress. + Dim variant for the empty side of the bar." + ([p] (gradient-color p false)) + ([p dim?] + (let [p (double p) + ;; Two segments: 0.0..0.5 green->yellow, 0.5..1.0 yellow->red + [r1 g1 b1 r2 g2 b2 t] (if (< p 0.5) + [100 220 140 255 200 100 (* p 2.0)] + [255 200 100 255 100 100 (* (- p 0.5) 2.0)]) + r (+ r1 (* t (- r2 r1))) + g (+ g1 (* t (- g2 g1))) + b (+ b1 (* t (- b2 b1))) + factor (if dim? 0.4 1.0)] + (style/rgb (int (* r factor)) + (int (* g factor)) + (int (* b factor)))))) + +(defn format-remaining + "Format milliseconds as human-readable remaining time." + [ms] + (let [total-seconds (max 0 (quot ms 1000)) + minutes (quot total-seconds 60) + seconds (rem total-seconds 60)] + (format "%02d:%02d" minutes seconds))) + +(defn view-timer + "Full-width progress bar with remaining time, sized to the terminal." + [state] + (let [{:keys [timer phase total-ms term-w]} state + paused? (= phase :paused) + done? (= phase :done) + remaining (max 0 (timer/timeout timer)) + elapsed (- total-ms remaining) + p (if (pos? total-ms) (/ elapsed total-ms) 0.0) + color (gradient-color p) + time-text (format-remaining remaining) + suffix (cond + done? " TIME!" + paused? " PAUSED" + :else "") + ;; account for time text + 2 spaces gap + suffix + bar-width (max 10 (- term-w (count time-text) (count suffix) 2)) + bar (progress/progress-bar :width bar-width + :percent p + :bar-style :thick + :full-style (style/style :fg color) + :empty-style (style/style :fg (gradient-color p true)))] + (str (progress/progress-view bar) + " " + (style/render (style/style :fg color :bold true) time-text) + (when (or paused? done?) + (style/render paused-style suffix))))) + +(defn card-style + "Card sized to fill the terminal minus title and help." + [{:keys [term-w term-h]}] + (style/style :border border/double-border + :border-fg style/cyan + :padding [1 2] + :width (- term-w 2) ; -2 for left/right border + :height (- term-h chrome-rows 2))) ; -2 for top/bottom border + +(defn init [] + (let [talk-ms (* 10 60 1000)] + [{:pager (paginator/paginator + :total-pages (count items) + :active-style active-dot-style + :inactive-style inactive-dot-style) + :arabic (paginator/paginator + :total-pages (count items) + :type :arabic + :arabic-format "page %d of %d" + :active-style pag-style) + :term-w 80 + :term-h 24 + :talk-ms talk-ms + :total-ms talk-ms + :phase :paused + :timer (timer/timer :timeout talk-ms + :interval 100 + :running false) + :help (help/help (help/from-pairs "←/→ or h/l" "navigate" + "p" "pause/resume" + "q" "quit"))} + nil])) + +(defn update-fn [state msg] + (let [{:keys [phase]} state] + (cond + (msg/window-size? msg) + [(assoc state :term-w (:width msg) :term-h (:height msg)) nil] + + (or (msg/key-match? msg "q") + (msg/key-match? msg "ctrl+c") + (msg/key-match? msg "esc")) + [state program/quit-cmd] + + (msg/key-match? msg "p") + (cond + (= phase :running) + (let [[new-timer _] (timer/stop (:timer state))] + [(assoc state :phase :paused :timer new-timer) nil]) + + (= phase :done) + [state nil] + + :else + (let [[new-timer cmd] (timer/start (:timer state))] + [(assoc state :phase :running :timer new-timer) cmd])) + + (timer/tick-msg? msg) + (let [[new-timer cmd] (timer/timer-update (:timer state) msg) + new-state (assoc state :timer new-timer)] + (if (timer/timed-out? new-timer) + [(assoc new-state :phase :done) nil] + [new-state cmd])) + + :else + (let [[pager _] (paginator/paginator-update (:pager state) msg) + [arabic _] (paginator/paginator-update (:arabic state) msg)] + [(assoc state :pager pager :arabic arabic) nil])))) + +(defn page-items + "Slice items for the current page." + [pager] + (let [[start end] (paginator/slice-bounds pager (count items))] + (subvec items start end))) + +(defn view [state] + (tap> state) + (let [{:keys [pager arabic term-w term-h help]} state + raw-items (page-items pager) + rows (->> raw-items + (map #(style/render item-style %)) + (str/join "\n")) + ;; Card content height = card visual - 2 borders - 2 vertical padding. + ;; The card auto-expands horizontally to fit its widest line, so use + ;; that as the alignment target rather than the configured :width. + content-rows (max 4 (- term-h chrome-rows 4)) + max-slide-w (->> raw-items + (mapcat str/split-lines) + (map count) + (apply max 0)) + content-cols (max (- term-w 6) max-slide-w) + slide-lines (inc (count (filter #{\newline} rows))) + pad-lines (max 1 (- content-rows slide-lines)) + footer-pad (apply str (repeat (max 0 (- content-cols (count footer-text))) " ")) + body (str rows + (apply str (repeat pad-lines "\n")) + footer-pad + (style/render footer-style footer-text)) + paginator (str (paginator/paginator-view pager) " " + (paginator/paginator-view arabic))] + (str (style/render title-style "babashka conf 2026 Amsterdam") "\n" + (style/render (card-style state) body) "\n" + (style/render pag-style paginator) "\n\n" + (view-timer state) "\n\n" + (style/render hint-style (help/short-help-view help))))) + +(defn -main [& _args] + (program/run {:init init + :update update-fn + :view view + :alt-screen true})) + +(comment + (def app (program/run-async {:init init + :update #'update-fn + :view #'view + :alt-screen true})) + + ((:quit! app)) + + (def help + [{:key "←/→ or h/l", :desc "navigate"} + {:key "p", :desc "pause/resume"} + {:key "q", :desc "quit"}]));; No width constraint diff --git a/src/charm/components/help.clj b/src/charm/components/help.clj index 44c7a4b..760a8d3 100644 --- a/src/charm/components/help.clj +++ b/src/charm/components/help.clj @@ -121,9 +121,7 @@ [hlp] (let [{:keys [bindings width separator separator-style ellipsis]} hlp sep (style/render separator-style separator)] - (if (zero? width) - ;; No width constraint - (str/join sep (map #(render-binding hlp %) bindings)) + (if width ;; With width constraint - truncate as needed (loop [result [] remaining bindings @@ -143,7 +141,9 @@ (str (str/join sep result) sep (render-bg (:bg hlp) ellipsis)) (recur (conj result rendered) (rest remaining) - new-width)))))))) + new-width))))) + ;; No width constraint + (str/join sep (map #(render-binding hlp %) bindings))))) (defn full-help-view "Render full help (multi-line grouped)." diff --git a/src/charm/components/paginator.clj b/src/charm/components/paginator.clj index 60c2f71..c3bd5e0 100644 --- a/src/charm/components/paginator.clj +++ b/src/charm/components/paginator.clj @@ -12,13 +12,6 @@ (:require [charm.style.core :as style] [charm.message :as msg])) -;; --------------------------------------------------------------------------- -;; Paginator Types -;; --------------------------------------------------------------------------- - -(def type-dots :dots) -(def type-arabic :arabic) - ;; --------------------------------------------------------------------------- ;; Key Bindings ;; --------------------------------------------------------------------------- diff --git a/src/charm/style/layout.clj b/src/charm/style/layout.clj index 0e0288e..b8c2647 100644 --- a/src/charm/style/layout.clj +++ b/src/charm/style/layout.clj @@ -38,11 +38,11 @@ ;; --------------------------------------------------------------------------- (defn- split-lines - "Split text into lines, handling empty strings." + "Split text into lines, preserving trailing empty lines." [s] (if (empty? s) [""] - (str/split-lines s))) + (str/split s #"\r?\n" -1))) (defn- widest-line "Get the width of the widest line in text."