Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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) |

Expand Down
130 changes: 125 additions & 5 deletions native/ftxui_jolt.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<ScrollNode>(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<int64_t()> state)
Expand Down Expand Up @@ -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<int64_t>(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<ScrollFrame>(s, c), [s] {
return (static_cast<int64_t>(s->value + 1) << 32) | static_cast<uint32_t>(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;
Expand Down
2 changes: 2 additions & 0 deletions native/ftxui_jolt.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 2 additions & 0 deletions src/ftxui/ffi.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 22 additions & 3 deletions src/ftxui/render.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
19 changes: 19 additions & 0 deletions src/ftxui/widget.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions test/ftxui/render_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)))))
Loading