Owl

repository·main·Indexed 19 days ago

https://github.com/fuelen/owl

A toolkit for building interactive command-line user interfaces in Elixir. Owl enhances standard CLI scripts with features such as progress bars, spinners, input prompts with validation and type casting, select/multi-select menus, tables, ASCII boxes, and colorized output using ANSI sequences. It also provides utilities for managing shell commands as supervised daemons and specialized data manipulation functions that respect ANSI tags and multibyte character widths.

Tokens
9.9K
Snippets
43
Records
46
Agent score
68%

What's inside Owl

  1. What is Owl and when to use it

    main

    Owl is a toolkit for writing command-line user interfaces (CLI) in Elixir. It is designed to enhance regular scripts and CLI tools with interactivity without turning them into full-screen terminal applications.

    Use Owl when:

    You want to enhance a standard top-to-bottom CLI tool with:

    • Colored text using tags
    • Input controls (with validation and type casting)
    • Select/multi-select menus (AUR-inspired)
    • Tables and ASCII boxes
    • Progress bars and spinners (including simultaneous multiple bars)
    • Live updating of multi-line blocks
    • Running shell commands with secure logging and masked secrets
    • True-color (24-bit) ANSI sequences and OSC 8 hyperlinks

    Do NOT use Owl when:

    You are building a full-screen terminal application (like htop or lazygit). For full-screen TUIs, consider libraries like TermUI, Ratatouille, or ExNcurses. For very complex interfaces, a Phoenix LiveView may be easier to maintain than a TUI.

  2. Install Owl via mix

    main

    To use Owl in your Elixir project, add it to your mix.exs dependencies.

    Note on Multibyte Characters: If your application needs to support multibyte characters (such as emojis), you should also include the ucwidth dependency to ensure correct width calculations.

    def deps do
      [
        {:owl, "~> 0.13"},
        # ucwidth is an optional dependency, uncomment it for multibyte characters support (emoji, etc)
        {:ucwidth, "~> 0.2"}
      ]
    end
  3. How Owl.Tag manages colorized text

    main

    An Owl.Tag is a container for data and associated ANSI sequences. It acts like an HTML tag for the console, providing local bindings for styles.

    When using raw ANSI sequences (e.g., IO.ANSI.red()), you must manually reset colors or backgrounds to prevent styles from leaking into subsequent text. Owl.Tag solves this by encapsulating the data and its styles, allowing you to nest tagged content within other tags without manual cleanup.

    To create a tag, use Owl.Data.tag/2 instead of the deprecated Owl.Tag.new/2.

    # Nesting tags automatically handles style cleanup
    substring = Owl.Data.tag("world", :green)
    Owl.IO.puts(Owl.Data.tag(["Hello ", substring, "!!"], :red))
  4. Process command output with handle_data

    main

    The Owl.Daemon allows you to intercept and process the stdout/stderr of a command using the :handle_data option.

    When providing :handle_data, you pass a tuple: {initial_state, callback}.

    • initial_state: Any term you want to carry through the lifecycle of the daemon.
    • callback: A function with the signature fn(text_lines, current_state) -> new_state end.

    Every time the command produces output, the callback is executed, and the returned new_state is stored and passed back into the next call. The output is also automatically prefixed and sent to the configured :device (defaulting to :stdio).

    # A daemon that accumulates all output lines into a list
    args = [
      command: "echo",
      args: ["hello world"],
      handle_data: {[], fn lines, acc -> acc ++ lines end}
    ]
    
    {:ok, _pid} = Owl.Daemon.start(args)
  5. How manual and automatic spinners differ

    main

    Owl.Spinner.run/2 (Automatic)

    • Best for: Simple, single-task operations where you don't need to change the status message mid-task.
    • Lifecycle: Starts $\rightarrow$ Executes function $\rightarrow$ Stops based on return value.
    • Limitation: You cannot call update_label/1 to change the text while the function is running.

    Owl.Spinner.start/1 (Manual)

    • Best for: Complex workflows where you want to update the user on progress (e.g., "Step 1/3", "Step 2/3").
    • Lifecycle: Requires explicit calls to start/1 and stop/1.
    • Advantage: Allows calling update_label/1 at any point during the task execution.
  6. Manage live terminal updates with Owl.LiveScreen

    main

    The Owl.LiveScreen module is a server that manages live-updating, multi-line blocks in a terminal. It implements the Erlang I/O protocol, allowing it to be used as an I/O device for Logger or standard IO functions. When used this way, standard output is printed above the dynamic blocks, preventing logs from overwriting your UI.

    Core Workflow

    1. Add a block: Define a block_id and a render function that transforms a state into a view (using Owl.Data.t()).
    2. Update state: Use update/3 to change the data associated with a block.
    3. Wait for render: Use await_render/1 to ensure the UI has caught up with your latest state changes before proceeding.
    Owl.LiveScreen.add_block(:status, state: "starting...")
    Owl.LiveScreen.update(:status, "done!")
    Owl.LiveScreen.await_render()
  7. Configure Logger to use Owl.LiveScreen

    main

    To use Owl.LiveScreen as a destination for Logger (so logs appear above your UI blocks), add it as a handler.

    Note for OTP 28+: When using the handler ID :default, you must explicitly set filter_default: :log and filters: []. Otherwise, OTP 28's default behavior may silently drop Elixir log events.

    :ok = :logger.remove_handler(:default)
    
    :ok = :logger.add_handler(:default, :logger_std_h, %{
      config: %{type: {:device, Owl.LiveScreen}},
      formatter: Logger.Formatter.new(),
      # Required for OTP 28+ compatibility
      filter_default: :log,
      filters: []
    })
  8. Configure Owl.ProgressBar options

    main

    When calling Owl.ProgressBar.start/1, you can pass several options to customize the appearance and behavior:

    OptionTypeDescription
    :idany()Required. A unique identifier for the progress bar.
    :labelOwl.Data.t()Required. The text label displayed before the bar.
    :totalpos_integer()Required. The target value for the progress bar.
    :currentnon_neg_integer()The starting value. Defaults to 0.
    :bar_width_ratiofloat()The width ratio of the bar relative to available space. Defaults to 0.7.
    :timerboolean()If true, displays an elapsed time timer (MM:SS) before the bar. Defaults to false.
    :absolute_valuesboolean()If true, shows current/total before the bar. Defaults to false.
    :start_symbolOwl.Data.t()Symbol at the start of the bar. Defaults to "["
    :end_symbolOwl.Data.t()Symbol at the end of the bar. Defaults to "]"
    :filled_symbolOwl.Data.t()Symbol used for completed segments. Defaults to "≡"
    :partial_symbols[Owl.Data.t()]A list of symbols used for sub-cell progress. Defaults to ["-", "="]
    :empty_symbolOwl.Data.t()Symbol used for empty segments. Defaults to " "
    :screen_widthpos_integer()The width of the output. Defaults to terminal width or 80.
    :live_screen_serverGenServer.server()The server to render on. Defaults to Owl.LiveScreen.
  9. Apply true color tags to data using Owl.TrueColor

    main

    You can combine Owl.TrueColor sequences with Owl.Data.tag/2 to apply specific foreground and background colors to strings before outputting them with Owl.IO.puts/1.

    "Hello"
      |> Owl.Data.tag([Owl.TrueColor.color(1, 244, 74), Owl.TrueColor.color_background(133, 48, 100)])
      |> Owl.IO.puts()
  10. Example: Managing multiple progress bars

    main

    You can run multiple progress bars concurrently by assigning each a unique :id. This is common when using Task.async to track parallel operations.

    1..10
    |> Enum.map(fn index ->
      Task.async(fn ->
        range = 1..Enum.random(100..500)
        label = "Demo Progress ##{index}"
    
        Owl.ProgressBar.start(
          id: {:demo, index},
          label: label,
          total: range.last,
          timer: true,
          bar_width_ratio: 0.3,
          filled_symbol: "#",
          partial_symbols: []
        )
    
        Enum.each(range, fn _ ->
          Process.sleep(Enum.random(10..50))
          Owl.ProgressBar.inc(id: {:demo, index})
        end)
      end)
    end)
    |> Task.await_many(:infinity)
    
    Owl.LiveScreen.await_render()
  11. Convert raw chardata to tagged data using Owl.Data.from_chardata/1

    main

    If you receive data that already contains ANSI escape sequences (e.g., from a subprocess like bat), use Owl.Data.from_chardata/1 to parse those sequences into Owl.Tag structures. This allows you to use Owl's layout tools (like Owl.Box) on pre-colored text.

    # Example: Parsing output from a system command
    {output, 0} = Owl.System.cmd("bat", ["README.md", "--color=always"])
    
    tagged_data = Owl.Data.from_chardata(output)
  12. Select a single item with `Owl.IO.select/2`

    main

    Selects one item from a non-empty list. If the list contains only one element, it is returned immediately (autoselected).

    Options

    • :label - A text label to display before the list. Defaults to nil.
    • :render_as - A function used to render each item in the list. Defaults to Function.identity/1.

    Examples

    # Basic selection
    Owl.IO.select(["one", "two", "three"])
    
    # Selection with custom rendering and a label
    ~D[2001-01-01]
    |> Date.range(~D[2001-01-03])
    |> Enum.to_list()
    |> Owl.IO.select(render_as: &Date.to_iso8601/1, label: "Please select a date")
    
    # Selection with complex data and color tagging
    packages = [
      %{name: "elixir", description: "programming language"},
      %{name: "asdf", description: "version manager"}
    ]
    Owl.IO.select(packages,
      render_as: fn %{name: name, description: description} ->
        [Owl.Data.tag(name, :cyan), "\n  ", Owl.Data.tag(description, :light_black)]
      end
    )
    Owl.IO.select(["one", "two", "three"])