diff options
| -rw-r--r-- | indexers/pikdex/index.go | 15 | ||||
| -rw-r--r-- | menu/git.go | 54 | ||||
| -rw-r--r-- | menu/input.go | 17 | ||||
| -rw-r--r-- | menu/menu.go | 4 | ||||
| -rw-r--r-- | menu/model.go | 50 | ||||
| -rw-r--r-- | menu/source.go | 78 | ||||
| -rw-r--r-- | menu/state.go | 29 | ||||
| -rw-r--r-- | menu/target.go | 36 | ||||
| -rw-r--r-- | motd/motd.go | 16 | ||||
| -rw-r--r-- | order/order.go | 50 | ||||
| -rw-r--r-- | runner/shell/shell.go | 6 | ||||
| -rw-r--r-- | runner/shell/target.go | 9 | ||||
| -rw-r--r-- | viewport/scroll.go | 46 | ||||
| -rw-r--r-- | viewport/viewport.go | 67 | ||||
| -rw-r--r-- | viewport/viewport_test.go | 54 |
15 files changed, 441 insertions, 90 deletions
diff --git a/indexers/pikdex/index.go b/indexers/pikdex/index.go index aec6b00..64b111f 100644 --- a/indexers/pikdex/index.go +++ b/indexers/pikdex/index.go @@ -121,7 +121,20 @@ func (u *pikdex) Index(absPath string, f fs.FS, runners []model.Runner) ([]model if err != nil { spool.Warn("%v\n", err) } - targets = append(targets, t) + sub := t.Sub() + if strings.Join(sub, " ") == t.ShortestId() { + desiredIndex := 0 + for i := len(targets) - 1; i >= 0; i-- { + if slices.Equal(targets[i].Sub(), sub) { + desiredIndex = i + } else { + break + } + } + targets = slices.Insert(targets, desiredIndex, t) + } else { + targets = append(targets, t) + } return nil } if err != nil { diff --git a/menu/git.go b/menu/git.go new file mode 100644 index 0000000..ed5a595 --- /dev/null +++ b/menu/git.go @@ -0,0 +1,54 @@ +package menu + +import ( + "github.com/charmbracelet/lipgloss" + "pik/menu/style" + "pik/model" + "strconv" +) + +var ( + GitColor = lipgloss.Color("4") + GitInfoStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().Background(GitColor).Border(lipgloss.OuterHalfBlockBorder(), false, false, false, true).BorderBackground(GitColor).Padding(0, 1) + }) + GitStatusStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Background(GitColor).PaddingLeft(1) + }) + GitAddColor = lipgloss.Color("2") + GitAddStyle = style.New(func() lipgloss.Style { + return GitStatusStyle.Get().Foreground(GitAddColor) + }) + GitRemoveColor = lipgloss.Color("1") + GitRemoveStyle = style.New(func() lipgloss.Style { + return GitStatusStyle.Get().Foreground(GitRemoveColor) + }) + GitChangeColor = lipgloss.Color("5") + GitChangeStyle = style.New(func() lipgloss.Style { + return GitStatusStyle.Get().Foreground(GitChangeColor) + }) +) + +func Git(info *model.GitInfo) string { + var parts = []string{ + " ", + info.Branch, + } + + if info.Insertions > 0 { + parts = append(parts, GitAddStyle.Render("+"+strconv.Itoa(info.Insertions))) + } + if info.Deletions > 0 { + parts = append(parts, GitRemoveStyle.Render("-"+strconv.Itoa(info.Deletions))) + } + if info.Changes > 0 { + parts = append(parts, GitChangeStyle.Render("~"+strconv.Itoa(info.Changes))) + } + if info.Changes == 0 && info.Deletions == 0 && info.Insertions == 0 { + parts = append(parts, GitAddStyle.Render("clean")) + } + + return GitInfoStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, + parts..., + )) +} diff --git a/menu/input.go b/menu/input.go index 6d05749..cd07543 100644 --- a/menu/input.go +++ b/menu/input.go @@ -5,6 +5,16 @@ import tea "github.com/charmbracelet/bubbletea" func (m *Model) HandleInput(msg tea.KeyMsg) (tea.Cmd, error) { var cmd tea.Cmd switch msg.String() { + case "i", "I": + if m.Alt { + m.Alt = false + m.AutoAlt = false + return tea.ExitAltScreen, nil + } else { + m.Alt = true + m.AutoAlt = false + return tea.EnterAltScreen, nil + } case "h", "left": m.Leap(-1) case "l", "right": @@ -14,10 +24,11 @@ func (m *Model) HandleInput(msg tea.KeyMsg) (tea.Cmd, error) { case "down", "j": m.Index++ case "q", "esc", "ctrl+c": - m.Quit = true - cmd = tea.Quit + m.Cancel = true + return tea.Quit, nil case "space", " ", "enter", "ctrl+d": - cmd = tea.Quit + m.Done = true + return tea.Quit, nil } m.Validate() diff --git a/menu/menu.go b/menu/menu.go index fdb1c6b..2677259 100644 --- a/menu/menu.go +++ b/menu/menu.go @@ -3,7 +3,6 @@ package menu import ( "errors" tea "github.com/charmbracelet/bubbletea" - "pik/flags" "pik/model" "pik/spool" ) @@ -17,9 +16,6 @@ func Show(st *model.State, hydrators []model.Modder) (*model.HydratedSource, mod } md := NewModel(st, hydrators) var opts []tea.ProgramOption - if !*flags.Inline { - opts = append(opts, tea.WithAltScreen()) - } program := tea.NewProgram(md, opts...) resultModel, err := program.Run() if err != nil { diff --git a/menu/model.go b/menu/model.go index 4eb2577..d1b9435 100644 --- a/menu/model.go +++ b/menu/model.go @@ -2,8 +2,12 @@ package menu import ( tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/term" + "github.com/spf13/pflag" "pik/model" + "pik/motd" "pik/spool" + "pik/viewport" ) type Model struct { @@ -11,24 +15,55 @@ type Model struct { Index int Indices map[int]model.HydratedTarget SourceIndices map[int]*model.HydratedSource - Quit bool + Cancel bool + Done bool + Height int + Alt bool + AutoAlt bool + Motd string } func (m *Model) Init() tea.Cmd { + _, h, err := term.GetSize(0) + if err != nil { + _, _ = spool.Warn("%v\n", err) + } + m.Height = h + wantsAlt := viewport.NeedsViewport(m.State(), m.Height) + if m.AutoAlt && wantsAlt { + return tea.EnterAltScreen + } return nil } +func (m *Model) HandleResize(msg tea.WindowSizeMsg) tea.Cmd { + if !m.AutoAlt { + return nil + } + m.Height = msg.Height + if viewport.NeedsViewport(m.State(), msg.Height) { + m.Alt = true + return tea.EnterAltScreen + } else { + m.Alt = false + return tea.ExitAltScreen + } + +} + func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var err error var result tea.Cmd switch mt := msg.(type) { + case tea.WindowSizeMsg: + result = m.HandleResize(mt) case tea.KeyMsg: result, err = m.HandleInput(mt) case tea.Cmd: result, err = m.HandleSignal(mt) } if err != nil { - spool.Warn("%v\n", err) + _, _ = spool.Warn("%v\n", err) } return m, result } @@ -38,11 +73,16 @@ func (m *Model) HandleSignal(cmd tea.Cmd) (tea.Cmd, error) { } func (m *Model) View() string { - return m.State(m.HydratedState) + if m.Cancel || m.Done { + return "" + } + result := m.State() + result = viewport.Process(result, m.Height) + return result } func (m *Model) Result() (*model.HydratedSource, model.HydratedTarget) { - if m.Quit { + if m.Cancel { return nil, nil } return m.SourceIndices[m.Index], m.Indices[m.Index] @@ -63,6 +103,8 @@ func NewModel(st *model.State, hydrators []model.Modder) *Model { Index: 0, Indices: make(map[int]model.HydratedTarget), SourceIndices: make(map[int]*model.HydratedSource), + AutoAlt: !pflag.Lookup("inline").Changed, + Motd: motd.One(), } idx := 0 for _, src := range st.Sources { diff --git a/menu/source.go b/menu/source.go index b5d462b..073c044 100644 --- a/menu/source.go +++ b/menu/source.go @@ -4,12 +4,13 @@ import ( "github.com/charmbracelet/lipgloss" "pik/menu/style" "pik/model" - "strconv" + "slices" + "strings" ) var ( SourceStyle = style.New(func() lipgloss.Style { - st := lipgloss.NewStyle().PaddingBottom(1) + st := lipgloss.NewStyle() return st }) SourceHeaderBackground = lipgloss.Color("5") @@ -35,8 +36,18 @@ var ( func (m *Model) Source(src *model.HydratedSource) string { targets := make([]string, 0, len(src.Targets)) + var sub []string for _, t := range src.HydratedTargets { - targets = append(targets, m.Target(t)) + ts := t.Sub() + header := !slices.Equal(sub, ts) + if header { + sub = ts + } + if header && strings.Join(ts, " ") != t.ShortestId() { + targets = append(targets, m.Category(strings.Join(ts, " "), "")) + header = false + } + targets = append(targets, m.Target(t, header)) } targetContent := lipgloss.JoinVertical(lipgloss.Top, targets...) @@ -56,64 +67,3 @@ func (m *Model) Source(src *model.HydratedSource) string { parts..., )) } - -var ( - StateStyle = style.New(func() lipgloss.Style { - return lipgloss.NewStyle().MarginBottom(1) - }) -) - -func (m *Model) State(st *model.HydratedState) string { - sources := make([]string, 0, len(st.Sources)) - for _, hs := range st.HydratedSources { - sources = append(sources, m.Source(hs)) - } - - return StateStyle.Render(lipgloss.JoinVertical(lipgloss.Top, sources...)) -} - -var ( - GitColor = lipgloss.Color("4") - GitInfoStyle = style.New(func() lipgloss.Style { - return lipgloss.NewStyle().Background(GitColor).Border(lipgloss.OuterHalfBlockBorder(), false, false, false, true).BorderBackground(GitColor).Padding(0, 1) - }) - GitStatusStyle = style.New(func() lipgloss.Style { - return lipgloss.NewStyle().Bold(true).Background(GitColor).PaddingLeft(1) - }) - GitAddColor = lipgloss.Color("2") - GitAddStyle = style.New(func() lipgloss.Style { - return GitStatusStyle.Get().Foreground(GitAddColor) - }) - GitRemoveColor = lipgloss.Color("1") - GitRemoveStyle = style.New(func() lipgloss.Style { - return GitStatusStyle.Get().Foreground(GitRemoveColor) - }) - GitChangeColor = lipgloss.Color("5") - GitChangeStyle = style.New(func() lipgloss.Style { - return GitStatusStyle.Get().Foreground(GitChangeColor) - }) -) - -func Git(info *model.GitInfo) string { - var parts = []string{ - " ", - info.Branch, - } - - if info.Insertions > 0 { - parts = append(parts, GitAddStyle.Render("+"+strconv.Itoa(info.Insertions))) - } - if info.Deletions > 0 { - parts = append(parts, GitRemoveStyle.Render("-"+strconv.Itoa(info.Deletions))) - } - if info.Changes > 0 { - parts = append(parts, GitChangeStyle.Render("~"+strconv.Itoa(info.Changes))) - } - if info.Changes == 0 && info.Deletions == 0 && info.Insertions == 0 { - parts = append(parts, GitAddStyle.Render("clean")) - } - - return GitInfoStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, - parts..., - )) -} diff --git a/menu/state.go b/menu/state.go new file mode 100644 index 0000000..16702a2 --- /dev/null +++ b/menu/state.go @@ -0,0 +1,29 @@ +package menu + +import ( + "github.com/charmbracelet/lipgloss" + "pik/menu/style" +) + +var ( + StateStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().MarginBottom(1) + }) + MotdStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().Faint(true).PaddingLeft(1) + }) +) + +func (m *Model) State() string { + st := m.HydratedState + sources := make([]string, 0, len(st.Sources)) + for i, hs := range st.HydratedSources { + src := m.Source(hs) + if i != len(st.HydratedSources)-1 { + src += "\n" + } + sources = append(sources, src) + } + + return StateStyle.Render(lipgloss.JoinVertical(lipgloss.Top, sources...), MotdStyle.Render("\n \U000F08B7 "+m.Motd)) +} diff --git a/menu/target.go b/menu/target.go index 04dfea1..6d6fdec 100644 --- a/menu/target.go +++ b/menu/target.go @@ -4,6 +4,7 @@ import ( "github.com/charmbracelet/lipgloss" "pik/menu/style" "pik/model" + "pik/viewport" ) var ( @@ -32,25 +33,48 @@ var ( st := lipgloss.NewStyle().PaddingLeft(1) return st }) + TargetIconSelectedStyle = style.New(func() lipgloss.Style { + return TargetIconStyle.Get().MarginLeft(1).PaddingLeft(0) + }) TargetSubStyle = style.New(func() lipgloss.Style { return lipgloss.NewStyle() }) + CategoryColor = lipgloss.Color("7") + CategoryStyle = style.New(func() lipgloss.Style { + return TargetStyle.Get().BorderForeground(CategoryColor).Background(CategoryColor).BorderBackground(CategoryColor) + }) + OrphanCategoryStyle = style.New(func() lipgloss.Style { + return TargetLabelStyle.Get().PaddingLeft(1) + }) ) -func (m *Model) Target(t model.HydratedTarget) string { - icon := TargetIconStyle.Render(PaddedIcon(t.Icon())) +func (m *Model) Target(t model.HydratedTarget, header bool) string { + _, selection := m.Result() + selected := selection != nil && selection.Target() == t.Target() + icon := "" + if selected { + icon = TargetIconSelectedStyle.Render(PaddedIcon(viewport.Caret)) + } else { + icon = TargetIconStyle.Render(PaddedIcon(t.Icon())) + } selectionStyle := TargetStyle selectionDescriptionStyle := TargetDescriptionStyle - _, sel := m.Result() - if sel != nil && sel.Target() == t.Target() { + if selected { selectionStyle = SelectedTargetStyle selectionDescriptionStyle = SelectedTargetDescriptionStyle + } else if header { + selectionStyle = CategoryStyle } var labelParts []string labelParts = append(labelParts, icon) - if t.Sub() != nil { - labelParts = append(labelParts, TargetSubStyle.Render(t.Sub()...)) + sub := t.Sub() + if sub != nil && sub[len(sub)-1] != t.ShortestId() { + labelParts = append(labelParts, TargetSubStyle.Render(sub...)) } labelParts = append(labelParts, TargetLabelStyle.Render(t.Label())) return lipgloss.JoinHorizontal(lipgloss.Left, selectionStyle.Render(labelParts...), selectionDescriptionStyle.Render(t.Description())) } + +func (m *Model) Category(input string, desc string) string { + return CategoryStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, OrphanCategoryStyle.Render(input), TargetDescriptionStyle.Render(desc))) +} diff --git a/motd/motd.go b/motd/motd.go new file mode 100644 index 0000000..e57e8b0 --- /dev/null +++ b/motd/motd.go @@ -0,0 +1,16 @@ +package motd + +import "math/rand/v2" + +var Messages = []string{ + "use the -a flag to invoke from anywhere", + "combine -a with -y to invoke from anywhere without confirming", + "`--env dev` will include .env, .env.dev, .env-dev, and dev.env", + "pik will start in a viewport if the terminal is too thin", + "instead of .pik you can use .tasks, or .bin, or any of these with _", +} + +func One() string { + idx := rand.Int() % len(Messages) + return Messages[idx] +} diff --git a/order/order.go b/order/order.go new file mode 100644 index 0000000..40e6e73 --- /dev/null +++ b/order/order.go @@ -0,0 +1,50 @@ +package order + +import ( + "bufio" + "io" + "io/fs" + "os" + "pik/describe" + "pik/identity" + "strings" +) + +type Element struct { + Identifier identity.Identity + Description string +} + +type Order struct { + Elements []Element +} + +var Empty = Order{} + +func FromFile(f fs.FS, path string) (Order, error) { + fd, err := os.Open(path) + if err != nil { + return Empty, err + } + defer fd.Close() + return FromReader(fd) +} + +func FromReader(r io.Reader) (Order, error) { + scanner := bufio.NewScanner(r) + scanner.Split(bufio.ScanLines) + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + if line == "" { + continue + } + + for _, p := range describe.DescriptionPrefixes { + if strings.HasPrefix(line, p) { + continue + } + } + } + +} diff --git a/runner/shell/shell.go b/runner/shell/shell.go index a4aee9d..f8e353c 100644 --- a/runner/shell/shell.go +++ b/runner/shell/shell.go @@ -99,10 +99,10 @@ func (s *shell) CreateTarget(fs fs.FS, src string, file string, _ fs.DirEntry) ( BaseTarget: runner.BaseTarget{ Identity: identity.New(filename), MyTags: model.TagsFromFilename(filename), + Sub: sub, }, - Shell: shell, - Script: filepath.Join(src, file), - SubValue: sub, + Shell: shell, + Script: filepath.Join(src, file), }, nil } diff --git a/runner/shell/target.go b/runner/shell/target.go index 0194b5d..8422cf0 100644 --- a/runner/shell/target.go +++ b/runner/shell/target.go @@ -8,9 +8,8 @@ import ( type Target struct { runner.BaseTarget - Shell string - Script string - SubValue []string + Shell string + Script string } func (s *Target) String() string { @@ -22,13 +21,13 @@ func (s *Target) Hydrate(_ *model.Source) (model.HydratedTarget, error) { } func (s *Target) Sub() []string { - return s.SubValue + return s.BaseTarget.Sub } func (s *Target) Label() string { return s.Identity.Full } -func (s *Target) Create(src *model.Source) *exec.Cmd { +func (s *Target) Create(_ *model.Source) *exec.Cmd { return exec.Command(s.Shell, s.Script) } diff --git a/viewport/scroll.go b/viewport/scroll.go new file mode 100644 index 0000000..809fe17 --- /dev/null +++ b/viewport/scroll.go @@ -0,0 +1,46 @@ +package viewport + +import ( + "github.com/charmbracelet/lipgloss" + "pik/menu/style" + "strings" +) + +var ( + ScrollTop = StyleBarBackground.Render("╷") + ScrollSpace = StyleBarBackground.Render("│") + ScrollBarTopEnd = StyleBar.Render("╓") + ScrollBar = StyleBar.Render("║") + ScrollBarBottomEnd = StyleBar.Render("╙") + ScrollBottom = StyleBarBackground.Render("╵") +) + +var ( + StyleBar = style.New(func() lipgloss.Style { + return lipgloss.NewStyle() + }) + StyleBarBackground = style.New(func() lipgloss.Style { + return StyleBar.Get().Faint(true) + }) +) + +func WithScroll(input string, barBegin int, barEnd int) string { + lines := strings.Split(input, "\n") + for i, line := range lines { + selection := ScrollSpace + switch { + case i == barBegin: + selection = StyleBar.Render(ScrollBarTopEnd) + case i == 0: + selection = StyleBarBackground.Render(ScrollTop) + case i == barEnd: + selection = StyleBar.Render(ScrollBarBottomEnd) + case i > barBegin && i < barEnd: + selection = StyleBar.Render(ScrollBar) + case i == len(lines)-1: + selection = StyleBar.Render(ScrollBottom) + } + lines[i] = selection + " " + line + } + return strings.Join(lines, "\n") +} diff --git a/viewport/viewport.go b/viewport/viewport.go new file mode 100644 index 0000000..ff1ca05 --- /dev/null +++ b/viewport/viewport.go @@ -0,0 +1,67 @@ +package viewport + +import ( + "strings" +) + +const Caret = "⏵" + +func NeedsViewport(input string, height int) bool { + lines := strings.Split(input, "\n") + return len(lines)-1 > height +} + +func Process(input string, height int) string { + lines := strings.Split(input, "\n") + if len(lines) > height { + cropped, top, bottom := Crop(input, lines, height) + return WithScroll(cropped, int(top*float32(height)), int(bottom*float32(height))) + } + return input +} + +func Focus(lines []string, needle string) int { + for i, l := range lines { + if strings.Contains(l, needle) { + return i + } + } + return -1 +} + +func Crop(input string, lines []string, height int) (output string, scrollStart float32, scrollEnd float32) { + output = input + selectionIndex := Focus(lines, Caret) + size := len(lines) + if size <= height { + return output, 0, 1 + } + + linesAbove := height / 2 + linesBelow := height - linesAbove + if linesAbove*2 < selectionIndex { + linesBelow++ + } + + start := selectionIndex - linesAbove + end := selectionIndex + linesBelow + + if start < 0 { + end += -start + start = 0 + } + + if end >= size { + diff := size - 1 - end + start += diff + end += diff + } + + scrollStart = float32(start) / float32(size) + scrollEnd = float32(end)/float32(size) + float32(1)/float32(size) + if scrollEnd > 1 { + scrollEnd = 1 + } + + return strings.Join(lines[start:end], "\n"), scrollStart, scrollEnd +} diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go new file mode 100644 index 0000000..9da4c3c --- /dev/null +++ b/viewport/viewport_test.go @@ -0,0 +1,54 @@ +//go:build test + +package viewport + +import ( + "github.com/stretchr/testify/assert" + "strings" + "testing" +) + +func TestCrop(t *testing.T) { + input := `0000 +AAAA +` + Caret + `BBB +CCC` + expected := `AAAA +` + Caret + `BBB` + result, _, _ := Crop(input, strings.Split(input, "\n"), 2) + assert.Equal(t, expected, result) +} + +func TestCrop_Under(t *testing.T) { + input := `0000 +AAAA +` + Caret + `BBB` + expected := `AAAA +` + Caret + `BBB` + result, _, _ := Crop(input, strings.Split(input, "\n"), 2) + assert.Equal(t, expected, result) +} + +func TestCrop_Unnecessary(t *testing.T) { + input := `AAAA +` + Caret + `BBB +CCC +DDDD` + expected := input + result, _, _ := Crop(input, strings.Split(input, "\n"), 8) + assert.Equal(t, expected, result) +} + +func TestNeedsViewport(t *testing.T) { + amount := 3 + input := strings.Repeat("\n", amount) + output := NeedsViewport(input, 4) + assert.Equal(t, false, output) +} + +func TestNeedsViewport_Negative(t *testing.T) { + amount := 8 + input := strings.Repeat("\n", amount) + output := NeedsViewport(input, 4) + assert.Equal(t, true, output) +} |
