Mosaic Terminal User Interface
Mark Elvers
4 min read

Categories

  • ocaml,mosaic

Tags

  • tunbury.org

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:

  1. count - the current value
  2. set_count - sets to a specific value (takes a value)
  3. 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:

  1. Component renders (first time or re-render due to state change)
  2. Framework checks if any values in ~deps changed since last render
  3. If they changed, run the effect function
  4. 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.