init layout, notifications, tabs & more

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-08-17 22:09:32 +02:00
parent f3a1877832
commit 5f99fa7521
68 changed files with 6375 additions and 330 deletions
+103
View File
@@ -0,0 +1,103 @@
// 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
@@ -0,0 +1,94 @@
// 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
@@ -0,0 +1,116 @@
// 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
@@ -0,0 +1,88 @@
// 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
@@ -0,0 +1,109 @@
// 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
@@ -0,0 +1,99 @@
// 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
@@ -0,0 +1,101 @@
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
@@ -0,0 +1,111 @@
// 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
@@ -0,0 +1,136 @@
// 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
@@ -0,0 +1,132 @@
// 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
@@ -0,0 +1,107 @@
// 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)
}
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/style"
)
const confirmID = "confirm"
type model struct {
m modal.Model
width, height int
}
func newModel() model {
return model{m: modal.New()}
}
func (m model) Init() tea.Cmd { return m.m.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 "m":
return m, modal.Show("Delete file?", "This can't be undone.\n\ny: confirm esc: cancel",
modal.WithID(confirmID))
case "n":
if m.m.Open() {
return m, modal.Show("Really sure?", "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)
}
}
}
var cmd tea.Cmd
m.m, cmd = m.m.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 modal.")
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"m: open modal n: open nested modal y: confirm esc: cancel q: quit")
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", body, "", help))
view := tea.NewView(m.m.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)
}
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/ilovetui/style"
)
var positions = []struct {
name string
pos notification.Position
}{
{"top", notification.Top},
{"top-left", notification.TopLeft},
{"top-right", notification.TopRight},
{"bottom", notification.Bottom},
{"bottom-left", notification.BottomLeft},
{"bottom-right", notification.BottomRight},
}
const stickyID = "sticky-demo"
type model struct {
notif notification.Model
posIdx int
width, height int
}
func newModel() model {
return model{notif: notification.New(notification.WithPosition(positions[0].pos))}
}
func (m model) Init() tea.Cmd {
return m.notif.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 "1":
return m, notification.Show("Info", "Just so you know.", notification.Info)
case "2":
return m, notification.Show("Success", "Config written to disk.", notification.Success)
case "3":
return m, notification.Show("Warning", "Disk space getting low on /dev/sda1.", notification.Warning)
case "4":
return m, notification.Show("Error", "Failed to reach the remote host.", notification.Error)
case "s":
return m, notification.Show("Sticky", "Stays until you press d.",
notification.Info, notification.WithID(stickyID), notification.WithDuration(0))
case "d":
return m, notification.Dismiss(stickyID)
case "p":
m.posIdx = (m.posIdx + 1) % len(positions)
m.notif = notification.New(notification.WithPosition(positions[m.posIdx].pos))
return m, m.notif.Init()
}
}
var cmd tea.Cmd
m.notif, cmd = m.notif.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"1-4: info/success/warning/error s: sticky d: dismiss sticky p: position (" +
positions[m.posIdx].name + ") q: quit")
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, help)
view := tea.NewView(m.notif.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)
}
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/tabs"
)
// pane is a minimal tabs.Tab implementation. It keeps its own counter to
// show that each tab's model has independent state that persists across
// switches, and its own width/height to show how a host propagates size
// down to a wrapped Tab (see model.Update's tea.WindowSizeMsg case).
type pane struct {
name string
count int
width, height int
}
func newPane(name string) pane {
return pane{name: name}
}
func (p pane) Init() tea.Cmd {
return nil
}
func (p pane) Update(msg tea.Msg) (tabs.Tab, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.width, p.height = msg.Width, msg.Height
case tea.KeyPressMsg:
if msg.String() == "+" {
p.count++
}
}
return p, nil
}
func (p pane) View() string {
return fmt.Sprintf("%s\n\npress + to increment: %d\n(content area: %dx%d)", p.name, p.count, p.width, p.height)
}
var docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2)
type model struct {
tabs tabs.Model
}
func newModel() model {
items := []tabs.Item{
{Title: "Lip Gloss", Model: newPane("Lip Gloss")},
{Title: "Blush", Model: newPane("Blush")},
{Title: "Eye Shadow", Model: newPane("Eye Shadow")},
{Title: "Mascara", Model: newPane("Mascara")},
{Title: "Foundation", Model: newPane("Foundation")},
}
return model{tabs: tabs.New(items)}
}
func (m model) Init() tea.Cmd {
return m.tabs.Init()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" || msg.String() == "q" {
return m, tea.Quit
}
case tea.WindowSizeMsg:
// Size the tabs component to fill the terminal, net of docStyle's own
// frame. The tab bar itself keeps its natural width; Content stretches.
m.tabs.SetSize(
msg.Width-docStyle.GetHorizontalFrameSize(),
msg.Height-docStyle.GetVerticalFrameSize(),
)
// tabs has no generic way to size an arbitrary Tab itself, so forward
// the actual usable content area as a WindowSizeMsg: tabs.Update
// already routes non-key messages to the active item's Update, so
// this reaches pane.Update's own tea.WindowSizeMsg case above.
var cmd tea.Cmd
m.tabs, cmd = m.tabs.Update(tea.WindowSizeMsg{
Width: m.tabs.ContentWidth(),
Height: m.tabs.ContentHeight(),
})
return m, cmd
}
var cmd tea.Cmd
m.tabs, cmd = m.tabs.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(docStyle.Render(m.tabs.View()))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}