make divider draggable + add space/h keys actions

This commit is contained in:
2026-01-04 02:14:26 +00:00
parent 8609cbd90c
commit 2caa47b8d0
3 changed files with 192 additions and 29 deletions
+47
View File
@@ -0,0 +1,47 @@
package main
// Layout constants to avoid magic numbers
const (
minFsTreeWidth = 5
minNoteViewWidth = 10
dividerWidth = 1
dragHitArea = 2 // +/- chars around divider
)
// calculateLayout computes the widths for the file tree and note view
// based on the terminal dimensions and the desired tree width.
// It returns the constrained tree width and the resulting note view width.
func calculateLayout(totalWidth, requestedTreeWidth int) (treeWidth, noteWidth int) {
treeWidth = requestedTreeWidth
// Default initial width if not set
if treeWidth == 0 {
treeWidth = totalWidth / 4
}
// Calculate maximum allowed width for the tree
// Total width - divider - minimum note view width
maxTreeWidth := totalWidth - dividerWidth - minNoteViewWidth
// Apply constraints
if treeWidth > maxTreeWidth {
treeWidth = maxTreeWidth
}
if treeWidth < minFsTreeWidth {
treeWidth = minFsTreeWidth
}
noteWidth = totalWidth - treeWidth - dividerWidth
// Safety check to ensure noteWidth is never negative
if noteWidth < 0 {
noteWidth = 0
}
return treeWidth, noteWidth
}
// isHoveringDivider checks if the mouse cursor is within the interaction area of the divider
func isHoveringDivider(mouseX, dividerPos int) bool {
return mouseX >= dividerPos-dragHitArea && mouseX <= dividerPos+dragHitArea
}
+75 -13
View File
@@ -41,6 +41,8 @@ type model struct {
rootPath string // path to load the tree from
loading bool
noteView *NoteView
isDragging bool
isHoveringDivider bool
}
func NewModel(rootPath string) *model {
@@ -78,8 +80,8 @@ func (m *model) loadTreeCmd(path string) tea.Cmd {
func (m *model) layout(width, height int) {
m.terminalWidth = width
m.terminalHeight = height
m.fsTreeWidth = width / 4
m.noteViewWidth = width - m.fsTreeWidth
m.fsTreeWidth, m.noteViewWidth = calculateLayout(width, m.fsTreeWidth)
}
func (m *model) Init() tea.Cmd {
@@ -132,17 +134,60 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "q", "ctrl+c":
return m, tea.Quit
}
var cmds []tea.Cmd
// Forward keyboard input to tree
var cmd tea.Cmd
if m.tree != nil {
_, cmd = m.tree.Update(msg)
_, cmd := m.tree.Update(msg)
cmds = append(cmds, cmd)
}
return m, cmd
// Forward keyboard input to noteView
_, cmd := m.noteView.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case tea.MouseMsg:
// Forward mouse input to tree
var cmd tea.Cmd
if msg.Action == tea.MouseActionRelease {
m.isDragging = false
}
if msg.Action == tea.MouseActionMotion {
m.isHoveringDivider = isHoveringDivider(msg.X, m.fsTreeWidth)
if m.isDragging {
m.fsTreeWidth, m.noteViewWidth = calculateLayout(m.terminalWidth, msg.X)
// Update children with new sizes
var cmds []tea.Cmd
if m.tree != nil {
_, cmd := m.tree.Update(tea.WindowSizeMsg{
Width: m.fsTreeWidth,
Height: m.terminalHeight,
})
cmds = append(cmds, cmd)
}
_, cmd := m.noteView.Update(tea.WindowSizeMsg{
Width: m.noteViewWidth,
Height: m.terminalHeight,
})
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
}
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
if isHoveringDivider(msg.X, m.fsTreeWidth) {
m.isDragging = true
return m, nil
}
}
// Forward mouse input to tree only if not dragging
var cmd tea.Cmd
if m.tree != nil && !m.isDragging {
_, cmd = m.tree.Update(msg)
}
return m, cmd
@@ -158,20 +203,37 @@ func (m model) View() string {
tree := m.tree.View()
tree = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
Height(m.terminalHeight).
Width(40).
Width(m.fsTreeWidth).
Align(lipgloss.Left).
PaddingRight(2).
BorderLeft(false).
BorderTop(false).
BorderBottom(false).
PaddingRight(1).
Render(tree)
var dividerChar string
if m.isDragging || m.isHoveringDivider {
dividerChar = "█"
} else {
dividerChar = "│"
}
// Repeat the character vertically to fill height
dividerLines := make([]string, m.terminalHeight)
for i := range dividerLines {
dividerLines[i] = dividerChar
}
divider := lipgloss.JoinVertical(lipgloss.Left, dividerLines...)
// Ensure divider has the correct height style applied (though JoinVertical does most of it)
divider = lipgloss.NewStyle().
Height(m.terminalHeight).
Render(divider)
notes := m.noteView.View()
full := lipgloss.JoinHorizontal(
lipgloss.Top,
tree,
divider,
notes,
)
return full
+61 -7
View File
@@ -8,6 +8,7 @@ package main
import (
"os"
"regexp"
"strings"
"github.com/charmbracelet/bubbles/viewport"
@@ -15,6 +16,14 @@ import (
"github.com/charmbracelet/glamour"
)
type ViewState int
const (
StateTitleOnly ViewState = iota
StateContent
StateHints
)
type NoteView struct {
// the actual content
path string
@@ -26,6 +35,7 @@ type NoteView struct {
loading bool
vp viewport.Model
mdRenderer *glamour.TermRenderer
viewState ViewState
}
// ================== messages ===================
@@ -49,6 +59,7 @@ func NewNoteView() *NoteView {
loading: true,
mdRenderer: mdRenderer,
vp: viewport.New(0, 0),
viewState: StateTitleOnly,
}
}
@@ -63,6 +74,19 @@ func (m *NoteView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.vp.Height = msg.Height
return m, nil
case tea.KeyMsg:
switch msg.String() {
case " ":
m.viewState = StateContent
m.vp.SetContent(m.renderNote())
case "h":
m.viewState = StateHints
m.vp.SetContent(m.renderNote())
}
var cmd tea.Cmd
m.vp, cmd = m.vp.Update(msg)
return m, cmd
case loadNote:
if m.path == msg.path {
return m, nil //noop
@@ -77,6 +101,7 @@ func (m *NoteView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.content = msg.content
m.hints = msg.hints
m.err = msg.err
m.viewState = StateTitleOnly
m.vp.SetContent(m.renderNote())
return m, nil
}
@@ -126,18 +151,47 @@ func fetchContent(path string) tea.Cmd {
}
func extractHints(content string) []string {
re := regexp.MustCompile(`(?s)\(hint:\s*(.*?)\)`)
matches := re.FindAllStringSubmatch(content, -1)
hints := make([]string, 0)
return hints // to add later
for _, match := range matches {
if len(match) > 1 {
hints = append(hints, match[1])
}
}
return hints
}
func (m NoteView) renderNote() string {
title, err1 := m.mdRenderer.Render(m.title)
content, err2 := m.mdRenderer.Render(m.content)
if err1 != nil || err2 != nil {
// some error return raw
return m.title + "\n\n" + m.content
if err1 != nil {
title = m.title + "\n\n"
}
return title + content
var body string
var err2 error
switch m.viewState {
case StateContent:
body, err2 = m.mdRenderer.Render(m.content)
case StateHints:
if len(m.hints) == 0 {
body = "No hints available."
} else {
// Format hints as a list
hintsList := ""
for _, h := range m.hints {
hintsList += "- " + h + "\n"
}
body, err2 = m.mdRenderer.Render(hintsList)
}
case StateTitleOnly:
body = "\n(Press 'space' to show content, 'h' to show hints)"
}
if err2 != nil {
return title + "\nError rendering content."
}
return title + body
}