From 996adc4fdd18937b655c0c55cdac31d5c6caefd5 Mon Sep 17 00:00:00 2001 From: Timo Kramer Date: Thu, 7 May 2026 16:45:31 +0200 Subject: [PATCH 1/2] doc: add talk presentation as paginator example --- doc/examples/bb.edn | 19 +++- doc/examples/deps.edn | 2 +- doc/examples/resources/talk/slide01.txt | 15 ++++ doc/examples/resources/talk/slide02.txt | 6 ++ doc/examples/resources/talk/slide03.txt | 15 ++++ doc/examples/resources/talk/slide04.txt | 13 +++ doc/examples/resources/talk/slide05.txt | 14 +++ doc/examples/resources/talk/slide06.txt | 8 ++ doc/examples/resources/talk/slide07.txt | 6 ++ doc/examples/src/examples/talk.clj | 114 ++++++++++++++++++++++++ src/charm/style/layout.clj | 4 +- 11 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 doc/examples/resources/talk/slide01.txt create mode 100644 doc/examples/resources/talk/slide02.txt create mode 100644 doc/examples/resources/talk/slide03.txt create mode 100644 doc/examples/resources/talk/slide04.txt create mode 100644 doc/examples/resources/talk/slide05.txt create mode 100644 doc/examples/resources/talk/slide06.txt create mode 100644 doc/examples/resources/talk/slide07.txt create mode 100644 doc/examples/src/examples/talk.clj diff --git a/doc/examples/bb.edn b/doc/examples/bb.edn index 8b32028..1bc3068 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)} + presentation {:doc "Run presentation example" + :task (exec 'examples.paginator-demo/-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..2fa1a06 --- /dev/null +++ b/doc/examples/resources/talk/slide01.txt @@ -0,0 +1,15 @@ +__ __ _ _ _ _ +\ \ / / __(_) |_ ___ ___| |__ __ _ _ __ _ __ ___ (_)_ __ __ _ + \ \ /\ / / '__| | __/ _ \ / __| '_ \ / _` | '__| '_ ` _ \| | '_ \ / _` | + \ V V /| | | | || __/ | (__| | | | (_| | | | | | | | | | | | | (_| | + \_/\_/ |_| |_|\__\___| \___|_| |_|\__,_|_| |_| |_| |_|_|_| |_|\__, | + |___/ + _____ _ _ ___ _ _ _ _ _ +|_ _| | | |_ _|___ (_)_ __ | |__ __ _| |__ __ _ ___| |__ | | ____ _ + | | | | | || |/ __| | | '_ \ | '_ \ / _` | '_ \ / _` / __| '_ \| |/ / _` | + | | | |_| || |\__ \ | | | | | | |_) | (_| | |_) | (_| \__ \ | | | < (_| | + |_| \___/|___|___/ |_|_| |_| |_.__/ \__,_|_.__/ \__,_|___/_| |_|_|\_\__,_| + +by Timo Kramer + +github.com/timokramer/charm.clj 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..0e37f22 --- /dev/null +++ b/doc/examples/resources/talk/slide03.txt @@ -0,0 +1,15 @@ + _ _ _ _ + (_) (_)_ __ ___ __ _ _ __ __| | + | | | | '_ \ / _ \ / _` | '_ \ / _` | + | | | | | | | __/ | (_| | | | | (_| | + _/ |_|_|_| |_|\___| \__,_|_| |_|\__,_| +|__/ + _ _ _ _ + ___| |__ __ _ _ __ _ __ ___ | |__ _ __ __ _ ___ ___| | ___| |_ + / __| '_ \ / _` | '__| '_ ` _ \| '_ \| '__/ _` |/ __/ _ \ |/ _ \ __| +| (__| | | | (_| | | | | | | | | |_) | | | (_| | (_| __/ | __/ |_ + \___|_| |_|\__,_|_| |_| |_| |_|_.__/|_| \__,_|\___\___|_|\___|\__| + +😈😊🏳️‍🌈🙂‍↔️👨‍👩‍👧🇳🇱👍🏿 + +github.com/timokramer/charm.clj diff --git a/doc/examples/resources/talk/slide04.txt b/doc/examples/resources/talk/slide04.txt new file mode 100644 index 0000000..d37f0cf --- /dev/null +++ b/doc/examples/resources/talk/slide04.txt @@ -0,0 +1,13 @@ + ____ _ _ _ +| __ ) __ _| |_| |_ ___ _ __(_) ___ ___ +| _ \ / _` | __| __/ _ \ '__| |/ _ \/ __| +| |_) | (_| | |_| || __/ | | | __/\__ \ +|____/ \__,_|\__|\__\___|_| |_|\___||___/ + + _ _ _ _ +(_)_ __ ___| |_ _ __| | ___ __| | +| | '_ \ / __| | | | |/ _` |/ _ \/ _` | +| | | | | (__| | |_| | (_| | __/ (_| | +|_|_| |_|\___|_|\__,_|\__,_|\___|\__,_| + +github.com/timokramer/charm.clj diff --git a/doc/examples/resources/talk/slide05.txt b/doc/examples/resources/talk/slide05.txt new file mode 100644 index 0000000..49276a0 --- /dev/null +++ b/doc/examples/resources/talk/slide05.txt @@ -0,0 +1,14 @@ + ____ _____ ____ _ +| _ \| ____| _ \| | +| |_) | _| | |_) | | +| _ <| |___| __/| |___ +|_| \_\_____|_| |_____| + + _ _ _ + __| | _____ _| | ___ _ __ _ __ ___ ___ _ __ | |_ + / _` |/ _ \ \ / / |/ _ \| '_ \| '_ ` _ \ / _ \ '_ \| __| +| (_| | __/\ V /| | (_) | |_) | | | | | | __/ | | | |_ + \__,_|\___| \_/ |_|\___/| .__/|_| |_| |_|\___|_| |_|\__| + |_| + +github.com/timokramer/charm.clj diff --git a/doc/examples/resources/talk/slide06.txt b/doc/examples/resources/talk/slide06.txt new file mode 100644 index 0000000..182068c --- /dev/null +++ b/doc/examples/resources/talk/slide06.txt @@ -0,0 +1,8 @@ + ___ _ + / _ \__ _____ _ __| | __ _ _ _ +| | | \ \ / / _ \ '__| |/ _` | | | | +| |_| |\ V / __/ | | | (_| | |_| | + \___/ \_/ \___|_| |_|\__,_|\__, | + |___/ + +github.com/timokramer/charm.clj diff --git a/doc/examples/resources/talk/slide07.txt b/doc/examples/resources/talk/slide07.txt new file mode 100644 index 0000000..244be02 --- /dev/null +++ b/doc/examples/resources/talk/slide07.txt @@ -0,0 +1,6 @@ + _____ _ _ +| ____|_ __ ___ ___ (_|_)___ +| _| | '_ ` _ \ / _ \| | / __| +| |___| | | | | | (_) | | \__ \ +|_____|_| |_| |_|\___// |_|___/ + |__/ diff --git a/doc/examples/src/examples/talk.clj b/doc/examples/src/examples/talk.clj new file mode 100644 index 0000000..b2b8e02 --- /dev/null +++ b/doc/examples/src/examples/talk.clj @@ -0,0 +1,114 @@ +(ns examples.talk + "10 mins lightning talk at babashka conf 2026" + (:require + [charm.components.paginator :as paginator] + [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 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)) + +;; Reserved rows: title (1) + blank (1) + blank (1) + help (1) = 4 +(def chrome-rows 4) + +(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 + #_#_:valign :center + #_#_:align :center)) + +(defn init [] + [{: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} + nil]) + +(defn update-fn [state msg] + (tap> 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] + + :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] + (let [{:keys [pager arabic]} state + rows (->> (page-items pager) + (map (fn [i] + (style/render item-style i))) + (str/join "\n")) + body (str rows + "\n\n") + paginator (str (paginator/paginator-view pager) " " + (paginator/paginator-view arabic))] + (str (style/render title-style "babashka conf 2026 Amsterdam city") "\n" + (style/render (card-style state) body) "\n" + (style/render pag-style paginator) "\n\n" + (style/render hint-style "←/→ or h/l to navigate, q to quit")))) + +(defn -main [& _args] + (program/run-async {: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))) 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." From 82424187961751e1a24ef0a7801aa7eb96f74285 Mon Sep 17 00:00:00 2001 From: Timo Kramer Date: Thu, 7 May 2026 19:14:29 +0200 Subject: [PATCH 2/2] doc: improve talk presentation example --- deps.edn | 2 +- doc/examples/bb.edn | 4 +- doc/examples/resources/talk/slide01.txt | 2 - doc/examples/resources/talk/slide03.txt | 2 - doc/examples/resources/talk/slide04.txt | 1 - doc/examples/resources/talk/slide05.txt | 14 +- doc/examples/resources/talk/slide06.txt | 1 - doc/examples/resources/talk/slide07.txt | 12 +- doc/examples/resources/talk/slide08.txt | 6 + doc/examples/resources/talk/slide09.txt | 6 + doc/examples/src/examples/pomodoro.clj | 50 ++---- doc/examples/src/examples/talk.clj | 202 ++++++++++++++++++------ src/charm/components/help.clj | 8 +- src/charm/components/paginator.clj | 7 - 14 files changed, 206 insertions(+), 111 deletions(-) create mode 100644 doc/examples/resources/talk/slide08.txt create mode 100644 doc/examples/resources/talk/slide09.txt 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 1bc3068..7f002af 100644 --- a/doc/examples/bb.edn +++ b/doc/examples/bb.edn @@ -22,8 +22,8 @@ form {:doc "Run form example" :task (exec 'examples.form/-main)} - presentation {:doc "Run presentation example" - :task (exec 'examples.paginator-demo/-main)} + talk {:doc "Run talk example" + :task (exec 'examples.talk/-main)} pomodoro {:doc "Run pomodoro example" :task (exec 'examples.pomodoro/-main)} diff --git a/doc/examples/resources/talk/slide01.txt b/doc/examples/resources/talk/slide01.txt index 2fa1a06..375510f 100644 --- a/doc/examples/resources/talk/slide01.txt +++ b/doc/examples/resources/talk/slide01.txt @@ -11,5 +11,3 @@ __ __ _ _ _ _ |_| \___/|___|___/ |_|_| |_| |_.__/ \__,_|_.__/ \__,_|___/_| |_|_|\_\__,_| by Timo Kramer - -github.com/timokramer/charm.clj diff --git a/doc/examples/resources/talk/slide03.txt b/doc/examples/resources/talk/slide03.txt index 0e37f22..e241f49 100644 --- a/doc/examples/resources/talk/slide03.txt +++ b/doc/examples/resources/talk/slide03.txt @@ -11,5 +11,3 @@ \___|_| |_|\__,_|_| |_| |_| |_|_.__/|_| \__,_|\___\___|_|\___|\__| 😈😊🏳️‍🌈🙂‍↔️👨‍👩‍👧🇳🇱👍🏿 - -github.com/timokramer/charm.clj diff --git a/doc/examples/resources/talk/slide04.txt b/doc/examples/resources/talk/slide04.txt index d37f0cf..47aa9af 100644 --- a/doc/examples/resources/talk/slide04.txt +++ b/doc/examples/resources/talk/slide04.txt @@ -10,4 +10,3 @@ | | | | | (__| | |_| | (_| | __/ (_| | |_|_| |_|\___|_|\__,_|\__,_|\___|\__,_| -github.com/timokramer/charm.clj diff --git a/doc/examples/resources/talk/slide05.txt b/doc/examples/resources/talk/slide05.txt index 49276a0..9ccb663 100644 --- a/doc/examples/resources/talk/slide05.txt +++ b/doc/examples/resources/talk/slide05.txt @@ -4,11 +4,9 @@ | _ <| |___| __/| |___ |_| \_\_____|_| |_____| - _ _ _ - __| | _____ _| | ___ _ __ _ __ ___ ___ _ __ | |_ - / _` |/ _ \ \ / / |/ _ \| '_ \| '_ ` _ \ / _ \ '_ \| __| -| (_| | __/\ V /| | (_) | |_) | | | | | | __/ | | | |_ - \__,_|\___| \_/ |_|\___/| .__/|_| |_| |_|\___|_| |_|\__| - |_| - -github.com/timokramer/charm.clj + _ _ _ + __| | _____ _____| | ___ _ __ _ __ ___ ___ _ __ | |_ + / _` |/ _ \ \ / / _ \ |/ _ \| '_ \| '_ ` _ \ / _ \ '_ \| __| +| (_| | __/\ V / __/ | (_) | |_) | | | | | | __/ | | | |_ + \__,_|\___| \_/ \___|_|\___/| .__/|_| |_| |_|\___|_| |_|\__| + |_| diff --git a/doc/examples/resources/talk/slide06.txt b/doc/examples/resources/talk/slide06.txt index 182068c..29fbc9d 100644 --- a/doc/examples/resources/talk/slide06.txt +++ b/doc/examples/resources/talk/slide06.txt @@ -5,4 +5,3 @@ \___/ \_/ \___|_| |_|\__,_|\__, | |___/ -github.com/timokramer/charm.clj diff --git a/doc/examples/resources/talk/slide07.txt b/doc/examples/resources/talk/slide07.txt index 244be02..3f7b1b9 100644 --- a/doc/examples/resources/talk/slide07.txt +++ b/doc/examples/resources/talk/slide07.txt @@ -1,6 +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 index b2b8e02..86b825b 100644 --- a/doc/examples/src/examples/talk.clj +++ b/doc/examples/src/examples/talk.clj @@ -1,7 +1,10 @@ (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] @@ -14,7 +17,7 @@ .listFiles (filter #(.endsWith (.getName %) ".txt")) (sort-by #(.getName %)) - (mapv slurp))) + (mapv #(str/trimr (slurp %))))) (def title-style (style/style :fg style/red :bold true)) @@ -34,8 +37,70 @@ (def inactive-dot-style (style/style :fg 240)) -;; Reserved rows: title (1) + blank (1) + blank (1) + help (1) = 4 -(def chrome-rows 4) +(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." @@ -44,39 +109,67 @@ :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 - #_#_:valign :center - #_#_:align :center)) + :height (- term-h chrome-rows 2))) ; -2 for top/bottom border (defn init [] - [{: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} - nil]) + (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] - (tap> 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] - - :else - (let [[pager _] (paginator/paginator-update (:pager state) msg) - [arabic _] (paginator/paginator-update (:arabic state) msg)] - [(assoc state :pager pager :arabic arabic) nil]))) + (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." @@ -85,25 +178,41 @@ (subvec items start end))) (defn view [state] - (let [{:keys [pager arabic]} state - rows (->> (page-items pager) - (map (fn [i] - (style/render item-style i))) + (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 - "\n\n") + (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 city") "\n" + (str (style/render title-style "babashka conf 2026 Amsterdam") "\n" (style/render (card-style state) body) "\n" (style/render pag-style paginator) "\n\n" - (style/render hint-style "←/→ or h/l to navigate, q to quit")))) + (view-timer state) "\n\n" + (style/render hint-style (help/short-help-view help))))) (defn -main [& _args] - (program/run-async {:init init - :update update-fn - :view view - :alt-screen true})) + (program/run {:init init + :update update-fn + :view view + :alt-screen true})) (comment (def app (program/run-async {:init init @@ -111,4 +220,9 @@ :view #'view :alt-screen true})) - ((:quit! app))) + ((: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 ;; ---------------------------------------------------------------------------