@@ -58,6 +58,7 @@ type Model interface {
5858
5959 ScrollToBottom () tea.Cmd
6060 AdjustBottomSlack (delta int )
61+ ScrollByWheel (delta int )
6162}
6263
6364// renderedItem represents a cached rendered message with position information
@@ -84,7 +85,7 @@ type model struct {
8485 // Height tracking system fields
8586 scrollOffset int // Current scroll position in lines
8687 bottomSlack int // Extra blank lines added after content shrinks
87- rendered string // Complete rendered content string
88+ renderedLines [] string // Cached rendered content as lines (avoids split/join per frame)
8889 renderedItems map [int ]renderedItem // Cache of rendered items with positions
8990 totalHeight int // Total height of all content in lines
9091 renderDirty bool // True when rendered content needs rebuild
@@ -333,27 +334,12 @@ func (m *model) handleMouseRelease(msg tea.MouseReleaseMsg) (layout.Model, tea.C
333334}
334335
335336func (m * model ) handleMouseWheel (msg tea.MouseWheelMsg ) (layout.Model , tea.Cmd ) {
336- const mouseScrollAmount = 2
337337 switch msg .Button .String () {
338338 case "wheelup" :
339- if m .scrollOffset > 0 {
340- m .userHasScrolled = true
341- m .bottomSlack = 0
342- for range mouseScrollAmount {
343- m .setScrollOffset (m .scrollOffset - defaultScrollAmount )
344- }
345- }
339+ m .scrollByWheel (- 1 )
346340 case "wheeldown" :
347- m .userHasScrolled = true
348- m .bottomSlack = 0
349- for range mouseScrollAmount {
350- m .setScrollOffset (m .scrollOffset + defaultScrollAmount )
351- }
352- if m .isAtBottom () {
353- m .userHasScrolled = false
354- }
341+ m .scrollByWheel (1 )
355342 }
356- m .scrollbar .SetScrollOffset (m .scrollOffset )
357343 return m , nil
358344}
359345
@@ -433,22 +419,28 @@ func (m *model) View() string {
433419 m .scrollOffset = max (0 , min (m .scrollOffset , maxScrollOffset ))
434420 }
435421
436- lines := strings .Split (m .rendered , "\n " )
437- if m .bottomSlack > 0 {
438- lines = append (lines , make ([]string , m .bottomSlack )... )
439- }
440- if len (lines ) == 0 {
422+ // Use cached lines directly - O(1) instead of O(totalHeight) split
423+ totalLines := len (m .renderedLines ) + m .bottomSlack
424+ if totalLines == 0 {
441425 return ""
442426 }
443427
444428 startLine := m .scrollOffset
445- endLine := min (startLine + m .height , len ( lines ) )
429+ endLine := min (startLine + m .height , totalLines )
446430
447431 if startLine >= endLine {
448432 return ""
449433 }
450434
451- visibleLines := lines [startLine :endLine ]
435+ // Copy only the visible window to avoid mutating cached lines
436+ // This is O(viewportHeight) instead of O(totalHeight)
437+ visibleLines := make ([]string , endLine - startLine )
438+ for i := startLine ; i < endLine ; i ++ {
439+ if i < len (m .renderedLines ) {
440+ visibleLines [i - startLine ] = m .renderedLines [i ]
441+ }
442+ // Lines beyond renderedLines are bottom slack (empty strings), already zero-valued
443+ }
452444
453445 if m .selection .active {
454446 visibleLines = m .applySelectionHighlight (visibleLines , startLine )
@@ -471,22 +463,18 @@ func (m *model) View() string {
471463 }
472464 }
473465
474- contentView := strings .Join (visibleLines , "\n " )
475466 scrollbarView := m .scrollbar .View ()
476467
477468 if scrollbarView != "" {
478- // For proper horizontal layout, all components must have the same height
479- contentLines := strings .Split (contentView , "\n " )
480-
481469 // Ensure content is exactly m.height lines by padding with empty lines if needed
482- for len (contentLines ) < m .height {
483- contentLines = append (contentLines , "" )
470+ for len (visibleLines ) < m .height {
471+ visibleLines = append (visibleLines , "" )
484472 }
485473 // Truncate if somehow longer (shouldn't happen but safety check)
486- if len (contentLines ) > m .height {
487- contentLines = contentLines [:m .height ]
474+ if len (visibleLines ) > m .height {
475+ visibleLines = visibleLines [:m .height ]
488476 }
489- paddedContentView := strings .Join (contentLines , "\n " )
477+ contentView := strings .Join (visibleLines , "\n " )
490478
491479 // Create spacer with exactly m.height lines
492480 spacerLines := make ([]string , m .height )
@@ -495,10 +483,10 @@ func (m *model) View() string {
495483 }
496484 spacer := strings .Join (spacerLines , "\n " )
497485
498- return lipgloss .JoinHorizontal (lipgloss .Top , paddedContentView , spacer , scrollbarView )
486+ return lipgloss .JoinHorizontal (lipgloss .Top , contentView , spacer , scrollbarView )
499487 }
500488
501- return contentView
489+ return strings . Join ( visibleLines , " \n " )
502490}
503491
504492// SetSize sets the dimensions of the component
@@ -560,7 +548,10 @@ func (m *model) Help() help.KeyMap {
560548}
561549
562550// Scrolling methods
563- const defaultScrollAmount = 1
551+ const (
552+ defaultScrollAmount = 1
553+ wheelScrollAmount = 2
554+ )
564555
565556func (m * model ) scrollUp () {
566557 if m .scrollOffset > 0 {
@@ -605,6 +596,28 @@ func (m *model) scrollToBottom() {
605596 m .setScrollOffset (9_999_999 ) // Will be clamped in View()
606597}
607598
599+ func (m * model ) ScrollByWheel (delta int ) {
600+ m .scrollByWheel (delta )
601+ }
602+
603+ func (m * model ) scrollByWheel (delta int ) {
604+ if delta == 0 {
605+ return
606+ }
607+
608+ prevOffset := m .scrollOffset
609+ m .setScrollOffset (m .scrollOffset + (delta * wheelScrollAmount * defaultScrollAmount ))
610+ if m .scrollOffset == prevOffset {
611+ return
612+ }
613+
614+ m .userHasScrolled = true
615+ m .bottomSlack = 0
616+ if m .isAtBottom () {
617+ m .userHasScrolled = false
618+ }
619+ }
620+
608621func (m * model ) setScrollOffset (offset int ) {
609622 maxOffset := max (0 , m .totalScrollableHeight ()- m .height )
610623 m .scrollOffset = max (0 , min (offset , maxOffset ))
@@ -788,12 +801,12 @@ func (m *model) needsSeparator(index int) bool {
788801}
789802
790803func (m * model ) ensureAllItemsRendered () {
791- if ! m .renderDirty && m . rendered != "" {
804+ if ! m .renderDirty && len ( m . renderedLines ) > 0 {
792805 return
793806 }
794807
795808 if len (m .views ) == 0 {
796- m .rendered = ""
809+ m .renderedLines = nil
797810 m .totalHeight = 0
798811 m .renderDirty = false
799812 return
@@ -816,7 +829,8 @@ func (m *model) ensureAllItemsRendered() {
816829 }
817830 }
818831
819- m .rendered = strings .Join (allLines , "\n " )
832+ // Store lines directly - avoid join/split on every View() call
833+ m .renderedLines = allLines
820834 m .totalHeight = len (allLines )
821835 m .renderDirty = false
822836}
@@ -830,7 +844,7 @@ func (m *model) invalidateItem(index int) {
830844
831845func (m * model ) invalidateAllItems () {
832846 m .renderedItems = make (map [int ]renderedItem )
833- m .rendered = ""
847+ m .renderedLines = nil
834848 m .totalHeight = 0
835849 m .renderDirty = true
836850}
@@ -978,7 +992,7 @@ func (m *model) LoadFromSession(sess *session.Session) tea.Cmd {
978992 m .messages = nil
979993 m .views = nil
980994 m .renderedItems = make (map [int ]renderedItem )
981- m .rendered = ""
995+ m .renderedLines = nil
982996 m .scrollOffset = 0
983997 m .totalHeight = 0
984998 m .bottomSlack = 0
0 commit comments