{"id":50426941,"url":"https://github.com/thatsme/harlock","last_synced_at":"2026-05-31T11:30:31.524Z","repository":{"id":357625120,"uuid":"1236736680","full_name":"thatsme/harlock","owner":"thatsme","description":"Pure-Elixir TUI framework — TEA-style model/update/view on top of OTP, with focus traversal, layout constraints, ANSI diff rendering, and a small termios NIF for direct /dev/tty control.","archived":false,"fork":false,"pushed_at":"2026-05-13T15:40:02.000Z","size":351,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-13T16:38:29.670Z","etag":null,"topics":["elixir","hex","ncurses","otp","ratatui","terminal","tui"],"latest_commit_sha":null,"homepage":null,"language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/thatsme.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":"ROADMAP.md","authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-12T14:22:14.000Z","updated_at":"2026-05-13T15:46:26.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/thatsme/harlock","commit_stats":null,"previous_names":["thatsme/harlock"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/thatsme/harlock","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thatsme%2Fharlock","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thatsme%2Fharlock/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thatsme%2Fharlock/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thatsme%2Fharlock/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/thatsme","download_url":"https://codeload.github.com/thatsme/harlock/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thatsme%2Fharlock/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33730240,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-31T02:00:06.040Z","response_time":95,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["elixir","hex","ncurses","otp","ratatui","terminal","tui"],"created_at":"2026-05-31T11:30:30.568Z","updated_at":"2026-05-31T11:30:31.517Z","avatar_url":"https://github.com/thatsme.png","language":"Elixir","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Harlock\n\n[![Hex.pm](https://img.shields.io/hexpm/v/harlock.svg)](https://hex.pm/packages/harlock)\n[![Hex Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/harlock)\n[![CI](https://github.com/thatsme/harlock/actions/workflows/ci.yml/badge.svg)](https://github.com/thatsme/harlock/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)\n\nA pure-Elixir TUI framework for Unix terminals. TEA-style\nmodel / update / view loop on top of OTP, with first-class focus\ntraversal, layout constraints, ANSI cell-diff rendering, and a small\ntermios NIF for direct `/dev/tty` control.\n\n![Harlock showcase](https://raw.githubusercontent.com/thatsme/harlock/v0.4.0/screenshots/showcase.jpg)\n\n```elixir\ndefmodule Counter do\n  use Harlock.App     # imports the view DSL (box/1, text/2, vbox/1, …)\n\n  def init(_), do: %{n: 0}\n\n  def update({:key, {:char, ?+}, []}, m), do: %{m | n: m.n + 1}\n  def update({:key, {:char, ?-}, []}, m), do: %{m | n: max(0, m.n - 1)}\n  def update({:key, {:char, ?q}, []}, _), do: :quit\n  def update(_, m), do: m\n\n  def view(m) do\n    box(\n      title: \"Counter\",\n      border: :rounded,\n      child: text(\"count: #{m.n}\")\n    )\n  end\nend\n\nHarlock.run(Counter)\n```\n\nA more realistic app wires focus traversal, a selectable table, a\nscrollable viewport, and a side-effect via `Cmd` — all together. Tab\nmoves focus between the two boxes; the focused widget owns its keys.\nFull source: [`examples/overview.exs`](examples/overview.exs).\n\n```elixir\ndefmodule Overview do\n  use Harlock.App\n  alias Harlock.{Cmd, Focus}\n\n  def init(_) do\n    %{\n      tasks: [\n        %{id: 1, name: \"compile\", state: \"done\"},\n        %{id: 2, name: \"test\", state: \"running\"},\n        %{id: 3, name: \"dialyzer\", state: \"queued\"},\n        %{id: 4, name: \"credo\", state: \"queued\"},\n        %{id: 5, name: \"publish\", state: \"blocked\"}\n      ],\n      selected: 1,\n      log: for(i \u003c- 1..40, do: \"[#{i}] event line #{i}\"),\n      log_offset: 0\n    }\n  end\n\n  def update({:key, {:char, ?q}, []}, _), do: :quit\n\n  def update({:key, {:char, ?r}, []}, m) do\n    cmd =\n      Cmd.from(fn -\u003e Enum.map(1..3, \u0026\"[refresh] new line #{\u00261}\") end)\n      |\u003e Cmd.map(fn lines -\u003e {:refreshed, lines} end)\n\n    {m, cmd}\n  end\n\n  def update({:refreshed, lines}, m), do: %{m | log: lines ++ m.log}\n\n  # The runtime auto-routes scroll keys to the focused viewport and\n  # delivers this message; the app just writes where the offset lives.\n  def update({:harlock_scroll, :log, new_offset}, m), do: %{m | log_offset: new_offset}\n\n  def update({:key, _, _} = ev, m) do\n    case Focus.current() do\n      :tasks -\u003e update_tasks(ev, m)\n      _ -\u003e m\n    end\n  end\n\n  def update(_, m), do: m\n\n  defp update_tasks({:key, :up, _}, m), do: %{m | selected: max(1, m.selected - 1)}\n\n  defp update_tasks({:key, :down, _}, m),\n    do: %{m | selected: min(length(m.tasks), m.selected + 1)}\n\n  defp update_tasks(_, m), do: m\n\n  def view(m) do\n    here = Focus.current()\n\n    vbox(\n      constraints: [fill: 1, length: 1],\n      children: [\n        hbox(\n          constraints: [percentage: 40, fill: 1],\n          children: [\n            box(\n              title: \"Tasks\",\n              border: :rounded,\n              border_style: border_style(here == :tasks),\n              focusable: :tasks,\n              child:\n                table(\n                  columns: [\n                    column(title: \"#\", width: {:length, 3}, render: \u0026Integer.to_string(\u00261.id)),\n                    column(title: \"name\", width: {:fill, 1}, render: \u0026 \u00261.name),\n                    column(title: \"state\", width: {:length, 8}, render: \u0026 \u00261.state)\n                  ],\n                  rows: m.tasks,\n                  row_id: \u0026 \u00261.id,\n                  selection: {:single, m.selected}\n                )\n            ),\n            box(\n              title: \"Log\",\n              border: :rounded,\n              border_style: border_style(here == :log),\n              child:\n                viewport(\n                  focusable: :log,\n                  offset: m.log_offset,\n                  content_height: length(m.log),\n                  child:\n                    vbox(\n                      constraints: List.duplicate({:length, 1}, length(m.log)),\n                      children: Enum.map(m.log, \u0026text/1)\n                    )\n                )\n            )\n          ]\n        ),\n        text(\"Tab focus  arrows/PgUp/PgDn scroll  r refresh  q quit\", style: [dim: true])\n      ]\n    )\n  end\n\n  defp border_style(true), do: [fg: :cyan, bold: true]\n  defp border_style(false), do: [dim: true]\nend\n\nHarlock.run(Overview)\n```\n\n## Installation\n\n```elixir\ndef deps do\n  [{:harlock, \"~\u003e 0.4\"}]\nend\n```\n\nHarlock builds a small C NIF (`c_src/termios.c`, ~250 LOC of POSIX) for\ntermios access — `elixir_make` handles this automatically. Requires a\nC compiler and `make` available at install time. macOS, Linux, and\n\\*BSD are supported; Windows native is not (WSL works).\n\n## Why Harlock\n\nIf you've written a Phoenix LiveView app you already know how to use\nHarlock — `init / update / view`, message-passing for events,\nside-effects as `Cmd` values. The runtime is a single OTP supervision\ntree: terminal owner → IO → cmd executor → TEA loop, with terminal\nrestoration guaranteed on any crash path via the supervisor's\n`rest_for_one` strategy.\n\nCompared to alternatives:\n\n- **[Owl](https://hex.pm/packages/owl)** is a styled-output library\n  (\"println but pretty\"). Harlock is a full interactive runtime —\n  focus, layout, dirty-flag rendering, async cmds, resize handling.\n- **[Ratatouille](https://hex.pm/packages/ratatouille)** wraps termbox\n  via a C port. Solid, but the C dep is bigger and the runtime model\n  is its own thing. Harlock is pure-Erlang for rendering with a small\n  in-process NIF only for termios — closer to \"Elixir all the way\n  down\" if that matters to you.\n- **ratatui-via-port** approaches (Rust binary speaking a wire\n  protocol to BEAM) ship as two artifacts: your Elixir release plus a\n  separately-compiled Rust binary that has to be on `PATH` at runtime.\n  Harlock ships as one mix release — no extra binary, no version-skew\n  between BEAM and renderer. The element tree is also ordinary Elixir\n  data, which makes testing and composition easier than a wire-protocol\n  boundary.\n\n## Status\n\nHarlock is `v0.4`. The API is intentionally narrow and stable for the\nprimitives it ships; widgets and ergonomics are still landing.\nAnything `@moduledoc false` is internal and free to change.\n\n| Area | Status |\n|---|---|\n| TEA runtime (`init` / `update` / `view` / `subs`) | ✓ |\n| OTP supervision + terminal restoration | ✓ |\n| Cmd executor (`Cmd.from`, `Cmd.batch`, `Cmd.map`) | ✓ |\n| Layout constraints (`:length`, `:percentage`, `:fill`, `:min`, `:max`) | ✓ |\n| Focus traversal + focus_trap overlays | ✓ |\n| Focus-aware widget key routing (`viewport` / `tabs` / `text_input`) | ✓ (v0.4) |\n| Wide-grapheme width (CJK, emoji, ZWJ, flags) | ✓ |\n| Theme tokens (`:header`, `:focus`, `:selection`, `:border`, `:primary`, `:accent`, `:muted`, `:error`) | ✓ (full set in v0.4) |\n| Built-in themes (`:default` / `:dark` / `:high_contrast`) | ✓ (v0.4) |\n| Caps-aware color downgrade (truecolor → 256 → 16 → mono) | ✓ (v0.4) |\n| Table style cascade (`:header_style` / `:row_style` / `:alt_row_style` / `:selected_style` / `:focus_style`) | ✓ (v0.4) |\n| `:default` theme byte-identical to v0.3 (golden-frame pin) | ✓ (v0.4) |\n| SIGWINCH resize via `ioctl(TIOCGWINSZ)` NIF | ✓ |\n| `text` / `vbox` / `hbox` / `box` / `spacer` / `overlay` / `table` / `list` / `text_input` | ✓ |\n| `progress` / `spinner` / `statusbar` / `keybar` / `tabs` | ✓ |\n| `viewport` (render-then-clip + scroll-into-view + cursor remap) | ✓ |\n| `:telemetry` events (frame render, input dispatch, cmd, reader) | ✓ |\n| Modified arrows / Home / End / F-keys (parser) | ✓ |\n| Mouse events (SGR parser) | ✓ (parser only — runtime enabling deferred) |\n| Kitty keyboard protocol (parser) | ✓ (parser only — runtime push deferred) |\n| `tree` / `menu` / `select` widgets | v0.5 |\n| Multi-line `text_area` | v0.5 |\n| Richer `Sub` kinds (`pubsub` / `file` / `signal` / `port`) | v0.5 |\n| `box(focus_proxy: id)` (visual focus mirroring) | v0.5 |\n\nSee [`ROADMAP.md`](ROADMAP.md) for the full plan through v1.0.\n\n## Examples\n\n```sh\n./scripts/run.sh counter    # simplest possible app — count up/down\n./scripts/run.sh sysmon     # live BEAM process monitor\n./scripts/run.sh contacts   # contact manager: search, list, modal forms, async save\n./scripts/run.sh showcase   # tabs, viewport, widgets, modified keys\n```\n\nThe `scripts/run.sh` wrapper is in the GitHub repo — clone the repo to\nrun the examples. The hex package itself is the library; apps depend\non `:harlock` and build their own runtime entry point (see the Counter\nsnippet above).\n\n`contacts` exercises most of the core primitives: tab focus traversal,\ntext_input fields, an overlay with focus_trap, async save via\n`Cmd.from`, custom theme, status bar with current-focus indicator.\n\n`showcase` is a four-tab tour of everything that landed in v0.3 — a\n200-row scrollable log viewer with `viewport` + scrollbar, a long form\nthat uses scroll-into-view to keep the focused field visible, a\nwidget gallery with animated progress/spinner/statusbar/keybar, and a\nkey-event inspector you can use to try out modified arrows\n(Ctrl-Up, Shift-Right, etc.).\n\n## Testing your app\n\n`Harlock.Test` boots an app under a headless backend — no `/dev/tty`\nrequired — and exposes synchronous helpers:\n\n```elixir\ntest \"Tab cycles focus through the form\" do\n  h = Harlock.Test.start_app(MyApp, init_arg)\n\n  Harlock.Test.send_key(h, :tab)\n  assert Harlock.Test.focused(h) == :email\n\n  Harlock.Test.send_key(h, :tab)\n  assert Harlock.Test.focused(h) == :submit\n\n  Harlock.Test.stop(h)\nend\n```\n\nSame code path as the real runtime — only the bytes-in / bytes-out\nboundary is mocked.\n\n## Smoke tests\n\nA handful of scripts in `priv/*_smoke.exs` exercise the real\nruntime + termios NIF via `script(1)`:\n\n```sh\n./scripts/smoke.sh\n```\n\nPicks the right flag syntax for BSD vs util-linux `script` automatically.\n\n## Contributing\n\nIssues and PRs welcome at \u003chttps://github.com/thatsme/harlock\u003e. The\ncodebase is small enough (~3k LOC of Elixir + ~250 LOC of C) to read\nin an afternoon. Start with `lib/harlock/app/runtime.ex` — everything\nelse is reachable from there.\n\n## License\n\nMIT. See [`LICENSE`](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthatsme%2Fharlock","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthatsme%2Fharlock","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthatsme%2Fharlock/lists"}