update.go (7449B)
1 package tui 2 3 import ( 4 "time" 5 6 "git.prime8.dev/moncheeta/prime8dev/internal/utils" 7 8 "github.com/charmbracelet/bubbles/key" 9 "github.com/charmbracelet/bubbles/viewport" 10 tea "github.com/charmbracelet/bubbletea" 11 ) 12 13 func tickCmd() tea.Cmd { 14 return tea.Tick(time.Second, func(t time.Time) tea.Msg { 15 return tickMsg(t) 16 }) 17 } 18 19 func (m portfolioModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 20 next, cmd := m.update(msg) 21 pm, ok := next.(portfolioModel) 22 if !ok { 23 return next, cmd 24 } 25 if title := pm.windowTitle(); title != pm.windowTitleSet { 26 pm.windowTitleSet = title 27 cmd = tea.Batch(cmd, tea.SetWindowTitle(title)) 28 } 29 return pm, cmd 30 } 31 32 func (m portfolioModel) windowTitle() string { 33 path := "~" 34 switch m.activeTab { 35 case tabExperience: 36 path += "/experience" 37 case tabProjects: 38 path += "/projects" 39 if p := m.selectedProject(); p != nil { 40 path += "/" + p.ID 41 } 42 } 43 return utils.WindowTitle(path) 44 } 45 46 func (m portfolioModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { 47 switch msg := msg.(type) { 48 case tea.WindowSizeMsg: 49 m.width = msg.Width 50 m.height = msg.Height 51 m.syncViewport() 52 return m, m.refreshDataCmd() 53 case cursorBlinkMsg: 54 // The viewport holds a rendered copy of the page, so the prompt line 55 // has to be re-rendered for the blink to show. SetContent keeps the 56 // scroll offset. 57 if m.activeTab == tabAbout && !m.bootActive { 58 m.viewport.SetContent(m.detailContent()) 59 } 60 return m, cursorBlinkCmd() 61 case tickMsg: 62 m.now = time.Time(msg) 63 return m, tea.Batch(tickCmd(), m.refreshDataCmd()) 64 case bootTickMsg: 65 if !m.bootActive { 66 return m, nil 67 } 68 m.syncBootFrame(time.Time(msg)) 69 if !m.bootActive { 70 return m, m.startAboutLogoReveal() 71 } 72 return m, bootTickCmd() 73 case wakaFooterInitMsg: 74 return m, m.maybeLoadWakaFooterCmd() 75 case projectReadmeMsg: 76 m.projectReadmeLoads[msg.projectID] = false 77 if msg.err != nil { 78 m.projectReadmeErrors[msg.projectID] = msg.err.Error() 79 } else { 80 if m.projectReadmes[msg.projectID] != msg.readme { 81 delete(m.projectReadmeRender, msg.projectID) // re-render on the next sync 82 } 83 m.projectReadmes[msg.projectID] = msg.readme 84 delete(m.projectReadmeErrors, msg.projectID) 85 } 86 if m.activeTab == tabProjects { 87 if selected := m.selectedProject(); selected != nil && selected.ID == msg.projectID { 88 m.syncViewport() 89 } 90 } 91 return m, nil 92 case aboutASCIIRenderedMsg: 93 m.applyAboutASCIIRendered(msg) 94 cmd := m.startAboutLogoReveal() 95 if m.activeTab == tabAbout { 96 m.syncViewport() 97 } 98 return m, cmd 99 case aboutLogoRowMsg: 100 cmd := m.stepAboutLogoReveal(msg) 101 if m.activeTab == tabAbout { 102 m.syncViewport() 103 } 104 return m, cmd 105 case wakaFooterMsg: 106 m.wakaLoading = false 107 m.wakaLoaded = true 108 if msg.ok { 109 m.wakaHours = msg.hours 110 } else { 111 m.wakaHours = "" 112 } 113 if m.activeTab == tabAbout { 114 m.syncViewport() 115 } 116 return m, nil 117 case tea.KeyMsg: 118 return m, m.handleKeyMsg(msg) 119 } 120 121 return m, nil 122 } 123 124 func (m *portfolioModel) refreshDataCmd() tea.Cmd { 125 return tea.Batch( 126 m.maybeLoadProjectReadmeCmd(), 127 m.maybePrefetchProjectReadmesCmd(), 128 m.maybeLoadWakaFooterCmd(), 129 m.maybeRenderAboutASCIICmd(), 130 ) 131 } 132 133 func (m *portfolioModel) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { 134 if m.bootActive { 135 if key.Matches(msg, m.keys.Quit) { 136 return tea.Quit 137 } 138 return nil 139 } 140 141 if m.help.ShowAll { 142 switch { 143 case key.Matches(msg, m.keys.Quit): 144 return tea.Quit 145 case key.Matches(msg, m.keys.Help), key.Matches(msg, m.keys.Back): 146 m.help.ShowAll = false 147 return nil 148 default: 149 return nil 150 } 151 } 152 153 if m.activeTab == tabAbout { 154 if cmd, ok := m.handleShellKey(msg); ok { 155 return cmd 156 } 157 } 158 159 if m.activeTab == tabProjects { 160 // With the readme focused, plain arrows pan sideways instead of 161 // switching tabs; left at the left edge falls through and backs out. 162 readme := m.projectReadmeScroll 163 switch { 164 case key.Matches(msg, m.keys.Left), 165 readme && msg.Type == tea.KeyLeft && m.detailXOffset > 0: 166 m.scrollDetailX(-detailHorizontalStep) 167 return nil 168 case key.Matches(msg, m.keys.Right), 169 readme && msg.Type == tea.KeyRight: 170 m.scrollDetailX(detailHorizontalStep) 171 return nil 172 } 173 } 174 175 if m.consumeProjectReadmeBack(msg) { 176 return nil 177 } 178 179 if tab, ok := tabIndexFromKey(msg.String()); ok { 180 return m.activateTab(tab) 181 } 182 183 switch { 184 case key.Matches(msg, m.keys.Quit): 185 return tea.Quit 186 case key.Matches(msg, m.keys.Help): 187 m.help.ShowAll = !m.help.ShowAll 188 return nil 189 case key.Matches(msg, m.keys.TabNext): 190 return m.activateTab(wrapIndex(m.activeTab+1, len(tabLabels))) 191 case key.Matches(msg, m.keys.TabPrev): 192 return m.activateTab(wrapIndex(m.activeTab-1, len(tabLabels))) 193 case key.Matches(msg, m.keys.Open): 194 return m.handleOpenKey() 195 case key.Matches(msg, m.keys.Back): 196 return m.handleBackKey() 197 case key.Matches(msg, m.keys.Up): 198 return tea.Batch(m.moveSelection(-1), m.refreshDataCmd()) 199 case key.Matches(msg, m.keys.Down): 200 return tea.Batch(m.moveSelection(1), m.refreshDataCmd()) 201 case key.Matches(msg, m.keys.PageUp): 202 if m.canPageScroll() { 203 m.viewport.HalfViewUp() 204 } 205 return nil 206 case key.Matches(msg, m.keys.PageDown): 207 if m.canPageScroll() { 208 m.viewport.HalfViewDown() 209 } 210 return nil 211 default: 212 return nil 213 } 214 } 215 216 func (m *portfolioModel) activateTab(tab int) tea.Cmd { 217 if tab < 0 || tab >= len(tabLabels) { 218 return nil 219 } 220 221 m.activeTab = tab 222 m.projectReadmeScroll = false 223 // coming back to a tab shows the cursor solid, like a fresh keystroke 224 m.shell.lastKey = time.Now() 225 m.resetViewport() 226 227 return tea.Batch(m.refreshDataCmd(), m.startAboutLogoReveal()) 228 } 229 230 func (m *portfolioModel) consumeProjectReadmeBack(msg tea.KeyMsg) bool { 231 if m.activeTab != tabProjects || !m.projectReadmeScroll { 232 return false 233 } 234 235 if msg.String() != "left" && !key.Matches(msg, m.keys.Back) { 236 return false 237 } 238 239 m.projectReadmeScroll = false 240 m.resetViewport() 241 return true 242 } 243 244 func (m *portfolioModel) handleOpenKey() tea.Cmd { 245 switch m.activeTab { 246 case tabProjects: 247 if len(m.projects) == 0 { 248 return nil 249 } 250 m.projectReadmeScroll = !m.projectReadmeScroll 251 return nil 252 default: 253 return nil 254 } 255 } 256 257 func (m *portfolioModel) handleBackKey() tea.Cmd { 258 if m.activeTab == tabProjects && m.projectReadmeScroll { 259 m.projectReadmeScroll = false 260 m.resetViewport() 261 return nil 262 } 263 264 return tea.Quit 265 } 266 267 func (m portfolioModel) canPageScroll() bool { 268 return m.activeTab != tabProjects || m.projectReadmeScroll 269 } 270 271 func tabIndexFromKey(keyString string) (int, bool) { 272 if len(keyString) != 1 { 273 return 0, false 274 } 275 276 if keyString[0] < '1' || keyString[0] > '9' { 277 return 0, false 278 } 279 280 idx := int(keyString[0] - '1') 281 if idx < 0 || idx >= len(tabLabels) { 282 return 0, false 283 } 284 285 return idx, true 286 } 287 288 func (m *portfolioModel) moveSelection(delta int) tea.Cmd { 289 if m.activeTab == tabAbout || (m.activeTab == tabProjects && m.projectReadmeScroll) { 290 scrollViewport(&m.viewport, delta) 291 return nil 292 } 293 294 switch m.activeTab { 295 case tabProjects: 296 if len(m.projects) == 0 { 297 scrollViewport(&m.viewport, delta) 298 return nil 299 } 300 m.projectIndex = wrapIndex(m.projectIndex+delta, len(m.projects)) 301 m.syncViewport() 302 case tabExperience: 303 if len(m.experience) == 0 { 304 scrollViewport(&m.viewport, delta) 305 return nil 306 } 307 m.experienceIndex = wrapIndex(m.experienceIndex+delta, len(m.experience)) 308 m.syncViewport() 309 } 310 311 return nil 312 } 313 314 func scrollViewport(vp *viewport.Model, delta int) { 315 if delta > 0 { 316 _ = vp.LineDown(delta) 317 return 318 } 319 _ = vp.LineUp(-delta) 320 }