diff --git a/cmd/gofzf/main.go b/cmd/gofzf/main.go index ec50a17..15f5547 100644 --- a/cmd/gofzf/main.go +++ b/cmd/gofzf/main.go @@ -34,7 +34,8 @@ var ( flagSelectedPrefix string flagUnselectedPrefix string - flagCountView bool + flagInputPosition string + flagCountView bool flagPromptFg string flagPromptBg string @@ -129,6 +130,8 @@ var rootCmd = &cobra.Command{ fzf.WithSelectedPrefix(flagSelectedPrefix), fzf.WithUnselectedPrefix(flagUnselectedPrefix), + fzf.WithInputPosition(fzf.InputPosition(flagInputPosition)), + fzf.WithCountViewEnabled(flagCountView), fzf.WithStyles( @@ -340,6 +343,8 @@ func init() { rootCmd.Flags().StringVar(&flagSelectedPrefix, "selected-prefix", "● ", "") rootCmd.Flags().StringVar(&flagUnselectedPrefix, "unselected-prefix", "◯ ", "") + rootCmd.Flags().StringVar(&flagInputPosition, "input-position", string(fzf.InputPositionTop), "position of input (top|bottom)") + rootCmd.Flags().BoolVar(&flagCountView, "count-view", true, "") rootCmd.Flags().StringVar(&flagPromptFg, "prompt-fg", "", "") diff --git a/docs/cli/README.ja.md b/docs/cli/README.ja.md index 66c5967..59e09f7 100644 --- a/docs/cli/README.ja.md +++ b/docs/cli/README.ja.md @@ -54,6 +54,7 @@ $ go install github.com/koki-develop/go-fzf/cmd/gofzf@latest `gofzf` CLI はフラグを使用して様々な見た目のカスタマイズができます。 - [プロンプト](#プロンプト) +- [インプットの位置](#インプットの位置) - [インプットのプレースホルダ](#インプットのプレースホルダ) - [インプットのテキスト](#インプットのテキスト) - [カーソル](#カーソル) @@ -75,6 +76,12 @@ $ go install github.com/koki-develop/go-fzf/cmd/gofzf@latest | `--prompt-underline` | `false` | プロンプトに下線を引く。 | | `--prompt-faint` | `false` | プロンプトを薄く表示する。 | +#### インプットの位置 + +| フラグ | デフォルト | 説明 | +| ------------------ | ---------- | ------------------------------------------------------- | +| `--input-position` | `"top"` | インプットの位置。 `top` もしくは `bottom` が有効です。 | + #### インプットのプレースホルダ | フラグ | デフォルト | 説明 | diff --git a/docs/cli/README.md b/docs/cli/README.md index c5c97c7..41757a6 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -54,6 +54,7 @@ Setting the `--no-limit` flag allows unlimited item selection. The `gofzf` CLI allows for various visual customizations using flags. - [Prompt](#prompt) +- [Position of input](#position-of-input) - [Placeholder for input](#placeholder-for-input) - [Input text](#input-text) - [Cursor](#cursor) @@ -75,6 +76,12 @@ The `gofzf` CLI allows for various visual customizations using flags. | `--prompt-underline` | `false` | Underline prompt. | | `--prompt-faint` | `false` | Faint prompt. | +#### Position of input + +| Flag | Default | Description | +| ------------------ | ------- | ----------------------------------------------------- | +| `--input-position` | `"top"` | Position of input. Either `top` or `bottom` is valid. | + #### Placeholder for input | Flag | Default | Description | diff --git a/docs/library/README.ja.md b/docs/library/README.ja.md index d83a955..fb9a020 100644 --- a/docs/library/README.ja.md +++ b/docs/library/README.ja.md @@ -154,6 +154,7 @@ if err != nil { - [プロンプト](#プロンプト) - [カーソル](#カーソル) - [選択中 / 未選択アイテムの接頭辞](#選択中--未選択アイテムの接頭辞) +- [インプットの位置](#インプットの位置) - [インプットのプレースホルダ](#インプットのプレースホルダ) - [カウントビュー](#カウントビュー) - [スタイル](#スタイル) @@ -201,6 +202,21 @@ if err != nil { [Example](/examples/prefix/) +#### インプットの位置 + +`fzf.WithInputPosition()` を使用するとインプットの位置を設定できます。 + +```go +f, err := fzf.New( + fzf.WithInputPosition(fzf.InputPositionBottom), +) +if err != nil { + // ... +} +``` + +[Example](/examples/input-position/) + #### インプットのプレースホルダ `fzf.WithInputPlaceholder()` を使用するとインプットのプレースホルダを設定できます。 diff --git a/docs/library/README.md b/docs/library/README.md index 1a05500..211780f 100644 --- a/docs/library/README.md +++ b/docs/library/README.md @@ -155,6 +155,7 @@ if err != nil { - [Prompt](#prompt) - [Cursor](#cursor) - [Prefix of selected/unselected items](#prefix-of-selectedunselected-items) +- [Position of input](#position-of-input) - [Placeholder for input](#placeholder-for-input) - [Count View](#count-view) - [Styles](#styles) @@ -203,6 +204,21 @@ if err != nil { [Example](/examples/prefix/) +#### Position of input + +`fzf.WithInputPosition()` can be used to set the position of input. + +```go +f, err := fzf.New( + fzf.WithInputPosition(fzf.InputPositionBottom), +) +if err != nil { + // ... +} +``` + +[Example](/examples/input-position/) + #### Placeholder for input `fzf.WithCursor()` can be used to set the placeholder for input. diff --git a/examples/input-position/README.md b/examples/input-position/README.md new file mode 100644 index 0000000..b6cb717 --- /dev/null +++ b/examples/input-position/README.md @@ -0,0 +1 @@ +![](./demo.gif) diff --git a/examples/input-position/demo.gif b/examples/input-position/demo.gif new file mode 100644 index 0000000..b26f950 Binary files /dev/null and b/examples/input-position/demo.gif differ diff --git a/examples/input-position/main.go b/examples/input-position/main.go new file mode 100644 index 0000000..2306f71 --- /dev/null +++ b/examples/input-position/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "log" + + "github.com/koki-develop/go-fzf" +) + +func main() { + items := []string{"hello", "world", "foo", "bar"} + + f, err := fzf.New( + fzf.WithInputPosition(fzf.InputPositionBottom), + ) + if err != nil { + log.Fatal(err) + } + + idxs, err := f.Find(items, func(i int) string { return items[i] }) + if err != nil { + log.Fatal(err) + } + + for _, i := range idxs { + fmt.Println(items[i]) + } +} diff --git a/fzf.go b/fzf.go index b9f60f4..16da0f4 100644 --- a/fzf.go +++ b/fzf.go @@ -29,6 +29,10 @@ func New(opts ...Option) (*FZF, error) { return nil, errors.New("limit must be at least 1") } + if err := o.inputPosition.Valid(); err != nil { + return nil, err + } + m := newModel(&o) return &FZF{ diff --git a/model.go b/model.go index 51e3e13..3551526 100644 --- a/model.go +++ b/model.go @@ -125,94 +125,143 @@ func (m *model) View() string { var v strings.Builder - _, _ = v.WriteString(m.headerView()) - _, _ = v.WriteRune('\n') - _, _ = v.WriteString(m.itemsView()) + var windowStyle lipgloss.Style + switch m.option.inputPosition { + case InputPositionTop: + windowStyle = lipgloss.NewStyle().Height(m.windowHeight).AlignVertical(lipgloss.Top) + _, _ = v.WriteString(m.inputView()) + _, _ = v.WriteRune('\n') + _, _ = v.WriteString(m.itemsView()) - return v.String() + case InputPositionBottom: + windowStyle = lipgloss.NewStyle().Height(m.windowHeight).AlignVertical(lipgloss.Bottom) + _, _ = v.WriteString(m.itemsView()) + _, _ = v.WriteRune('\n') + _, _ = v.WriteString(m.inputView()) + } + + return windowStyle.Render(v.String()) } -func (m *model) headerView() string { +func (m *model) inputView() string { var v strings.Builder - // input - _, _ = v.WriteString(m.input.View()) - // count - if m.option.countViewEnabled { - _, _ = v.WriteRune('\n') - _, _ = v.WriteString(m.option.countViewFunc(CountViewMeta{ - ItemsCount: m.items.Len(), - MatchesCount: len(m.matches), - SelectedCount: len(m.choices), - WindowWidth: m.windowWidth, - Limit: m.option.limit, - NoLimit: m.option.noLimit, - })) + switch m.option.inputPosition { + case InputPositionTop: + // input + _, _ = v.WriteString(m.input.View()) + // count + if m.option.countViewEnabled { + _, _ = v.WriteRune('\n') + _, _ = v.WriteString(m.option.countViewFunc(CountViewMeta{ + ItemsCount: m.items.Len(), + MatchesCount: len(m.matches), + SelectedCount: len(m.choices), + WindowWidth: m.windowWidth, + Limit: m.option.limit, + NoLimit: m.option.noLimit, + })) + } + + case InputPositionBottom: + // count + if m.option.countViewEnabled { + _, _ = v.WriteString(m.option.countViewFunc(CountViewMeta{ + ItemsCount: m.items.Len(), + MatchesCount: len(m.matches), + SelectedCount: len(m.choices), + WindowWidth: m.windowWidth, + Limit: m.option.limit, + NoLimit: m.option.noLimit, + })) + _, _ = v.WriteRune('\n') + } + // input + _, _ = v.WriteString(m.input.View()) } return v.String() } -func (m *model) headerHeight() int { - return lipgloss.Height(m.headerView()) +func (m *model) inputHeight() int { + return lipgloss.Height(m.inputView()) } func (m *model) itemsView() string { var v strings.Builder - headerHeight := m.headerHeight() + inputHeight := m.inputHeight() - for i, match := range m.matches { - if i < m.windowYPosition { - continue - } - - cursorLine := m.cursorPosition == i + switch m.option.inputPosition { + case InputPositionTop: + for i, match := range m.matches { + if i < m.windowYPosition { + continue + } - // write cursor - if cursorLine { - _, _ = v.WriteString(m.cursor) - } else { - _, _ = v.WriteString(m.nocursor) + cursorLine := m.cursorPosition == i + m.writeItem(&v, match, cursorLine) + if i+1-m.windowYPosition >= m.windowHeight-inputHeight { + break + } + v.WriteRune('\n') } + case InputPositionBottom: + for i := len(m.matches) - 1; i >= 0; i-- { + if len(m.matches)-i+m.windowHeight-inputHeight < m.windowYPosition { + continue + } - // write toggle - if m.option.multiple() { - if intContains(m.choices, match.Index) { - _, _ = v.WriteString(m.selectedPrefix) - } else { - _, _ = v.WriteString(m.unselectedPrefix) + match := m.matches[i] + cursorLine := m.cursorPosition == i + m.writeItem(&v, match, cursorLine) + if i-1 < m.windowYPosition { + break } + v.WriteRune('\n') } + } - // write item prefix - if m.findOption.itemPrefixFunc != nil { - _, _ = v.WriteString(stringLinesToSpace(m.findOption.itemPrefixFunc(match.Index))) + return v.String() +} + +func (m *model) writeItem(v *strings.Builder, match Match, cursorLine bool) { + // write cursor + if cursorLine { + _, _ = v.WriteString(m.cursor) + } else { + _, _ = v.WriteString(m.nocursor) + } + + // write toggle + if m.option.multiple() { + if intContains(m.choices, match.Index) { + _, _ = v.WriteString(m.selectedPrefix) + } else { + _, _ = v.WriteString(m.unselectedPrefix) } + } - // write item - for ci, c := range match.Str { - // matches - if intContains(match.MatchedIndexes, ci) { - if cursorLine { - _, _ = v.WriteString(m.cursorLineMatchesStyle.Render(string(c))) - } else { - _, _ = v.WriteString(m.matchesStyle.Render(string(c))) - } - } else if cursorLine { - _, _ = v.WriteString(m.cursorLineStyle.Render(string(c))) + // write item prefix + if m.findOption.itemPrefixFunc != nil { + _, _ = v.WriteString(stringLinesToSpace(m.findOption.itemPrefixFunc(match.Index))) + } + + // write item + for ci, c := range match.Str { + // matches + if intContains(match.MatchedIndexes, ci) { + if cursorLine { + _, _ = v.WriteString(m.cursorLineMatchesStyle.Render(string(c))) } else { - _, _ = v.WriteRune(c) + _, _ = v.WriteString(m.matchesStyle.Render(string(c))) } + } else if cursorLine { + _, _ = v.WriteString(m.cursorLineStyle.Render(string(c))) + } else { + _, _ = v.WriteRune(c) } - - if i+1-m.windowYPosition >= m.windowHeight-headerHeight { - break - } - v.WriteString("\n") } - - return v.String() } /* @@ -245,12 +294,22 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.toggle() case key.Matches(msg, m.option.keymap.Up): // up - m.cursorUp() + switch m.option.inputPosition { + case InputPositionTop: + m.cursorUp() + case InputPositionBottom: + m.cursorDown() + } m.fixYPosition() m.fixCursor() case key.Matches(msg, m.option.keymap.Down): // down - m.cursorDown() + switch m.option.inputPosition { + case InputPositionTop: + m.cursorDown() + case InputPositionBottom: + m.cursorUp() + } m.fixYPosition() m.fixCursor() } @@ -355,9 +414,9 @@ func (m *model) fixCursor() { } func (m *model) fixYPosition() { - headerHeight := m.headerHeight() + inputHeight := m.inputHeight() - if m.windowHeight-headerHeight > len(m.matches) { + if m.windowHeight-inputHeight > len(m.matches) { m.windowYPosition = 0 return } @@ -367,8 +426,8 @@ func (m *model) fixYPosition() { return } - if m.cursorPosition+1 >= (m.windowHeight-headerHeight)+m.windowYPosition { - m.windowYPosition = max(m.cursorPosition+1-(m.windowHeight-headerHeight), 0) + if m.cursorPosition+1 >= (m.windowHeight-inputHeight)+m.windowYPosition { + m.windowYPosition = max(m.cursorPosition+1-(m.windowHeight-inputHeight), 0) return } } diff --git a/option.go b/option.go index d732f03..ef64d78 100644 --- a/option.go +++ b/option.go @@ -1,6 +1,7 @@ package fzf import ( + "fmt" "strconv" "strings" "sync" @@ -28,6 +29,8 @@ var defaultOption = option{ Abort: key.NewBinding(key.WithKeys("ctrl+c", "esc")), }, + inputPosition: InputPositionTop, + countViewEnabled: true, countViewFunc: func(meta CountViewMeta) string { var v strings.Builder @@ -66,6 +69,24 @@ type CountViewMeta struct { NoLimit bool } +// InputPosition represents the position of input. +type InputPosition string + +const ( + InputPositionTop InputPosition = "top" + InputPositionBottom InputPosition = "bottom" +) + +// Valid validates the value of InputPosition. +func (p InputPosition) Valid() error { + switch p { + case InputPositionTop, InputPositionBottom: + return nil + default: + return fmt.Errorf("invalid input position: %s", p) + } +} + type option struct { limit int noLimit bool @@ -80,6 +101,8 @@ type option struct { keymap *keymap + inputPosition InputPosition + countViewEnabled bool countViewFunc func(meta CountViewMeta) string @@ -197,3 +220,10 @@ func WithCaseSensitive(s bool) Option { o.caseSensitive = s } } + +// WithInputPosition sets the position of input. +func WithInputPosition(p InputPosition) Option { + return func(o *option) { + o.inputPosition = p + } +} diff --git a/tapes/library/input-position.tape b/tapes/library/input-position.tape new file mode 100644 index 0000000..b378122 --- /dev/null +++ b/tapes/library/input-position.tape @@ -0,0 +1,39 @@ +# configuration +Output ./examples/input-position/demo.gif +Set Shell "bash" +Set FontSize 32 +Set Width 1200 +Set Height 600 + +# setup +Hide +Type "mkdir ./tmp" Enter +Type "cp ./examples/input-position/main.go ./tmp/main.go" Enter +Type "cd ./tmp" Enter +Ctrl+l +Show + +# --- + +Type "go run ./main.go" Sleep 750ms Enter +Sleep 2s + +Up 2 +Sleep 1s + +Down 2 +Sleep 1s + +Type "world" +Sleep 750ms + +Enter + +Sleep 3s + +# --- + +# cleanup +Hide +Type "cd ../" Enter +Type "\rm -rf ./tmp" Enter