From ea1f52bf17f76b95b9c5f8d115e854d7bfb89bb3 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Thu, 24 Sep 2026 05:49:22 -0400 Subject: [PATCH] Add a row-scrolling :scroll pane, keep focus across frames, drop inverted input focus yframe scrolls to the focused element, so a pane of plain rows could not be scrolled and a pane of widgets scrolled wherever focus sat. :scroll scrolls by rows, follows the bottom until scrolled, takes the wheel over it, and reports {:top :max :rows} so an app can page it from its own keys. A frame that rebuilt a container (a new sibling ahead of the focused widget, or a container created where there was one child) moved the focus to the first child. The widget that held the focus now gets it back. The soft-wrapping input no longer renders in reverse video when focused; the cursor already shows it. --- README.md | 3 +- native/ftxui_jolt.cpp | 130 +++++++++++++++++++++++++++++++++++-- native/ftxui_jolt.h | 2 + src/ftxui/ffi.clj | 2 + src/ftxui/render.clj | 25 ++++++- src/ftxui/widget.clj | 19 ++++++ test/ftxui/render_test.clj | 39 +++++++++++ 7 files changed, 211 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5fd5ee9..51217af 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ colors the text but not the border. Nest explicit tags for a different order: | tag | props | events | |---|---|---| | `:button` | `:label` (or a string child), `:style :simple/:ascii/:border/:animated` | `:on-click` | -| `:input` | `:value`, `:placeholder`, `:password`, `:multiline`, `:wrap` (soft-wrap long lines at the width given; the box is as tall as its rows) | `:on-change` (text), `:on-enter` (text) | +| `:input` | `:value`, `:placeholder`, `:password`, `:multiline`, `:wrap` (soft-wrap long lines at the width given; the box is as tall as its rows, and focus shows as the cursor rather than reverse video) | `:on-change` (text), `:on-enter` (text) | | `:checkbox` | `:label`, `:checked` | `:on-change` (boolean) | | `:menu` | `:entries`, `:selected`, `:direction :down/:up/:left/:right`, `:style :plain/:animated/:toggle` | `:on-change` (index), `:on-enter` (index) | | `:toggle` | `:entries`, `:selected` | as menu | @@ -192,6 +192,7 @@ colors the text but not the border. Nest explicit tags for a different order: | `:maybe` | `:show`; one child subtree | — | | `:resizable-split` | `:direction :left/:right/:up/:down`, `:size` (cells), `:min`, `:max`; two children | `:on-change` (size) | | `:hoverable` | one child subtree | `:on-change` (boolean) | +| `:scroll` | `:top` (first row shown; `nil` follows the bottom), one child subtree; the wheel over it scrolls three rows | `:on-change` (`{:top :max :rows}`) | | `:floating-window` | `:title`, `:left`, `:top`, `:width`, `:height`, `:resize`; one child subtree | `:on-change` (`{:left :top :width :height}`) | | `:catch-event` | `:on-event`; the children it guards | `:on-event` (event map) | diff --git a/native/ftxui_jolt.cpp b/native/ftxui_jolt.cpp index c96ef58..ca3a4ae 100644 --- a/native/ftxui_jolt.cpp +++ b/native/ftxui_jolt.cpp @@ -510,11 +510,8 @@ class WrapInput : public ComponentBase { [focused](Element e) { return e | focused; }, shared_) | yframe; } - if (is_focused) { - element |= inverted; - } else if (hovered_) { - element |= underlined; - } + // No inverted focus style: the cursor already says where the focus is, + // and a whole box in reverse video reads as a white slab on a dark theme. return element | xflex | reflect(shared_->box); } @@ -602,6 +599,103 @@ class JoltNode : public ComponentBase { // refs they were given and offer no callback of their own, so this wraps them // and fires FJ_ACTION_CHANGE once an event left the watched state elsewhere. // State pushed from jolt is not a user change: the setters call resync. +// --- a scrolling pane ---------------------------------------------------------- +// +// FTXUI's yframe scrolls to keep the FOCUSED element in view: a pane of plain +// rows cannot be scrolled at all, and a pane holding widgets scrolls wherever +// the focus happens to sit. ScrollFrame scrolls by ROWS. The slot's `value` is +// the top row shown, or -1 to follow the bottom as the content grows; the +// wheel over the pane moves it three rows, and scrolling back past the end +// follows again. `max` (the last top row) and `height` (rows shown) are +// written on every layout, so an application can page it from keys of its own. + +class ScrollNode : public Node { + public: + ScrollNode(Element child, Slot* s) : Node(Elements{std::move(child)}), s_(s) {} + + void ComputeRequirement() override { + children_[0]->ComputeRequirement(); + requirement_ = children_[0]->requirement(); + requirement_.min_x += 1; // the scrollbar's column + requirement_.min_y = 0; + requirement_.flex_grow_y = 1; + requirement_.flex_shrink_y = 1; + } + + void SetBox(Box box) override { + Node::SetBox(box); + content_ = children_[0]->requirement().min_y; + rows_ = std::max(0, box.y_max - box.y_min + 1); + const int max = std::max(0, content_ - rows_); + s_->max = max; + s_->height = rows_; + top_ = s_->value < 0 ? max : std::clamp(s_->value, 0, max); + Box inner = box; + inner.x_max = box.x_max - 1; + inner.y_min = box.y_min - top_; + inner.y_max = inner.y_min + std::max(content_, rows_) - 1; + children_[0]->SetBox(inner); + } + + void Render(Screen& screen) override { + const Box saved = screen.stencil; + screen.stencil = Box::Intersection(box_, screen.stencil); + children_[0]->Render(screen); + screen.stencil = saved; + // A border cut by the pane's edge must not merge with the frame around + // it: the side lines of a box scrolled half out would join the panel's + // separator as ┬ and ┴. + for (int x = box_.x_min; x <= box_.x_max && rows_ > 0; ++x) { + screen.PixelAt(x, box_.y_min).automerge = false; + screen.PixelAt(x, box_.y_max).automerge = false; + } + if (content_ <= rows_ || rows_ <= 0) return; + // A thumb in the reserved column: its size is the share of the content + // shown, its place where the view is. + const int thumb = std::max(1, rows_ * rows_ / content_); + const int at = (rows_ - thumb) * top_ / std::max(1, content_ - rows_); + for (int y = 0; y < rows_; ++y) { + screen.PixelAt(box_.x_max, box_.y_min + y).character = + (y >= at && y < at + thumb) ? "┃" : " "; + } + } + + private: + Slot* s_; + int content_ = 0; + int rows_ = 0; + int top_ = 0; +}; + +class ScrollFrame : public ComponentBase { + public: + ScrollFrame(Slot* s, Component child) : s_(s) { Add(std::move(child)); } + + Element OnRender() override { + return std::make_shared(ChildAt(0)->Render(), s_) | reflect(box_); + } + + bool OnEvent(Event event) override { + if (event.is_mouse()) { + const Mouse& m = event.mouse(); + // A row scrolled out of the pane is still laid out where it would be, + // over some other pane; a click there is not this pane's to deliver. + if (!box_.Contain(m.x, m.y)) return false; + if (m.button == Mouse::WheelUp || m.button == Mouse::WheelDown) { + const int top = s_->value < 0 ? s_->max : s_->value; + const int to = std::max(0, top + (m.button == Mouse::WheelUp ? -3 : 3)); + s_->value = to >= s_->max ? -1 : to; + return true; + } + } + return ComponentBase::OnEvent(event); + } + + private: + Slot* s_; + Box box_; +}; + class ChangeWatcher : public ComponentBase { public: ChangeWatcher(int32_t id, Component child, std::function state) @@ -1195,6 +1289,32 @@ void fj_resizable_split_new(int32_t id, int32_t main, int32_t back, int32_t dir) watch(s, ResizableSplit(opt), [s] { return static_cast(s->value); }); } +void fj_scroll_new(int32_t id, int32_t child) { + Component c = comp(child); + if (!c) return; + Slot* s = new_slot(id); + s->value = -1; + s->max = 0; + s->height = 0; + // Reports a change of the view AND of how far it can go, so the jolt side + // always holds a current `max` to page from. + watch(s, Make(s, c), [s] { + return (static_cast(s->value + 1) << 32) | static_cast(s->max); + }); +} + +// A scroll pane's geometry: 0 the top row (-1 following), 1 the last top row, +// 2 the rows shown. +int32_t fj_scroll_get(int32_t id, int32_t which) { + Slot* s = slot(id); + if (!s) return 0; + switch (which) { + case 0: return s->value; + case 1: return s->max; + default: return s->height; + } +} + void fj_hoverable_new(int32_t id, int32_t child) { Component c = comp(child); if (!c) return; diff --git a/native/ftxui_jolt.h b/native/ftxui_jolt.h index 6d10fcf..1bad432 100644 --- a/native/ftxui_jolt.h +++ b/native/ftxui_jolt.h @@ -224,6 +224,8 @@ FJ_API void fj_resizable_split_new(int32_t id, int32_t main, int32_t back, int32 /* Tracks whether the mouse is over `child`: the state is the slot's checked * flag, and a change fires FJ_ACTION_CHANGE. */ FJ_API void fj_hoverable_new(int32_t id, int32_t child); +FJ_API void fj_scroll_new(int32_t id, int32_t child); +FJ_API int32_t fj_scroll_get(int32_t id, int32_t which); /* A floating, draggable, resizable frame around `inner`, titled by the slot's * label. Several of them belong in one stacked container. A drag or a resize * moves the geometry below and fires FJ_ACTION_CHANGE. */ diff --git a/src/ftxui/ffi.clj b/src/ftxui/ffi.clj index f2b5517..4da2073 100644 --- a/src/ftxui/ffi.clj +++ b/src/ftxui/ffi.clj @@ -140,6 +140,8 @@ (ffi/defcfn collapsible-new "fj_collapsible_new" [:int :int] :void) (ffi/defcfn resizable-split-new "fj_resizable_split_new" [:int :int :int :int] :void) (ffi/defcfn hoverable-new "fj_hoverable_new" [:int :int] :void) +(ffi/defcfn scroll-new "fj_scroll_new" [:int :int] :void) +(ffi/defcfn scroll-get "fj_scroll_get" [:int :int] :int) (ffi/defcfn window-component-new "fj_window_component_new" [:int :int] :void) (ffi/defcfn window-set-rect "fj_window_set_rect" [:int :int :int :int :int] :void) (ffi/defcfn window-get "fj_window_get" [:int :int] :int) diff --git a/src/ftxui/render.clj b/src/ftxui/render.clj index d1c222b..d6fdcaa 100644 --- a/src/ftxui/render.clj +++ b/src/ftxui/render.clj @@ -346,18 +346,37 @@ :when (not (contains? visited path))] (dispose! m path entry)))) +(defn- focused-leaf + "The id of the leaf widget holding the focus, or nil." + [m] + (some (fn [[_ e]] (when (and (= :widget (:type e)) (empty? (:subs e)) + (= 1 (f/focused (:id e)))) + (:id e))) + @(:cache m))) + (defn prepare-frame! "Run the root component, walk its hiccup, keep the FTXUI component tree in step, and return the prepared root node (stored on the mount for the - render callback as well)." + render callback as well). + + THE FOCUS STAYS WHERE IT WAS. A container is rebuilt when its focusable + children change — one created where there was a single child, or a new + sibling ahead of the focused one — and a fresh container focuses its first + child. So the widget that held the focus before the frame gets it back, + while it is still mounted, unless the frame asked for an :autofocus." [m] - (let [ctx {:mount m :visited (atom #{}) :autofocus (atom [])} + (let [held (focused-leaf m) + ctx {:mount m :visited (atom #{}) :autofocus (atom [])} {:keys [node focus]} (prepare ctx (:hiccup m) []) root-id (:root-id m) focus (focus-root! ctx [] (or focus []) :vertical)] (f/set-children root-id focus) (sweep! ctx) - (doseq [id @(:autofocus ctx)] (f/take-focus id)) + (if (seq @(:autofocus ctx)) + (doseq [id @(:autofocus ctx)] (f/take-focus id)) + (when (and held (zero? (f/focused held)) + (some #(= held (:id %)) (vals @(:cache m)))) + (f/take-focus held))) (swap! (:subtrees m) assoc root-id node) node)) diff --git a/src/ftxui/widget.clj b/src/ftxui/widget.clj index 02c543a..e461973 100644 --- a/src/ftxui/widget.clj +++ b/src/ftxui/widget.clj @@ -204,6 +204,25 @@ :events {:on-change {:kind 1 :arg f/get-value}} :consumes [:direction :size :min :max :on-change]} + ;; a pane that scrolls its subtree by rows: :top is the first row shown, + ;; nil to follow the bottom as it grows; the wheel over it scrolls it. + ;; :on-change gets {:top :max :rows} — :max the last top row, :rows the + ;; rows shown — whenever the view or its extent moves, so keys the + ;; application handles can page it. Leave :top out to let it keep its own. + :scroll + {:subtrees :one + :ctor (fn [id _ [child]] (f/scroll-new id child)) + :apply (fn [id props _prev] + (when (contains? props :top) + (let [v (if-let [t (:top props)] (int t) -1)] + (when (not= v (f/scroll-get id 0)) (f/set-value id v))))) + :events {:on-change {:kind 1 :arg (fn [id] + (let [t (f/scroll-get id 0)] + {:top (when-not (neg? t) t) + :max (f/scroll-get id 1) + :rows (f/scroll-get id 2)}))}} + :consumes [:top :on-change]} + ;; tracks whether the mouse is over its subtree :hoverable {:subtrees :one diff --git a/test/ftxui/render_test.clj b/test/ftxui/render_test.clj index 80fa773..7109700 100644 --- a/test/ftxui/render_test.clj +++ b/test/ftxui/render_test.clj @@ -401,3 +401,42 @@ (ui/send-mouse! s {:x 2 :y 1}) (ui/send-char! s "X") (is (= "aaaa bbXbb" @text))))) + +(deftest scroll-follows-the-bottom-and-the-wheel-scrolls-it + (let [seen (atom []) + top (atom nil) + rows (fn [] (into [:vbox] (for [i (range 10)] [:text (str "r" i)]))) + app (fn [] [:scroll {:top @top :on-change #(do (swap! seen conj %) (reset! top (:top %)))} + (rows)])] + (with-screen [s app] + (is (= ["r7" "r8" "r9 ┃"] (map str/trimr (lines (ui/render-text s 4 3)))) + "following: the newest rows, the thumb at the foot of the last column") + (ui/send-mouse! s {:x 0 :y 1 :button :wheel-up :motion :pressed}) + (is (= {:top 4 :max 7 :rows 3} (last @seen)) "three rows up from the bottom") + (is (= ["r4" "r5 ┃" "r6"] (map str/trimr (lines (ui/render-text s 4 3))))) + (ui/send-mouse! s {:x 0 :y 1 :button :wheel-down :motion :pressed}) + (is (nil? (:top (last @seen))) "back at the end: following again") + (testing "the wheel outside the pane is not its" + (let [n (count @seen)] + (ui/send-mouse! s {:x 0 :y 9 :button :wheel-up :motion :pressed}) + (is (= n (count @seen))))) + (testing ":top set from outside is where it shows" + (reset! top 0) + (is (= ["r0 ┃" "r1" "r2"] (map str/trimr (lines (ui/render-text s 4 3))))))))) + +(deftest the-focus-stays-on-its-widget-when-siblings-appear + ;; A container knew its focused child by index: a menu appearing ahead of + ;; an input moved the focus to the menu, and typing went nowhere. + (let [menu? (atom false) + text (atom "") + app (fn [] [:vbox + (when @menu? [:menu {:entries ["a" "b"] :selected 0}]) + [:checkbox {:label "x"}] + [:input {:autofocus true :value @text :on-change #(reset! text %)}]])] + (with-screen [s app] + (ui/render-text s 20 5) + (reset! menu? true) + (ui/refresh! s) + (ui/render-text s 20 5) + (ui/send-char! s "hi") + (is (= "hi" @text)))))