diff --git a/pkg/tui/chat.go b/pkg/tui/chat.go index 5155c64..f2be4e5 100644 --- a/pkg/tui/chat.go +++ b/pkg/tui/chat.go @@ -253,6 +253,7 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) { m.conversation = (*models.Conversation)(msg) cmds = append(cmds, m.loadMessages(m.conversation)) case msgMessagesLoaded: + m.selectedMessage = len(msg) - 1 m.setMessages(msg) m.updateContent() m.content.GotoBottom() @@ -407,7 +408,7 @@ func (m *chatModel) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) { m.selectedMessage-- m.updateContent() offset := m.messageOffsets[m.selectedMessage] - scrollIntoView(&m.content, offset, 0.1) + scrollIntoView(&m.content, offset, m.content.Height/2) } return true, nil case "ctrl+j": @@ -415,7 +416,7 @@ func (m *chatModel) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) { m.selectedMessage++ m.updateContent() offset := m.messageOffsets[m.selectedMessage] - scrollIntoView(&m.content, offset, 0.1) + scrollIntoView(&m.content, offset, m.content.Height/2) } return true, nil case "ctrl+r": @@ -441,7 +442,7 @@ func (m *chatModel) handleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) { m.selectedMessage = len(m.messages) - 1 } offset := m.messageOffsets[m.selectedMessage] - scrollIntoView(&m.content, offset, 0.1) + scrollIntoView(&m.content, offset, m.content.Height/2) } m.updateContent() m.input.Blur() diff --git a/pkg/tui/conversations.go b/pkg/tui/conversations.go index 39ab0e6..fb3b69b 100644 --- a/pkg/tui/conversations.go +++ b/pkg/tui/conversations.go @@ -29,7 +29,8 @@ type conversationsModel struct { basemodel conversations []loadedConversation - cursor int // index of the currently selected message message + cursor int // index of the currently selected conversation + itemOffsets []int // keeps track of the viewport y offset of each rendered item content viewport.Model } @@ -59,13 +60,33 @@ func (m *conversationsModel) handleInput(msg tea.KeyMsg) (bool, tea.Cmd) { case "j", "down": if m.cursor < len(m.conversations)-1 { m.cursor++ + if m.cursor == len(m.conversations)-1 { + // if last conversation, simply scroll to the bottom + m.content.GotoBottom() + } else { + // this hack positions the *next* conversatoin slightly + // *off* the screen, ensuring the entire m.cursor is shown, + // even if its height may not be constant due to wrapping. + scrollIntoView(&m.content, m.itemOffsets[m.cursor+1], -1) + } m.content.SetContent(m.renderConversationList()) + } else { + m.cursor = len(m.conversations) - 1 + m.content.GotoBottom() } return true, nil case "k", "up": if m.cursor > 0 { m.cursor-- + if m.cursor == 0 { + m.content.GotoTop() + } else { + scrollIntoView(&m.content, m.itemOffsets[m.cursor], 1) + } m.content.SetContent(m.renderConversationList()) + } else { + m.cursor = 0 + m.content.GotoTop() } return true, nil case "n": @@ -186,7 +207,6 @@ func (m *conversationsModel) renderConversationList() string { {"Older", now.Sub(time.Time{})}, } - // TODO: pick nice color categoryStyle := lipgloss.NewStyle(). MarginBottom(1). Foreground(lipgloss.Color("170")). @@ -201,8 +221,12 @@ func (m *conversationsModel) renderConversationList() string { untitledStyle := lipgloss.NewStyle().Faint(true).Italic(true) selectedStyle := lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("6")) + var currentOffset int var currentCategory string + m.itemOffsets = make([]int, len(m.conversations)) sb := &strings.Builder{} + sb.WriteRune('\n') + currentOffset += 1 for i, c := range m.conversations { lastReplyAge := now.Sub(c.lastReply.CreatedAt) @@ -217,7 +241,10 @@ func (m *conversationsModel) renderConversationList() string { // print the category if category != currentCategory { currentCategory = category - fmt.Fprintf(sb, "%s\n", categoryStyle.Render(currentCategory)) + heading := categoryStyle.Render(currentCategory) + sb.WriteString(heading) + currentOffset += height(heading) + sb.WriteRune('\n') } tStyle := titleStyle.Copy() @@ -234,13 +261,17 @@ func (m *conversationsModel) renderConversationList() string { title = ">" + title[1:] } - heading := fmt.Sprintf( + m.itemOffsets[i] = currentOffset + item := itemStyle.Render(fmt.Sprintf( "%s\n%s", title, - padding + ageStyle.Render(util.HumanTimeElapsedSince(lastReplyAge)), - ) - sb.WriteString(itemStyle.Render(heading)) - sb.WriteRune('\n') + padding+ageStyle.Render(util.HumanTimeElapsedSince(lastReplyAge)), + )) + sb.WriteString(item) + currentOffset += height(item) + if i < len(m.conversations)-1 { + sb.WriteRune('\n') + } } return sb.String() } diff --git a/pkg/tui/util.go b/pkg/tui/util.go index f236f50..3a6a8a5 100644 --- a/pkg/tui/util.go +++ b/pkg/tui/util.go @@ -73,7 +73,7 @@ func truncateToCellWidth(str string, width int, tail string) string { // fraction is the fraction of the total screen height into view the offset // should be scrolled into view. 0.5 = items will be snapped to middle of // view -func scrollIntoView(vp *viewport.Model, offset int, fraction float32) { +func scrollIntoView(vp *viewport.Model, offset int, edge int) { currentOffset := vp.YOffset if offset >= currentOffset && offset < currentOffset+vp.Height { return @@ -81,9 +81,9 @@ func scrollIntoView(vp *viewport.Model, offset int, fraction float32) { distance := currentOffset - offset if distance < 0 { // we should scroll down until it just comes into view - vp.SetYOffset(currentOffset - (distance + (vp.Height - int(float32(vp.Height)*fraction))) + 1) + vp.SetYOffset(currentOffset - (distance + (vp.Height - edge)) + 1) } else { // we should scroll up - vp.SetYOffset(currentOffset - distance - int(float32(vp.Height)*fraction)) + vp.SetYOffset(currentOffset - distance - edge) } }