{- | The editor's state ('AppState') and the pure functions that transform
it: cursor navigation (@move*@\/@goTo*@), zoom, and display-mode
cycling. Kept separate from "Termtab.UI.Editing" (which mutates the
'Song' itself) and from "Termtab.UI.Keybindings" (which decides /which/
of these functions a keypress calls) — this module only knows how to
move the cursor and answer questions about where it is, not how to edit
notes.
-}
module Termtab.UI.Types (
    AppState (..),
    DisplayMode (..),
    InputMode (..),
    ResourceName (..),
    AppEvent (..),
    initAppState,
    -- Pure navigation functions
    moveBeatRight,
    moveBeatLeft,
    moveMeasureForward,
    moveMeasureBack,
    moveStringUp,
    moveStringDown,
    goToStart,
    goToEnd,
    goToMeasureStart,
    goToMeasureEnd,
    goToFirstString,
    goToLastString,
    cycleDisplayMode,
    zoomIn,
    zoomOut,
    -- Helpers
    currentTrack,
    currentMeasure,
    currentMeasureBeatCount,
    atLastBeat,
    atLastMeasure,
    atFirstBeat,
    atFirstMeasure,
    isMeasureEmpty,
    isTrailingEmpty,
    beatsForTrackMeasure,
    stringCountForTrack,
    measureCount,
) where

import Brick.BChan (BChan)
import Codec.Picture (PixelRGBA8)
import Data.Map.Strict qualified as Map
import Termtab.Audio.PlaybackThread (PlaybackEnv)
import Termtab.Audio.Types (PlaybackStatus (..))
import Termtab.Graphics.Detect (GraphicsProtocol (..))
import Termtab.Graphics.Font (GlyphFont)
import Termtab.Graphics.TermColor (defaultInk)
import Termtab.Types

