Skip to content

Commit 46ab3fc

Browse files
committed
feat(leaves): New list component.
The list can be filtered, and elements can be selected.
1 parent fca6652 commit 46ab3fc

File tree

5 files changed

+401
-1
lines changed

5 files changed

+401
-1
lines changed

examples/list/dune

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
(executable
2+
(name main)
3+
(libraries minttea spices leaves str))

examples/list/main.ml

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
open Minttea
2+
module Input = Leaves.Text_input
3+
module FList = Leaves.Filtered_list
4+
5+
type model = {
6+
elements : FList.t;
7+
choices : string list option;
8+
edit_filter : bool;
9+
filter_input : Input.t;
10+
}
11+
12+
let initial_model =
13+
{
14+
(* Choices is to Some list at the end of the program *)
15+
choices = None;
16+
(* A Text_input is used to enter a substring used for filtering *)
17+
filter_input = Input.make "" ~prompt:"/" ();
18+
edit_filter = false;
19+
elements =
20+
FList.make
21+
[
22+
"brain 🧠";
23+
"bread 🍞";
24+
"butter 🧈";
25+
"cake 🍰";
26+
"carrots 🥕";
27+
"chocolate 🍫";
28+
"cupcakes 🧁";
29+
"empanadas 🥟";
30+
"hamburgers 🍔";
31+
"ice cream 🍦";
32+
"milk 🥛";
33+
"pizza 🍕";
34+
"strawberries 🍓";
35+
"waffles 🧇";
36+
"yogurt 🥛";
37+
]
38+
~style_selected:Spices.(default |> bold true)
39+
();
40+
}
41+
42+
let init _model = Command.Noop
43+
44+
let update event model : model * Command.t =
45+
if model.edit_filter then
46+
match event with
47+
(* validate the search and go back to navigating the list *)
48+
| Event.KeyDown Enter ->
49+
let elements =
50+
FList.show_string_contains model.elements
51+
(Input.current_text model.filter_input)
52+
in
53+
({ model with elements; edit_filter = false }, Command.Noop)
54+
(* cancel the search and go back to navigating the list *)
55+
| Event.KeyDown Escape ->
56+
let elements = FList.show_all model.elements in
57+
( {
58+
model with
59+
elements;
60+
edit_filter = false;
61+
filter_input = Input.set_text "" model.filter_input;
62+
},
63+
Command.Noop )
64+
(* everything else is passed to underlying component *)
65+
| _ ->
66+
let filter_input = Input.update model.filter_input event in
67+
(* incremental search: update the search on all event *)
68+
let elements =
69+
FList.show_string_contains model.elements
70+
(Input.current_text filter_input)
71+
in
72+
({ model with filter_input; elements }, Command.Noop)
73+
else
74+
match event with
75+
(* Validate the selection, print it and quit *)
76+
| Event.KeyDown Enter ->
77+
let elements = FList.get_selection model.elements in
78+
({ model with choices = Some elements }, Command.Quit)
79+
(* Quit right away *)
80+
| Event.KeyDown (Key "q" | Escape) -> (model, Command.Quit)
81+
(* Open the search Text_input *)
82+
| Event.KeyDown (Key "/") ->
83+
({ model with edit_filter = true }, Command.Noop)
84+
(* Delegate the rest to the list *)
85+
| _ ->
86+
let elements = FList.update event model.elements in
87+
({ model with elements }, Command.Noop)
88+
89+
let view model =
90+
match model.choices with
91+
(* ready to leave *)
92+
| Some elements -> String.concat "\n" elements
93+
(* normal running *)
94+
| None ->
95+
let help_msg =
96+
if model.edit_filter then "Esc: cancel filter, Enter: validate filter"
97+
else "q: quit, /: search, j/k: up/down, space: select, enter: validate."
98+
in
99+
Format.sprintf {|Pick your favorite food:
100+
101+
%s
102+
%s
103+
104+
%s|}
105+
(FList.view model.elements)
106+
(if model.edit_filter then Input.view model.filter_input else "")
107+
help_msg
108+
109+
let app = Minttea.app ~init ~update ~view ()
110+
let () = Minttea.start app ~initial_model

leaves/dune

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
(library
55
(public_name leaves)
66
(name leaves)
7-
(libraries minttea spices ptime ptime.clock.os uuseg))
7+
(libraries minttea spices ptime ptime.clock.os uused str))

