diff --git a/detail.go b/detail.go new file mode 100644 index 0000000..a384d7c --- /dev/null +++ b/detail.go @@ -0,0 +1,269 @@ +package main + +import ( + "bufio" + "fmt" + "math" + "os" + "os/exec" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" +) + +type SysValue[T int | float64] struct { + Val T + Fmt string + Inf bool +} + +type Detail struct { + Name string + Desc string + Memory SysValue[float64] + MemoryMax SysValue[float64] + Tasks SysValue[int] + TasksMax SysValue[int] + Cpu float64 + Active string + Load string + Sub string +} + +type DetailLoadedMsg struct { + Detail Detail + Err error +} + +func (m *model) LoadDetail() tea.Msg { + d, err := m.loadDetail() + + if err != nil { + return DetailLoadedMsg{Err: err} + } + + if d == nil { + return DetailLoadedMsg{} + } + + cpu, _ := strconv.ParseFloat(d["CPUUsageNSec"], 64) + + return DetailLoadedMsg{Detail: Detail{ + Name: m.filtered[m.cursor].Name, + Desc: m.filtered[m.cursor].Desc, + Memory: take[float64](d, "MemoryCurrent", "", "B", 2, 1000), + MemoryMax: take[float64](d, "MemoryMax", "", "B", 2, 1000), + Tasks: take[int](d, "TasksCurrent", "", "", 1, 1000), + TasksMax: take[int](d, "TasksMax", "", "", 1, 1000), + Cpu: cpu / 1000000000, + Active: d["ActiveState"], + Load: d["LoadState"], + Sub: d["SubState"], + }} +} + +func take[T int | float64](d map[string]string, key string, start string, unit string, decimals int, step int) SysValue[T] { + val, _ := takeVal[T](d, key, start) + inf := normUnit(d, key, start) == "" + fmt := formatUnit(d, key, start, unit, decimals, step) + return SysValue[T]{Val: val, Inf: inf, Fmt: fmt} +} + +func (v SysValue[T]) Alt(alt SysValue[T]) SysValue[T] { + if v.Inf { + return alt + } + return v +} + +func (v SysValue[T]) Div(y SysValue[T]) float64 { + return float64(v.Val) / float64(y.Val) +} + +func takeVal[T int | float64](d map[string]string, key string, start string) (T, error) { + x := normUnit(d, key, start) + var zero T + switch any(zero).(type) { + case int: + v, err := strconv.Atoi(x) + return any(v).(T), err + case float64: + v, err := strconv.ParseFloat(x, 64) + return any(v).(T), err + default: + return zero, fmt.Errorf("unsupported type") + } +} + +func (m *model) loadDetail() (map[string]string, error) { + if len(m.filtered) == 0 { + return nil, nil + } + + detail := make(map[string]string) + + name := m.filtered[m.cursor].Name + + cmd := exec.Command( + "systemctl", "show", name, + "-p", "CPUUsageNSec", + "-p", "CPUQuotaPerSecUSec", + "-p", "MemoryCurrent", + "-p", "MemoryMax", + "-p", "TasksCurrent", + "-p", "TasksMax", + "-p", "ActiveState", + "-p", "LoadState", + "-p", "SubState", + ) + + out, err := cmd.Output() + if err != nil { + return nil, err + } + + lines := strings.SplitSeq(string(out), "\n") + + for line := range lines { + if parts := strings.SplitN(line, "=", 2); len(parts) == 2 { + detail[parts[0]] = parts[1] + } + } + + return detail, nil +} + +type System struct { + Memory SysValue[float64] + Tasks SysValue[int] +} + +type SystemLoadedMsg struct { + System System + Err error +} + +func LoadSystem() tea.Msg { + d, err := loadMemSystem() + + if err != nil { + return SystemLoadedMsg{Err: err} + } + + if d == nil { + return SystemLoadedMsg{} + } + + return SystemLoadedMsg{System: System{ + Memory: take[float64](d, "MemTotal", "K", "B", 2, 1000), + Tasks: take[int](d, "TasksMax", "", "", 1, 1000), + }} +} + +func loadMemSystem() (map[string]string, error) { + f, err := os.Open("/proc/meminfo") + if err != nil { + return nil, err + } + defer f.Close() + + l := make(map[string]string) + + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := scanner.Text() + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + key := strings.TrimSuffix(parts[0], ":") + val, err := strconv.ParseUint(parts[1], 10, 64) + if err != nil { + continue + } + + l[key] = strconv.FormatUint(val, 10) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return l, nil +} + +func normUnit(dic map[string]string, key string, start string) string { + start = strings.ToUpper(start) + + st := dic[key] + + if st == "infinity" || st == "" { + return "" + } + + sizes := []string{"", "K", "M", "G", "T", "P", "E", "Z", "Y"} + + n := 0 + for i, s := range sizes { + if s == start { + n = i + break + } + } + + return fmt.Sprintf("%s%s", dic[key], strings.Repeat("0", n*3)) +} + +func formatUnit(dic map[string]string, key string, start string, unit string, decimals int, step int) string { + start = strings.ToUpper(start) + fstep := float64(step) + + st := dic[key] + + if st == "infinity" || st == "" { + return fmt.Sprintf("∞ %s", unit) + } + + value, _ := strconv.ParseFloat(st, 64) + + if value == 0 { + return fmt.Sprintf("0 %s%s", start, unit) + } + + sizes := []string{"", "K", "M", "G", "T", "P", "E", "Z", "Y"} + + n := 0 + for i, s := range sizes { + if s == start { + n = i + break + } + } + + i := n + int(math.Floor(math.Log(value)/math.Log(fstep))) + if i < 0 { + i = 0 + } + if i >= len(sizes) { + i = len(sizes) - 1 + } + + value = value / math.Pow(fstep, float64(i-n)) + + if math.Abs(value-math.Round(value)) < 0.05 { + value = math.Round(value) + } + + var formatted string + if math.Mod(value, 1) == 0 { + formatted = fmt.Sprintf("%.0f", value) + } else { + format := fmt.Sprintf("%%.%df", decimals) + formatted = fmt.Sprintf(format, value) + } + + return fmt.Sprintf("%s %s%s", formatted, sizes[i], unit) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1bece49 --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module sysui + +go 1.26.2 + +require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.6 + charm.land/lipgloss/v2 v2.0.3 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6aaaf49 --- /dev/null +++ b/go.sum @@ -0,0 +1,46 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= +charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/help.go b/help.go new file mode 100644 index 0000000..6d05e7f --- /dev/null +++ b/help.go @@ -0,0 +1,17 @@ +package main + +import ( + "charm.land/bubbles/v2/key" +) + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Help, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down}, + {k.Search, k.Reload}, + {k.Help, k.Quit}, + } +} diff --git a/keys.go b/keys.go new file mode 100644 index 0000000..234e546 --- /dev/null +++ b/keys.go @@ -0,0 +1,51 @@ +package main + +import ( + "charm.land/bubbles/v2/key" +) + +type keyMap struct { + Up key.Binding + Down key.Binding + Search key.Binding + Back key.Binding + Enter key.Binding + Help key.Binding + Reload key.Binding + Quit key.Binding +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k", "w"), + key.WithHelp("↑/k/w", "move up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j", "s"), + key.WithHelp("↓/j/s", "move down"), + ), + Search: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "search"), + ), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "enter"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Reload: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "reload"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..fdd5ade --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" +) + +func main() { + if _, err := tea.NewProgram(initModel()).Run(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..8f856a8 --- /dev/null +++ b/model.go @@ -0,0 +1,151 @@ +package main + +import ( + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" +) + +type model struct { + err error + width int + height int + amount int + + keys keyMap + + services []Service + filtered []Service + query textinput.Model + cursor int + offset int + + help help.Model + + system System + detail Detail +} + +func initModel() model { + query := textinput.New() + query.Placeholder = "Search..." + + return model{ + keys: keys, + query: query, + help: help.New(), + } +} + +func (m model) Init() tea.Cmd { + return tea.Batch(LoadSystem, m.TickDetails()) +} + +type DetailTickMsg struct{} + +func (m *model) TickDetails() tea.Cmd { + return tea.Tick(1*time.Second, func(time.Time) tea.Msg { + return DetailTickMsg{} + }) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.query.SetWidth(msg.Width) + m.help.SetWidth(msg.Width) + m.width = msg.Width + m.height = msg.Height + m.calcAmount() + return m, nil + + case ServicesLoadedMsg: + m.services = msg.services + m.err = msg.err + m.filterServices() + return m, m.LoadDetail + + case SystemLoadedMsg: + m.system = msg.System + m.err = msg.Err + return m, LoadServices + + case DetailLoadedMsg: + m.detail = msg.Detail + m.err = msg.Err + for si, s := range m.services { + if s.Name == msg.Detail.Name && s.Active != msg.Detail.Active { + m.services[si].Active = msg.Detail.Active + for fi, f := range m.filtered { + if f.Name == msg.Detail.Name { + m.filtered[fi].Active = msg.Detail.Active + } + } + } + } + return m, nil + + case DetailTickMsg: + return m, tea.Batch(m.LoadDetail, m.TickDetails()) + + case tea.KeyPressMsg: + if m.query.Focused() { + switch { + case key.Matches(msg, m.keys.Back), key.Matches(msg, m.keys.Enter): + m.query.Blur() + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + } + m.query, cmd = m.query.Update(msg) + m.filterServices() + return m, tea.Batch(cmd, m.LoadDetail) + } + + switch { + case key.Matches(msg, m.keys.Up): + if m.cursor > 0 { + m.cursor-- + if m.cursor < m.offset { + m.offset-- + } + } + return m, m.LoadDetail + case key.Matches(msg, m.keys.Down): + if m.cursor < len(m.filtered)-1 { + m.cursor++ + if m.cursor >= m.offset+m.amount { + m.offset++ + } + } + return m, m.LoadDetail + case key.Matches(msg, m.keys.Search): + m.query.Focus() + case key.Matches(msg, m.keys.Back): + m.query.Blur() + case key.Matches(msg, m.keys.Enter): + s := m.filtered[m.cursor] + if s.Active != "active" { + m.StartService() + } else { + m.StopService() + } + case key.Matches(msg, m.keys.Reload): + return m, LoadServices + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + m.calcAmount() + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + } + } + + return m, cmd +} + +func (m *model) calcAmount() { + m.amount = (m.height - 2 - If(m.help.ShowAll, 2, 1)) / 3 +} diff --git a/service.go b/service.go new file mode 100644 index 0000000..520bdf2 --- /dev/null +++ b/service.go @@ -0,0 +1,121 @@ +package main + +import ( + "os/exec" + "strings" + + tea "charm.land/bubbletea/v2" +) + +type Service struct { + Name string + Desc string + Active string +} + +type ServicesLoadedMsg struct { + services []Service + err error +} + +func LoadServices() tea.Msg { + cmd := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-pager", "--plain") + out, err := cmd.Output() + if err != nil { + return ServicesLoadedMsg{err: err} + } + + services := parseServices(out) + return ServicesLoadedMsg{services: services} +} + +func parseServices(out []byte) []Service { + lines := strings.Split(string(out), "\n") + var services []Service + + for i, line := range lines { + if i == 0 { + continue + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + if strings.HasPrefix(line, "Legend:") { + return services + } + + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + + services = append(services, Service{ + Name: fields[0], + Active: fields[2], + Desc: strings.Join(fields[4:], " "), + }) + } + + return services +} + +func (m *model) filterServices() { + q := strings.ToLower(strings.TrimSpace(m.query.Value())) + + if q == "" { + m.filtered = slicesClone(m.services) + } else { + var filtered []Service + for _, s := range m.services { + if matchesService(s, q) { + filtered = append(filtered, s) + } + } + m.filtered = filtered + } + + if len(m.filtered) == 0 { + m.cursor = 0 + m.offset = 0 + return + } + if m.cursor >= len(m.filtered) { + m.cursor = len(m.filtered) - 1 + m.offset = max(0, m.cursor-m.amount) + } +} + +func matchesService(s Service, q string) bool { + return strings.Contains(strings.ToLower(s.Name), q) || + strings.Contains(strings.ToLower(s.Active), q) || + strings.Contains(strings.ToLower(s.Desc), q) +} + +func slicesClone(in []Service) []Service { + out := make([]Service, len(in)) + copy(out, in) + return out +} + +func (m *model) StopService() { + s := m.filtered[m.cursor] + + if s.Active != "active" { + return + } + + cmd := exec.Command("systemctl", "stop", s.Name) + cmd.Run() +} + +func (m *model) StartService() { + s := m.filtered[m.cursor] + + if s.Active != "active" { + cmd := exec.Command("systemctl", "start", s.Name) + cmd.Run() + } +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..f84667e --- /dev/null +++ b/status.go @@ -0,0 +1,33 @@ +package main + +import ( + "charm.land/lipgloss/v2" +) + +var ( + greenDot = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("●") + redDot = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("●") + yellowDot = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("●") + grayDot = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("●") + whiteDot = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Render("●") +) + +var ( + stop = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("[STOP]") + start = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("[START]") +) + +func status(s string) string { + switch s { + case "active": + return greenDot + case "failed": + return redDot + case "activating", "reloading": + return yellowDot + case "inactive": + return grayDot + default: + return whiteDot + } +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..2df0a97 --- /dev/null +++ b/util.go @@ -0,0 +1,8 @@ +package main + +func If[T any](cond bool, vtrue, vfalse T) T { + if cond { + return vtrue + } + return vfalse +} diff --git a/view.go b/view.go new file mode 100644 index 0000000..a226d96 --- /dev/null +++ b/view.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/progress" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +func (m model) View() tea.View { + view := tea.NewView(view(m)) + + view.AltScreen = true + + return view +} + +func view(m model) string { + if m.width == 0 || m.height == 0 { + return "Loading..." + } + + if m.err != nil { + return fmt.Sprintf("Error: %v\n\nPress q to quit.", m.err) + } + + var b strings.Builder + + renderQuery(&b, m) + + if len(m.filtered) == 0 { + b.WriteString(" No services found.\n") + renderHelp(&b, m) + return b.String() + } + + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, renderList(m), renderDetails(m))) + + renderHelp(&b, m) + + return b.String() +} + +func renderQuery(b *strings.Builder, m model) { + b.WriteString(m.query.View()) + b.WriteString("\n\n") +} + +func renderHelp(b *strings.Builder, m model) { + if height := m.height - strings.Count(b.String(), "\n") - If(m.help.ShowAll, 2, 1); height > 0 { + b.WriteString(strings.Repeat("\n", height)) + } + + b.WriteString(m.help.View(m.keys)) +} + +func renderList(m model) string { + var panel = lipgloss.NewStyle().Height(m.amount*3 - 1).Width(m.width / 2) + + var b strings.Builder + + last := min(len(m.filtered), m.offset+m.amount) + + for i := m.offset; i < last; i++ { + s := m.filtered[i] + + cursor := " " + if i == m.cursor { + cursor = ">" + } + + status := status(s.Active) + + line := fmt.Sprintf( + "%s %s %s \n %s\n"+If(i == last-1, "", "\n"), + cursor, status, s.Name, s.Desc, + ) + b.WriteString(line) + } + + return panel.Render(b.String()) +} + +func renderDetails(m model) string { + var panel = lipgloss.NewStyle().Height(m.amount*3 - 1).MaxWidth(m.width / 2) + + var b strings.Builder + + d := m.detail + sys := m.system + + fmt.Fprintf(&b, "%s\n%s\n\n", d.Name, d.Desc) + + prog := progress.New(progress.WithColors(lipgloss.White), progress.WithFillCharacters('█', '░'), progress.WithoutPercentage()) + + if d.Active == "active" { + fmt.Fprintf(&b, "▣ Memory - %s / %s\n%s\n\n", d.Memory.Fmt, d.MemoryMax.Alt(sys.Memory).Fmt, prog.ViewAs(d.Memory.Div(d.MemoryMax.Alt(sys.Memory)))) + + fmt.Fprintf(&b, "≡ Tasks - %s / %s\n%s\n\n", d.Tasks.Fmt, d.TasksMax.Alt(sys.Tasks).Fmt, prog.ViewAs(d.Tasks.Div(d.TasksMax.Alt(sys.Tasks)))) + + fmt.Fprintf(&b, "⌁ CPU - %ds\n\n", int(d.Cpu)) + } + + fmt.Fprintf(&b, "%s %s / %s / %s\n", status(d.Active), d.Active, d.Load, d.Sub) + + state := If(d.Active == "active" || d.Active == "failed", 1, If(d.Active == "inactive", -1, 0)) + + b.WriteString(lipgloss.NewStyle().Width(prog.Width()).Align(lipgloss.Right).Render(If(state == 1, stop, If(state == -1, start, "")))) + + return panel.Render(b.String()) +}