diff --git a/examples/README.md b/examples/README.md
index 27f3590..93c3c61 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -18,6 +18,12 @@
+## List
+
+
+
+
+
## Progress
diff --git a/examples/list/demo.gif b/examples/list/demo.gif
new file mode 100644
index 0000000..12b111a
Binary files /dev/null and b/examples/list/demo.gif differ
diff --git a/examples/list/demo.tape b/examples/list/demo.tape
new file mode 100644
index 0000000..4779fc4
--- /dev/null
+++ b/examples/list/demo.tape
@@ -0,0 +1,30 @@
+Output demo.gif
+
+Require echo
+
+Set Shell "bash"
+Set Framerate 24
+Set FontSize 20
+Set Width 1200
+Set Height 600
+
+Sleep 1s
+Type "dune exec --no-print-directory ./main.exe"
+Enter
+
+Sleep 1s
+Type " "
+Type "j "
+Sleep 500ms
+Type "j "
+Sleep 500ms
+Type "/"
+Sleep 500ms
+Type "str"
+Sleep 500ms
+Enter
+Sleep 500ms
+Type " "
+Sleep 500ms
+Enter
+Sleep 2s
diff --git a/examples/list/dune b/examples/list/dune
new file mode 100644
index 0000000..890388a
--- /dev/null
+++ b/examples/list/dune
@@ -0,0 +1,3 @@
+(executable
+ (name main)
+ (libraries minttea spices leaves str))
diff --git a/examples/list/main.ml b/examples/list/main.ml
new file mode 100644
index 0000000..d4c2c12
--- /dev/null
+++ b/examples/list/main.ml
@@ -0,0 +1,110 @@
+open Minttea
+module Input = Leaves.Text_input
+module FList = Leaves.Filtered_list
+
+type model = {
+ elements : FList.t;
+ choices : string list option;
+ edit_filter : bool;
+ filter_input : Input.t;
+}
+
+let initial_model =
+ {
+ (* Choices is to Some list at the end of the program *)
+ choices = None;
+ (* A Text_input is used to enter a substring used for filtering *)
+ filter_input = Input.make "" ~prompt:"/" ();
+ edit_filter = false;
+ elements =
+ FList.make
+ [
+ "brain 🧠";
+ "bread 🍞";
+ "butter 🧈";
+ "cake 🍰";
+ "carrots 🥕";
+ "chocolate 🍫";
+ "cupcakes 🧁";
+ "empanadas 🥟";
+ "hamburgers 🍔";
+ "ice cream 🍦";
+ "milk 🥛";
+ "pizza 🍕";
+ "strawberries 🍓";
+ "waffles 🧇";
+ "yogurt 🥛";
+ ]
+ ~style_selected:Spices.(default |> bold true)
+ ();
+ }
+
+let init _model = Command.Noop
+
+let update event model : model * Command.t =
+ if model.edit_filter then
+ match event with
+ (* validate the search and go back to navigating the list *)
+ | Event.KeyDown Enter ->
+ let elements =
+ FList.show_string_contains model.elements
+ (Input.current_text model.filter_input)
+ in
+ ({ model with elements; edit_filter = false }, Command.Noop)
+ (* cancel the search and go back to navigating the list *)
+ | Event.KeyDown Escape ->
+ let elements = FList.show_all model.elements in
+ ( {
+ model with
+ elements;
+ edit_filter = false;
+ filter_input = Input.set_text "" model.filter_input;
+ },
+ Command.Noop )
+ (* everything else is passed to underlying component *)
+ | _ ->
+ let filter_input = Input.update model.filter_input event in
+ (* incremental search: update the search on all event *)
+ let elements =
+ FList.show_string_contains model.elements
+ (Input.current_text filter_input)
+ in
+ ({ model with filter_input; elements }, Command.Noop)
+ else
+ match event with
+ (* Validate the selection, print it and quit *)
+ | Event.KeyDown Enter ->
+ let elements = FList.get_selection model.elements in
+ ({ model with choices = Some elements }, Command.Quit)
+ (* Quit right away *)
+ | Event.KeyDown (Key "q" | Escape) -> (model, Command.Quit)
+ (* Open the search Text_input *)
+ | Event.KeyDown (Key "/") ->
+ ({ model with edit_filter = true }, Command.Noop)
+ (* Delegate the rest to the list *)
+ | _ ->
+ let elements = FList.update event model.elements in
+ ({ model with elements }, Command.Noop)
+
+let view model =
+ match model.choices with
+ (* ready to leave *)
+ | Some elements -> String.concat "\n" elements
+ (* normal running *)
+ | None ->
+ let help_msg =
+ if model.edit_filter then "Esc: cancel filter, Enter: validate filter"
+ else "q: quit, /: search, j/k: up/down, space: select, enter: validate."
+ in
+ Format.sprintf {|Pick your favorite food:
+
+%s
+%s
+
+%s|}
+ (FList.view model.elements)
+ (if model.edit_filter then Input.view model.filter_input else "")
+ help_msg
+
+let app = Minttea.app ~init ~update ~view ()
+let () = Minttea.start app ~initial_model
diff --git a/leaves/dune b/leaves/dune
index c0af131..9c10a58 100644
--- a/leaves/dune
+++ b/leaves/dune
@@ -4,4 +4,4 @@
(library
(public_name leaves)
(name leaves)
- (libraries minttea spices ptime ptime.clock.os uuseg))
+ (libraries minttea spices ptime ptime.clock.os uuseg str))
diff --git a/leaves/filtered_list.ml b/leaves/filtered_list.ml
new file mode 100644
index 0000000..94896cd
--- /dev/null
+++ b/leaves/filtered_list.ml
@@ -0,0 +1,252 @@
+module Command = Minttea.Command
+module Event = Minttea.Event
+module Input = Text_input
+
+type t = {
+ elements : (bool * string) list;
+ shown : int list option;
+ cursor : int;
+ max_height : int;
+ cursor_string : string;
+ style_selected : Spices.style;
+ style_unselected : Spices.style;
+ predicate : int -> string -> bool;
+}
+
+let default_cursor = ">"
+
+let make (elements : string list) ?(cursor = default_cursor)
+ ?(style_selected = Spices.default) ?(style_unselected = Spices.default)
+ ?(max_height = 10) () =
+ {
+ elements = List.map (fun e -> (false, e)) elements;
+ cursor = 0;
+ shown = None;
+ cursor_string = cursor;
+ predicate = (fun _ _ -> true);
+ max_height;
+ style_selected;
+ style_unselected;
+ }
+
+let rec last = function
+ | [] -> raise Not_found
+ | [ e ] -> e
+ | _ :: rest -> last rest
+
+let show_pred model predicate =
+ let indices =
+ (* list of shown indices *)
+ model.elements
+ |> List.mapi (fun idx (_, e) -> (idx, predicate idx e))
+ |> List.filter (fun (_, selected) -> selected)
+ |> List.map fst
+ in
+ let cursor =
+ (* set the cursor to the closest visible element *)
+ if List.length indices = 0 then 0
+ else
+ match List.find (( <= ) model.cursor) indices with
+ | exception Not_found -> last indices
+ | idx -> idx
+ in
+ { model with cursor; predicate; shown = Some indices }
+
+let show_string_contains model s =
+ (* return true if the filter matches the element *)
+ let match_filter filter _ element =
+ match Str.search_forward (Str.regexp filter) element 0 with
+ | exception Not_found -> false
+ | _ -> true
+ in
+ show_pred model (match_filter s)
+
+let show_all model = { model with shown = None }
+
+(* move the cursor in the list of visible elements, eventually wrapping *)
+let prev_visible cur shown =
+ if shown = [] then 0
+ else
+ let last = last shown in
+ let rec loop cur shown =
+ match shown with
+ | a :: _ when a < cur -> a
+ | _ :: rest -> loop cur rest
+ | [] -> last
+ in
+ loop cur (List.rev shown)
+
+(* move the cursor in the list of visible elements, eventually wrapping *)
+let next_visible cur shown =
+ let first = match shown with a :: _ -> a | [] -> 0 in
+ let rec loop cur shown =
+ match shown with
+ | a :: _ when a > cur -> a
+ | _ :: rest -> loop cur rest
+ | [] -> first
+ in
+ loop cur shown
+
+let update event (model : t) =
+ match event with
+ | Event.KeyDown (Key "s" | Space) ->
+ (* select current element *)
+ {
+ model with
+ elements =
+ List.mapi
+ (fun idx (s, e) ->
+ if idx = model.cursor then (not s, e) else (s, e))
+ model.elements;
+ }
+ | Event.KeyDown (Up | Key "k") ->
+ let len = List.length model.elements in
+ if len = 0 then model
+ else
+ {
+ model with
+ cursor =
+ (match model.shown with
+ | None -> (model.cursor + len - 1) mod len
+ | Some shown -> prev_visible model.cursor shown);
+ }
+ | Event.KeyDown (Down | Key "j") ->
+ let len = List.length model.elements in
+ if len = 0 then model
+ else
+ {
+ model with
+ cursor =
+ (match model.shown with
+ | None -> (model.cursor + 1) mod len
+ | Some shown -> next_visible model.cursor shown);
+ }
+ | Event.KeyDown (Left | Key "h") ->
+ (* previous page, not wrapping *)
+ { model with cursor = max (model.cursor - model.max_height) 0 }
+ | Event.KeyDown (Right | Key "l") ->
+ (* next page, not wrapping *)
+ {
+ model with
+ cursor =
+ min
+ (model.cursor + model.max_height)
+ (max 0 (List.length model.elements - 1));
+ }
+ | _ -> model
+
+(* drop the first n elements of the list *)
+let rec drop n lst =
+ if n = 0 then lst
+ else match lst with _ :: rest -> drop (n - 1) rest | [] -> []
+
+(* keep the first n elements of the list *)
+let take n lst =
+ let rec aux lst n acc =
+ if n = 0 then List.rev acc
+ else
+ match lst with
+ | x :: rest -> aux rest (n - 1) (x :: acc)
+ | [] -> List.rev acc
+ in
+ aux lst n []
+
+(* only keep visible items, counting on the order of shown and elems *)
+let pick_visible shown elems =
+ let rec loop shown elems acc =
+ match (shown, elems) with
+ | [], _ -> List.rev acc
+ | _, [] -> List.rev acc
+ | idxa :: shown, (idxb, s, e) :: elems when idxa = idxb ->
+ loop shown elems ((idxa, s, e) :: acc)
+ | shown, _ :: elems -> loop shown elems acc
+ in
+ loop shown elems []
+
+let visible_cursor model =
+ match model.shown with
+ | None -> model.cursor
+ | Some shown ->
+ let rec loop rest acc =
+ match rest with
+ | [] -> acc
+ | idx :: _ when idx = model.cursor -> acc
+ | _ :: rest -> loop rest (acc + 1)
+ in
+ loop shown 0
+
+let view (model : t) =
+ let npages =
+ (match model.shown with
+ | Some shown -> List.length shown / model.max_height
+ | None -> List.length model.elements / model.max_height)
+ + 1
+ in
+ let page = 1 + (visible_cursor model / model.max_height) in
+ let elems =
+ model.elements
+ |> List.mapi (fun idx (selected, element) -> (idx, selected, element))
+ |> (match model.shown with
+ | None -> fun x -> x
+ | Some shown -> pick_visible shown)
+ |> drop ((page - 1) * model.max_height)
+ |> take model.max_height
+ in
+
+ (* Represent rows with cursor, index and selection marker *)
+ let format_row (idx, selected, element) =
+ let cursor =
+ if model.cursor = idx then model.cursor_string
+ else String.make (String.length model.cursor_string) ' '
+ in
+ let bullet =
+ if selected then Format.sprintf "[%2d]" (idx + 1)
+ else Format.sprintf " %2d " (idx + 1)
+ in
+ let style =
+ if selected then model.style_selected else model.style_unselected
+ in
+ Spices.build style "%s %s %s" cursor bullet element
+ in
+
+ let rows = List.map format_row elems in
+ let lst =
+ if List.length rows < model.max_height then
+ String.concat "\n"
+ (rows @ List.init (model.max_height - List.length rows) (fun _ -> ""))
+ else String.concat "\n" rows
+ in
+ let page_indicator =
+ String.concat " "
+ @@ List.init npages (fun idx -> if idx + 1 = page then "*" else ".")
+ in
+ lst ^ "\n\n" ^ page_indicator
+
+(* Append more elements at the end of the list *)
+let append model elements =
+ let model =
+ {
+ model with
+ elements = model.elements @ List.map (fun e -> (false, e)) elements;
+ }
+ in
+ (* reapply the predicate with currently active *)
+ match model.shown with
+ | None -> model
+ | Some _ -> show_pred model model.predicate
+
+let filter model predicate =
+ let model =
+ {
+ model with
+ elements = List.filteri (fun idx (_, e) -> predicate idx e) model.elements;
+ }
+ in
+ (* reapply the filter predicate with currently active *)
+ match model.shown with
+ | None -> model
+ | Some _ -> show_pred model model.predicate
+
+(* Return the selected elements of the list *)
+let get_selection model =
+ model.elements |> List.filter (fun (selected, _) -> selected) |> List.map snd
diff --git a/leaves/filtered_list.mli b/leaves/filtered_list.mli
new file mode 100644
index 0000000..8493ee7
--- /dev/null
+++ b/leaves/filtered_list.mli
@@ -0,0 +1,35 @@
+type t
+
+val make :
+ string list ->
+ ?cursor:string ->
+ ?style_selected:Spices.style ->
+ ?style_unselected:Spices.style ->
+ ?max_height:int ->
+ unit ->
+ t
+(** Create a new list component *)
+
+val show_string_contains : t -> string -> t
+(** Only show elements that contain a given substring *)
+
+val show_pred : t -> (int -> string -> bool) -> t
+(** Show elements matching a predicate *)
+
+val show_all : t -> t
+(** Clear filtering *)
+
+val update : Minttea.Event.t -> t -> t
+(** Update the component based on events *)
+
+val view : t -> string
+(** Produce the view as a string *)
+
+val get_selection : t -> string list
+(** Return the selected elements of the list *)
+
+val append : t -> string list -> t
+(** Append more elements at the end of the list *)
+
+val filter : t -> (int -> string -> bool) -> t
+(** Permanently remove elements not verifying the predicate *)