Signed-off-by: Hadi <hadi@example.fr>
This commit is contained in:
Hadi
2026-08-18 16:15:03 +02:00
parent 5f99fa7521
commit aa6496901f
48 changed files with 2001 additions and 3626 deletions
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/style"
)
type model struct {
d drawer.Model
width, height int
}
func newModel() model {
return model{d: drawer.New()}
}
func (m model) Init() tea.Cmd { return m.d.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "l":
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"),
drawer.WithSide(drawer.Left), drawer.WithWidth(20))
case "r":
return m, drawer.Show("Inspector", drawer.Text("id: 42\nstatus: ok"),
drawer.WithSide(drawer.Right), drawer.WithWidth(20))
case "esc":
if m.d.Open() {
return m, drawer.Close()
}
}
}
var cmd tea.Cmd
m.d, cmd = m.d.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App")
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(
"Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the drawer.")
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"l: open left drawer r: open right drawer esc: close the top one q: quit")
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", body, "", help))
view := tea.NewView(m.d.Render(background))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+231
View File
@@ -0,0 +1,231 @@
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/examples/fullapp/metrics"
"github.com/anotherhadi/ilovetui/examples/fullapp/overview"
"github.com/anotherhadi/ilovetui/examples/fullapp/settings"
"github.com/anotherhadi/ilovetui/examples/fullapp/sidebar"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/ilovetui/style"
)
var pages = []struct {
item sidebar.NavItem
new func() tea.Model
}{
{sidebar.NavItem{Icon: "󰋜", Name: "Overview"}, func() tea.Model { return overview.New() }},
{sidebar.NavItem{Icon: "󰄨", Name: "Metrics"}, func() tea.Model { return metrics.New() }},
{sidebar.NavItem{Icon: "󰒓", Name: "Settings"}, func() tea.Model { return settings.New() }},
}
type HelpProvider interface {
HelpBindings() []key.Binding
}
func navItems() []sidebar.NavItem {
items := make([]sidebar.NavItem, len(pages))
for i, p := range pages {
items[i] = p.item
}
return items
}
type keyMap struct {
FocusSidebar key.Binding
Close key.Binding
Help key.Binding
Quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
FocusSidebar: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "focus sidebar")),
Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q / ctrl+c", "quit")),
}
}
type Model struct {
sidebar sidebar.Model
page tea.Model
help helpbar.Model
notif notification.Model
modal modal.Model
drawer drawer.Model
keys keyMap
sidebarWidth int
hideNav bool
contentFocused bool
width, height int
}
func NewModel() Model {
keys := defaultKeyMap()
return Model{
sidebar: sidebar.New(navItems()...),
sidebarWidth: 24,
page: pages[0].new(),
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.FocusSidebar, keys.Quit)),
notif: notification.New(),
modal: modal.New(),
drawer: drawer.New(),
keys: keys,
hideNav: true,
}
}
func (m Model) Init() tea.Cmd {
return m.page.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m.resize()
case sidebar.SelectMsg:
m.page = pages[msg.Index].new()
var cmd tea.Cmd
m, cmd = m.resize()
return m, tea.Batch(m.page.Init(), cmd)
case sidebar.BlurMsg:
m.contentFocused = true
return m.resize()
case tea.KeyPressMsg:
if m.modal.Open() {
if key.Matches(msg, m.keys.Close) {
return m, modal.Close()
}
var cmd tea.Cmd
m.modal, cmd = m.modal.Update(msg)
return m, cmd
}
if m.drawer.Open() {
if key.Matches(msg, m.keys.Close) {
return m, drawer.Close()
}
var cmd tea.Cmd
m.drawer, cmd = m.drawer.Update(msg)
return m, cmd
}
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.help, _ = m.help.Update(msg)
return m.resize()
case key.Matches(msg, m.keys.FocusSidebar):
m.contentFocused = !m.contentFocused
return m.resize()
}
if m.contentFocused {
var cmd tea.Cmd
m.page, cmd = m.page.Update(msg)
return m, cmd
}
var cmd tea.Cmd
m.sidebar, cmd = m.sidebar.Update(msg)
return m, cmd
}
var notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd tea.Cmd
m.notif, notifCmd = m.notif.Update(msg)
m.modal, modalCmd = m.modal.Update(msg)
m.drawer, drawerCmd = m.drawer.Update(msg)
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
m.page, pageCmd = m.page.Update(msg)
return m, tea.Batch(notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd)
}
func (m Model) navWidth() int {
if m.hideNav && m.contentFocused {
return 0
}
return m.sidebarWidth
}
func (m Model) resize() (Model, tea.Cmd) {
if m.width <= 0 || m.height <= 0 {
return m, nil
}
m.help.SetWidth(m.width)
inner := style.ContentHeight(m.height - m.help.Height(m.focusedHelp()...))
navW := m.navWidth()
m.sidebar.SetSize(max(navW-2, 0), inner)
var cmd tea.Cmd
m.page, cmd = m.page.Update(tea.WindowSizeMsg{
Width: max(m.width-navW-2, 0),
Height: inner,
})
return m, cmd
}
func (m Model) focusedHelp() []key.Binding {
if !m.contentFocused {
return m.sidebar.HelpBindings()
}
if hp, ok := m.page.(HelpProvider); ok {
return hp.HelpBindings()
}
return nil
}
func (m Model) View() tea.View {
if m.width <= 0 || m.height <= 0 {
return tea.NewView("")
}
helpBar := m.help.View(m.focusedHelp()...)
bodyH := max(m.height-lipgloss.Height(helpBar), 0)
navW := m.navWidth()
body := style.RenderWithTitle(
panel(m.contentFocused), m.sidebar.Selected().Name, m.page.View().Content, m.width-navW, bodyH)
if navW > 0 {
left := style.RenderWithTitle(
panel(!m.contentFocused), "Menu", m.sidebar.View(), navW, bodyH)
body = lipgloss.JoinHorizontal(lipgloss.Top, left, body)
}
appView := lipgloss.JoinVertical(lipgloss.Left,
body,
helpBar,
)
view := tea.NewView(m.notif.Render(m.modal.Render(m.drawer.Render(appView))))
view.AltScreen = true
return view
}
func panel(focused bool) lipgloss.Style {
if focused {
return style.S.PanelFocused
}
return style.S.Panel
}
func main() {
if _, err := tea.NewProgram(NewModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+81
View File
@@ -0,0 +1,81 @@
// Package metrics is the fullapp example's second page: a spinner and a few
// gauges. It has no keys either, but unlike overview it runs a command, so
// it's what proves the shell keeps feeding ticks to a pane that doesn't have
// focus.
package metrics
import (
"fmt"
"charm.land/bubbles/v2/progress"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/style"
)
// gaugeWidth is how wide a bar renders, before the label in front of it.
const gaugeWidth = 30
type gauge struct {
name string
percent float64
}
// Model is the page. Values are hardcoded - this is a layout test, not a
// monitoring tool.
type Model struct {
spinner spinner.Model
bar progress.Model
gauges []gauge
width, height int
}
func New() Model {
bar := bubbles.NewProgress()
bar.SetWidth(gaugeWidth)
return Model{
spinner: bubbles.NewSpinner(spinner.WithSpinner(spinner.MiniDot)),
bar: bar,
gauges: []gauge{
{"cpu", 0.42},
{"memory", 0.71},
{"disk", 0.13},
},
}
}
// Init starts the spinner ticking. The shell returns it from its own Init,
// or the spinner never starts.
func (m Model) Init() tea.Cmd { return m.spinner.Tick }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = size.Width, size.Height
return m, nil
}
// Every other message goes to the spinner, whose own tick keeps it
// turning - including while another pane has focus.
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m Model) View() tea.View {
rows := []string{style.S.Bold.Render(m.spinner.View() + " collecting")}
for _, g := range m.gauges {
// ViewAs renders a given percentage without animating toward it,
// which is all a fixed value needs.
rows = append(rows, fmt.Sprintf("%-8s %s", g.name, m.bar.ViewAs(g.percent)))
}
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
}
+45
View File
@@ -0,0 +1,45 @@
// Package overview is the fullapp example's first page: the simplest pane
// there is - no keys, no commands, just text sized to whatever room the
// shell gives it.
package overview
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// Model is the page. It's an ordinary tea.Model: nothing about it knows it
// lives in a shell, and it never implements HelpBindings because it has no
// keys of its own to advertise.
type Model struct {
width, height int
}
func New() Model { return Model{} }
func (m Model) Init() tea.Cmd { return nil }
// Update only tracks the size the shell hands down as a tea.WindowSizeMsg,
// same as if the page were the whole program.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = size.Width, size.Height
}
return m, nil
}
func (m Model) View() tea.View {
body := lipgloss.JoinVertical(lipgloss.Center,
style.S.Bold.Render("Overview"),
"",
style.S.Faint.Render("tab focuses this pane, but there's"),
style.S.Faint.Render("nothing here to focus on."),
)
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(body))
}
+102
View File
@@ -0,0 +1,102 @@
// Package settings is the fullapp example's third page: a short list of
// toggles. It's the one page with keys of its own, so it's what proves the
// shell routes presses to the focused pane and lists that pane's bindings in
// the help bar.
package settings
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
type toggle struct {
name string
on bool
}
type keyMap struct {
Up key.Binding
Down key.Binding
Toggle key.Binding
}
// Model is the page. The cursor is a plain int: three lines don't need a
// list.Model behind them.
type Model struct {
keys keyMap
toggles []toggle
cursor int
width, height int
}
func New() Model {
return Model{
keys: keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Toggle: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")),
},
toggles: []toggle{
{name: "Nerd fonts", on: style.S.NerdFonts},
{name: "Notifications", on: true},
{name: "Telemetry", on: false},
},
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
// The shell only sends these while this pane has focus, so there's
// no focused check to make here.
switch {
case key.Matches(msg, m.keys.Up):
m.cursor = max(m.cursor-1, 0)
case key.Matches(msg, m.keys.Down):
m.cursor = min(m.cursor+1, len(m.toggles)-1)
case key.Matches(msg, m.keys.Toggle):
m.toggles[m.cursor].on = !m.toggles[m.cursor].on
}
}
return m, nil
}
func (m Model) View() tea.View {
rows := make([]string, len(m.toggles))
for i, t := range m.toggles {
cursor, box := " ", "[ ]"
if i == m.cursor {
cursor = "> "
}
if t.on {
box = "[x]"
}
line := cursor + box + " " + t.name
if i == m.cursor {
line = lipgloss.NewStyle().Foreground(style.S.Primary).Render(line)
}
rows[i] = line
}
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
}
// HelpBindings makes the page's keys show up in the shell's help bar while
// it has focus. It's the optional half of the contract: overview and metrics
// have no keys and don't implement it.
func (m Model) HelpBindings() []key.Binding {
return []key.Binding{m.keys.Up, m.keys.Down, m.keys.Toggle}
}
+134
View File
@@ -0,0 +1,134 @@
package sidebar
import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
)
type NavItem struct {
Icon string
Name string
}
func (n NavItem) Title() string {
if n.Icon == "" {
return n.Name
}
return n.Icon + " " + n.Name
}
func (n NavItem) Description() string { return "" }
func (n NavItem) FilterValue() string { return n.Name }
type SelectMsg struct {
Index int
Item NavItem
}
// BlurMsg is the sidebar asking to be given up: it goes out with the
// SelectMsg, on the grounds that picking an entry means you're done with the
// menu. Where focus lands instead is the host's call - the sidebar has no
// idea what else is on screen.
type BlurMsg struct{}
// blur is BlurMsg's command form.
func blur() tea.Msg { return BlurMsg{} }
type KeyMap struct {
Select key.Binding
}
func DefaultKeyMap() KeyMap {
return KeyMap{
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
}
}
type Model struct {
KeyMap KeyMap
list list.Model
items []NavItem
selected int
}
func New(items ...NavItem) Model {
d := bubbles.NewDefaultDelegate()
d.ShowDescription = false
d.SetSpacing(0)
l := bubbles.NewList(listItems(items), 0, 0)
l.SetDelegate(d)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetShowPagination(false)
l.SetFilteringEnabled(false)
l.DisableQuitKeybindings()
return Model{
KeyMap: DefaultKeyMap(),
list: l,
items: items,
}
}
func listItems(items []NavItem) []list.Item {
out := make([]list.Item, len(items))
for i, item := range items {
out[i] = item
}
return out
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if press, ok := msg.(tea.KeyPressMsg); ok && key.Matches(press, m.KeyMap.Select) {
// Only the interactive path asks for focus to move on. Select on its
// own doesn't, because the host also calls it to set the starting
// entry - which must not steal focus from anything.
return m, tea.Batch(m.Select(m.list.Index()), blur)
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m *Model) Select(index int) tea.Cmd {
if index < 0 || index >= len(m.items) {
return nil
}
m.selected = index
m.list.Select(index)
item := m.items[index]
return func() tea.Msg { return SelectMsg{Index: index, Item: item} }
}
func (m *Model) SetSize(width, height int) { m.list.SetSize(width, height) }
func (m Model) Selected() NavItem {
if m.selected < 0 || m.selected >= len(m.items) {
return NavItem{}
}
return m.items[m.selected]
}
func (m Model) SelectedIndex() int { return m.selected }
func (m Model) Cursor() int { return m.list.Index() }
func (m Model) View() string { return m.list.View() }
func (m Model) HelpBindings() []key.Binding {
return []key.Binding{
m.list.KeyMap.CursorUp,
m.list.KeyMap.CursorDown,
m.KeyMap.Select,
}
}
-103
View File
@@ -1,103 +0,0 @@
// Command basic demonstrates the minimum layout needs: a 2x2 grid of plain
// panes, no custom border, ctrl+hjkl moving focus between them out of the
// box.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
// textPane is the simplest possible Pane: it just reports its own id, size
// and focus state. It still renders to exactly the width/height it was
// told (via lipgloss's own Width/Height), which is the one rule every Pane
// has to follow - layout composes View() output side by side and never
// pads it itself.
type textPane struct {
id string
w, h int
focused bool
}
func newTextPane(id string) *textPane { return &textPane{id: id} }
func (p *textPane) Init() tea.Cmd { return nil }
func (p *textPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *textPane) View() string {
state := "blurred"
if p.focused {
state = "focused"
}
content := fmt.Sprintf("%s\n(%s, %dx%d)", p.id, state, p.w, p.h)
return lipgloss.NewStyle().
Width(p.w).
Height(p.h).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(content)
}
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
}
func (m model) Init() tea.Cmd { return m.layout.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, cmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(m.layout.View())
view.AltScreen = true
return view
}
func main() {
root := layout.HSplit(0.5,
layout.VSplit(0.5,
layout.Leaf("top-left", newTextPane("top-left")),
layout.Leaf("bottom-left", newTextPane("bottom-left")),
),
layout.VSplit(0.5,
layout.Leaf("top-right", newTextPane("top-right")),
layout.Leaf("bottom-right", newTextPane("bottom-right")),
),
)
m := model{layout: layout.New(root, layout.AsRoot())}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-94
View File
@@ -1,94 +0,0 @@
// Command bordered demonstrates that layout draws no chrome of its own:
// each pane here owns its border and picks its color from FocusMsg/BlurMsg,
// via the optional layout.Bordered helper.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
type borderedPane struct {
id string
w, h int
focused bool
}
func newBorderedPane(id string) *borderedPane { return &borderedPane{id: id} }
func (p *borderedPane) Init() tea.Cmd { return nil }
func (p *borderedPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *borderedPane) View() string {
inner := lipgloss.NewStyle().
Width(p.w - 2).
Height(p.h - 2).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(p.id)
// layout.Bordered already draws the border at exactly p.w x p.h, so the
// content it wraps must already be sized to w-2 x h-2 (border eats one
// cell on each side) - same rule as any other Pane, just one layer in.
return layout.Bordered(p.focused, p.w, p.h, inner)
}
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
}
func (m model) Init() tea.Cmd { return m.layout.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, cmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(m.layout.View())
view.AltScreen = true
return view
}
func main() {
root := layout.HSplit(0.3,
layout.Leaf("sidebar", newBorderedPane("sidebar")),
layout.VSplit(0.6,
layout.Leaf("main", newBorderedPane("main")),
layout.Leaf("log", newBorderedPane("log")),
),
)
m := model{layout: layout.New(root, layout.AsRoot())}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-116
View File
@@ -1,116 +0,0 @@
// Command fullapp demonstrates a more realistic app shell: the sidebar
// leaf isn't a plain placeholder pane, it's a full layout.Model of its own
// (sidebar.New(), a themed list, see sidebar/sidebar.go) embedded exactly
// like the "router" one below - layout.Model implements Pane, so any
// package can hand back one to be wrapped in a Leaf, no adapter needed.
package first
import (
"fmt"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
type pane struct {
id string
w, h int
focused bool
}
func newPane(id string) *pane { return &pane{id: id} }
func (p *pane) Init() tea.Cmd { return nil }
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *pane) View() string {
content := lipgloss.NewStyle().
Width(p.w - 2).Height(p.h - 2).
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
Render(p.id)
return layout.Bordered(p.focused, p.w, p.h, content)
}
// HelpBindings implements layout.HelpProvider, so every pane - at the top
// level or nested three levels deep, doesn't matter - shows up correctly
// in the single, outer help bar.
func (p *pane) HelpBindings() []key.Binding {
return []key.Binding{key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "use "+p.id))}
}
// editorPane keeps a counter (press +) that's otherwise irrelevant to the
// app but proves the point of router.Model owning pages permanently:
// switch to "Second" and back, and it's still there. A plain pane (like
// terminal below) would just get reconstructed from scratch every time if
// something recreated it on each switch instead of keeping it alive.
type editorPane struct {
w, h int
focused bool
count int
}
func newEditorPane() *editorPane { return &editorPane{} }
func (p *editorPane) Init() tea.Cmd { return nil }
func (p *editorPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
case tea.KeyPressMsg:
if msg.String() == "+" {
p.count++
}
}
return p, nil
}
func (p *editorPane) View() string {
content := lipgloss.NewStyle().
Width(p.w - 2).Height(p.h - 2).
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
Render(fmt.Sprintf("editor\n\npress + to increment: %d", p.count))
return layout.Bordered(p.focused, p.w, p.h, content)
}
// HelpBindings implements layout.HelpProvider.
func (p *editorPane) HelpBindings() []key.Binding {
return []key.Binding{key.NewBinding(key.WithKeys("+"), key.WithHelp("+", "increment"))}
}
// NewWorkspace builds the "First" page's own nested layout.Model: note the
// lack of layout.AsRoot() here - only the outermost Model (see main) should
// render a help bar, or focused help would show up twice. second.
// NewWorkspace and sidebar.New() below are built the same way, same reason.
//
// This Model is built exactly once, by router.New(), and kept alive for
// the whole app's lifetime - router.Model just changes which page is
// rendered/routed to, it never reconstructs one. That's what lets
// editorPane's counter above survive switching to "Second" and back; see
// package router's own doc comment for why that ownership lives there
// and not in the sidebar.
func NewWorkspace() layout.Model {
root := layout.VSplit(0.7,
layout.Leaf("editor", newEditorPane()),
layout.Leaf("terminal", newPane("terminal")),
)
return layout.New(root)
}
-88
View File
@@ -1,88 +0,0 @@
// Command fullapp demonstrates a more realistic app shell: the sidebar
// leaf isn't a plain placeholder pane, it's a full layout.Model of its own
// (sidebar.New(), a themed list, see sidebar/sidebar.go) embedded exactly
// like the "router" one below - layout.Model implements Pane, so any
// package can hand back one to be wrapped in a Leaf, no adapter needed.
//
// It also owns the app's single notification.Model (see second/second.go,
// which triggers toasts via notification.Show without ever holding a
// reference to it) and composites its toasts over the whole rendered
// layout, top-right - notification has no dependency on layout, so this is
// the same Render(background string) pattern as examples/notification, just
// with layout.Model.View() as the background instead of a plain string.
//
// Same story for the app's single modal.Model (see third/third.go), except
// modal is composited last, on top of notif's own output: a modal is meant
// to command the whole screen's attention, so it should flatten any toast
// already showing to the same dim gray as everything else, not leave it
// floating on top in full color.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/router"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/sidebar"
"github.com/anotherhadi/ilovetui/layout"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
)
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
notif notification.Model
modal modal.Model
}
func (m model) Init() tea.Cmd {
return tea.Batch(m.layout.Init(), m.notif.Init(), m.modal.Init())
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, layoutCmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
var notifCmd, modalCmd tea.Cmd
m.notif, notifCmd = m.notif.Update(msg)
m.modal, modalCmd = m.modal.Update(msg)
return m, tea.Batch(layoutCmd, notifCmd, modalCmd)
}
func (m model) View() tea.View {
view := tea.NewView(m.modal.Render(m.notif.Render(m.layout.View())))
view.AltScreen = true
return view
}
func main() {
root := layout.HSplit(0.25,
layout.Leaf("sidebar", sidebar.New()),
layout.Leaf("router", router.New()),
).WithMaximum(20)
m := model{
layout: layout.New(root, layout.AsRoot()),
notif: notification.New(notification.WithPosition(notification.TopRight)),
modal: modal.New(),
}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-109
View File
@@ -1,109 +0,0 @@
// Package router owns every page's own layout.Model for the lifetime of
// the whole app, so switching pages never discards their state - only
// which one is currently active/rendered changes. It slots into the root
// tree's "router" leaf exactly like a single nested layout.Model would
// (see examples/layout/nested), because Model implements layout.Navigable
// itself on top of layout.Pane, delegating everything to whichever page is
// active: ctrl+hjkl, the help bar, and SendMsg/RequestFocusMsg addressed to
// ids inside that page all keep working transparently.
package router
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/first"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/second"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/third"
"github.com/anotherhadi/ilovetui/layout"
)
// SelectMsg asks Model to switch its active page, by id ("first", "second"
// or "third"). Sent by the sidebar via layout.SendMsg{Target: "router"} -
// it never holds a reference to Model either.
type SelectMsg struct{ Page string }
type Model struct {
active string
pages map[string]layout.Model
}
func New() *Model {
return &Model{
active: "first",
pages: map[string]layout.Model{
"first": first.NewWorkspace(),
"second": second.NewWorkspace(),
"third": third.NewWorkspace(),
},
}
}
func (m *Model) current() layout.Model { return m.pages[m.active] }
func (m *Model) Init() tea.Cmd {
var cmds []tea.Cmd
for _, p := range m.pages {
if cmd := p.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
func (m *Model) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
if sel, ok := msg.(SelectMsg); ok {
if _, exists := m.pages[sel.Page]; exists {
m.active = sel.Page
}
return m, nil
}
if size, ok := msg.(layout.SizeMsg); ok {
// Every page needs to stay correctly sized even while hidden - the
// outer tree only re-sends SizeMsg when the "router" leaf's own
// Rect changes, never on a plain page switch, so a page that was
// inactive during a resize must still learn about it here, or it
// renders at a stale size whenever it becomes active again.
var cmds []tea.Cmd
for id, p := range m.pages {
updated, cmd := p.Update(size)
m.pages[id] = updated.(layout.Model)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
updated, cmd := m.current().Update(msg)
m.pages[m.active] = updated.(layout.Model)
return m, cmd
}
func (m *Model) View() string {
return m.current().View()
}
// Leaves, MoveFocus, Route, Focus and FocusedHelp implement
// layout.Navigable, delegating to whichever page is currently active.
func (m *Model) Leaves() []layout.LeafRect {
return m.current().Leaves()
}
func (m *Model) MoveFocus(dir layout.FocusDirection) bool {
return m.current().MoveFocus(dir)
}
func (m *Model) Route(target string, msg tea.Msg) (bool, tea.Cmd) {
return m.current().Route(target, msg)
}
func (m *Model) Focus(id string) (bool, tea.Cmd) {
return m.current().Focus(id)
}
func (m *Model) FocusedHelp() []key.Binding {
return m.current().FocusedHelp()
}
-99
View File
@@ -1,99 +0,0 @@
// Package second is the "Second" page's own workspace: deliberately a
// different shape from first's (a single pane holding a themed list, not an
// editor/terminal split), so swapping between the two via sidebar.Model's
// "enter" case is visibly a real change of sub-app, not just a relabeled
// copy of first. It also demonstrates notification.Show being called from
// deep inside a nested layout.Model, with nothing wiring it to the
// notification.Model that actually renders it - see main.go's model.View.
package second
import (
"charm.land/bubbles/v2/key"
bubbleslist "charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/layout"
"github.com/anotherhadi/ilovetui/notification"
)
// kind is a list entry: a notification.Kind plus the title/message Show
// gets called with when it's selected.
type kind struct {
title string
message string
kind notification.Kind
}
func (k kind) Title() string { return k.title }
func (k kind) Description() string { return "" }
func (k kind) FilterValue() string { return k.title }
type pane struct {
list bubbleslist.Model
w, h int
focused bool
}
func newPane() *pane {
items := []bubbleslist.Item{
kind{title: "Info", message: "Just so you know.", kind: notification.Info},
kind{title: "Success", message: "Config written to disk.", kind: notification.Success},
kind{title: "Warning", message: "Disk space getting low on /dev/sda1.", kind: notification.Warning},
kind{title: "Error", message: "Failed to reach the remote host.", kind: notification.Error},
}
list := bubbles.NewList(items, 0, 0)
list.Title = "Notify"
// Same reasoning as sidebar.Model: the built-in help footer is
// redundant with layout's own centralized help bar (see HelpBindings
// below).
list.SetShowHelp(false)
return &pane{list: list}
}
func (p *pane) Init() tea.Cmd { return nil }
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
p.list.SetSize(p.w-2, p.h-2)
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
if !p.focused {
return p, nil
}
if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "enter" {
if selected, ok := p.list.SelectedItem().(kind); ok {
return p, notification.Show(selected.title, selected.message, selected.kind)
}
}
var cmd tea.Cmd
p.list, cmd = p.list.Update(msg)
return p, cmd
}
func (p *pane) View() string {
return layout.Bordered(p.focused, p.w, p.h, p.list.View())
}
// HelpBindings implements layout.HelpProvider.
func (p *pane) HelpBindings() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "show notification")),
key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "go down")),
key.NewBinding(key.WithKeys("k"), key.WithHelp("k", "go up")),
}
}
// NewWorkspace builds the "Second" page's own nested layout.Model. No
// layout.AsRoot() - see first.NewWorkspace's doc comment.
func NewWorkspace() layout.Model {
return layout.New(layout.Leaf("content", newPane()))
}
-101
View File
@@ -1,101 +0,0 @@
package sidebar
import (
"charm.land/bubbles/v2/key"
bubbleslist "charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/router"
"github.com/anotherhadi/ilovetui/layout"
)
// page is a sidebar entry: just an id (matching one of router.Model's
// own page keys) and a label. Model has no idea what a page actually is or
// how it's built - that's router's business entirely, this only needs
// to name it.
type page struct {
id string
title string
}
func (p page) Title() string { return p.title }
func (p page) Description() string { return "" }
func (p page) FilterValue() string { return p.title }
type Model struct {
id string // learned from SizeMsg.ID, needed as RequestFocusMsg.Source
list bubbleslist.Model
w, h int
focused bool
}
func New() *Model {
items := []bubbleslist.Item{
page{id: "first", title: "First"},
page{id: "second", title: "Second"},
page{id: "third", title: "Third"},
}
list := bubbles.NewList(items, 0, 0)
list.Title = "Sidebar"
// list's own built-in help footer is redundant - Model already feeds
// layout's single centralized help bar via HelpBindings below - and at
// a narrow sidebar width it word-wraps onto multiple lines, which
// list.SetSize doesn't account for: list.View() ends up taller than
// the height it was given, breaking layout's "render exactly h" rule
// and pushing the border past the bottom of the pane.
list.SetShowHelp(false)
return &Model{list: list}
}
func (m *Model) Init() tea.Cmd { return nil }
func (m *Model) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
m.id, m.w, m.h = msg.ID, msg.Width, msg.Height
m.list.SetSize(m.w-2, m.h-2)
case layout.FocusMsg:
m.focused = true
case layout.BlurMsg:
m.focused = false
}
if !m.focused {
return m, nil
}
if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "enter" {
if selected, ok := m.list.SelectedItem().(page); ok {
// tea.Sequence, not tea.Batch: the page switch must land
// before the focus jump, or RequestFocusMsg could cascade
// into router while it's still showing the previous page
// (Batch runs both concurrently with no ordering guarantee).
return m, tea.Sequence(
func() tea.Msg {
return layout.SendMsg{Target: "router", Msg: router.SelectMsg{Page: selected.id}}
},
func() tea.Msg {
return layout.RequestFocusMsg{Source: m.id, Target: "router"}
},
)
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m *Model) View() string {
return layout.Bordered(m.focused, m.w, m.h, m.list.View())
}
func (m *Model) HelpBindings() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open page")),
key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "go down")),
key.NewBinding(key.WithKeys("k"), key.WithHelp("k", "go up")),
}
}
-111
View File
@@ -1,111 +0,0 @@
// Package third is the "Third" page's own workspace: a single pane showing
// modal.Show being called from deep inside a nested layout.Model, exactly
// like second/second.go does for notification.Show - modal has no
// dependency on layout either, so nothing here needs a reference to the
// modal.Model that actually renders it (see main.go's model.View).
package third
import (
"charm.land/bubbles/v2/key"
bubbleslist "charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/layout"
"github.com/anotherhadi/ilovetui/modal"
)
// action is a list entry: the title/content Show gets called with when it's
// selected, and whether closing it needs an explicit "y" (a destructive
// confirm) or any key at all (a plain info popup).
type action struct {
title, content string
confirm bool
}
func (a action) Title() string { return a.title }
func (a action) Description() string { return "" }
func (a action) FilterValue() string { return a.title }
type pane struct {
list bubbleslist.Model
w, h int
focused bool
// open/confirm track the modal this pane itself opened, so it knows to
// swallow keys instead of forwarding them to the list underneath while
// it's up - modal.Model has no notion of "focus" of its own, the pane
// that triggered it is responsible for gating input while it's open.
open bool
confirm bool
}
func newPane() *pane {
items := []bubbleslist.Item{
action{title: "Delete file", content: "This can't be undone.\n\ny: confirm esc: cancel", confirm: true},
action{title: "About", content: "modal renders a centered popup over a\nflat-dimmed background.\n\nesc: close"},
}
list := bubbles.NewList(items, 0, 0)
list.Title = "Modal"
// Same reasoning as sidebar/second: redundant with layout's own
// centralized help bar (see HelpBindings below).
list.SetShowHelp(false)
return &pane{list: list}
}
func (p *pane) Init() tea.Cmd { return nil }
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
p.list.SetSize(p.w-2, p.h-2)
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
if !p.focused {
return p, nil
}
if key, ok := msg.(tea.KeyPressMsg); ok {
if p.open {
if !p.confirm || key.String() == "y" || key.String() == "esc" {
p.open = false
return p, modal.Close()
}
return p, nil
}
if key.String() == "enter" {
if selected, ok := p.list.SelectedItem().(action); ok {
p.open, p.confirm = true, selected.confirm
return p, modal.Show(selected.title+"?", selected.content)
}
}
}
var cmd tea.Cmd
p.list, cmd = p.list.Update(msg)
return p, cmd
}
func (p *pane) View() string {
return layout.Bordered(p.focused, p.w, p.h, p.list.View())
}
// HelpBindings implements layout.HelpProvider.
func (p *pane) HelpBindings() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open modal")),
key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "go down")),
key.NewBinding(key.WithKeys("k"), key.WithHelp("k", "go up")),
}
}
// NewWorkspace builds the "Third" page's own nested layout.Model. No
// layout.AsRoot() - see first.NewWorkspace's doc comment.
func NewWorkspace() layout.Model {
return layout.New(layout.Leaf("content", newPane()))
}
-136
View File
@@ -1,136 +0,0 @@
// Command help demonstrates the dynamic help bar: each pane implements
// layout.HelpProvider (or doesn't) and the bottom bar always reflects
// whichever one is currently focused, with no wiring beyond that.
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
// helpfulPane implements layout.HelpProvider with a binding or two of its
// own, on top of the usual Size/Focus/Blur bookkeeping.
type helpfulPane struct {
id string
w, h int
focused bool
bindings []key.Binding
}
func newHelpfulPane(id string, bindings []key.Binding) *helpfulPane {
return &helpfulPane{id: id, bindings: bindings}
}
func (p *helpfulPane) Init() tea.Cmd { return nil }
func (p *helpfulPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *helpfulPane) View() string {
content := lipgloss.NewStyle().
Width(p.w - 2).Height(p.h - 2).
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
Render(p.id)
return layout.Bordered(p.focused, p.w, p.h, content)
}
// HelpBindings implements layout.HelpProvider.
func (p *helpfulPane) HelpBindings() []key.Binding { return p.bindings }
// silentPane deliberately does NOT implement layout.HelpProvider, to show
// that the help bar just falls back to layout's own controls (ctrl+hjkl,
// ?) instead of disappearing or erroring when it's focused.
type silentPane struct {
w, h int
focused bool
}
func (p *silentPane) Init() tea.Cmd { return nil }
func (p *silentPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *silentPane) View() string {
content := lipgloss.NewStyle().
Width(p.w - 2).Height(p.h - 2).
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
Render("no help bindings\n(watch the bar below)")
return layout.Bordered(p.focused, p.w, p.h, content)
}
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
}
func (m model) Init() tea.Cmd { return m.layout.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, cmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(m.layout.View())
view.AltScreen = true
return view
}
func main() {
writer := newHelpfulPane("writer", []key.Binding{
key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")),
key.NewBinding(key.WithKeys("ctrl+z"), key.WithHelp("ctrl+z", "undo")),
})
browser := newHelpfulPane("browser", []key.Binding{
key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
})
root := layout.HSplit(0.34,
layout.Leaf("writer", writer),
layout.HSplit(0.5,
layout.Leaf("browser", browser),
layout.Leaf("scratch", &silentPane{}),
),
)
m := model{layout: layout.New(root, layout.AsRoot())}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-132
View File
@@ -1,132 +0,0 @@
// Command messaging demonstrates the two ways panes talk without holding a
// reference to each other: "control" sends arbitrary commands to "editor"
// by id via SendMsg (1/2/3 keys), and asks layout to move focus there via
// RequestFocusMsg (enter key) - the same pattern a real sidebar would use
// to both drive and jump to a content pane it selected.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
// commandMsg is what "control" sends to "editor" - an app-defined message,
// entirely opaque to layout itself (see SendMsg).
type commandMsg struct{ text string }
type controlPane struct {
id string // learned from SizeMsg.ID, needed as RequestFocusMsg.Source
w, h int
focused bool
}
func (p *controlPane) Init() tea.Cmd { return nil }
func (p *controlPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.id, p.w, p.h = msg.ID, msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
case tea.KeyPressMsg:
switch msg.String() {
case "1", "2", "3":
text := "command " + msg.String()
return p, func() tea.Msg {
return layout.SendMsg{Target: "editor", Msg: commandMsg{text: text}}
}
case "enter":
return p, func() tea.Msg {
return layout.RequestFocusMsg{Source: p.id, Target: "editor"}
}
}
}
return p, nil
}
func (p *controlPane) View() string {
content := "control\n\n1/2/3: send a command\nenter: focus editor"
inner := lipgloss.NewStyle().Width(p.w - 2).Height(p.h - 2).Render(content)
return layout.Bordered(p.focused, p.w, p.h, inner)
}
type editorPane struct {
w, h int
focused bool
last string
}
func (p *editorPane) Init() tea.Cmd { return nil }
func (p *editorPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
case commandMsg:
p.last = msg.text
}
return p, nil
}
func (p *editorPane) View() string {
last := p.last
if last == "" {
last = "(nothing yet)"
}
content := fmt.Sprintf("editor\n\nlast command received:\n%s", last)
inner := lipgloss.NewStyle().Width(p.w - 2).Height(p.h - 2).Render(content)
return layout.Bordered(p.focused, p.w, p.h, inner)
}
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
}
func (m model) Init() tea.Cmd { return m.layout.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, cmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(m.layout.View())
view.AltScreen = true
return view
}
func main() {
root := layout.HSplit(0.35,
layout.Leaf("control", &controlPane{}),
layout.Leaf("editor", &editorPane{}),
)
m := model{layout: layout.New(root, layout.AsRoot())}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-107
View File
@@ -1,107 +0,0 @@
// Command nested demonstrates composability: "workspace" is itself a full
// layout.Model (its own two-pane split) embedded as an ordinary Leaf inside
// the outer tree. ctrl+hjkl bubbles in and out of it transparently, and
// only the outer Model shows a help bar - the inner one is built without
// layout.AsRoot(), see newWorkspace.
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
type pane struct {
id string
w, h int
focused bool
}
func newPane(id string) *pane { return &pane{id: id} }
func (p *pane) Init() tea.Cmd { return nil }
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *pane) View() string {
content := lipgloss.NewStyle().
Width(p.w - 2).Height(p.h - 2).
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
Render(p.id)
return layout.Bordered(p.focused, p.w, p.h, content)
}
// HelpBindings implements layout.HelpProvider, so every pane - at the top
// level or nested three levels deep, doesn't matter - shows up correctly
// in the single, outer help bar.
func (p *pane) HelpBindings() []key.Binding {
return []key.Binding{key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "use "+p.id))}
}
// newWorkspace builds the nested layout.Model: note the lack of
// layout.AsRoot() here - only the outermost Model (see main) should render
// a help bar, or focused help would show up twice.
func newWorkspace() layout.Model {
root := layout.VSplit(0.7,
layout.Leaf("editor", newPane("editor")),
layout.Leaf("terminal", newPane("terminal")),
)
return layout.New(root)
}
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
}
func (m model) Init() tea.Cmd { return m.layout.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, cmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(m.layout.View())
view.AltScreen = true
return view
}
func main() {
root := layout.HSplit(0.25,
layout.Leaf("sidebar", newPane("sidebar")).WithMinimum(20),
layout.Leaf("workspace", newWorkspace()),
)
m := model{layout: layout.New(root, layout.AsRoot())}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+38 -11
View File
@@ -11,10 +11,36 @@ import (
"github.com/anotherhadi/ilovetui/style"
)
const confirmID = "confirm"
// confirmedMsg is what the confirmation modal reports back with. The app
// listens for it like any other message - it never holds a reference to the
// modal, and the modal never knows what confirming means.
type confirmedMsg struct{}
// confirm is the modal's content: a model, so it owns its own keys. The host
// no longer has to ask which modal is on top to know where "y" should go.
type confirm struct{}
func (c confirm) Init() tea.Cmd { return nil }
func (c confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "y" {
// Report back and close itself: Close() is a package-level command,
// so the content needs no reference to the modal.Model either.
return c, tea.Batch(
func() tea.Msg { return confirmedMsg{} },
modal.Close(),
)
}
return c, nil
}
func (c confirm) View() tea.View {
return tea.NewView("This can't be undone.\n\ny: confirm esc: cancel")
}
type model struct {
m modal.Model
deleted bool
width, height int
}
@@ -30,29 +56,27 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width, m.height = msg.Width, msg.Height
return m, nil
case confirmedMsg:
m.deleted = true
return m, nil
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "m":
return m, modal.Show("Delete file?", "This can't be undone.\n\ny: confirm esc: cancel",
modal.WithID(confirmID))
return m, modal.Show("Delete file?", confirm{})
case "n":
if m.m.Open() {
return m, modal.Show("Really sure?", "There's no undo for this one either.")
return m, modal.Show("Really sure?", modal.Text("There's no undo for this one either."))
}
case "esc":
if m.m.Open() {
return m, modal.Close()
}
case "y":
if m.m.TopID() == confirmID {
return m, modal.Dismiss(confirmID)
}
}
}
@@ -63,8 +87,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m model) View() tea.View {
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App")
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(
"Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the modal.")
text := "Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the modal."
if m.deleted {
text = "File deleted - the modal's content reported back\nwith its own message, and closed itself."
}
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(text)
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"m: open modal n: open nested modal y: confirm esc: cancel q: quit")
+235
View File
@@ -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)
}
}