mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 12:05:49 +02:00
@@ -0,0 +1,235 @@
|
||||
// Command sidebar is the whole app-shell pattern in one file: a sidebar on
|
||||
// the left, an ordinary tea.Model on the right, a global help bar at the
|
||||
// bottom, tab to move focus between the two. No layout package involved -
|
||||
// lipgloss.JoinHorizontal/JoinVertical and style.RenderWithTitle already do
|
||||
// all of it, and the panes stay plain tea.Models.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/bubbles"
|
||||
"github.com/anotherhadi/ilovetui/helpbar"
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// sidebarWidth is the sidebar's total width, border included.
|
||||
const sidebarWidth = 24
|
||||
|
||||
// HelpProvider is the only contract in this pattern, and it's optional: a
|
||||
// pane that implements it gets its own bindings listed in the global help
|
||||
// bar while it's focused. A pane that doesn't just contributes nothing.
|
||||
type HelpProvider interface {
|
||||
HelpBindings() []key.Binding
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shell keys
|
||||
|
||||
type keyMap struct {
|
||||
Focus key.Binding
|
||||
Help key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeyMap() keyMap {
|
||||
return keyMap{
|
||||
Focus: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch pane")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q", "quit")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the shell
|
||||
|
||||
type model struct {
|
||||
sidebar list.Model
|
||||
content tea.Model
|
||||
help helpbar.Model
|
||||
keys keyMap
|
||||
|
||||
contentFocused bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
items := []list.Item{page("Overview"), page("Metrics"), page("Settings")}
|
||||
sidebar := bubbles.NewList(items, sidebarWidth-2, 0)
|
||||
sidebar.SetShowTitle(false)
|
||||
sidebar.SetShowStatusBar(false)
|
||||
sidebar.SetShowHelp(false)
|
||||
|
||||
keys := defaultKeyMap()
|
||||
return model{
|
||||
sidebar: sidebar,
|
||||
content: newCounter(),
|
||||
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.Focus, keys.Quit)),
|
||||
keys: keys,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.content.Init() }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.w, m.h = msg.Width, msg.Height
|
||||
return m.resize()
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Help):
|
||||
// Expanding the bar takes rows away from the panes.
|
||||
m.help, _ = m.help.Update(msg)
|
||||
return m.resize()
|
||||
case key.Matches(msg, m.keys.Focus):
|
||||
m.contentFocused = !m.contentFocused
|
||||
return m, nil
|
||||
}
|
||||
// Only the focused pane sees key presses.
|
||||
if m.contentFocused {
|
||||
var cmd tea.Cmd
|
||||
m.content, cmd = m.content.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.sidebar, cmd = m.sidebar.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Everything else (ticks, HTTP responses...) goes to both, so a blurred
|
||||
// pane keeps working.
|
||||
var sidebarCmd, contentCmd tea.Cmd
|
||||
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
|
||||
m.content, contentCmd = m.content.Update(msg)
|
||||
return m, tea.Batch(sidebarCmd, contentCmd)
|
||||
}
|
||||
|
||||
// bodyHeight is the height left for the two panes once the help bar has
|
||||
// taken its share. resize and View both go through it so they can't disagree.
|
||||
func (m model) bodyHeight() int {
|
||||
return max(m.h-m.help.Height(m.focusedHelp()...), 0)
|
||||
}
|
||||
|
||||
func (m model) resize() (model, tea.Cmd) {
|
||||
if m.w <= 0 || m.h <= 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.help.SetWidth(m.w)
|
||||
inner := style.ContentHeight(m.bodyHeight())
|
||||
|
||||
m.sidebar.SetSize(sidebarWidth-2, inner)
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.content, cmd = m.content.Update(tea.WindowSizeMsg{
|
||||
Width: max(m.w-sidebarWidth-2, 0), Height: inner,
|
||||
})
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// focusedHelp is the focused pane's own bindings, if it offers any.
|
||||
func (m model) focusedHelp() []key.Binding {
|
||||
if m.contentFocused {
|
||||
if hp, ok := m.content.(HelpProvider); ok {
|
||||
return hp.HelpBindings()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
if m.w <= 0 || m.h <= 0 {
|
||||
return tea.NewView("")
|
||||
}
|
||||
helpBar := m.help.View(m.focusedHelp()...)
|
||||
bodyH := m.bodyHeight()
|
||||
|
||||
left := style.RenderWithTitle(
|
||||
panel(!m.contentFocused), "Menu", m.sidebar.View(), sidebarWidth, bodyH)
|
||||
right := style.RenderWithTitle(
|
||||
panel(m.contentFocused), "Content", m.content.View().Content, m.w-sidebarWidth, bodyH)
|
||||
|
||||
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
|
||||
lipgloss.JoinHorizontal(lipgloss.Top, left, right),
|
||||
helpBar,
|
||||
))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
// panel picks the bordered panel style matching a pane's focus state.
|
||||
func panel(focused bool) lipgloss.Style {
|
||||
if focused {
|
||||
return style.S.PanelFocused
|
||||
}
|
||||
return style.S.Panel
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- the right-hand pane
|
||||
|
||||
// counter is an ordinary tea.Model - nothing about it knows it's living in a
|
||||
// shell. It implements HelpProvider purely to appear in the help bar.
|
||||
type counter struct {
|
||||
n int
|
||||
w, h int
|
||||
keys struct{ Inc, Dec key.Binding }
|
||||
}
|
||||
|
||||
func newCounter() *counter {
|
||||
c := &counter{}
|
||||
c.keys.Inc = key.NewBinding(key.WithKeys("+", "k"), key.WithHelp("+/k", "increment"))
|
||||
c.keys.Dec = key.NewBinding(key.WithKeys("-", "j"), key.WithHelp("-/j", "decrement"))
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *counter) Init() tea.Cmd { return nil }
|
||||
|
||||
func (c *counter) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
c.w, c.h = msg.Width, msg.Height
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, c.keys.Inc):
|
||||
c.n++
|
||||
case key.Matches(msg, c.keys.Dec):
|
||||
c.n--
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *counter) View() tea.View {
|
||||
return tea.NewView(lipgloss.NewStyle().
|
||||
Width(c.w).Height(c.h).
|
||||
AlignHorizontal(lipgloss.Center).
|
||||
AlignVertical(lipgloss.Center).
|
||||
Render(fmt.Sprintf("count: %d", c.n)))
|
||||
}
|
||||
|
||||
func (c *counter) HelpBindings() []key.Binding {
|
||||
return []key.Binding{c.keys.Inc, c.keys.Dec}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- sidebar items
|
||||
|
||||
type page string
|
||||
|
||||
func (p page) Title() string { return string(p) }
|
||||
func (p page) Description() string { return "" }
|
||||
func (p page) FilterValue() string { return string(p) }
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user