clone

a terminal ui for alamo drafthouse showtimes, as movie cards or a timeline of every screen

main
showings / internal / state / model.go
model.go 5.2K
1package state23import (4 "github.com/charmbracelet/bubbles/viewport"5 tea "github.com/charmbracelet/bubbletea"6 "github.com/dami/showings/internal/api"7 "github.com/dami/showings/internal/models"8 "github.com/dami/showings/internal/views"9 "time"10)1112type ViewState int1314const (15 ViewLoading ViewState = iota16 ViewShowtimes17)1819type FetchError struct {20 Err error21}2223type DataLoaded struct {24 Data models.Data25 Presentations []models.Presentation26}2728// VenueOption represents a selectable cinema location.29type VenueOption struct {30 Slug string31 Name string32}3334type Model struct {35 viewState ViewState36 presentations []models.Presentation37 data *models.Data38 apiClient *api.Client39 venue string40 cinemaName string4142 viewport viewport.Model43 ganttMode bool44 err error4546 selectedDate *time.Time47 availableDates []time.Time4849 // Venue selector modal50 showVenueModal bool51 venues []VenueOption52 venueCursor int5354 width int55 height int56}5758func New(venue string, dateFilter *time.Time, gantt bool) *Model {59 return &Model{60 viewState: ViewLoading,61 venue: venue,62 apiClient: api.NewClient(),63 selectedDate: dateFilter,64 ganttMode: gantt,65 width: 120,66 height: 40,67 }68}6970func (m *Model) Init() tea.Cmd {71 return m.fetchData72}7374func (m *Model) fetchData() tea.Msg {75 data, err := m.apiClient.FetchSchedule(m.venue)76 if err != nil {77 return FetchError{err}78 }7980 presentations := data.Data.FilterUpcomingPresentations()8182 return DataLoaded{83 Data: data.Data,84 Presentations: presentations,85 }86}8788func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {89 switch msg := msg.(type) {90 case FetchError:91 m.err = msg.Err92 return m, nil9394 case DataLoaded:95 m.data = &msg.Data96 m.presentations = msg.Presentations97 m.viewState = ViewShowtimes9899 // Extract cinema name and available venues100 m.venues = nil101 m.cinemaName = m.venue102 for _, market := range m.data.Market {103 for _, cinema := range market.Cinemas {104 m.venues = append(m.venues, VenueOption{105 Slug: cinema.Slug,106 Name: cinema.Name,107 })108 if cinema.Slug == m.venue {109 m.cinemaName = cinema.Name110 }111 }112 }113 // Set cursor to current venue114 for i, v := range m.venues {115 if v.Slug == m.venue {116 m.venueCursor = i117 break118 }119 }120121 m.computeAvailableDates()122 m.rebuildView()123 return m, nil124125 case tea.WindowSizeMsg:126 m.width = msg.Width127 m.height = msg.Height128 if m.data != nil {129 m.rebuildView()130 }131 return m, nil132133 case tea.KeyMsg:134 // Modal input takes priority when open135 if m.showVenueModal {136 return m.updateVenueModal(msg)137 }138139 switch msg.String() {140 case "q", "ctrl+c":141 return m, tea.Quit142143 case "t":144 m.setTodayFilter()145 m.rebuildView()146 return m, nil147148 case "n":149 m.shiftDate(1)150 m.rebuildView()151 return m, nil152153 case "p":154 m.shiftDate(-1)155 m.rebuildView()156 return m, nil157158 case "g":159 m.ganttMode = !m.ganttMode160 m.rebuildView()161 return m, nil162163 case "v":164 m.showVenueModal = true165 return m, nil166 }167 }168169 if m.viewState == ViewShowtimes && !m.showVenueModal {170 var cmd tea.Cmd171 m.viewport, cmd = m.viewport.Update(msg)172 return m, cmd173 }174175 return m, nil176}177178func (m *Model) updateVenueModal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {179 switch msg.String() {180 case "esc", "v":181 m.showVenueModal = false182 return m, nil183184 case "up", "k":185 if m.venueCursor > 0 {186 m.venueCursor--187 }188 return m, nil189190 case "down", "j":191 if m.venueCursor < len(m.venues)-1 {192 m.venueCursor++193 }194 return m, nil195196 case "enter":197 if m.venueCursor >= 0 && m.venueCursor < len(m.venues) {198 selected := m.venues[m.venueCursor]199 if selected.Slug != m.venue {200 m.venue = selected.Slug201 m.cinemaName = selected.Name202 m.showVenueModal = false203 m.viewState = ViewLoading204 return m, m.fetchData205 }206 }207 m.showVenueModal = false208 return m, nil209210 case "q", "ctrl+c":211 return m, tea.Quit212 }213214 return m, nil215}216217func (m *Model) View() string {218 if m.err != nil {219 return errorStyle.Render(m.err.Error())220 }221222 var base string223 switch m.viewState {224 case ViewLoading:225 base = loadingStyle.Render("Loading schedule...")226 case ViewShowtimes:227 base = m.renderShowtimesView()228 }229230 if m.showVenueModal {231 return m.renderWithModal(base)232 }233234 return base235}236237func (m *Model) viewportHeight() int {238 chrome := 6239 h := m.height - chrome240 if h < 5 {241 h = 5242 }243 return h244}245246func (m *Model) contentWidth() int {247 w := m.width - 2248 if w < 40 {249 w = 40250 }251 return w252}253254func (m *Model) collectSessions() []models.Session {255 var sessions []models.Session256 for _, p := range m.presentations {257 s := m.data.GetSessionsForPresentation(p.Slug)258 if m.selectedDate != nil {259 s = m.filterSessionsByDate(s)260 }261 sessions = append(sessions, s...)262 }263 return sessions264}265266func (m *Model) rebuildView() {267 sessions := m.collectSessions()268269 var content string270 if m.ganttMode {271 isToday := m.selectedDate != nil && m.selectedDate.Equal(time.Now().Truncate(24*time.Hour))272 content = views.RenderGantt(sessions, m.data, m.presentations, m.contentWidth(), isToday)273 } else {274 groups := views.GroupByMovie(sessions, m.data, m.presentations)275 content = views.RenderGroups(groups, m.contentWidth())276 }277278 m.viewport = viewport.New(m.width, m.viewportHeight())279 m.viewport.SetContent(content)280}