prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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 }