diff --git a/CHANGELOG.md b/CHANGELOG.md index 28af61d4..cf1f46a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Bug fixes & pull requests - Merged pull request for README clean-ups (theverything:add-missing-option-to-readme) - Merge Nord color scheme (jrswab:nordColorScheme) - Merge support for multiple (and filtering) network interfaces (mattLLVW:feature/network_interface_list) +- Merge filtering subprocesses by substring (rephorm:filter) ## [3.1.0] - 2020-02-13 diff --git a/README.md b/README.md index 6f1061c1..09036241 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,9 @@ Unzip it and then move `gotop` into your `$PATH` somewhere. If you're on a Debi ### Keybinds - Quit: `q` or `` -- Process navigation +- Process navigation: - `k` and ``: up - - `j` and ``: down - ``: half page up - ``: half page down - ``: full page up @@ -55,6 +55,11 @@ Unzip it and then move `gotop` into your `$PATH` somewhere. If you're on a Debi - `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/cmd/gotop/main.go b/cmd/gotop/main.go index bf9b72d4..d3384b07 100644 --- a/cmd/gotop/main.go +++ b/cmd/gotop/main.go @@ -221,6 +221,10 @@ func eventLoop(c gotop.Config, grid *layout.MyGrid) { } } case e := <-uiEvents: + if grid.Proc != nil && grid.Proc.HandleEvent(e) { + ui.Render(grid.Proc) + break + } switch e.ID { case "q", "": return @@ -354,6 +358,11 @@ func eventLoop(c gotop.Config, grid *layout.MyGrid) { grid.Proc.ChangeProcSortMethod(w.ProcSortMethod(e.ID)) ui.Render(grid.Proc) } + case "/": + if grid.Proc != nil { + grid.Proc.SetEditingFilter(true) + ui.Render(grid.Proc) + } } if previousKey == e.ID { diff --git a/config.go b/config.go index 7eff4952..f6cf4a59 100644 --- a/config.go +++ b/config.go @@ -14,7 +14,6 @@ import ( // TODO: Merge #167 configuration file (jrswab:configFile111) // TODO: Merge #157 FreeBSD fixes & Nvidia GPU support (kraust:master) // TODO: Merge #156 Added temperatures for NVidia GPUs (azak-azkaran:master) -// TODO: Merge #147 filtering subprocesses by substring (rephorm:filter) // TODO: Merge #140 color-related fix (Tazer:master) // TODO: Merge #135 linux console font (cmatsuoka:console-font) type Config struct { diff --git a/go.mod b/go.mod index e0750853..3ccbb21f 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/docopt/docopt.go v0.0.0-20180111231733-ee0de3bc6815 github.com/gizak/termui/v3 v3.0.0 github.com/go-ole/go-ole v1.2.4 // indirect + github.com/mattn/go-runewidth v0.0.4 github.com/shirou/gopsutil v2.18.11+incompatible github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4 // indirect github.com/stretchr/testify v1.4.0 diff --git a/go.sum b/go.sum index 91a8c8b0..b05f2825 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-runewidth v0.0.2 h1:UnlwIPBGaTZfPQ6T1IGzPI0EkYAQmT9fAEJ/poFC63o= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= diff --git a/termui/entry.go b/termui/entry.go new file mode 100644 index 00000000..2ba4ecce --- /dev/null +++ b/termui/entry.go @@ -0,0 +1,113 @@ +package termui + +import ( + "image" + "strings" + "unicode/utf8" + + . "github.com/gizak/termui/v3" + rw "github.com/mattn/go-runewidth" + "github.com/xxxserxxx/gotop/utils" +) + +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/termui/table.go b/termui/table.go index eeb82afb..e587839a 100644 --- a/termui/table.go +++ b/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/utils/runes.go b/utils/runes.go new file mode 100644 index 00000000..76cb5e32 --- /dev/null +++ b/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/utils/runes_test.go b/utils/runes_test.go new file mode 100644 index 00000000..67ceefc1 --- /dev/null +++ b/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/widgets/help.go b/widgets/help.go index 953de455..9f78dd07 100644 --- a/widgets/help.go +++ b/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 @@ -26,11 +26,17 @@ Process actions: - d3: kill selected process or group of processes with SIGQUIT (3) - d9: kill selected process or group of processes with SIGKILL (9) -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 @@ -47,12 +53,8 @@ func NewHelpMenu() *HelpMenu { } func (self *HelpMenu) Resize(termWidth, termHeight int) { - var textWidth = 0 - for _, line := range strings.Split(KEYBINDS, "\n") { - textWidth = maxInt(len(line), textWidth) - } - textWidth += 2 - textHeight := 28 + textWidth := 53 + textHeight := strings.Count(KEYBINDS, "\n") + 1 x := (termWidth - textWidth) / 2 y := (termHeight - textHeight) / 2 diff --git a/widgets/proc.go b/widgets/proc.go index 58ad6dde..9fed067b 100644 --- a/widgets/proc.go +++ b/widgets/proc.go @@ -6,10 +6,12 @@ import ( "os/exec" "sort" "strconv" + "strings" "time" psCPU "github.com/shirou/gopsutil/cpu" + tui "github.com/gizak/termui/v3" ui "github.com/xxxserxxx/gotop/termui" "github.com/xxxserxxx/gotop/utils" ) @@ -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)