In testing various visual components, terminal resizing, keyboard handling and the use of hooks, I inadvertently wrote the less
tool in Mosaic. Below are my notes on using the framework.
use_state
is a React-style hook that manages local component state. It returns a tuple of (value, set, update) where:
- count - the current value
- set_count - sets to a specific value (takes a value)
- update_count - transforms the current value (takes a function)
Thus, you might have
let (count, set_count, update_count) = use_state 0;;
count (* returns the current value - zero in this case *)
set_count 5 (* set the value to 5 *)
update_count (fun x -> x + 1) (* adds 1 to the current value *)
In practice, this could be used to keep track of the selected index in a table of values:
let directory_browser dir_info window_height window_width set_mode =
let open Ui in
let selected_index, set_selected_index, _ = use_state 0 in
use_subscription
(Sub.keyboard_filter (fun event ->
match event.Input.key with
| Input.Up -> set_selected_index (max 0 (selected_index - 1)); None
| Input.Down -> set_selected_index (min (num_entries - 1) (selected_index + 1)); None
| Input.Enter -> set_mode (load_path entry.full_path); Some ()
| _ -> None));
Any change in the value of a state causes the UI component to be re-rendered. Consider this snippet, which uses the subscription Sub.window
to update the window size, which calls set_window_height
and set_window_width
.
let app path =
let mode, set_mode, _ = use_state (load_path path) in
let window_height, set_window_height, _ = use_state 24 in
let window_width, set_window_width, _ = use_state 80 in
(* Handle window resize *)
use_subscription
(Sub.window (fun size ->
set_window_height size.height;
set_window_width size.width));
(* Return a Ui.element using window_height and window_width *)
directory_browser dir_info window_height window_width set_mode
let () =
run ~alt_screen:true (fun () -> app path)
In my testing, this worked but left unattached text fragments on the screen. This forced me to add a Cmd.clear_screen
to manually clear the screen. Cmd.repaint
doesn’t seem strictly necessary. The working subscription was:
use_subscription
(Sub.window (fun size ->
set_window_height size.height;
set_window_width size.width;
dispatch_cmd (Cmd.batch [ Cmd.clear_screen; Cmd.repaint ])));
It is also possible to monitor values using use_effect
. In the example below, the scroll position is reset when the filename is changed. The effect is triggered only when the component is rendered and when the value differs from the value on the previous render.
use_effect ~deps:(Deps.keys [Deps.string content.filename]) (fun () ->
set_scroll_offset 0;
set_h_scroll_offset 0;
None
);
The sequence is:
- Component renders (first time or re-render due to state change)
- Framework checks if any values in ~deps changed since last render
- If they changed, run the effect function
- If the effect returns cleanup, that cleanup runs before the next effect
For some widgets, I found I needed to perform manual calculations on the size to fill the space and correctly account for panel borders, header, dividers, and status. window_height - 6
. In other cases, ~expand:true
was available.
scroll_view
~height:(`Cells (window_height - 6))
~h_offset:h_scroll_offset
~v_offset:scroll_offset
file_content;
Colours can be defined as RGB values and then composed into Syles with the ++
operator. Styles are then applied to elements such as table headers:
module Colors = struct
let primary_blue = Style.rgb 66 165 245 (* Material Blue 400 *)
end
module Styles = struct
let header = Style.(fg Colors.primary_blue ++ bold)
end
table ~header_style:Styles.header ...
The panel serves as the primary container for our application content, providing both visual framing and structural organisation:
panel
~title:(Printf.sprintf "Directory Browser - %s" (Filename.basename dir_info.path))
~box_style:Rounded
~border_style:Styles.accent
~expand:true
(vbox [
(* content goes here *)
])
Mosaic provides the table widget, which I found had a layout issue when the column widths exceeded the table width. It worked pretty well, but it takes about 1 second per 1000 rows on my machine, so consider pagination.
let table_columns = [
Table.{ (default_column ~header:"Name") with style = Styles.file };
Table.{ (default_column ~header:"Type") with style = Styles.file };
Table.{ (default_column ~header:"Size") with style = Styles.file; justify = `Right };
] in
table
~columns:table_columns
~rows:table_rows
~box_style:Table.Minimal
~expand:true
~header_style:Styles.header
~row_styles:table_row_styles
~width:(Some (window_width - 4))
()
The primary layout primitives are vbox
and hbox
:
Vertical Box (vbox) - for stacking components vertically.
vbox [
text "Header";
divider ~orientation:`Horizontal ();
content;
text "Footer";
]
Horizontal Box (hbox) - for arranging components horizontally.
hbox ~gap:(`Cells 2) [
text "Left column";
text "Right column";
]
As I mentioned earlier, a subscription-based event handling system, for example, a component could subscribe to the keyboard events.
use_subscription
(Sub.keyboard_filter (fun event ->
match event.Input.key with
| Input.Char c when Uchar.to_int c = 0x71 -> (* 'q' *)
dispatch_cmd Cmd.quit; Some ()
| Input.Enter ->
(* handle enter *)
Some ()
| _ -> None))
The keyboard_filter
function allows components to selectively handle keyboard events, returning Some ()
for events that are handled and None
for events that should be passed to other components.
Mosaic provides a command system for handling side effects and application lifecycle events some of these you will have seen in earlier examples.
dispatch_cmd Cmd.quit (* Exit the application *)
dispatch_cmd Cmd.repaint (* Force a screen repaint *)
dispatch_cmd (Cmd.batch [ (* Execute multiple commands *)
Cmd.clear_screen;
Cmd.repaint
])
I found that using Unicode characters in strings caused alignment errors, as their length was the number of data bytes, not the visual space used on the screen.
The mless application is available on GitHub for further investigation or as a starter project.