Skip to content

Commit 306ee32

Browse files
committed
feat(leaves): New list component.
The list can be filtered, and elements can be selected.
1 parent 7a64431 commit 306ee32

File tree

5 files changed

+390
-1
lines changed

5 files changed

+390
-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))
7+
(libraries minttea spices ptime ptime.clock.os str))

leaves/filtered_list.ml

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

leaves/filtered_list.mli

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
type t
2+
3+
val make :
4+
string list ->
5+
?cursor:string ->
6+
?style_selected:Spices.style ->
7+
?style_unselected:Spices.style ->
8+
?max_height:int ->
9+
unit ->
10+
t
11+
(** Create a new list component *)
12+
13+
val show_string_contains : t -> string -> t
14+
(** Only show elements that contain a given substring *)
15+
16+
val show_pred : t -> (int -> string -> bool) -> t
17+
(** Show elements matching a predicate *)
18+
19+
val show_all : t -> t
20+
(** Clear filtering *)
21+
22+
val update : Minttea.Event.t -> t -> t
23+
(** Update the component based on events *)
24+
25+
val view : t -> string
26+
(** Produce the view as a string *)
27+
28+
val get_selection : t -> string list
29+
(** Return the selected elements of the list *)
30+
31+
val append : t -> string list -> t
32+
(** Append more elements at the end of the list *)
33+
34+
val filter : t -> (int -> string -> bool) -> t
35+
(** Permanently remove elements not verifying the predicate *)

0 commit comments

Comments
 (0)