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