{- | What a single track's viewport renders: ASCII tab, the rasterized
Bravura staff notation, or both stacked. Stored per-track in
'asDisplayModes' since a mixed 'Standard' + 'Guitar' song may want
different modes per track (see 'initAppState''s @defaultModeFor@).
-}
data DisplayMode
    = TabOnly
    | NotationOnly
    | TabAndNotation
    deriving (Int -> DisplayMode -> ShowS
[DisplayMode] -> ShowS
DisplayMode -> String
(Int -> DisplayMode -> ShowS)
-> (DisplayMode -> String)
-> ([DisplayMode] -> ShowS)
-> Show DisplayMode
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> DisplayMode -> ShowS
showsPrec :: Int -> DisplayMode -> ShowS
$cshow :: DisplayMode -> String
show :: DisplayMode -> String
$cshowList :: [DisplayMode] -> ShowS
showList :: [DisplayMode] -> ShowS
Show, DisplayMode -> DisplayMode -> Bool
(DisplayMode -> DisplayMode -> Bool)
-> (DisplayMode -> DisplayMode -> Bool) -> Eq DisplayMode
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: DisplayMode -> DisplayMode -> Bool
== :: DisplayMode -> DisplayMode -> Bool
$c/= :: DisplayMode -> DisplayMode -> Bool
/= :: DisplayMode -> DisplayMode -> Bool
Eq)

{- | Which set of keybindings "Termtab.UI.Keybindings" is currently
interpreting input as. 'CommandMode' carries the command line typed so
far; 'MenuMode' carries the path of menu keys pressed so far (@[]@ is
the root menu, @[\"s\"]@ is the subdivide submenu, etc), since menus
nest.
-}
data InputMode
    = NormalMode
    | CommandMode String
    | GoToMode
    | VisualMode
    | MenuMode [String] -- menu path, e.g. [] = root, ["s"] = subdivide submenu
    deriving (Int -> InputMode -> ShowS
[InputMode] -> ShowS
InputMode -> String
(Int -> InputMode -> ShowS)
-> (InputMode -> String)
-> ([InputMode] -> ShowS)
-> Show InputMode
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> InputMode -> ShowS
showsPrec :: Int -> InputMode -> ShowS
$cshow :: InputMode -> String
show :: InputMode -> String
$cshowList :: [InputMode] -> ShowS
showList :: [InputMode] -> ShowS
Show, InputMode -> InputMode -> Bool
(InputMode -> InputMode -> Bool)
-> (InputMode -> InputMode -> Bool) -> Eq InputMode
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: InputMode -> InputMode -> Bool
== :: InputMode -> InputMode -> Bool
$c/= :: InputMode -> InputMode -> Bool
/= :: InputMode -> InputMode -> Bool
Eq)

{- | Brick widget-name identifiers, used to address specific viewports\/
regions for scrolling and extent lookups (e.g. 'Brick.lookupExtent').
-}
data ResourceName
    = TrackViewport TrackIndex
    | StatusBarResource
    | CommandLineResource
    | -- Reserved region a track's notation image is blitted over.
      NotationRegion TrackIndex
    deriving (Int -> ResourceName -> ShowS
[ResourceName] -> ShowS
ResourceName -> String
(Int -> ResourceName -> ShowS)
-> (ResourceName -> String)
-> ([ResourceName] -> ShowS)
-> Show ResourceName
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> ResourceName -> ShowS
showsPrec :: Int -> ResourceName -> ShowS
$cshow :: ResourceName -> String
show :: ResourceName -> String
$cshowList :: [ResourceName] -> ShowS
showList :: [ResourceName] -> ShowS
Show, ResourceName -> ResourceName -> Bool
(ResourceName -> ResourceName -> Bool)
-> (ResourceName -> ResourceName -> Bool) -> Eq ResourceName
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: ResourceName -> ResourceName -> Bool
== :: ResourceName -> ResourceName -> Bool
$c/= :: ResourceName -> ResourceName -> Bool
/= :: ResourceName -> ResourceName -> Bool
Eq, Eq ResourceName
Eq ResourceName =>
(ResourceName -> ResourceName -> Ordering)
-> (ResourceName -> ResourceName -> Bool)
-> (ResourceName -> ResourceName -> Bool)
-> (ResourceName -> ResourceName -> Bool)
-> (ResourceName -> ResourceName -> Bool)
-> (ResourceName -> ResourceName -> ResourceName)
-> (ResourceName -> ResourceName -> ResourceName)
-> Ord ResourceName
ResourceName -> ResourceName -> Bool
ResourceName -> ResourceName -> Ordering
ResourceName -> ResourceName -> ResourceName
forall a.
Eq a =>
(a -> a -> Ordering)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> a)
-> (a -> a -> a)
-> Ord a
$ccompare :: ResourceName -> ResourceName -> Ordering
compare :: ResourceName -> ResourceName -> Ordering
$c< :: ResourceName -> ResourceName -> Bool
< :: ResourceName -> ResourceName -> Bool
$c<= :: ResourceName -> ResourceName -> Bool
<= :: ResourceName -> ResourceName -> Bool
$c> :: ResourceName -> ResourceName -> Bool
> :: ResourceName -> ResourceName -> Bool
$c>= :: ResourceName -> ResourceName -> Bool
>= :: ResourceName -> ResourceName -> Bool
$cmax :: ResourceName -> ResourceName -> ResourceName
max :: ResourceName -> ResourceName -> ResourceName
$cmin :: ResourceName -> ResourceName -> ResourceName
min :: ResourceName -> ResourceName -> ResourceName
Ord)

{- | Custom Brick events delivered through 'asBChan'. 'PlaybackTick' drives
UI redraws while a song plays; 'GraphicsRefresh' is posted once at
startup to (re-)blit notation images after the terminal has reported
real widget extents (which aren't known on the very first frame).
-}
data AppEvent
    = PlaybackTick
    | -- Posted at startup so the notation images are placed once extents exist.
      GraphicsRefresh
    deriving (Int -> AppEvent -> ShowS
[AppEvent] -> ShowS
AppEvent -> String
(Int -> AppEvent -> ShowS)
-> (AppEvent -> String) -> ([AppEvent] -> ShowS) -> Show AppEvent
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> AppEvent -> ShowS
showsPrec :: Int -> AppEvent -> ShowS
$cshow :: AppEvent -> String
show :: AppEvent -> String
$cshowList :: [AppEvent] -> ShowS
showList :: [AppEvent] -> ShowS
Show, AppEvent -> AppEvent -> Bool
(AppEvent -> AppEvent -> Bool)
-> (AppEvent -> AppEvent -> Bool) -> Eq AppEvent
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: AppEvent -> AppEvent -> Bool
== :: AppEvent -> AppEvent -> Bool
$c/= :: AppEvent -> AppEvent -> Bool
/= :: AppEvent -> AppEvent -> Bool
Eq)

{- | The entire mutable state of the running editor: the document itself
('asSong') plus cursor position, input mode, undo\/redo history,
playback status, and resolved graphics capabilities. This is the sole
state threaded through Brick's event loop — every keybinding and
rendering function takes and\/or produces one of these.
-}
data AppState = AppState
    { AppState -> Song
asSong :: Song
    , AppState -> Maybe String
asFilePath :: Maybe FilePath
    , AppState -> MeasureIndex
asCurrentMeasure :: MeasureIndex
    , AppState -> BeatIndex
asCurrentBeat :: BeatIndex
    , AppState -> TrackIndex
asCurrentTrack :: TrackIndex
    , AppState -> StringIndex
asCurrentString :: StringIndex
    , AppState -> InputMode
asInputMode :: InputMode
    , AppState -> Map TrackIndex DisplayMode
asDisplayModes :: Map.Map TrackIndex DisplayMode
    , AppState -> Int
asZoom :: Int
    , AppState -> Maybe String
asMessage :: Maybe String
    , AppState -> PlaybackStatus
asPlaybackStatus :: PlaybackStatus
    , AppState -> Maybe MeasureIndex
asPlayheadMeasure :: Maybe MeasureIndex
    , AppState -> Maybe BeatIndex
asPlayheadBeat :: Maybe BeatIndex
    , AppState -> Maybe PlaybackEnv
asPlaybackEnv :: Maybe PlaybackEnv
    , AppState -> BChan AppEvent
asBChan :: BChan AppEvent
    , AppState -> [Song]
asUndoStack :: [Song]
    , AppState -> [Song]
asRedoStack :: [Song]
    , AppState -> Maybe FretNumber
asFretEntry :: Maybe FretNumber
    , AppState -> Maybe (MeasureIndex, BeatIndex)
asSelectionStart :: Maybe (MeasureIndex, BeatIndex)
    , -- Graphics notation (resolved once at startup):
      AppState -> GraphicsProtocol
asProtocol :: GraphicsProtocol
    , AppState -> Maybe GlyphFont
asGlyphFont :: Maybe GlyphFont
    , AppState -> PixelRGBA8
asInkColor :: PixelRGBA8
    , -- Per-track signature of the last-blitted notation (position + width +
      -- visible beats). Used to skip re-blitting when nothing changed, which
      -- avoids flicker on in-place navigation.
      AppState -> Map TrackIndex String
asNotationSigs :: Map.Map TrackIndex String
    }

{- | Build the starting 'AppState' for @song@ (loaded from @mPath@, or
'Nothing' for a brand-new document), with the cursor at measure 0 beat 0,
no undo history, and a per-track default 'DisplayMode' (fretted
instruments start as 'TabAndNotation', 'Standard' tracks as
'NotationOnly' since they have no tab to show). Graphics fields
('asProtocol', 'asGlyphFont', etc) start at their no-graphics defaults —
the caller resolves and fills those in separately once terminal
capabilities are detected.
-}
initAppState :: Maybe FilePath -> Song -> BChan AppEvent -> AppState
initAppState :: Maybe String -> Song -> BChan AppEvent -> AppState
initAppState Maybe String
mPath Song
song BChan AppEvent
bChan =
    AppState
        { asSong :: Song
asSong = Song
song
        , asFilePath :: Maybe String
asFilePath = Maybe String
mPath
        , asCurrentMeasure :: MeasureIndex
asCurrentMeasure = Int -> MeasureIndex
MeasureIndex Int
0
        , asCurrentBeat :: BeatIndex
asCurrentBeat = Int -> BeatIndex
BeatIndex Int
0
        , asCurrentTrack :: TrackIndex
asCurrentTrack = Int -> TrackIndex
TrackIndex Int
0
        , asCurrentString :: StringIndex
asCurrentString = Int -> StringIndex
StringIndex Int
0
        , asInputMode :: InputMode
asInputMode = InputMode
NormalMode
        , asDisplayModes :: Map TrackIndex DisplayMode
asDisplayModes = [(TrackIndex, DisplayMode)] -> Map TrackIndex DisplayMode
forall k a. Ord k => [(k, a)] -> Map k a
Map.fromList [(TrackIndex, DisplayMode)]
defaultModes
        , asZoom :: Int
asZoom = Int
4
        , asMessage :: Maybe String
asMessage = Maybe String
forall a. Maybe a
Nothing
        , asPlaybackStatus :: PlaybackStatus
asPlaybackStatus = PlaybackStatus
Stopped
        , asPlayheadMeasure :: Maybe MeasureIndex
asPlayheadMeasure = Maybe MeasureIndex
forall a. Maybe a
Nothing
        , asPlayheadBeat :: Maybe BeatIndex
asPlayheadBeat = Maybe BeatIndex
forall a. Maybe a
Nothing
        , asPlaybackEnv :: Maybe PlaybackEnv
asPlaybackEnv = Maybe PlaybackEnv
forall a. Maybe a
Nothing
        , asBChan :: BChan AppEvent
asBChan = BChan AppEvent
bChan
        , asUndoStack :: [Song]
asUndoStack = []
        , asRedoStack :: [Song]
asRedoStack = []
        , asFretEntry :: Maybe FretNumber
asFretEntry = Maybe FretNumber
forall a. Maybe a
Nothing
        , asSelectionStart :: Maybe (MeasureIndex, BeatIndex)
asSelectionStart = Maybe (MeasureIndex, BeatIndex)
forall a. Maybe a
Nothing
        , asProtocol :: GraphicsProtocol
asProtocol = GraphicsProtocol
TextOnly
        , asGlyphFont :: Maybe GlyphFont
asGlyphFont = Maybe GlyphFont
forall a. Maybe a
Nothing
        , asInkColor :: PixelRGBA8
asInkColor = PixelRGBA8
defaultInk
        , asNotationSigs :: Map TrackIndex String
asNotationSigs = Map TrackIndex String
forall k a. Map k a
Map.empty
        }
  where
    defaultModes :: [(TrackIndex, DisplayMode)]
defaultModes =
        [ (Int -> TrackIndex
TrackIndex Int
i, Instrument -> DisplayMode
defaultModeFor (Track -> Instrument
trackInstrument Track
t))
        | (Int
i, Track
t) <- [Int] -> [Track] -> [(Int, Track)]
forall a b. [a] -> [b] -> [(a, b)]
zip [Int
0 ..] (Song -> [Track]
songTracks Song
song)
        ]
    defaultModeFor :: Instrument -> DisplayMode
defaultModeFor (Guitar [Pitch]
_ Int
_) = DisplayMode
TabAndNotation
    defaultModeFor (Bass [Pitch]
_ Int
_) = DisplayMode
TabAndNotation
    defaultModeFor (Standard MidiProgram
_) = DisplayMode
NotationOnly

-- Helpers

{- | The 'Track' at 'asCurrentTrack', or 'Nothing' if the cursor's track
index is out of range (e.g. the last track was just deleted).
-}
currentTrack :: AppState -> Maybe Track
currentTrack :: AppState -> Maybe Track
currentTrack AppState
st =
    let TrackIndex Int
i = AppState -> TrackIndex
asCurrentTrack AppState
st
     in case Int -> [Track] -> [Track]
forall a. Int -> [a] -> [a]
drop Int
i (Song -> [Track]
songTracks (AppState -> Song
asSong AppState
st)) of
            (Track
t : [Track]
_) -> Track -> Maybe Track
forall a. a -> Maybe a
Just Track
t
            [] -> Maybe Track
forall a. Maybe a
Nothing

-- | The 'Measure' at 'asCurrentMeasure', or 'Nothing' if out of range.
currentMeasure :: AppState -> Maybe Measure
currentMeasure :: AppState -> Maybe Measure
currentMeasure AppState
st =
    let MeasureIndex Int
i = AppState -> MeasureIndex
asCurrentMeasure AppState
st
     in case Int -> [Measure] -> [Measure]
forall a. Int -> [a] -> [a]
drop Int
i (Song -> [Measure]
songMeasures (AppState -> Song
asSong AppState
st)) of
            (Measure
m : [Measure]
_) -> Measure -> Maybe Measure
forall a. a -> Maybe a
Just Measure
m
            [] -> Maybe Measure
forall a. Maybe a
Nothing

-- | The beats a track has for one measure, or @[]@ if it has none there.
beatsForTrackMeasure :: Track -> MeasureIndex -> [Beat]
beatsForTrackMeasure :: Track -> MeasureIndex -> [Beat]
beatsForTrackMeasure Track
track MeasureIndex
mi =
    [Beat] -> ([Beat] -> [Beat]) -> Maybe [Beat] -> [Beat]
forall b a. b -> (a -> b) -> Maybe a -> b
maybe [] [Beat] -> [Beat]
forall a. a -> a
id (MeasureIndex -> Map MeasureIndex [Beat] -> Maybe [Beat]
forall k a. Ord k => k -> Map k a -> Maybe a
Map.lookup MeasureIndex
mi (Track -> Map MeasureIndex [Beat]
trackBeats Track
track))

{- | Number of fretboard strings for a track, or 0 for a 'Standard'
(non-fretted) instrument.
-}
stringCountForTrack :: Track -> Int
stringCountForTrack :: Track -> Int
stringCountForTrack Track
track = case Track -> Instrument
trackInstrument Track
track of
    Guitar [Pitch]
_ Int
n -> Int
n
    Bass [Pitch]
_ Int
n -> Int
n
    Standard MidiProgram
_ -> Int
0

-- | Total number of measures in the current song.
measureCount :: AppState -> Int
measureCount :: AppState -> Int
measureCount AppState
st = [Measure] -> Int
forall a. [a] -> Int
forall (t :: * -> *) a. Foldable t => t a -> Int
length (Song -> [Measure]
songMeasures (AppState -> Song
asSong AppState
st))

{- | A measure is empty if no track has any notes in it.
Empty beat lists or beats with no notes all count as empty.
-}
isMeasureEmpty :: MeasureIndex -> AppState -> Bool
isMeasureEmpty :: MeasureIndex -> AppState -> Bool
isMeasureEmpty MeasureIndex
mi AppState
st =
    (Track -> Bool) -> [Track] -> Bool
forall (t :: * -> *) a. Foldable t => (a -> Bool) -> t a -> Bool
all Track -> Bool
trackEmpty (Song -> [Track]
songTracks (AppState -> Song
asSong AppState
st))
  where
    trackEmpty :: Track -> Bool
trackEmpty Track
track =
        let beats :: [Beat]
beats = Track -> MeasureIndex -> [Beat]
beatsForTrackMeasure Track
track MeasureIndex
mi
         in (Beat -> Bool) -> [Beat] -> Bool
forall (t :: * -> *) a. Foldable t => (a -> Bool) -> t a -> Bool
all Beat -> Bool
beatEmpty [Beat]
beats
    beatEmpty :: Beat -> Bool
beatEmpty Beat
beat = [Note] -> Bool
forall a. [a] -> Bool
forall (t :: * -> *) a. Foldable t => t a -> Bool
null (Beat -> [Note]
beatNotes Beat
beat)

{- | A measure is trailing-empty if it and all measures after it are empty.
Only trailing-empty measures should be auto-deleted on backward navigation.
-}
isTrailingEmpty :: MeasureIndex -> AppState -> Bool
isTrailingEmpty :: MeasureIndex -> AppState -> Bool
isTrailingEmpty (MeasureIndex Int
mi) AppState
st =
    let total :: Int
total = AppState -> Int
measureCount AppState
st
     in (Int -> Bool) -> [Int] -> Bool
forall (t :: * -> *) a. Foldable t => (a -> Bool) -> t a -> Bool
all (\Int
i -> MeasureIndex -> AppState -> Bool
isMeasureEmpty (Int -> MeasureIndex
MeasureIndex Int
i) AppState
st) [Int
mi .. Int
total Int -> Int -> Int
forall a. Num a => a -> a -> a
- Int
1]

-- Navigation

{- | Number of navigable beats in the current measure.
Accounts for actual beats plus empty slots that fill the remaining
duration from the time signature.
-}
currentMeasureBeatCount :: AppState -> Int
currentMeasureBeatCount :: AppState -> Int
currentMeasureBeatCount AppState
st =
    let timeSigBeats :: Int
timeSigBeats = case AppState -> Maybe Measure
currentMeasure AppState
st of
            Just Measure
m -> let TimeSignature Int
num Int
_ = Measure -> TimeSignature
timeSignature Measure
m in Int
num
            Maybe Measure
Nothing -> Int
4
        beats :: [Beat]
beats = case AppState -> Maybe Track
currentTrack AppState
st of
            Just Track
t -> Track -> MeasureIndex -> [Beat]
beatsForTrackMeasure Track
t (AppState -> MeasureIndex
asCurrentMeasure AppState
st)
            Maybe Track
Nothing -> []
        actualCount :: Int
actualCount = [Beat] -> Int
forall a. [a] -> Int
forall (t :: * -> *) a. Foldable t => t a -> Int
length [Beat]
beats
        -- Duration units used by actual beats (quarter = 4 units)
        usedUnits :: Int
usedUnits = [Int] -> Int
forall a. Num a => [a] -> a
forall (t :: * -> *) a. (Foldable t, Num a) => t a -> a
sum ((Beat -> Int) -> [Beat] -> [Int]
forall a b. (a -> b) -> [a] -> [b]
map (Duration -> Int
durationWeightForNav (Duration -> Int) -> (Beat -> Duration) -> Beat -> Int
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Beat -> Duration
beatDuration) [Beat]
beats)
        totalUnits :: Int
totalUnits = Int
timeSigBeats Int -> Int -> Int
forall a. Num a => a -> a -> a
* Int
4 -- each quarter = 4 units
        remainingUnits :: Int
remainingUnits = Int -> Int -> Int
forall a. Ord a => a -> a -> a
max Int
0 (Int
totalUnits Int -> Int -> Int
forall a. Num a => a -> a -> a
- Int
usedUnits)
        emptySlots :: Int
emptySlots = Int
remainingUnits Int -> Int -> Int
forall a. Integral a => a -> a -> a
`div` Int
4 -- each empty slot = 1 quarter
     in Int
actualCount Int -> Int -> Int
forall a. Num a => a -> a -> a
+ Int
emptySlots

-- | Duration weight for navigation (quarter = 4 units, matching the renderer).
durationWeightForNav :: Duration -> Int
durationWeightForNav :: Duration -> Int
durationWeightForNav Duration
Whole = Int
16
durationWeightForNav Duration
Half = Int
8
durationWeightForNav Duration
Quarter = Int
4
durationWeightForNav Duration
Eighth = Int
2
durationWeightForNav Duration
Sixteenth = Int
1
durationWeightForNav Duration
Thirty2nd = Int
1
durationWeightForNav (Dotted Duration
d) = Duration -> Int
durationWeightForNav Duration
d Int -> Int -> Int
forall a. Num a => a -> a -> a
+ Duration -> Int
durationWeightForNav Duration
d Int -> Int -> Int
forall a. Integral a => a -> a -> a
`div` Int
2
durationWeightForNav (Triplet Duration
d) = Duration -> Int
durationWeightForNav Duration
d Int -> Int -> Int
forall a. Num a => a -> a -> a
* Int
2 Int -> Int -> Int
forall a. Integral a => a -> a -> a
`div` Int
3

-- | Are we at the last beat of the current measure?
atLastBeat :: AppState -> Bool
atLastBeat :: AppState -> Bool
atLastBeat AppState
st =
    let BeatIndex Int
b = AppState -> BeatIndex
asCurrentBeat AppState
st
     in Int
b Int -> Int -> Bool
forall a. Ord a => a -> a -> Bool
>= AppState -> Int
currentMeasureBeatCount AppState
st Int -> Int -> Int
forall a. Num a => a -> a -> a
- Int
1

-- | Are we at the last measure?
atLastMeasure :: AppState -> Bool
atLastMeasure :: AppState -> Bool
atLastMeasure AppState
st =
    let MeasureIndex Int
m = AppState -> MeasureIndex
asCurrentMeasure AppState
st
     in Int
m Int -> Int -> Bool
forall a. Ord a => a -> a -> Bool
>= AppState -> Int
measureCount AppState
st Int -> Int -> Int
forall a. Num a => a -> a -> a
- Int
1

{- | Move the cursor one beat right, wrapping to beat 0 of the next
measure if already at the last beat (unless already in the last
measure, in which case the cursor doesn't move here — auto-creating a
new measure at the end is handled by the caller in
"Termtab.UI.Keybindings", not by this pure navigation function).
-}
moveBeatRight :: AppState -> AppState
moveBeatRight :: AppState -> AppState
moveBeatRight AppState
st
    | AppState -> Bool
atLastBeat AppState
st Bool -> Bool -> Bool
&& Bool -> Bool
not (AppState -> Bool
atLastMeasure AppState
st) =
        -- Wrap to next measure
        AppState
st{asCurrentMeasure = let MeasureIndex m = asCurrentMeasure st in MeasureIndex (m + 1), asCurrentBeat = BeatIndex 0}
    | AppState -> Bool
atLastBeat AppState
st = AppState
st -- at end of last measure, handled by keybinding for auto-create
    | Bool
otherwise =
        let BeatIndex Int
b = AppState -> BeatIndex
asCurrentBeat AppState
st
         in AppState
st{asCurrentBeat = BeatIndex (b + 1)}

-- | Are we at the first beat of the current measure?
atFirstBeat :: AppState -> Bool
atFirstBeat :: AppState -> Bool
atFirstBeat AppState
st = AppState -> BeatIndex
asCurrentBeat AppState
st BeatIndex -> BeatIndex -> Bool
forall a. Eq a => a -> a -> Bool
== Int -> BeatIndex
BeatIndex Int
0

-- | Are we at the first measure?
atFirstMeasure :: AppState -> Bool
atFirstMeasure :: AppState -> Bool
atFirstMeasure AppState
st = AppState -> MeasureIndex
asCurrentMeasure AppState
st MeasureIndex -> MeasureIndex -> Bool
forall a. Eq a => a -> a -> Bool
== Int -> MeasureIndex
MeasureIndex Int
0

{- | Move the cursor one beat left, wrapping to the last beat of the
previous measure if already at beat 0 (computed by temporarily moving
the cursor there and re-running 'currentMeasureBeatCount', since that
count depends on which measure\/track is current).
-}
moveBeatLeft :: AppState -> AppState
moveBeatLeft :: AppState -> AppState
moveBeatLeft AppState
st
    | AppState -> Bool
atFirstBeat AppState
st Bool -> Bool -> Bool
&& Bool -> Bool
not (AppState -> Bool
atFirstMeasure AppState
st) =
        -- Wrap to last beat of previous measure
        let MeasureIndex Int
m = AppState -> MeasureIndex
asCurrentMeasure AppState
st
            prevM :: MeasureIndex
prevM = Int -> MeasureIndex
MeasureIndex (Int
m Int -> Int -> Int
forall a. Num a => a -> a -> a
- Int
1)
            -- Temporarily set cursor to previous measure to compute its beat count
            prevSt :: AppState
prevSt = AppState
st{asCurrentMeasure = prevM}
            prevBeatCount :: Int
prevBeatCount = AppState -> Int
currentMeasureBeatCount AppState
prevSt
         in AppState
st{asCurrentMeasure = prevM, asCurrentBeat = BeatIndex (prevBeatCount - 1)}
    | AppState -> Bool
atFirstBeat AppState
st = AppState
st
    | Bool
otherwise =
        let BeatIndex Int
b = AppState -> BeatIndex
asCurrentBeat AppState
st
         in AppState
st{asCurrentBeat = BeatIndex (b - 1)}

{- | Jump the cursor to beat 0 of the next measure, clamped at the last
measure (does not wrap or auto-create).
-}
moveMeasureForward :: AppState -> AppState
moveMeasureForward :: AppState -> AppState
moveMeasureForward AppState
st =
    let MeasureIndex Int
m = AppState -> MeasureIndex
asCurrentMeasure AppState
st
        maxM :: Int
maxM = Int -> Int -> Int
forall a. Ord a => a -> a -> a
max Int
0 (AppState -> Int
measureCount AppState
st Int -> Int -> Int
forall a. Num a => a -> a -> a
- Int
1)
     in AppState
st
            { asCurrentMeasure = MeasureIndex (min (m + 1) maxM)
            , asCurrentBeat = BeatIndex 0
            }

-- | Jump the cursor to beat 0 of the previous measure, clamped at measure 0.
moveMeasureBack :: AppState -> AppState
moveMeasureBack :: AppState -> AppState
moveMeasureBack AppState
st =
    let MeasureIndex Int
m = AppState -> MeasureIndex
asCurrentMeasure AppState
st
     in AppState
st
            { asCurrentMeasure = MeasureIndex (max 0 (m - 1))
            , asCurrentBeat = BeatIndex 0
            }

-- | Move the cursor's string index up (toward index 0), clamped there.
moveStringUp :: AppState -> AppState
moveStringUp :: AppState -> AppState
moveStringUp AppState
st =
    let StringIndex Int
s = AppState -> StringIndex
asCurrentString AppState
st
     in AppState
st{asCurrentString = StringIndex (max 0 (s - 1))}

{- | Move the cursor's string index down, clamped to the current track's
last string (0 for a 'Standard' track, since it has no strings).
-}
moveStringDown :: AppState -> AppState
moveStringDown :: AppState -> AppState
moveStringDown AppState
st =
    let StringIndex Int
s = AppState -> StringIndex
asCurrentString AppState
st
        maxS :: Int
maxS = case AppState -> Maybe Track
currentTrack AppState
st of
            Maybe Track
Nothing -> Int
0
            Just Track
t -> Int -> Int -> Int
forall a. Ord a => a -> a -> a
max Int
0 (Track -> Int
stringCountForTrack Track
t Int -> Int -> Int
forall a. Num a => a -> a -> a
- Int
1)
     in AppState
st{asCurrentString = StringIndex (min (s + 1) maxS)}

-- | Jump the cursor to measure 0, beat 0.
goToStart :: AppState -> AppState
goToStart :: AppState -> AppState
goToStart AppState
st =
    AppState
st
        { asCurrentMeasure = MeasureIndex 0
        , asCurrentBeat = BeatIndex 0
        }

{- | Jump the cursor to the last beat of the last measure, per that
measure's own time signature (not 'currentMeasureBeatCount', which
would also account for empty padding slots — this uses the raw
numerator instead).
-}
goToEnd :: AppState -> AppState
goToEnd :: AppState -> AppState
goToEnd AppState
st =
    let lastM :: Int
lastM = Int -> Int -> Int
forall a. Ord a => a -> a -> a
max Int
0 (AppState -> Int
measureCount AppState
st Int -> Int -> Int
forall a. Num a => a -> a -> a
- Int
1)
        measures :: [Measure]
measures = Song -> [Measure]
songMeasures (AppState -> Song
asSong AppState
st)
        lastBeat :: Int
lastBeat = case Int -> [Measure] -> [Measure]
forall a. Int -> [a] -> [a]
drop Int
lastM [Measure]
measures of
            (Measure
m : [Measure]
_) -> let TimeSignature Int
num Int
_ = Measure -> TimeSignature
timeSignature Measure
m in Int
num Int -> Int -> Int
forall a. Num a => a -> a -> a
- Int
1
            [] -> Int
0
     in AppState
st
            { asCurrentMeasure = MeasureIndex lastM
            , asCurrentBeat = BeatIndex lastBeat
            }

-- | Jump to beat 0 of the current measure.
goToMeasureStart :: AppState -> AppState
goToMeasureStart :: AppState -> AppState
goToMeasureStart AppState
st = AppState
st{asCurrentBeat = BeatIndex 0}

{- | Jump to the last beat of the current measure, per its time signature
numerator (see 'goToEnd' for the same caveat about padding slots).
-}
goToMeasureEnd :: AppState -> AppState
goToMeasureEnd :: AppState -> AppState
goToMeasureEnd AppState
st =
    let lastBeat :: Int
lastBeat = case AppState -> Maybe Measure
currentMeasure AppState
st of
            Just Measure
m -> let TimeSignature Int
num Int
_ = Measure -> TimeSignature
timeSignature Measure
m in Int
num Int -> Int -> Int
forall a. Num a => a -> a -> a
- Int
1
            Maybe Measure
Nothing -> Int
0
     in AppState
st{asCurrentBeat = BeatIndex lastBeat}

-- | Jump the cursor to string index 0.
goToFirstString :: AppState -> AppState
goToFirstString :: AppState -> AppState
goToFirstString AppState
st = AppState
st{asCurrentString = StringIndex 0}

-- | Jump the cursor to the current track's last string.
goToLastString :: AppState -> AppState
goToLastString :: AppState -> AppState
goToLastString AppState
st =
    let maxS :: Int
maxS = case AppState -> Maybe Track
currentTrack AppState
st of
            Maybe Track
Nothing -> Int
0
            Just Track
t -> Int -> Int -> Int
forall a. Ord a => a -> a -> a
max Int
0 (Track -> Int
stringCountForTrack Track
t Int -> Int -> Int
forall a. Num a => a -> a -> a
- Int
1)
     in AppState
st{asCurrentString = StringIndex maxS}

{- | Advance the current track's 'DisplayMode' to the next in the cycle
@TabOnly -> NotationOnly -> TabAndNotation -> TabOnly@.
-}
cycleDisplayMode :: AppState -> AppState
cycleDisplayMode :: AppState -> AppState
cycleDisplayMode AppState
st =
    let tIdx :: TrackIndex
tIdx = AppState -> TrackIndex
asCurrentTrack AppState
st
        current :: DisplayMode
current = DisplayMode
-> TrackIndex -> Map TrackIndex DisplayMode -> DisplayMode
forall k a. Ord k => a -> k -> Map k a -> a
Map.findWithDefault DisplayMode
TabOnly TrackIndex
tIdx (AppState -> Map TrackIndex DisplayMode
asDisplayModes AppState
st)
        next :: DisplayMode
next = case DisplayMode
current of
            DisplayMode
TabOnly -> DisplayMode
NotationOnly
            DisplayMode
NotationOnly -> DisplayMode
TabAndNotation
            DisplayMode
TabAndNotation -> DisplayMode
TabOnly
     in AppState
st{asDisplayModes = Map.insert tIdx next (asDisplayModes st)}

-- | Increase 'asZoom', clamped to a maximum of 12.
zoomIn :: AppState -> AppState
zoomIn :: AppState -> AppState
zoomIn AppState
st = AppState
st{asZoom = min 12 (asZoom st + 1)}

-- | Decrease 'asZoom', clamped to a minimum of 2.
zoomOut :: AppState -> AppState
zoomOut :: AppState -> AppState
zoomOut AppState
st = AppState
st{asZoom = max 2 (asZoom st - 1)}