diff --git a/README.md b/README.md index 458d534c..b34b0806 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,9 @@ snap connect gotop-cjbassi:system-observe ### Keybinds - Quit: `q` or `` -- Process navigation +- Process navigation: - `k` and ``: up - - `j` and ``: down - ``: half page up - ``: half page down - ``: full page up @@ -83,10 +83,15 @@ snap connect gotop-cjbassi:system-observe - Process actions: - ``: toggle process grouping - `dd`: kill selected process or group of processes -- Process sorting +- Process sorting: - `c`: CPU - `m`: Mem - `p`: PID +- Process filtering: + - `/`: start editing filter + - (while editing): + - `` accept filter + - `` and ``: clear filter - CPU and Mem graph scaling: - `h`: scale in - `l`: scale out diff --git a/main.go b/main.go index 4ed02317..3cf4add1 100644 --- a/main.go +++ b/main.go @@ -296,6 +296,10 @@ func eventLoop() { } } case e := <-uiEvents: + if proc.HandleEvent(e) { + ui.Render(proc) + break + } switch e.ID { case "q", "": return @@ -389,6 +393,9 @@ func eventLoop() { case "m", "c", "p": proc.ChangeProcSortMethod(w.ProcSortMethod(e.ID)) ui.Render(proc) + case "/": + proc.SetEditingFilter(true) + ui.Render(proc) } if previousKey == e.ID { diff --git a/src/termui/entry.go b/src/termui/entry.go new file mode 100644 index 00000000..1bdcb390 --- /dev/null +++ b/src/termui/entry.go @@ -0,0 +1,113 @@ +package termui + +import ( + "image" + "strings" + "unicode/utf8" + + "github.com/cjbassi/gotop/src/utils" + . "github.com/gizak/termui/v3" + rw "github.com/mattn/go-runewidth" +) + +const ( + ELLIPSIS = "…" + CURSOR = " " +) + +type Entry struct { + Block + + Style Style + + Label string + Value string + ShowWhenEmpty bool + UpdateCallback func(string) + + editing bool +} + +func (self *Entry) SetEditing(editing bool) { + self.editing = editing +} + +func (self *Entry) update() { + if self.UpdateCallback != nil { + self.UpdateCallback(self.Value) + } +} + +// HandleEvent handles input events if the entry is being edited. +// Returns true if the event was handled. +func (self *Entry) HandleEvent(e Event) bool { + if !self.editing { + return false + } + if utf8.RuneCountInString(e.ID) == 1 { + self.Value += e.ID + self.update() + return true + } + switch e.ID { + case "", "": + self.Value = "" + self.editing = false + self.update() + case "": + self.editing = false + case "": + if self.Value != "" { + r := []rune(self.Value) + self.Value = string(r[:len(r)-1]) + self.update() + } + case "": + self.Value += " " + self.update() + default: + return false + } + return true +} + +func (self *Entry) Draw(buf *Buffer) { + if self.Value == "" && !self.editing && !self.ShowWhenEmpty { + return + } + + style := self.Style + label := self.Label + if self.editing { + label += "[" + style = NewStyle(style.Fg, style.Bg, ModifierBold) + } + cursorStyle := NewStyle(style.Bg, style.Fg, ModifierClear) + + p := image.Pt(self.Min.X, self.Min.Y) + buf.SetString(label, style, p) + p.X += rw.StringWidth(label) + + tail := " " + if self.editing { + tail = "] " + } + + maxLen := self.Max.X - p.X - rw.StringWidth(tail) + if self.editing { + maxLen -= 1 // for cursor + } + value := utils.TruncateFront(self.Value, maxLen, ELLIPSIS) + buf.SetString(value, self.Style, p) + p.X += rw.StringWidth(value) + + if self.editing { + buf.SetString(CURSOR, cursorStyle, p) + p.X += rw.StringWidth(CURSOR) + if remaining := maxLen - rw.StringWidth(value); remaining > 0 { + buf.SetString(strings.Repeat(" ", remaining), self.TitleStyle, p) + p.X += remaining + } + } + buf.SetString(tail, style, p) +} diff --git a/src/termui/table.go b/src/termui/table.go index eeb82afb..e587839a 100644 --- a/src/termui/table.go +++ b/src/termui/table.go @@ -131,6 +131,9 @@ func (self *Table) Draw(buf *Buffer) { func (self *Table) drawLocation(buf *Buffer) { total := len(self.Rows) topRow := self.TopRow + 1 + if topRow > total { + topRow = total + } bottomRow := self.TopRow + self.Inner.Dy() - 1 if bottomRow > total { bottomRow = total diff --git a/src/utils/runes.go b/src/utils/runes.go new file mode 100644 index 00000000..76cb5e32 --- /dev/null +++ b/src/utils/runes.go @@ -0,0 +1,24 @@ +package utils + +import ( + rw "github.com/mattn/go-runewidth" +) + +func TruncateFront(s string, w int, prefix string) string { + if rw.StringWidth(s) <= w { + return s + } + r := []rune(s) + pw := rw.StringWidth(prefix) + w -= pw + width := 0 + i := len(r) - 1 + for ; i >= 0; i-- { + cw := rw.RuneWidth(r[i]) + width += cw + if width > w { + break + } + } + return prefix + string(r[i+1:len(r)]) +} diff --git a/src/utils/runes_test.go b/src/utils/runes_test.go new file mode 100644 index 00000000..67ceefc1 --- /dev/null +++ b/src/utils/runes_test.go @@ -0,0 +1,50 @@ +package utils + +import "testing" + +const ( + ELLIPSIS = "…" +) + +func TestTruncateFront(t *testing.T) { + tests := []struct { + s string + w int + prefix string + want string + }{ + {"", 0, ELLIPSIS, ""}, + {"", 1, ELLIPSIS, ""}, + {"", 10, ELLIPSIS, ""}, + + {"abcdef", 0, ELLIPSIS, ELLIPSIS}, + {"abcdef", 1, ELLIPSIS, ELLIPSIS}, + {"abcdef", 2, ELLIPSIS, ELLIPSIS + "f"}, + {"abcdef", 5, ELLIPSIS, ELLIPSIS + "cdef"}, + {"abcdef", 6, ELLIPSIS, "abcdef"}, + {"abcdef", 10, ELLIPSIS, "abcdef"}, + + {"abcdef", 0, "...", "..."}, + {"abcdef", 1, "...", "..."}, + {"abcdef", 3, "...", "..."}, + {"abcdef", 4, "...", "...f"}, + {"abcdef", 5, "...", "...ef"}, + {"abcdef", 6, "...", "abcdef"}, + {"abcdef", 10, "...", "abcdef"}, + + {"⦅full~width⦆", 15, ".", "⦅full~width⦆"}, + {"⦅full~width⦆", 14, ".", ".full~width⦆"}, + {"⦅full~width⦆", 13, ".", ".ull~width⦆"}, + {"⦅full~width⦆", 10, ".", ".~width⦆"}, + {"⦅full~width⦆", 9, ".", ".width⦆"}, + {"⦅full~width⦆", 8, ".", ".width⦆"}, + {"⦅full~width⦆", 3, ".", ".⦆"}, + {"⦅full~width⦆", 2, ".", "."}, + } + + for _, test := range tests { + if got := TruncateFront(test.s, test.w, test.prefix); got != test.want { + t.Errorf("TruncateFront(%q, %d, %q) = %q; want %q", test.s, test.w, test.prefix, got, test.want) + } + } +} diff --git a/src/widgets/help.go b/src/widgets/help.go index eaa6f59a..97cfbd67 100644 --- a/src/widgets/help.go +++ b/src/widgets/help.go @@ -10,7 +10,7 @@ import ( const KEYBINDS = ` Quit: q or -Process navigation +Process navigation: - k and : up - j and : down - : half page up @@ -24,11 +24,17 @@ Process actions: - : toggle process grouping - dd: kill selected process or group of processes -Process sorting +Process sorting: - c: CPU - m: Mem - p: PID +Process filtering: + - /: start editing filter + - (while editing): + - : accept filter + - and : clear filter + CPU and Mem graph scaling: - h: scale in - l: scale out @@ -46,7 +52,7 @@ func NewHelpMenu() *HelpMenu { func (self *HelpMenu) Resize(termWidth, termHeight int) { textWidth := 53 - textHeight := 22 + textHeight := strings.Count(KEYBINDS, "\n") + 1 x := (termWidth - textWidth) / 2 y := (termHeight - textHeight) / 2 diff --git a/src/widgets/proc.go b/src/widgets/proc.go index d48ed9a0..748fb64c 100644 --- a/src/widgets/proc.go +++ b/src/widgets/proc.go @@ -6,12 +6,14 @@ import ( "os/exec" "sort" "strconv" + "strings" "time" psCPU "github.com/shirou/gopsutil/cpu" ui "github.com/cjbassi/gotop/src/termui" "github.com/cjbassi/gotop/src/utils" + tui "github.com/gizak/termui/v3" ) const ( @@ -37,9 +39,11 @@ type Proc struct { type ProcWidget struct { *ui.Table + entry *ui.Entry cpuCount int updateInterval time.Duration sortMethod ProcSortMethod + filter string groupedProcs []Proc ungroupedProcs []Proc showGroupedProcs bool @@ -56,6 +60,16 @@ func NewProcWidget() *ProcWidget { cpuCount: cpuCount, sortMethod: ProcSortCpu, showGroupedProcs: true, + filter: "", + } + self.entry = &ui.Entry{ + Style: self.TitleStyle, + Label: " Filter: ", + Value: "", + UpdateCallback: func(val string) { + self.filter = val + self.update() + }, } self.Title = " Processes " self.ShowCursor = true @@ -86,6 +100,37 @@ func NewProcWidget() *ProcWidget { return self } +func (self *ProcWidget) SetEditingFilter(editing bool) { + self.entry.SetEditing(editing) +} + +func (self *ProcWidget) HandleEvent(e tui.Event) bool { + return self.entry.HandleEvent(e) +} + +func (self *ProcWidget) SetRect(x1, y1, x2, y2 int) { + self.Table.SetRect(x1, y1, x2, y2) + self.entry.SetRect(x1+2, y2-1, x2-2, y2) +} + +func (self *ProcWidget) Draw(buf *tui.Buffer) { + self.Table.Draw(buf) + self.entry.Draw(buf) +} + +func (self *ProcWidget) filterProcs(procs []Proc) []Proc { + if self.filter == "" { + return procs + } + var filtered []Proc + for _, proc := range procs { + if strings.Contains(proc.FullCommand, self.filter) || strings.Contains(fmt.Sprintf("%d", proc.Pid), self.filter) { + filtered = append(filtered, proc) + } + } + return filtered +} + func (self *ProcWidget) update() { procs, err := getProcs() if err != nil { @@ -98,6 +143,7 @@ func (self *ProcWidget) update() { procs[i].Cpu /= float64(self.cpuCount) } + procs = self.filterProcs(procs) self.ungroupedProcs = procs self.groupedProcs = groupProcs(procs)