leaves/filtered_list.ml

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
module Command = Minttea.Command
2+
module Event = Minttea.Event
3+
module Input = Text_input
4+
5+
type t = {
6+
elements : (bool * string) list;
7+
shown : int list option;
8+
cursor : int;
9+
max_height : int;
10+
cursor_string : string;
11+
style_selected : Spices.style;
12+
style_unselected : Spices.style;
13+
predicate : int -> string -> bool;
14+
}
15+
16+
let default_cursor = ">"
17+
18+
let make (elements : string list) ?(cursor = default_cursor)
19+
?(style_selected = Spices.default) ?(style_unselected = Spices.default)
20+
?(max_height = 10) () =
21+
{
22+
elements = List.map (fun e -> (false, e)) elements;
23+
cursor = 0;
24+
shown = None;
25+
cursor_string = cursor;
26+
predicate = (fun _ _ -> true);
27+
max_height;
28+
style_selected;
29+
style_unselected;
30+
}
31+
32+
let rec last = function
33+
| [] -> raise Not_found
34+
| [ e ] -> e
35+
| _ :: rest -> last rest
36+
37+
let show_pred model predicate =
38+
let indices =
39+
(* list of shown indices *)
40+
model.elements
41+
|> List.mapi (fun idx (_, e) -> (idx, predicate idx e))
42+
|> List.filter (fun (_, selected) -> selected)
43+
|> List.map fst
44+
in
45+
let cursor =
46+
(* set the cursor to the closest visible element *)
47+
if List.length indices = 0 then 0
48+
else
49+
match List.find (( <= ) model.cursor) indices with
50+
| exception Not_found -> last indices
51+
| idx -> idx
52+
in
53+
{ model with cursor; predicate; shown = Some indices }
54+
55+
let show_string_contains model s =
56+
(* return true if the filter matches the element *)
57+
let match_filter filter _ element =
58+
match Str.search_forward (Str.regexp filter) element 0 with
59+
| exception Not_found -> false
60+
| _ -> true
61+
in
62+
show_pred model (match_filter s)
63+
64+
let show_all model = { model with shown = None }
65+
66+
(* move the cursor in the list of visible elements, eventually wrapping *)
67+
let prev_visible cur shown =
68+
if shown = [] then 0
69+
else
70+
let last = last shown in
71+
let rec loop cur shown =
72+
match shown with
73+
| a :: _ when a < cur -> a
74+
| _ :: rest -> loop cur rest
75+
| [] -> last
76+
in
77+
loop cur (List.rev shown)
78+
79+
(* move the cursor in the list of visible elements, eventually wrapping *)
80+
let next_visible cur shown =
81+
let first = match shown with a :: _ -> a | [] -> 0 in
82+
let rec loop cur shown =
83+
match shown with
84+
| a :: _ when a > cur -> a
85+
| _ :: rest -> loop cur rest
86+
| [] -> first
87+
in
88+
loop cur shown
89+
90+
let update event (model : t) =
91+
match event with
92+
| Event.KeyDown (Key "s" | Space) ->
93+
(* select current element *)
94+
{
95+
model with
96+
elements =
97+
List.mapi
98+
(fun idx (s, e) ->
99+
if idx = model.cursor then (not s, e) else (s, e))
100+
model.elements;
101+
}
102+
| Event.KeyDown (Up | Key "k") ->
103+
let len = List.length model.elements in
104+
if len = 0 then model
105+
else
106+
{
107+
model with
108+
cursor =
109+
(match model.shown with
110+
| None -> (model.cursor + len - 1) mod len
111+
| Some shown -> prev_visible model.cursor shown);
112+
}
113+
| Event.KeyDown (Down | Key "j") ->
114+
let len = List.length model.elements in
115+
if len = 0 then model
116+
else
117+
{
118+
model with
119+
cursor =
120+
(match model.shown with
121+
| None -> (model.cursor + 1) mod len
122+
| Some shown -> next_visible model.cursor shown);
123+
}
124+
| Event.KeyDown (Left | Key "h") ->
125+
(* previous page, not wrapping *)
126+
{ model with cursor = max (model.cursor - model.max_height) 0 }
127+
| Event.KeyDown (Right | Key "l") ->
128+
(* next page, not wrapping *)
129+
{
130+
model with
131+
cursor =
132+
min
133+
(model.cursor + model.max_height)
134+
(max 0 (List.length model.elements - 1));
135+
}
136+
| _ -> model
137+
138+
(* drop the first n elements of the list *)
139+
let rec drop n lst =
140+
if n = 0 then lst
141+
else match lst with _ :: rest -> drop (n - 1) rest | [] -> []
142+
143+
(* keep the first n elements of the list *)
144+
let take n lst =
145+
let rec aux lst n acc =
146+
if n = 0 then List.rev acc
147+
else
148+
match lst with
149+
| x :: rest -> aux rest (n - 1) (x :: acc)
150+
| [] -> List.rev acc
151+
in
152+
aux lst n []
153+
154+
(* only keep visible items, counting on the order of shown and elems *)
155+
let pick_visible shown elems =
156+
let rec loop shown elems acc =
157+
match (shown, elems) with
158+
| [], _ -> List.rev acc
159+
| _, [] -> List.rev acc
160+
| idxa :: shown, (idxb, s, e) :: elems when idxa = idxb ->
161+
loop shown elems ((idxa, s, e) :: acc)
162+
| shown, _ :: elems -> loop shown elems acc
163+
in
164+
loop shown elems []
165+
166+
let visible_cursor model =
167+
match model.shown with
168+
| None -> model.cursor
169+
| Some shown ->
170+
let rec loop rest acc =
171+
match rest with
172+
| [] -> acc
173+
| idx :: _ when idx = model.cursor -> acc
174+
| _ :: rest -> loop rest (acc + 1)
175+
in
176+
loop shown 0
177+
178+
let view (model : t) =
179+
let npages =
180+
(match model.shown with
181+
| Some shown -> List.length shown / model.max_height
182+
| None -> List.length model.elements / model.max_height)
183+
+ 1
184+
in
185+
let page = 1 + (visible_cursor model / model.max_height) in
186+
let elems =
187+
model.elements
188+
|> List.mapi (fun idx (selected, element) -> (idx, selected, element))
189+
|> (match model.shown with
190+
| None -> fun x -> x
191+
| Some shown -> pick_visible shown)
192+
|> drop ((page - 1) * model.max_height)
193+
|> take model.max_height
194+
in
195+
196+
(* Represent rows with cursor, index and selection marker *)
197+
let format_row (idx, selected, element) =
198+
let cursor =
199+
if model.cursor = idx then model.cursor_string
200+
else String.make (String.length model.cursor_string) ' '
201+
in
202+
let bullet =
203+
if selected then Format.sprintf "[%2d]" (idx + 1)
204+
else Format.sprintf " %2d " (idx + 1)
205+
in
206+
let style =
207+
if selected then model.style_selected else model.style_unselected
208+
in
209+
Spices.build style "%s %s %s" cursor bullet element
210+
in
211+
212+
let rows = List.map format_row elems in
213+
let lst =
214+
if List.length rows < model.max_height then
215+
String.concat "\n"
216+
(rows @ List.init (model.max_height - List.length rows) (fun _ -> ""))
217+
else String.concat "\n" rows
218+
in
219+
let page_indicator =
220+
String.concat " "
221+
@@ List.init npages (fun idx -> if idx + 1 = page then "*" else ".")
222+
in
223+
lst ^ "\n\n" ^ page_indicator
224+
225+
(* Append more elements at the end of the list *)
226+
let append model elements =
227+
let model =
228+
{
229+
model with
230+
elements = model.elements @ List.map (fun e -> (false, e)) elements;
231+
}
232+
in
233+
(* reapply the predicate with currently active *)
234+
match model.shown with
235+
| None -> model
236+
| Some _ -> show_pred model model.predicate
237+
238+
let filter model predicate =
239+
let model =
240+
{
241+
model with
242+
elements = List.filteri (fun idx (_, e) -> predicate idx e) model.elements;
243+
}
244+
in
245+
(* reapply the filter predicate with currently active *)
246+
match model.shown with
247+
| None -> model
248+
| Some _ -> show_pred model model.predicate
249+
250+
(* Return the selected elements of the list *)
251+
let get_selection model =
252+
model.elements |> List.filter (fun (selected, _) -> selected) |> List.map snd

0 commit comments

Comments
 (0)