Refine mouse interactions and info pane UX
This commit is contained in:
parent
3d26d835af
commit
ef63a2479b
4 changed files with 367 additions and 47 deletions
|
|
@ -27,7 +27,7 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
program := tea.NewProgram(model, tea.WithAltScreen())
|
program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
if _, err := program.Run(); err != nil {
|
if _, err := program.Run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "runtime error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "runtime error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,13 @@ func ShortTime(t time.Time) string {
|
||||||
return t.Format("2006-01-02 15:04")
|
return t.Format("2006-01-02 15:04")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CompactTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return "n/a"
|
||||||
|
}
|
||||||
|
return t.Format("01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
func Permissions(mode fs.FileMode) string {
|
func Permissions(mode fs.FileMode) string {
|
||||||
return mode.String()
|
return mode.String()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/help"
|
"github.com/charmbracelet/bubbles/help"
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
|
@ -72,6 +73,18 @@ type opMsg struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mouseClickState struct {
|
||||||
|
pane PaneID
|
||||||
|
index int
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type hoverState struct {
|
||||||
|
pane PaneID
|
||||||
|
index int
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
configPath string
|
configPath string
|
||||||
|
|
@ -93,6 +106,9 @@ type Model struct {
|
||||||
modal modalState
|
modal modalState
|
||||||
status string
|
status string
|
||||||
busy bool
|
busy bool
|
||||||
|
|
||||||
|
lastClick mouseClickState
|
||||||
|
hover hoverState
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(cfg config.Config, configPath string) (Model, error) {
|
func NewModel(cfg config.Config, configPath string) (Model, error) {
|
||||||
|
|
@ -195,10 +211,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.status = fmt.Sprintf("Created %s", msg.targetPath)
|
m.status = fmt.Sprintf("Created %s", msg.targetPath)
|
||||||
case opEdit:
|
case opEdit:
|
||||||
m.status = "Editor closed"
|
m.status = "Editor closed"
|
||||||
return m, m.loadPreviewCmd()
|
return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd())
|
||||||
case opView:
|
case opView:
|
||||||
m.status = "Viewer closed"
|
m.status = "Viewer closed"
|
||||||
return m, nil
|
return m, enableMouseCmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
activeSelection := selectedName(m.activePane())
|
activeSelection := selectedName(m.activePane())
|
||||||
|
|
@ -247,11 +263,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.moveCursor(max(m.bodyHeight()-6, 5))
|
m.moveCursor(max(m.bodyHeight()-6, 5))
|
||||||
return m, m.loadPreviewCmd()
|
return m, m.loadPreviewCmd()
|
||||||
case key.Matches(msg, m.keys.Open):
|
case key.Matches(msg, m.keys.Open):
|
||||||
if err := m.enterSelected(); err != nil {
|
return m.handleOpenSelected()
|
||||||
m.status = err.Error()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, m.loadPreviewCmd()
|
|
||||||
case key.Matches(msg, m.keys.Back):
|
case key.Matches(msg, m.keys.Back):
|
||||||
if err := m.goParent(); err != nil {
|
if err := m.goParent(); err != nil {
|
||||||
m.status = err.Error()
|
m.status = err.Error()
|
||||||
|
|
@ -271,6 +283,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
case key.Matches(msg, m.keys.Delete):
|
case key.Matches(msg, m.keys.Delete):
|
||||||
return m.handleDelete()
|
return m.handleDelete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case tea.MouseMsg:
|
||||||
|
return m.handleMouse(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
@ -293,7 +308,7 @@ func (m Model) View() string {
|
||||||
if m.active == PaneLeft {
|
if m.active == PaneLeft {
|
||||||
panels = lipgloss.JoinHorizontal(
|
panels = lipgloss.JoinHorizontal(
|
||||||
lipgloss.Top,
|
lipgloss.Top,
|
||||||
renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true),
|
renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft)),
|
||||||
gap,
|
gap,
|
||||||
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight),
|
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight),
|
||||||
)
|
)
|
||||||
|
|
@ -302,15 +317,15 @@ func (m Model) View() string {
|
||||||
lipgloss.Top,
|
lipgloss.Top,
|
||||||
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight),
|
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight),
|
||||||
gap,
|
gap,
|
||||||
renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true),
|
renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
panels = lipgloss.JoinHorizontal(
|
panels = lipgloss.JoinHorizontal(
|
||||||
lipgloss.Top,
|
lipgloss.Top,
|
||||||
renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft),
|
renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft, m.hoverIndexFor(PaneLeft)),
|
||||||
gap,
|
gap,
|
||||||
renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight),
|
renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight, m.hoverIndexFor(PaneRight)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,9 +423,11 @@ func (m *Model) refreshAllPanes(status string) (tea.Model, tea.Cmd) {
|
||||||
func (m *Model) moveCursor(delta int) {
|
func (m *Model) moveCursor(delta int) {
|
||||||
pane := m.activePane()
|
pane := m.activePane()
|
||||||
pane.Move(delta, max(m.bodyHeight()-4, 1))
|
pane.Move(delta, max(m.bodyHeight()-4, 1))
|
||||||
|
m.hover = hoverState{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) enterSelected() error {
|
func (m *Model) enterSelected() error {
|
||||||
|
m.hover = hoverState{}
|
||||||
pane := m.activePane()
|
pane := m.activePane()
|
||||||
selected, ok := pane.Selected()
|
selected, ok := pane.Selected()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -429,7 +446,28 @@ func (m *Model) enterSelected() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
|
||||||
|
selected, ok := m.activePane().Selected()
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected.IsDir {
|
||||||
|
if err := m.enterSelected(); err != nil {
|
||||||
|
m.status = err.Error()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, m.loadPreviewCmd()
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEditableEntry(selected) {
|
||||||
|
return m.handleEdit()
|
||||||
|
}
|
||||||
|
return m.handleOpenExternal()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) goParent() error {
|
func (m *Model) goParent() error {
|
||||||
|
m.hover = hoverState{}
|
||||||
pane := m.activePane()
|
pane := m.activePane()
|
||||||
parent := filepath.Dir(pane.Path)
|
parent := filepath.Dir(pane.Path)
|
||||||
if parent == pane.Path {
|
if parent == pane.Path {
|
||||||
|
|
@ -573,6 +611,25 @@ func (m *Model) handleView() (tea.Model, tea.Cmd) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleOpenExternal() (tea.Model, tea.Cmd) {
|
||||||
|
selected, ok := m.activePane().Selected()
|
||||||
|
if !ok || selected.IsParent || selected.IsDir {
|
||||||
|
m.status = "Select a file to open"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
command, name, err := externalCommand("", []string{"xdg-open", "open"}, selected.Path)
|
||||||
|
if err != nil {
|
||||||
|
m.status = "No system opener found (tried xdg-open/open)"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name)
|
||||||
|
return m, tea.ExecProcess(command, func(err error) tea.Msg {
|
||||||
|
return opMsg{kind: opView, sourcePath: selected.Path, err: err}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) handleEdit() (tea.Model, tea.Cmd) {
|
func (m *Model) handleEdit() (tea.Model, tea.Cmd) {
|
||||||
selected, ok := m.activePane().Selected()
|
selected, ok := m.activePane().Selected()
|
||||||
if !ok || selected.IsParent || selected.IsDir {
|
if !ok || selected.IsParent || selected.IsDir {
|
||||||
|
|
@ -580,9 +637,9 @@ func (m *Model) handleEdit() (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
command, name, err := externalCommand("EDITOR", []string{"nvim", "vim", "vi", "nano"}, selected.Path)
|
command, name, err := externalCommandFromEnv([]string{"VISUAL", "EDITOR"}, []string{"nvim", "vim", "vi", "nano"}, selected.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.status = "Set $EDITOR or install nvim/vim/vi/nano to enable F4 editing"
|
m.status = "Set $VISUAL/$EDITOR or install nvim/vim/vi/nano to enable F4 editing"
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -592,6 +649,89 @@ func (m *Model) handleEdit() (tea.Model, tea.Cmd) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case msg.Action == tea.MouseActionMotion:
|
||||||
|
paneID, index, ok := m.mouseTarget(msg.X, msg.Y)
|
||||||
|
if ok {
|
||||||
|
m.hover = hoverState{pane: paneID, index: index, ok: true}
|
||||||
|
} else {
|
||||||
|
m.hover = hoverState{}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelUp:
|
||||||
|
if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) {
|
||||||
|
m.previewModel.LineUp(3)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.moveCursor(-1)
|
||||||
|
return m, m.loadPreviewCmd()
|
||||||
|
case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelDown:
|
||||||
|
if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) {
|
||||||
|
m.previewModel.LineDown(3)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.moveCursor(1)
|
||||||
|
return m, m.loadPreviewCmd()
|
||||||
|
case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft:
|
||||||
|
paneID, index, ok := m.mouseTarget(msg.X, msg.Y)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.hover = hoverState{pane: paneID, index: index, ok: true}
|
||||||
|
m.active = paneID
|
||||||
|
pane := m.paneByID(paneID)
|
||||||
|
if index >= 0 && index < len(pane.Entries) {
|
||||||
|
pane.Cursor = index
|
||||||
|
pane.EnsureVisible(max(m.bodyHeight()-4, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
doubleClick := m.lastClick.pane == paneID && m.lastClick.index == index && now.Sub(m.lastClick.at) <= 450*time.Millisecond
|
||||||
|
m.lastClick = mouseClickState{pane: paneID, index: index, at: now}
|
||||||
|
if doubleClick {
|
||||||
|
return m.handleOpenSelected()
|
||||||
|
}
|
||||||
|
m.status = fmt.Sprintf("Selected %s pane", strings.ToUpper(string(paneID)))
|
||||||
|
return m, m.loadPreviewCmd()
|
||||||
|
case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonRight:
|
||||||
|
if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) {
|
||||||
|
m.infoMode = false
|
||||||
|
m.resizePreview()
|
||||||
|
m.syncPreviewContent()
|
||||||
|
m.status = "Info mode: off"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
paneID, index, ok := m.mouseTarget(msg.X, msg.Y)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.infoMode && paneID == m.active && index == m.activePane().Cursor {
|
||||||
|
m.infoMode = false
|
||||||
|
m.hover = hoverState{pane: paneID, index: index, ok: true}
|
||||||
|
m.status = "Info mode: off"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.hover = hoverState{pane: paneID, index: index, ok: true}
|
||||||
|
m.active = paneID
|
||||||
|
pane := m.paneByID(paneID)
|
||||||
|
if index >= 0 && index < len(pane.Entries) {
|
||||||
|
pane.Cursor = index
|
||||||
|
pane.EnsureVisible(max(m.bodyHeight()-4, 1))
|
||||||
|
}
|
||||||
|
m.infoMode = true
|
||||||
|
m.resizePreview()
|
||||||
|
m.syncPreviewContent()
|
||||||
|
m.status = fmt.Sprintf("Info mode: %s selection", strings.ToUpper(string(paneID)))
|
||||||
|
return m, m.loadPreviewCmd()
|
||||||
|
default:
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) toggleInfo() (tea.Model, tea.Cmd) {
|
func (m *Model) toggleInfo() (tea.Model, tea.Cmd) {
|
||||||
m.infoMode = !m.infoMode
|
m.infoMode = !m.infoMode
|
||||||
m.resizePreview()
|
m.resizePreview()
|
||||||
|
|
@ -774,27 +914,34 @@ func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg c
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int) string {
|
func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int) string {
|
||||||
rows := []string{
|
leftRows := []string{
|
||||||
fmt.Sprintf("kind: %s", fallback(meta.Kind, "n/a")),
|
fmt.Sprintf("kind: %s", fallback(meta.Kind, "n/a")),
|
||||||
fmt.Sprintf("size: %s", metaSize(meta)),
|
fmt.Sprintf("size: %s", metaSize(meta)),
|
||||||
fmt.Sprintf("created: %s", fallback(meta.CreatedAt, "n/a")),
|
fmt.Sprintf("created: %s", fallback(meta.CreatedAt, "n/a")),
|
||||||
|
}
|
||||||
|
rightRows := []string{
|
||||||
fmt.Sprintf("modified: %s", fallback(meta.ModifiedAt, "n/a")),
|
fmt.Sprintf("modified: %s", fallback(meta.ModifiedAt, "n/a")),
|
||||||
fmt.Sprintf("mode: %s", fallback(meta.Permissions, "n/a")),
|
fmt.Sprintf("mode: %s", fallback(meta.Permissions, "n/a")),
|
||||||
}
|
}
|
||||||
if meta.ImageFormat != "" {
|
if meta.ImageFormat != "" {
|
||||||
rows = append(rows, fmt.Sprintf("image: %s %s", meta.ImageFormat, meta.ImageSize))
|
rightRows = append(rightRows, fmt.Sprintf("image: %s %s", meta.ImageFormat, meta.ImageSize))
|
||||||
}
|
}
|
||||||
rows = append(rows, fmt.Sprintf("path: %s", truncateMiddle(meta.Path, max(width-10, 12))))
|
|
||||||
|
|
||||||
leftWidth := max(width/2, 18)
|
leftWidth := max(width/2, 18)
|
||||||
|
rightWidth := max(width-leftWidth, 18)
|
||||||
left := lipgloss.NewStyle().
|
left := lipgloss.NewStyle().
|
||||||
Width(leftWidth).
|
Width(leftWidth).
|
||||||
Foreground(palette.Muted).
|
Foreground(palette.Muted).
|
||||||
Render(strings.Join(rows[:min(3, len(rows))], "\n"))
|
Render(strings.Join(leftRows, "\n"))
|
||||||
right := lipgloss.NewStyle().
|
right := lipgloss.NewStyle().
|
||||||
Width(width - leftWidth).
|
Width(rightWidth).
|
||||||
Foreground(palette.Text).
|
Foreground(palette.Text).
|
||||||
Render(strings.Join(rows[min(3, len(rows)):], "\n"))
|
Render(strings.Join(rightRows, "\n"))
|
||||||
|
|
||||||
|
pathLine := lipgloss.NewStyle().
|
||||||
|
Width(width).
|
||||||
|
Foreground(palette.Text).
|
||||||
|
Render(fmt.Sprintf("path: %s", truncateMiddle(meta.Path, max(width-8, 16))))
|
||||||
|
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Width(width).
|
Width(width).
|
||||||
|
|
@ -803,7 +950,12 @@ func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int) string
|
||||||
BorderStyle(lipgloss.NormalBorder()).
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
BorderBottom(true).
|
BorderBottom(true).
|
||||||
BorderForeground(palette.Border).
|
BorderForeground(palette.Border).
|
||||||
Render(lipgloss.JoinHorizontal(lipgloss.Top, left, right))
|
Render(lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
lipgloss.JoinHorizontal(lipgloss.Top, left, right),
|
||||||
|
"",
|
||||||
|
pathLine,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTitleBar(m Model) string {
|
func renderTitleBar(m Model) string {
|
||||||
|
|
@ -1052,28 +1204,54 @@ func operationVerb(kind fileOpKind) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func externalCommand(envVar string, fallbacks []string, path string) (*exec.Cmd, string, error) {
|
func externalCommand(envVar string, fallbacks []string, path string) (*exec.Cmd, string, error) {
|
||||||
commandLine := strings.TrimSpace(os.Getenv(envVar))
|
envVars := []string{}
|
||||||
|
if envVar != "" {
|
||||||
|
envVars = append(envVars, envVar)
|
||||||
|
}
|
||||||
|
return externalCommandFromEnv(envVars, fallbacks, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalCommandFromEnv(envVars []string, fallbacks []string, path string) (*exec.Cmd, string, error) {
|
||||||
|
commandLine := ""
|
||||||
|
source := "fallbacks"
|
||||||
|
for _, envVar := range envVars {
|
||||||
|
commandLine = strings.TrimSpace(os.Getenv(envVar))
|
||||||
|
if commandLine != "" {
|
||||||
|
source = envVar
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if commandLine == "" {
|
if commandLine == "" {
|
||||||
for _, candidate := range fallbacks {
|
for _, candidate := range fallbacks {
|
||||||
if resolved, err := exec.LookPath(candidate); err == nil {
|
if resolved, err := exec.LookPath(candidate); err == nil {
|
||||||
commandLine = resolved
|
commandLine = resolved
|
||||||
|
source = candidate
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if commandLine == "" {
|
if commandLine == "" {
|
||||||
return nil, "", fmt.Errorf("no command for %s", envVar)
|
if len(envVars) > 0 {
|
||||||
|
return nil, "", fmt.Errorf("no command for %s", strings.Join(envVars, "/"))
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("no fallback command found")
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Fields(commandLine)
|
parts := strings.Fields(commandLine)
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return nil, "", fmt.Errorf("invalid command for %s", envVar)
|
return nil, "", fmt.Errorf("invalid command for %s", source)
|
||||||
}
|
}
|
||||||
|
|
||||||
args := append(parts[1:], path)
|
args := append(parts[1:], path)
|
||||||
return exec.Command(parts[0], args...), filepath.Base(parts[0]), nil
|
return exec.Command(parts[0], args...), filepath.Base(parts[0]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func enableMouseCmd() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return tea.EnableMouseCellMotion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resolveStartPath(raw string, fallback string) (string, error) {
|
func resolveStartPath(raw string, fallback string) (string, error) {
|
||||||
value := strings.TrimSpace(raw)
|
value := strings.TrimSpace(raw)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|
@ -1113,3 +1291,99 @@ func max(a, b int) int {
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) mouseTarget(x, y int) (PaneID, int, bool) {
|
||||||
|
if m.width <= 0 || m.height <= 0 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
leftWidth, previewWidth, rightWidth := m.layoutWidths()
|
||||||
|
top := 0
|
||||||
|
if m.cfg.UI.ShowTitleBar {
|
||||||
|
top++
|
||||||
|
}
|
||||||
|
bodyHeight := m.bodyHeight()
|
||||||
|
if y < top || y >= top+bodyHeight {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
gap := m.cfg.UI.PaneGap
|
||||||
|
leftStart := 0
|
||||||
|
rightStart := leftWidth + gap
|
||||||
|
if m.infoMode && m.active == PaneRight {
|
||||||
|
rightStart = previewWidth + gap
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case x >= leftStart && x < leftStart+leftWidth:
|
||||||
|
if m.infoMode && m.active == PaneRight {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
return PaneLeft, paneIndexFromMouse(y-top, bodyHeight, &m.left), true
|
||||||
|
case x >= rightStart && x < rightStart+rightWidth:
|
||||||
|
if m.infoMode && m.active == PaneLeft {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
return PaneRight, paneIndexFromMouse(y-top, bodyHeight, &m.right), true
|
||||||
|
default:
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paneIndexFromMouse(localY int, height int, pane *BrowserPane) int {
|
||||||
|
if localY < 1 || localY >= height-1 {
|
||||||
|
return pane.Cursor
|
||||||
|
}
|
||||||
|
row := localY - 1
|
||||||
|
index := pane.Offset + row
|
||||||
|
if index < 0 {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
if index >= len(pane.Entries) {
|
||||||
|
index = len(pane.Entries) - 1
|
||||||
|
}
|
||||||
|
if index < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEditableEntry(entry vfs.Entry) bool {
|
||||||
|
switch entry.Category() {
|
||||||
|
case "text", "config", "executable":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) hoverIndexFor(pane PaneID) int {
|
||||||
|
if m.hover.ok && m.hover.pane == pane {
|
||||||
|
return m.hover.index
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) mouseOverPreview(x, y int) bool {
|
||||||
|
if !m.infoMode || m.width <= 0 || m.height <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
leftWidth, previewWidth, _ := m.layoutWidths()
|
||||||
|
top := 0
|
||||||
|
if m.cfg.UI.ShowTitleBar {
|
||||||
|
top++
|
||||||
|
}
|
||||||
|
bodyHeight := m.bodyHeight()
|
||||||
|
if y < top || y >= top+bodyHeight {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
gap := m.cfg.UI.PaneGap
|
||||||
|
if m.active == PaneLeft {
|
||||||
|
startX := leftWidth + gap
|
||||||
|
return x >= startX && x < startX+previewWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
return x >= 0 && x < previewWidth
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ func renderPane(
|
||||||
width int,
|
width int,
|
||||||
height int,
|
height int,
|
||||||
active bool,
|
active bool,
|
||||||
|
hoverIndex int,
|
||||||
) string {
|
) string {
|
||||||
if width <= 0 || height <= 0 {
|
if width <= 0 || height <= 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -127,7 +128,7 @@ func renderPane(
|
||||||
|
|
||||||
rowsHeight := max(height-4, 1)
|
rowsHeight := max(height-4, 1)
|
||||||
headerRow := renderColumnsHeader(cfg, innerWidth, palette)
|
headerRow := renderColumnsHeader(cfg, innerWidth, palette)
|
||||||
rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active)
|
rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex)
|
||||||
content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows)
|
content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows)
|
||||||
return box.Render(content)
|
return box.Render(content)
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +161,7 @@ func renderPaneHeader(pane BrowserPane, cfg config.Config, palette theme.Palette
|
||||||
func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette) string {
|
func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette) string {
|
||||||
columns := buildColumns(cfg, width)
|
columns := buildColumns(cfg, width)
|
||||||
parts := make([]string, 0, len(columns))
|
parts := make([]string, 0, len(columns))
|
||||||
for _, column := range columns {
|
for idx, column := range columns {
|
||||||
style := lipgloss.NewStyle().
|
style := lipgloss.NewStyle().
|
||||||
Width(column.Width).
|
Width(column.Width).
|
||||||
Foreground(palette.Muted).
|
Foreground(palette.Muted).
|
||||||
|
|
@ -169,14 +170,17 @@ func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette) st
|
||||||
style = style.Align(lipgloss.Right)
|
style = style.Align(lipgloss.Right)
|
||||||
}
|
}
|
||||||
parts = append(parts, style.Render(truncateRight(column.Title, column.Width)))
|
parts = append(parts, style.Render(truncateRight(column.Title, column.Width)))
|
||||||
|
if idx < len(columns)-1 {
|
||||||
|
parts = append(parts, columnSeparator(column.Key, palette, lipgloss.Color("")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Width(width).
|
Width(width).
|
||||||
Background(palette.Panel).
|
Background(palette.Panel).
|
||||||
Render(strings.Join(parts, " "))
|
Render(strings.Join(parts, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool) string {
|
func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool, hoverIndex int) string {
|
||||||
if len(pane.Entries) == 0 {
|
if len(pane.Entries) == 0 {
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Width(width).
|
Width(width).
|
||||||
|
|
@ -193,7 +197,7 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette,
|
||||||
lines := make([]string, 0, visibleHeight)
|
lines := make([]string, 0, visibleHeight)
|
||||||
for idx := pane.Offset; idx < end; idx++ {
|
for idx := pane.Offset; idx < end; idx++ {
|
||||||
entry := pane.Entries[idx]
|
entry := pane.Entries[idx]
|
||||||
row := renderEntryRow(entry, cfg, width, idx == pane.Cursor, active, palette)
|
row := renderEntryRow(entry, cfg, width, idx == pane.Cursor, idx == hoverIndex, active, palette)
|
||||||
lines = append(lines, row)
|
lines = append(lines, row)
|
||||||
}
|
}
|
||||||
for len(lines) < visibleHeight {
|
for len(lines) < visibleHeight {
|
||||||
|
|
@ -205,14 +209,25 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette,
|
||||||
Render(strings.Join(lines, "\n"))
|
Render(strings.Join(lines, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, active bool, palette theme.Palette) string {
|
func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, hovered bool, active bool, palette theme.Palette) string {
|
||||||
columns := buildColumns(cfg, width)
|
columns := buildColumns(cfg, width)
|
||||||
|
rowBackground := lipgloss.Color("")
|
||||||
|
switch {
|
||||||
|
case selected:
|
||||||
|
rowBackground = palette.Selection
|
||||||
|
case hovered:
|
||||||
|
rowBackground = palette.PanelElevated
|
||||||
|
}
|
||||||
|
|
||||||
parts := make([]string, 0, len(columns))
|
parts := make([]string, 0, len(columns))
|
||||||
for _, column := range columns {
|
for idx, column := range columns {
|
||||||
value := column.Value(entry, cfg.Browser.HumanReadableSize)
|
value := column.Value(entry, cfg.Browser.HumanReadableSize)
|
||||||
style := lipgloss.NewStyle().
|
style := lipgloss.NewStyle().
|
||||||
Width(column.Width).
|
Width(column.Width).
|
||||||
Foreground(entryColor(entry, palette))
|
Foreground(entryColor(entry, palette))
|
||||||
|
if rowBackground != lipgloss.Color("") {
|
||||||
|
style = style.Background(rowBackground)
|
||||||
|
}
|
||||||
|
|
||||||
if entry.IsHidden {
|
if entry.IsHidden {
|
||||||
style = style.Foreground(palette.Muted)
|
style = style.Foreground(palette.Muted)
|
||||||
|
|
@ -221,17 +236,20 @@ func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool
|
||||||
style = style.Align(lipgloss.Right)
|
style = style.Align(lipgloss.Right)
|
||||||
}
|
}
|
||||||
parts = append(parts, style.Render(truncateForColumn(value, column.Width, column.AlignRight)))
|
parts = append(parts, style.Render(truncateForColumn(value, column.Width, column.AlignRight)))
|
||||||
}
|
if idx < len(columns)-1 {
|
||||||
|
parts = append(parts, columnSeparator(column.Key, palette, rowBackground))
|
||||||
rowStyle := lipgloss.NewStyle().Width(width)
|
|
||||||
if selected {
|
|
||||||
rowStyle = rowStyle.Background(palette.Selection)
|
|
||||||
if active {
|
|
||||||
rowStyle = rowStyle.Bold(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return rowStyle.Render(strings.Join(parts, " "))
|
rowStyle := lipgloss.NewStyle().Width(width)
|
||||||
|
if rowBackground != lipgloss.Color("") {
|
||||||
|
rowStyle = rowStyle.Background(rowBackground)
|
||||||
|
}
|
||||||
|
if selected && active {
|
||||||
|
rowStyle = rowStyle.Bold(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowStyle.Render(strings.Join(parts, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
type columnSpec struct {
|
type columnSpec struct {
|
||||||
|
|
@ -299,13 +317,13 @@ func buildColumns(cfg config.Config, totalWidth int) []columnSpec {
|
||||||
fixed = append(fixed, columnSpec{
|
fixed = append(fixed, columnSpec{
|
||||||
Key: "created",
|
Key: "created",
|
||||||
Title: "Created",
|
Title: "Created",
|
||||||
Width: 16,
|
Width: 11,
|
||||||
MinWidth: 10,
|
MinWidth: 8,
|
||||||
Value: func(entry vfs.Entry, _ bool) string {
|
Value: func(entry vfs.Entry, _ bool) string {
|
||||||
if !entry.CreatedKnown {
|
if !entry.CreatedKnown {
|
||||||
return "n/a"
|
return "n/a"
|
||||||
}
|
}
|
||||||
return vfs.ShortTime(entry.CreatedAt)
|
return vfs.CompactTime(entry.CreatedAt)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -313,16 +331,19 @@ func buildColumns(cfg config.Config, totalWidth int) []columnSpec {
|
||||||
fixed = append(fixed, columnSpec{
|
fixed = append(fixed, columnSpec{
|
||||||
Key: "modified",
|
Key: "modified",
|
||||||
Title: "Modified",
|
Title: "Modified",
|
||||||
Width: 16,
|
Width: 11,
|
||||||
MinWidth: 10,
|
MinWidth: 8,
|
||||||
Value: func(entry vfs.Entry, _ bool) string {
|
Value: func(entry vfs.Entry, _ bool) string {
|
||||||
return vfs.ShortTime(entry.ModifiedAt)
|
return vfs.CompactTime(entry.ModifiedAt)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
minNameWidth := 12
|
minNameWidth := 4
|
||||||
gaps := max(len(fixed), 0)
|
gaps := 0
|
||||||
|
for _, column := range fixed {
|
||||||
|
gaps += separatorWidth(column.Key)
|
||||||
|
}
|
||||||
availableForColumns := totalWidth - gaps
|
availableForColumns := totalWidth - gaps
|
||||||
if availableForColumns < minNameWidth {
|
if availableForColumns < minNameWidth {
|
||||||
availableForColumns = minNameWidth
|
availableForColumns = minNameWidth
|
||||||
|
|
@ -360,6 +381,24 @@ func buildColumns(cfg config.Config, totalWidth int) []columnSpec {
|
||||||
return append([]columnSpec{name}, fixed...)
|
return append([]columnSpec{name}, fixed...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func columnSeparator(columnKey string, palette theme.Palette, background lipgloss.Color) string {
|
||||||
|
width := separatorWidth(columnKey)
|
||||||
|
style := lipgloss.NewStyle().
|
||||||
|
Width(width).
|
||||||
|
Foreground(palette.Border)
|
||||||
|
if background != lipgloss.Color("") {
|
||||||
|
style = style.Background(background)
|
||||||
|
}
|
||||||
|
return style.Render(strings.Repeat(" ", width))
|
||||||
|
}
|
||||||
|
|
||||||
|
func separatorWidth(columnKey string) int {
|
||||||
|
if columnKey == "size" {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
func borderStyle(value string) lipgloss.Border {
|
func borderStyle(value string) lipgloss.Border {
|
||||||
switch strings.ToLower(value) {
|
switch strings.ToLower(value) {
|
||||||
case "double":
|
case "double":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue