mirror of
https://github.com/anomalyco/opentui.git
synced 2026-04-25 13:06:00 +03:00
[GH-ISSUE #553] Proposal: The Grid™ #918
Labels
No labels
bug
core
documentation
feature
good first issue
help wanted
pull-request
question
react
solid
tmux
windows
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
starred/opentui#918
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @necropheus on GitHub (Jan 19, 2026).
Original GitHub issue: https://github.com/anomalyco/opentui/issues/553
Idea
This is an idea I've been working on over the weekend, a grid component that has baked-in logic for keyboard-based layout navigation.
Problem: navigating the UI with your keyboard
Right now, if you want to navigate through the UI with your keyboard, you have to do 2 things:
useKeyboardBut that comes with some problems (in my opinion):
Tracking
Tracking your own focus system is not trivial. The more things you have in your UI, the more complex it gets:
if focus is "foo" but not "bar" and flag "qux" === "mom", etc. It's basically a global state that grows in complexity as your app grows.Key handling
You have to have multiple scattered
useKeyboardcalls through your project to manage their respective UIs, but that potentially becomes a performance issue, and it's hard to track (if you change something regarding your focus system, you have to remember to change theuseKeyboardcalls that use said system), not to mention you probably have to repeat logic everywhere, meaning, eachuseKeyboardhas to goif (globalFocus === myContext).An alternative would be to have just one giant
useKeyboardcall. That would solve the scattered calls and the repeated logic, but it would grow in complexity as your app grows, making it again hard to maintain.Logical UI bugs
It's prone to bugs. The combination of multiple
useKeyboardcalls and the custom focus system has a high potential for logical bugs where a specific combination of keys and focus states breaks your whole UIPartial solution (that should also be implemented in general, not just for the grid): focusable boxes
Now, there's one thing that could solve some of these problems: boxes being able to be focused, thus being able to receive key events.
Why would this solve some of these issues?
No more
useKeyboardNo need for multiple
useKeyboardcalls. Each box hasonKeyDown, and acts as its own focus system. Let's imagine this:These are boxes. Arrays represent the fact that the UI has multiple elements:
onKeyDownto capture left and right key events, because its UI goes left to rightonKeyDownto capture up and down events, because its UI goes up and downonKeyDownto capture left and right key events, same as D, because its UI goes left to right tooonKeyDownto capture ctrl+{arrowKey} because its UI goes in all directionsctrl+left, it bubbles up all the way to A so it can focus one of B's siblingsAnd so on so forth, you get the idea
But, that also has a big problem: the focus model is too limited. Let's take point 2 as an example:
The things it can't solve easily
leftand there's nothing to my left, instead of focusing one of C's siblings, I want to focus one of its children?To do all of these things I have to start throwing refs everywhere, keep flags, add extra logic, etc.
So, a solution to that would be a component that manages all of that by itself. A component that natively has internal logic to be used as a UI navigation system...
Enter: The Matr-I(x) mean, The Grid™ (plus a new feature for
focusthat I'll explain later)What is The
Grid™component? It's very straightforward, it behaves similarly to what I just described:First, let's just look at a simple example of how it could work (keep in mind this is just a POC, it can probably look prettier and have better ergonomics):
The Grid™ Breakdance
We first have the general UI, which is inside a
<grid id="opencode" ...>component. In there you can also notice that a grid receives alayoutprop, which behaves very much like a CSS grid (except this one does not apply styles, you have to do that yourself. It could have styles but that's not relevant for this proposal).It's basically a 2D matrix that defines what's where. It must match the inner children's IDs so it knows what's what.
You might already have noticed that some layouts receive just one level. That's why we have the
alignprop. It tells the grid if it's just one column (the most common case probably), or if it's a row (maybe a rail? It can have some use cases)It also receives a
keysprop that tells it how to navigate its inner components, how to focus something, and how to unfocus (gain focus back) something (you might notice that this differs from my example files because I just had the idea of focus and unfocus)Then, inside that main grid there are other grids that do other things. For example:
gridorgrid_node. As long as your component is focusable, it will focus itonFocus,onFocusWithin,onFocusLocaland itsblurcounterparts obviously, and each one of them has its uses. I'll give you some examples:onFocuscould most commonly be used to pass focus from intermediate UI elements to actual elements you want to focus inside of it. It can also be used to visually tell the user where they're actually focusing, to distinguish from the local focus of any other component; the actual, main focus.onFocusWithincan be used by intermediate UI components again, so they can know when something inside of them is being focused without having to keep any global state of the thing focused right now is one of my descendantsonFocusLocalcan be used similarly to the last part of theonFocusabove: to signal which thing is selected but in a more global state. For example: to tell the user hey, you are looking at your "How to center a div" session, even if they are right now copying the code from the terminal for how to center a div, which is obviously another branch in the node tree that is not inside nor is thesessionsbranchblurcounterparts complement those obviouslygrid_nodecomponent is just a focusable box with extra features to interact with a gridInside The Grid™
Now, how does the grid work inside? It relies on 2 things:
focusWithin, which tells a component if any descendant (or itself) is focused.IMHO this feature should be baked into OpenTUI. If you want to navigate your UI with your keyboard (like everyone wants in a terminal), you must have a deeper understanding of focus.
The web implementation right now lacks depth, it's very simple: we have one focus, for one thing only, and in most websites by default you can only navigate it with
TABwhich is just not very useful for this kind of thing.focusLocal, a system for the grid to always tracks which component it focused last, and that focus doesn't go away when the grid or its entire branch loses focus. You can manually do it though if for some reason you want toGeneral The Grid™ stuff
The rest of the grid is just logic to know where
id="thing-2"lives relative to the current focusedid="thing-1"when it receives a keymap, and logic for giving and taking focusThere's also some stuff on inter-grid focus and blur. For instance: what happens when you suddenly focus something else, like with your mouse or with a command? Well, the grid or whichever thing you had focused will trigger its
blurthing. Then, with the newfocusWithinlogic, it should tell its parent hey, I just blurred, you should do the same, and that chain repeats until we find something that can't be focused (thus can't be blurred).This chain does not blur the
focusLocal, that stays right where it is, so the UI can keep selections and stuff.Final notes
There's files attached here if you want to try it. You'll notice some things are different because I had some ideas while writing this lol, but the core concept is there.
It's somewhat broken though because it was mainly done by Codex, so it has some rough edges with managing focus, but the general idea is there. It needs to be properly re-implemented from scratch to fix those rough edges and to make code cleaner (and probably more performant since right now it has some very naive algorithms I think)
Some enhancements that could be done:
For example, let's say you have a slightly modified version of the previous example:
In this case, if you are focusing the chat and you press left, you go to the sessions menu, but what happens if you then press up? With the current algorithm it needs to iterate over every "session" in every array inside it until it finds something different (the "burger-menu"). Navigation would then be
O(chat.length), which is not ideal for those long running sessions.If you instead had a graph, it could simply do
currentLocalFocus.topElement.focus(). It's probably more complex to implement since you have to build that graph from its children (which is not trivial at all) and then update it (again, not trivial), but it would make navigation fasterSummary
You now solved all of the problems I mentioned:
useKeyboardscattered all over the place with complexif key is "right" and focus is "something" but focus isn't "something else" and the sun is doing whatever then you can focus this component. Each grid has its own directional movement keys. They can be repeated through multiple grids without worrying about stepping each other because it all relies in the focus system andonKeyDownevents.Final note (this time for real)
I think key events should also expose the event target, just like the web does. I don't know if other events do, but if they don't I think they should too, it's a very useful feature (even for The Grid™)
@necropheus commented on GitHub (Jan 19, 2026):
Oh, I can't attach files. I'll have to make a draft PR and link it here I guess
Edit: here it is https://github.com/anomalyco/opentui/pull/554
The
grid.tsxexample was made for solid, but you can translate it to react and it should work tooYou can just copy the files into a folder, import that folder inside of your
index.tsxfile so it can catch thecomponents.d.tsfile and stuff, and then you have thegrid.tsxfile to try it out.You might notice that some tiny stuff with focus seems to be broken but it works in general
A quick explanation of the UI:
F = Focused (native)
W = Focused Within
L = Focused Local
@remorses commented on GitHub (Jan 19, 2026):
I think this adds unnecessary complexity. The problems you describe are already solved elegantly with simple React/Solid patterns using state + the
focusedprop.Here's your opencode example rewritten:
This solves all the same problems:
isFocused("sessions")for container stylingWhy this is simpler:
focusedprop already handles keyboard input routing@necropheus commented on GitHub (Jan 19, 2026):
I get the point, and it would only work for a small/static UI, but I don’t think it actually addresses the general problem I'm describing.
It solves the demo by turning navigation into an app-level keyboard router + a global mode state, which is exactly what starts to collapse as your app grows (both in size and complexity).
useKeyboard” becomes an app-level state machineIn your example, the handler is manageable because the number of sections is fixed and small, each section is basically a linear list + an index and there are no nested focus modes
But in real case scenarios you quickly add features that force layered branching. I'll give you some examples:
downmeaning navigating your results or moving to the section below your search bar? It's another layer of checkingescapehas different meaning.escapemeans exiting normal mode or visual mode and not exiting the chat?escapekeymap? What about 3escapekeymaps? How do you order the if checks? First check the focus and then the key? The other way? What if this pattern repeats for like 10 keys? Which order do you pick for which keys? It's yet another layer of checkings that becomes increasily dangerousonBlur={() => tellTheGlobalFocusStateThatIBlurred()}onFocus={() => tellTheGlobalFocusStateThatIFocused()}That turns the handler into something like:
vimModethen arrows mean something else, alsoescapemeans something else, etc.This is exactly the global focus complexity grows with the app issue. The code isn’t “wrong”, it just accumulates complexity in one place. And it accumulates unnecessary checks on every keydown that could be avoided by using a focus system
With keys like
escape(orenter,ctrl+k, etc.) you don’t just need logic for “what does this key do?”, you need a consistent rule for “who gets to handle it first?”.Example:
escapecan mean different things depending on where you are:In a central handler, this becomes an ordering problem:
Do you check
if inputFocusedbeforeif chatMode?What if a global
escapeshould override local ones?What if a local
escapeshould override the global one only in some states?Once you add more global shortcuts that also exist locally (
ctrl+k,ctrl+p,enter,tab,/for search, etc.), you get either:Repeated checks everywhere because you either first check the focus and then the key, or check the key and then the focus:
if (paletteOpen) { if (key.name === "down") }if (modalOpen) { if (key.name === "down") }if (searchbarFocused) { if (key.name === "escape")}if (chatFocused && vimMode && visualMode) { if (key.name === "escape) }And you have to do all of this for each key that is used multiple times through your app, maintaining coherence and correctness, and creating a lot of duplicated code
Or you encode a manual priority system (basically reinventing event propagation):
But that’s exactly my point: with a central router you inevitably rebuild a propagation model by hand, and as you add 10 global keymaps that also repeat in 3–4 components, you start duplicating logic and fighting precedence bugs.
In contrast, with focusable nodes + bubbling:
escapeas “cancel/back”)Your demo has a section (which panel is active), a sessionIndex and a chatIndex
But as soon as “chat” has an internal sub-mode (like your vim mode example), you get extra state:
Same with sidebar:
Now the “global focus” is no longer “just indices”. It’s a global state machine describing many independent components’ internals.
With the grid, those states can be encapsulated into the component that actually use them, and without having to worry about focus or external events, they just act. If they aren't on focus, they simply don't get the event at all, they don't have to check, the grid does it for them
focused/isFocused(section)is not the same asfocusWithinYour argument says “The focused prop already handles keyboard routing”.
That helps decide “who receives key input”, but it doesn’t provide the behavior I’m asking for: parents reacting to descendant focus without manual wiring.
Concrete examples where focus within matters:
a) Styling parents when a deep child is focused
Say you have:
If the user focuses the Input inside SessionItem (mouse or programmatically), the SessionsPanel often wants a “focus-within” style (like
:focus-withinon CSS).With
section === "sessions", you’re not expressing a descendant is focused, you’re expressing this panel is the active navigation mode.Those are different. You can be in sessions mode but focused on a nested input in a different branch, or you can programmatically focus something without updating
section.To solve this, you'd have to call
setSection("sessions")on every component that could potentially be focused in your UI that's inside the same branch as the "sessions" componentWith the grid you don't need to, the grid does that internally and tells the component "hey, you are focused within, do your thing". No need to track global focus for every component
b) Programmatic focus / mouse focus
If some command does
inputRef.focus()or the user clicks into chat, your keyboard router must updatesectionand any relevant sub-state to match reality, otherwise your global state is out of sync with actual focus.So you either:
The grid handles this too: focus is the source of truth, and
focusWithinlets ancestors react without the app-level router needing to know the entire subtree.In your snippet you render the session item inline:
But in a real project, you don’t keep it inline. You do:
Now
SessionItemneeds to know whether it’s focused, whether its parent panel is focused, and how to change the local index on arrow keys (or at least which index is active)So you either:
SessionsContext,FocusContext,KeyboardContextSessionItembecomes “dumb”,Appowns everythingAll three are workable, but they’re exactly what I’m trying to avoid: navigation logic leaking across component boundaries
With a component-local navigation model,
SessionItemcan encapsulate:onFocusLocal/onBlurLocalstylingand the parent doesn’t need to know its internal structure, nor does it need to keep checking if it's children are actually being focused on every keystroke.
Concrete example:
So we have:
What you'd want:
left/rightmoves insideActionsRowif you’re in itlefton the leftmost action, the event bubbles:ActionsRowcan’t move further -> bubble toOutputItem/ChatPanelChatPaneldecides thatleftmeans “move to Sessions panel” (it doesn't even decide because it doesn't need to check for anything, it just haskeys={{ left: { name: "left" }}}) and when it gets a left event it moves to the leftIn a central router, this becomes:
ActionsRowActionsRowis at an edgeThat is basically manually encoding a focus graph and edge rules at the app level. And adds the same problems I've been saying: growing complexity of branch checking, if statements, repeated logic, etc.
The grid approach encodes the adjacency rules once (layout) and lets local components manage their own internal navigation, while still having a consistent escape hatch (bubbling) when a local move is impossible.
Again, I'm not saying your point isn't valid, it works, it's a good minimal pattern for small TUIs, but it doesn’t provide:
focusWithinsemantics for ancestor reactionsbubbling/delegationfor “can’t handle this key, parent decide”As soon as it starts growing, it starts to increase in complexity, thus making it harder to maintain and increases the chances of global focus state sync bugs, and those are the problems I’m proposing to solve with the grid
OpenTUI follows React/Solid patterns, which come from the web, but the web was designed around navigating with the mouse.
The keyboard is a secondary input there: tab order is linear, navigation is limited, and anything more complex is implemented manually in userland.
A terminal is different: the keyboard is not a secondary input, it is the primary one.
That means we have a different playground here: keyboard navigation as a first class native concept, the same way mouse navigation is native on the web.
Today, using a central
useKeyboardrouter is effectively treating the keyboard like a low-level event stream: “a key was pressed, now decide what to do”.But that’s analogous to handling mouse navigation on the web by listening to global mouse events and manually deciding which component is under the cursor.
We don’t do that because the DOM already encodes structure, hierarchy, and spatial relationships.
My proposal is about doing the same for keyboard navigation: focus and navigation should follow your keyboard naturally, by design, because you are in a keyboard first environment.
To me it should be something that you shouldn't have to implement manually every time you make a TUI. It should just be the default, and then you can add on top of it.
Damn that was a long yap yap session
@necropheus commented on GitHub (Jan 24, 2026):
Ok so, I obviously wasn't going to let this die, so I spent like 5 days refactoring the code, basically doing it from scratch all by hand (it went from like +1.2k LOC to barely 500, amazing).
As I was doing it, I had some ideas on how to make the grid better. Here are most of them:
Layout? More Like Gone-ayout
I scratched the whole
layoutthing away: now everything relies on node coordinates. Why?layoutonly makes sense if you are going to add native styles to the grid, because it gives you a visual idea of how much a section will span, but in this case, the grid has no styling opinions, it just knows left-right-up-down, nothing elsecoordsprop on every child and move around based on thatLet's say you are focusing
sessionsand press right. How does The Grid™ decide what to focus? How does it know, on mount, what is right ofsessions? You have 2 ways:lastFocusedNeighbor...toMyRight, thenlastFocusedNeighborToMyLeft, and so on. You get the idea: it's a waste of memory and it adds a whole new class of complexitySo, I made a decision: your grid should be rectangular in terms of nodes. Meaning: for every node, there's at most 4 neighbors (left, right, up, down).
But, Gus, what do we do if we want something like the layout?
That's quite simple, friend: just slap another The Grid™ lol. It would look something like this (conceptually):
and then, "mid" is just another grid
Of course, those are not IDs. Now The Grid™ just saves an internal matrix of node references and when it needs to move it's local focus, it just checks the current locally focused element's
coordsprops and moves to whatever node reference is in the direction you just asked it to move (plus some nice features that I'll explain below)Getting rid of the layout was a good decision in my opinion.
All Hail Coordinates
They are quite simple to understand: each
grid_node(orgrid, which is just an extension of agrid_node) receives acoords: [number, number]prop. With that, when a child is added to a The Grid™, The Grid™ does all the funny things to add its reference based on its coordinates to the internal matrix. It relies mostly on overridingaddandremovesince these are the times where your The Grid™ changes... except for, you know, when it doesn't.The Solid Problem
The coordinates are fun and all, but they come with a trade-off (only for dynamic grids tho, static grids are fine): Solid is very clever on fine-grained updates, and it messes with ids, which in turn messes with The Grid™ references. It's not an unsolvable one tho, for now you can work around it by simply using
crypto.randomUUID()orcreateUniqueID()(from solidjs package) directly called inside the<Index>or<For>component (there's an example in the files)As I said, this only happens in dynamic grids because Solid, instead of behaving like react and just nuking the entire The Grid™ and recreating it, it does some quick tricks like copying the last element, then doing identity shifting and weird stuff, changing ids, whatever whatever, so it ends up poluting your internal references. You can force it to not do that and instead use a combination of adds and removes if you do as I said.
It can be fixed tho, this is just a working prototype, but it can be better optimized for these kinds of things
Extra features
focusLocalname toselectedbut that's already taken so that's a feature we won't have lolSummary
There are more stuff here and there but those are the 2 main things. I went through quite a lot of iterations and this is the best I could do for now.
You'll notice I tried my best to leave comments clarifying some stuff and generally explaining the code.
Also, as I said at the end of my previous comment, this isn't just about making a grid. It's about having the same default the web has for mouse navigation.
On the web, you don't need to constantly capture the mouse position, then caputer its clicks, then search through the dom until you find whatever is under the mouse with the highest z-index and only then you know what you are focusing or clicking. You jus add
onClickto something and you interact with it.A TUI should have the same thing but for keyboard. You should just be able to navigate it, focus things, interact with things, without having to specify all the internal logic of navigating your TUI, it should be as simple as this is how I move, this is how I select something, now let me actually program my thing and the engine/component should handle the entire navigation for you without it being in the way
Edit: the updated files https://github.com/anomalyco/opentui/pull/554
@simonklee commented on GitHub (Mar 9, 2026):
Thanks for the thoughts and the writeup @necropheus. At this moment we're not moving forward with this so I'm closing the issue.