返回 DeepSeek-Reasonix
theme_sweep.go
根目录 / internal / cli / theme_sweep.go
1 package cli
2
3 import (
4 "strings"
5 "time"
6
7 tea "charm.land/bubbletea/v2"
8 "github.com/charmbracelet/x/ansi"
9 )
10
11 const (
12 themeSweepInterval = 16 * time.Millisecond
13 themeSweepFrames = 18
14 themeSweepMinWidth = 24
15 )
16
17 // themeSweep wipes the incoming palette across the frame from left to right.
18 // Both frames are rendered once up front: the transcript is frozen for the
19 // duration, so each step is column slicing only.
20 type themeSweep struct {
21 before []string
22 after []string
23 col int
24 step int
25 width int
26 }
27
28 type themeSweepTickMsg struct{}
29
30 func themeSweepTick() tea.Cmd {
31 return tea.Tick(themeSweepInterval, func(time.Time) tea.Msg { return themeSweepTickMsg{} })
32 }
33
34 // startThemeSweep returns nil when the terminal or the moment cannot carry the
35 // animation, and the caller keeps the plain instant switch.
36 func (m *chatTUI) startThemeSweep(from, to cliPalette) tea.Cmd {
37 if !colorOn() || m.width < themeSweepMinWidth || m.state == tuiRunning {
38 return nil
39 }
40 if from.name == to.name && from.style == to.style {
41 return nil
42 }
43 before := strings.Split(m.frameWithTheme(from), "\n")
44 after := strings.Split(m.frameWithTheme(to), "\n")
45 if len(before) != len(after) {
46 return nil
47 }
48 step := max(m.width/themeSweepFrames, 1)
49 m.themeSweep = &themeSweep{
50 before: before,
51 after: after,
52 col: step,
53 step: step,
54 width: m.width,
55 }
56 return themeSweepTick()
57 }
58
59 // frameWithTheme renders the whole view under a palette other than the active
60 // one. The value receiver keeps the swapped-in sweep state local to this call.
61 func (m chatTUI) frameWithTheme(p cliPalette) string {
62 m.themeSweep = nil
63 prev := activeCLITheme
64 activeCLITheme = p
65 refreshCLIStyles()
66 defer func() {
67 activeCLITheme = prev
68 refreshCLIStyles()
69 }()
70 return m.View().Content
71 }
72
73 func (s *themeSweep) advance() bool {
74 s.col += s.step
75 return s.col < s.width
76 }
77
78 func (s *themeSweep) render() string {
79 rows := make([]string, len(s.after))
80 for i := range s.after {
81 rows[i] = s.composeRow(s.after[i], s.before[i])
82 }
83 return strings.Join(rows, "\n")
84 }
85
86 // composeRow builds the row to an exact cell count. Slicing on an odd column
87 // cannot split a double-width rune, so both sides are re-clamped and padded;
88 // otherwise a CJK transcript pushes the boundary out of vertical alignment.
89 func (s *themeSweep) composeRow(after, before string) string {
90 lead := min(max(s.col, 0), s.width)
91 row := padCells(ansi.Truncate(after, lead, ""), lead)
92 if lead == s.width {
93 return row
94 }
95 tailWidth := s.width - lead
96 tail := ansi.Truncate(ansi.TruncateLeft(before, lead, ""), tailWidth, "")
97 return row + padCells(tail, tailWidth)
98 }
99
100 // padCells restores the exact column count after a truncation that landed on a
101 // double-width cell.
102 func padCells(s string, w int) string {
103 if d := w - visibleWidth(s); d > 0 {
104 return s + strings.Repeat(" ", d)
105 }
106 return s
107 }
108
108 lines GO