jjenkins
I’ve been working on a TUI framework for Elixir called Drafter, inspired by Python’s Textual but built around Elixir idioms — declarative rendering, pattern-matched event handling, and OTP-native architecture.
The basic idea:
Mix.install([{:drafter, "~> 0.1.3"}])
defmodule CounterApp do
use Drafter.App
def mount(_props), do: %{count: 0}
def render(state) do
vertical([
header("Counter"),
label("Count: #{state.count}", flex: 1),
horizontal([
button("−", on_click: :dec),
button("+", on_click: :inc)
], gap: 2),
footer(bindings: [{"q", "Quit"}])
])
end
def handle_event(:inc, _data, state), do: {:ok, %{state | count: state.count + 1}}
def handle_event(:dec, _data, state), do: {:ok, %{state | count: state.count - 1}}
def handle_event({:key, :q}, _state), do: {:stop, :normal}
def handle_event(_event, state), do: {:noreply, state}
end
Drafter.run(CounterApp)
What’s in the box:
- 30+ widgets — DataTable, Tree, Charts, TextInput, TextArea, Checkbox, RadioSet, Markdown, CodeView, and more
- Flexible layouts — vertical, horizontal, grid, scrollable, sidebar
- Multi-screen navigation — push/pop screens, modals, popovers, panels, toasts
- Theming system with HSL/RGB/hex color support
- Declarative widget event handling with automatic focus management
- DOM-like three-phase event system (capture → target → bubble)
- Animation engine with 30+ easing functions
- Syntax highlighting via tree-sitter (optional)
- Headless testing harness for ExUnit
- Many examples
TUI over SSH:
Drafter.Server.start_ssh(ChatApp,
port: 2222,
mode: :shared,
auth: [{"alice", "pass"}, {"bob", "pass"}]
)
Any standard SSH client connects and gets a full interactive TUI session. :shared mode means all connected clients see the same state — input from any client updates everyone’s view in real time. The included ssh_chat.exs example is a working multi-user chat app you can try in a couple of minutes.
Built on OTP 28 — raw terminal mode, proper Unicode, and lazy input reading. Earlier OTP versions won’t work correctly.
Feedback and contributions welcome. Still early, but it’s been running real apps reliably.
Trending in Announcing
Other Trending Topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #library
- #deployment
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #elixirconf
- #channels
- #exunit
- #discussion
- #code-sync
- #podcasts
- #javascript
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixirconf-us
- #ai
- #blog-post
- #elixir-ls
- #phoenix_html
- #iex
- #graphql
- #genstage
- #websockets
- #supervisor
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #hex
- #security
- #metaprogramming











Showing Posts 1 to 10- Show Best Posts
- Show All (oldest first)
- Show All (newest first)
SilverLink
it’s a cool project bro! Love it
jjenkins
Thanks!
I’m hoping we see some nice Elixir/beam TUI apps, as well as any feedback for improvements, or any additions.
i will, hopefully soon, drop my heavily WIP project that rides on top of it. Grafana TUI client.
Just need to generate some sharable pics.
fuelen
It looks interesting. I’m wondering how it differs from https://github.com/pcharbon70/term_ui ?
jjenkins
Thanks For pointing that out. I had gone through all of the TUI libraries I could find for Elixir (and checked other ecosystems) before starting on my own library.
I’ve taken a look at term_ui and really like a number of the widgets, and the clean simplicity. I would ask that you try out all of the examples from both projeccts. I know that Drafter is still very much a WIP, but it’s getting there.
There are a number of things we do differently, including colors, themes, animations, mouse support, etc.
I may look to borrow some of the themes/ideas of term_ui as I continue to work on Drafter.
Please let me know what you think after trying both.
Thanks.
jjenkins
I made an example runner and recorded a quick dash through almost all of the examples so people can see what’s available without having to pull down the repo.
Asd
Hi, good library.
I am still reading the code (theres 30k lines of code since March 12th) and I have these comments:
Drafter.PubSubmodule is unused. Name is used, functions are notPhoenix.PubSubusage is strange. I suspect that it is not necessary in the first place. How its used: you have a “SharedState” PubSub server running, and it has a single topic named after that server’s pid. Then app loops subscribe to this single topic. First of all, it could be just a genserver which handles subscriptions in it’s state. Second, you can use:pgto not handle the subscriptions manually. Third, I suspect that sharing state with subscription is an error-prone approach because it may result in data races.My suggestion: just keep the SharedState server with two calls: subscribe and update_state. Subscribe will monitor the caller and add the pid of the caller in state. update_state will synchronously call every saved pid to propagate the state. It will not receive other update until all callers replied that they have reacted to update. You already have an
EventManagerwhich does exactly that. Perhaps it would make sense to reuse this process.credo, but fixing most of them would definitely improve the library.props = %{text: text} |> Map.merge(Map.new(opts))which is justMap.put_new(Map.new(opts), :text, text)Drafter.run(for example to start an SSH session in handler) inside some callback, which would generate a very very unusual bug.cache[{:app, app_module}]value which is never accessed{or}, or if any string contains wide characters, because it doesnt check for escaping and has a mix of regular expression and binary matching parserDrafter.Event.CustomRegistryis a bottleneck, never used and mostly undocumented. If you want to let user specify their own events, I think that users can perform their own validations withdefstructor any other validations library of their choice. This is a strange design decisionActionRegistryis a bottleneck and I’d suggest to not use a mutable state for this. User can just provide a list of action handlers on app start which can be just stored in session stateFocusRegistryis storing keybindings, and storing them globally. Which would create strange bugs when for example two SSH sessions focus different widgets with different keybindings. But this is never used, since it requires widget module to export functionkeybindings/0, but this callback is listed in App module, not in Widget, which is strange. Oh, and its used only to check if app focuses anything, data is not accessed in any way.Widget, you can usedefoverridable Drafter.Widgetto make allcallbacksoverridable, instead of listing them all. See this docSkinManagerhandles the character sets globally. Again, this will be a problem in case of two SSH sessions having two different character sets. Then, it uses persistent_term which causes global GC each time it is changed. Not a big deal given that character sets change infrequently, but it could be just stored in the session state, avoiding any of these problems.Session.start_sharedis never called. SSH and telnet connections bypass it and start the SharedState serves directly.ScreenManager,ThemeManager,EventHandlerandEvent.Managerare started globally and then one more time per each session. Essentially you have two session states per one session: one in pdict and one in workers, and these sessions.Event.Manageris strange. It receives messages, batches them in a queue, but dispatches them out one-by-one. You could’ve just dispatched messages inhandle_cast, because inbox is already a queue, so there’s no need to implement another queue on top of it.I can see that you name many of your GenServers as
Registry, but that is kinda misleading, becauseRegistryis usually an ets which tracks some state which is linked to some process, and is usually using the Registry Elixir module.I dont have time to read the rendering code now, but I will try to read it next weekend. Please dont just send these comments to your coding agent LLM
, use my review as an opportunity to level up your Elixir.
Anyways, Drafter is a lot of code, a lot of work, and I think that this is a good library and it is has a lot of room for improvement. Congratulations on initial release!
jjenkins
Thanks for the feedback.
Just for a bit of background: I’ve been working on a number of projects at the same time, focusing on getting the result, and then returning to refactor, as Claude does make some stupid choices. My day job keeps me busy with actual hand-written code, which is not open source/public, so I’ve been delegating the many side-projects to Claude to get those results, which I’ll go back to. With an eye on the results, I haven’t looked at the quality, except in those instances where I see really silly things flying by in the console, in which case I redirect and have Claude do things a bit more sanely.
I really do appreciate the time you’ve taken to do a review. I tink it’s almost in a place where I have most of the functionality, and can now focus on the code quality (though I think I want to get one more chart type so that I can get one of my other projects polished up).
I have a crammed schedule until the second week of April or so. May be pushing out some Claude generated things, but after that, I can clean it up properly, starting with your detailed list here.
A funny thing, just scanning your list, I see #13 about widgets. Claude’s md file has this as a critical point:
It’s really interesting to see it ignore important directives.
In any case, thanks for the suggestions/comments. I will polish this up, especially as time pressures ease.
ademenev
Does it support double-width characters? CJK, emojis
jjenkins
Great question… and with me sitting here in Asia, should have been top on my list of things to ensure..
…
And, now, yes…. working. Please let me know how it works for you.
Thank you for the feedback.
ademenev
I’ll give it a try as soon as I upgrade to OTP 28.