diff --git a/layout.go b/layout.go new file mode 100644 index 0000000..fe3ee6c --- /dev/null +++ b/layout.go @@ -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 +} diff --git a/main.go b/main.go index bcef19e..a2593c4 100644 --- a/main.go +++ b/main.go @@ -32,15 +32,17 @@ this data in msg when returned to the update func is used to update the model */ type model struct { - width int - terminalWidth int - terminalHeight int - fsTreeWidth int - noteViewWidth int - tree *FsTree - rootPath string // path to load the tree from - loading bool - noteView *NoteView + width int + terminalWidth int + terminalHeight int + fsTreeWidth int + noteViewWidth int + tree *FsTree + 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 + 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 { + 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 diff --git a/notes.go b/notes.go index ecdd9fe..926ff1e 100644 --- a/notes.go +++ b/notes.go @@ -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 }