mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 20:15:49 +02:00
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user