| | | 1 | | using Spectre.Console; |
| | | 2 | | |
| | | 3 | | namespace Punch.CLI; |
| | | 4 | | |
| | | 5 | | // Drives the TUI event loop: reads keys and mutates the PunchSession, persisting |
| | | 6 | | // and re-rendering after each keypress. Rendering lives in PunchView; the data |
| | | 7 | | // model and occupancy invariants live in DaySchedule. |
| | | 8 | | internal sealed class PunchController |
| | | 9 | | { |
| | | 10 | | private readonly PunchSession _session; |
| | | 11 | | private readonly PunchView _view; |
| | | 12 | | private readonly Func<int> _windowHeight; |
| | | 13 | | |
| | | 14 | | // Two-step confirmations for quit (Ctrl+Q then Q) and delete (Ctrl+D then D). |
| | | 15 | | private bool _confirming; |
| | | 16 | | private bool _confirmingDelete; |
| | | 17 | | |
| | 72 | 18 | | public PunchController(PunchSession session, PunchView view, Func<int>? windowHeight = null) |
| | 72 | 19 | | { |
| | 72 | 20 | | _session = session; |
| | 72 | 21 | | _view = view; |
| | 72 | 22 | | _windowHeight = windowHeight ?? (() => System.Console.WindowHeight); |
| | 72 | 23 | | } |
| | | 24 | | |
| | 3 | 25 | | internal bool IsConfirmingQuit => _confirming; |
| | 4 | 26 | | internal bool IsConfirmingDelete => _confirmingDelete; |
| | | 27 | | |
| | 328 | 28 | | private DaySchedule Schedule => _session.Schedule; |
| | | 29 | | |
| | | 30 | | public void Run(LiveDisplayContext ctx) |
| | 0 | 31 | | { |
| | 0 | 32 | | Render(ctx); |
| | | 33 | | |
| | 0 | 34 | | while (true) |
| | 0 | 35 | | { |
| | 0 | 36 | | var key = System.Console.ReadKey(true); |
| | 0 | 37 | | if (HandleKey(key)) |
| | 0 | 38 | | break; |
| | 0 | 39 | | Render(ctx); |
| | 0 | 40 | | } |
| | 0 | 41 | | } |
| | | 42 | | |
| | | 43 | | // Processes one keypress. Returns true when the app should quit. |
| | | 44 | | internal bool HandleKey(ConsoleKeyInfo key) |
| | 217 | 45 | | { |
| | | 46 | | // Two-step quit: Q confirms, anything else cancels. |
| | 217 | 47 | | if (_confirming) |
| | 3 | 48 | | { |
| | 3 | 49 | | if (key.Key == ConsoleKey.Q) |
| | 2 | 50 | | return true; |
| | 1 | 51 | | _confirming = false; |
| | 1 | 52 | | return false; |
| | | 53 | | } |
| | | 54 | | |
| | | 55 | | // Two-step delete: D confirms, anything else cancels. |
| | 214 | 56 | | if (_confirmingDelete) |
| | 2 | 57 | | { |
| | 2 | 58 | | if (key.Key == ConsoleKey.D) |
| | 1 | 59 | | DeleteSelected(); |
| | 2 | 60 | | _confirmingDelete = false; |
| | 2 | 61 | | return false; |
| | | 62 | | } |
| | | 63 | | |
| | 212 | 64 | | if (key.Key == ConsoleKey.Q && key.Modifiers.HasFlag(ConsoleModifiers.Control)) |
| | 4 | 65 | | { |
| | 4 | 66 | | _confirming = true; |
| | 4 | 67 | | return false; |
| | | 68 | | } |
| | | 69 | | |
| | | 70 | | // Any key dismisses help. |
| | 208 | 71 | | if (_session.ShowHelp) |
| | 1 | 72 | | { |
| | 1 | 73 | | _session.ShowHelp = false; |
| | 1 | 74 | | return false; |
| | | 75 | | } |
| | | 76 | | |
| | | 77 | | // Ticket picker is modal: arrows move the highlight, Enter assigns, |
| | | 78 | | // Esc/F4/Ctrl+P cancel; all other keys are swallowed. |
| | 207 | 79 | | if (_session.ShowTicketPicker) |
| | 11 | 80 | | { |
| | 11 | 81 | | switch (key.Key) |
| | | 82 | | { |
| | | 83 | | case ConsoleKey.UpArrow: |
| | 2 | 84 | | if (_session.TicketPickerCursor > 0) |
| | 1 | 85 | | _session.TicketPickerCursor--; |
| | 2 | 86 | | break; |
| | | 87 | | case ConsoleKey.DownArrow: |
| | 3 | 88 | | if (_session.TicketPickerCursor < _session.Tickets.Count - 1) |
| | 2 | 89 | | _session.TicketPickerCursor++; |
| | 3 | 90 | | break; |
| | | 91 | | case ConsoleKey.Enter: |
| | 2 | 92 | | ApplyTicketPick(); |
| | 2 | 93 | | break; |
| | | 94 | | case ConsoleKey.Escape: |
| | | 95 | | case ConsoleKey.F4: |
| | 2 | 96 | | _session.ShowTicketPicker = false; |
| | 2 | 97 | | break; |
| | 1 | 98 | | case ConsoleKey.P when key.Modifiers.HasFlag(ConsoleModifiers.Control): |
| | 1 | 99 | | _session.ShowTicketPicker = false; |
| | 1 | 100 | | break; |
| | | 101 | | } |
| | 11 | 102 | | return false; |
| | | 103 | | } |
| | | 104 | | |
| | | 105 | | // F4 (or Ctrl+P, for terminals/recorders that can't send F-keys) opens |
| | | 106 | | // the ticket picker for the selected block (not while editing). |
| | 196 | 107 | | if ((key.Key == ConsoleKey.F4 |
| | 196 | 108 | | || (key.Key == ConsoleKey.P && key.Modifiers.HasFlag(ConsoleModifiers.Control))) |
| | 196 | 109 | | && _session.SelectedBlock != null && !_session.Editing) |
| | 9 | 110 | | { |
| | 9 | 111 | | _session.Tickets = PunchStorage.LoadTickets(); |
| | 9 | 112 | | _session.TicketPickerCursor = 0; |
| | 9 | 113 | | _session.ShowTicketPicker = true; |
| | 9 | 114 | | return false; |
| | | 115 | | } |
| | | 116 | | |
| | | 117 | | // Esc/F3/Ctrl+T dismiss the ticket summary; other keys are swallowed. |
| | 187 | 118 | | if (_session.ShowTicketSummary) |
| | 4 | 119 | | { |
| | 4 | 120 | | if (key.Key == ConsoleKey.F3 || key.Key == ConsoleKey.Escape |
| | 4 | 121 | | || (key.Key == ConsoleKey.T && key.Modifiers.HasFlag(ConsoleModifiers.Control))) |
| | 3 | 122 | | { |
| | 3 | 123 | | _session.ShowTicketSummary = false; |
| | 3 | 124 | | } |
| | 4 | 125 | | return false; |
| | | 126 | | } |
| | | 127 | | |
| | | 128 | | // F3 (or Ctrl+T, for terminals/recorders that can't send F-keys) opens |
| | | 129 | | // the ticket summary. |
| | 183 | 130 | | if (key.Key == ConsoleKey.F3 |
| | 183 | 131 | | || (key.Key == ConsoleKey.T && key.Modifiers.HasFlag(ConsoleModifiers.Control))) |
| | 6 | 132 | | { |
| | 6 | 133 | | _session.ShowTicketSummary = true; |
| | 6 | 134 | | return false; |
| | | 135 | | } |
| | | 136 | | |
| | | 137 | | // Toggle help when not typing: a selected block parks the input |
| | | 138 | | // fields, so '?' is free over an occupied slot. With a free cursor |
| | | 139 | | // it only toggles when the input fields are empty so '?' can still |
| | | 140 | | // be typed into a description. |
| | 177 | 141 | | if (key.KeyChar == '?' && !_session.Editing |
| | 177 | 142 | | && (_session.SelectedBlock != null |
| | 177 | 143 | | || (_session.InputBuffer.Length == 0 && _session.TicketBuffer.Length == 0))) |
| | 4 | 144 | | { |
| | 4 | 145 | | _session.ShowHelp = !_session.ShowHelp; |
| | 4 | 146 | | return false; |
| | | 147 | | } |
| | | 148 | | |
| | 173 | 149 | | if (key.Key == ConsoleKey.D && key.Modifiers.HasFlag(ConsoleModifiers.Control) && _session.SelectedBlock != null |
| | 3 | 150 | | { |
| | 3 | 151 | | _confirmingDelete = true; |
| | 3 | 152 | | return false; |
| | | 153 | | } |
| | | 154 | | |
| | 170 | 155 | | DispatchKey(key); |
| | | 156 | | |
| | 170 | 157 | | AutoScrollToSelection(); |
| | 170 | 158 | | ClampScrollOffset(); |
| | 170 | 159 | | return false; |
| | 217 | 160 | | } |
| | | 161 | | |
| | | 162 | | private void DispatchKey(ConsoleKeyInfo key) |
| | 170 | 163 | | { |
| | 170 | 164 | | switch (key.Key) |
| | | 165 | | { |
| | | 166 | | case ConsoleKey.LeftArrow: |
| | 39 | 167 | | HandleLeftArrow(); |
| | 39 | 168 | | break; |
| | | 169 | | case ConsoleKey.RightArrow: |
| | 10 | 170 | | HandleRightArrow(); |
| | 10 | 171 | | break; |
| | | 172 | | case ConsoleKey.UpArrow: |
| | 12 | 173 | | HandleGrow(); |
| | 12 | 174 | | break; |
| | | 175 | | case ConsoleKey.DownArrow: |
| | 5 | 176 | | HandleShrink(); |
| | 5 | 177 | | break; |
| | | 178 | | case ConsoleKey.PageUp: |
| | 1 | 179 | | _session.LogScrollOffset = Math.Max(0, _session.LogScrollOffset - 5); |
| | 1 | 180 | | break; |
| | | 181 | | case ConsoleKey.PageDown: |
| | 1 | 182 | | _session.LogScrollOffset = Math.Min(Math.Max(0, Schedule.Count - 1), _session.LogScrollOffset + 5); |
| | 1 | 183 | | break; |
| | 11 | 184 | | case ConsoleKey.E when key.Modifiers.HasFlag(ConsoleModifiers.Control): |
| | 11 | 185 | | HandleEditStart(); |
| | 11 | 186 | | break; |
| | | 187 | | case ConsoleKey.Enter: |
| | 5 | 188 | | HandleEnter(); |
| | 5 | 189 | | break; |
| | | 190 | | case ConsoleKey.Backspace: |
| | 10 | 191 | | if (_session.IsInputActive && _session.ActiveCursor > 0) |
| | 9 | 192 | | { |
| | 9 | 193 | | _session.ActiveBuffer.Remove(_session.ActiveCursor - 1, 1); |
| | 9 | 194 | | _session.ActiveCursor--; |
| | 9 | 195 | | } |
| | 10 | 196 | | break; |
| | | 197 | | case ConsoleKey.Delete: |
| | 1 | 198 | | if (_session.IsInputActive && _session.ActiveCursor < _session.ActiveBuffer.Length) |
| | 1 | 199 | | _session.ActiveBuffer.Remove(_session.ActiveCursor, 1); |
| | 1 | 200 | | break; |
| | | 201 | | case ConsoleKey.End: |
| | 1 | 202 | | if (_session.IsInputActive && _session.ActiveBuffer.Length > 0) |
| | 1 | 203 | | _session.ActiveCursor = _session.ActiveBuffer.Length; |
| | 1 | 204 | | break; |
| | | 205 | | case ConsoleKey.Home: |
| | 4 | 206 | | if (_session.IsInputActive && _session.ActiveBuffer.Length > 0) |
| | 3 | 207 | | _session.ActiveCursor = 0; |
| | 4 | 208 | | break; |
| | | 209 | | case ConsoleKey.Tab: |
| | 5 | 210 | | if (_session.IsInputActive) |
| | 5 | 211 | | { |
| | 5 | 212 | | _session.ActiveField = _session.ActiveField == 0 ? 1 : 0; |
| | 5 | 213 | | _session.ActiveCursor = _session.ActiveBuffer.Length; |
| | 5 | 214 | | } |
| | 5 | 215 | | break; |
| | | 216 | | default: |
| | 65 | 217 | | HandleTextInput(key); |
| | 65 | 218 | | break; |
| | | 219 | | } |
| | 170 | 220 | | } |
| | | 221 | | |
| | | 222 | | private void HandleLeftArrow() |
| | 39 | 223 | | { |
| | | 224 | | // While typing, Left moves the text cursor within the active field. |
| | 39 | 225 | | if (_session.ActiveBuffer.Length > 0) |
| | 1 | 226 | | { |
| | 1 | 227 | | if (_session.ActiveCursor > 0) |
| | 1 | 228 | | _session.ActiveCursor--; |
| | 1 | 229 | | return; |
| | | 230 | | } |
| | | 231 | | |
| | | 232 | | // Otherwise it moves along the timeline. |
| | | 233 | | // If abandoning an edit, restore the block's original slots (freed on Ctrl+E). |
| | 38 | 234 | | if (_session.Editing && _session.SelectedBlock != null) |
| | 1 | 235 | | Schedule.FillSlots(_session.SelectedBlock); |
| | | 236 | | |
| | 38 | 237 | | if (_session.SelectedBlock != null) |
| | 8 | 238 | | { |
| | | 239 | | // On an existing block: jump to the slot just before it. |
| | 8 | 240 | | var target = _session.SelectedBlock.StartSlot - 1; |
| | 8 | 241 | | if (target >= 0) |
| | 7 | 242 | | MoveTo(target); |
| | 8 | 243 | | } |
| | 30 | 244 | | else if (_session.CursorSlot > 0) |
| | 29 | 245 | | { |
| | | 246 | | // Free selection: slide left by 1, snapping onto an adjacent block. |
| | 29 | 247 | | var newStart = _session.CursorSlot - 1; |
| | 29 | 248 | | var adj = Schedule.FindAt(newStart); |
| | 29 | 249 | | if (adj != null) |
| | 28 | 250 | | SelectBlock(adj); |
| | 1 | 251 | | else if (Schedule.IsFree(newStart)) |
| | 1 | 252 | | _session.CursorSlot = newStart; |
| | 29 | 253 | | } |
| | | 254 | | |
| | 38 | 255 | | _session.ResetInput(); |
| | 39 | 256 | | } |
| | | 257 | | |
| | | 258 | | private void HandleRightArrow() |
| | 10 | 259 | | { |
| | | 260 | | // While typing, Right moves the text cursor within the active field. |
| | 10 | 261 | | if (_session.ActiveBuffer.Length > 0) |
| | 1 | 262 | | { |
| | 1 | 263 | | if (_session.ActiveCursor < _session.ActiveBuffer.Length) |
| | 1 | 264 | | _session.ActiveCursor++; |
| | 1 | 265 | | return; |
| | | 266 | | } |
| | | 267 | | |
| | | 268 | | // Otherwise it moves along the timeline. |
| | | 269 | | // If abandoning an edit, restore the block's original slots (freed on Ctrl+E). |
| | 9 | 270 | | if (_session.Editing && _session.SelectedBlock != null) |
| | 0 | 271 | | Schedule.FillSlots(_session.SelectedBlock); |
| | | 272 | | |
| | 9 | 273 | | if (_session.SelectedBlock != null) |
| | 2 | 274 | | { |
| | | 275 | | // On an existing block: jump to the slot just after it. |
| | 2 | 276 | | var target = _session.SelectedBlock.StartSlot + _session.SelectedBlock.Length; |
| | 2 | 277 | | if (target < 96) |
| | 2 | 278 | | MoveTo(target); |
| | 2 | 279 | | } |
| | 7 | 280 | | else if (_session.CursorSlot + _session.SelectionLength < 96) |
| | 6 | 281 | | { |
| | | 282 | | // Free selection: slide right by 1, snapping onto an adjacent block. |
| | 6 | 283 | | var newEnd = _session.CursorSlot + _session.SelectionLength; |
| | 6 | 284 | | var adj = Schedule.FindAt(newEnd); |
| | 6 | 285 | | if (adj != null) |
| | 4 | 286 | | SelectBlock(adj); |
| | 2 | 287 | | else if (Schedule.IsFree(newEnd)) |
| | 2 | 288 | | _session.CursorSlot++; |
| | 6 | 289 | | } |
| | | 290 | | |
| | 9 | 291 | | _session.ResetInput(); |
| | 10 | 292 | | } |
| | | 293 | | |
| | | 294 | | // Lands the cursor on the given slot, selecting a block if one covers it. |
| | | 295 | | private void MoveTo(int slot) |
| | 9 | 296 | | { |
| | 9 | 297 | | var adj = Schedule.FindAt(slot); |
| | 9 | 298 | | if (adj != null) |
| | 6 | 299 | | { |
| | 6 | 300 | | SelectBlock(adj); |
| | 6 | 301 | | } |
| | | 302 | | else |
| | 3 | 303 | | { |
| | 3 | 304 | | _session.CursorSlot = slot; |
| | 3 | 305 | | _session.SelectionLength = 1; |
| | 3 | 306 | | _session.SelectedBlock = null; |
| | 3 | 307 | | } |
| | 9 | 308 | | } |
| | | 309 | | |
| | | 310 | | private void SelectBlock(TimeBlock block) |
| | 38 | 311 | | { |
| | 38 | 312 | | _session.CursorSlot = block.StartSlot; |
| | 38 | 313 | | _session.SelectionLength = block.Length; |
| | 38 | 314 | | _session.SelectedBlock = block; |
| | 38 | 315 | | } |
| | | 316 | | |
| | | 317 | | private void HandleGrow() |
| | 12 | 318 | | { |
| | 12 | 319 | | if (_session.SelectedBlock == null) |
| | 9 | 320 | | { |
| | 9 | 321 | | var newLen = _session.SelectionLength + 1; |
| | 9 | 322 | | if (_session.CursorSlot + newLen <= 96 && !Schedule.IsOverlapping(_session.CursorSlot, newLen)) |
| | 7 | 323 | | _session.SelectionLength = newLen; |
| | 9 | 324 | | } |
| | 3 | 325 | | else if (_session.Editing && Schedule.CanGrow(_session.SelectedBlock.StartSlot, _session.SelectionLength)) |
| | 1 | 326 | | { |
| | 1 | 327 | | _session.SelectionLength++; |
| | 1 | 328 | | } |
| | 12 | 329 | | } |
| | | 330 | | |
| | | 331 | | private void HandleShrink() |
| | 5 | 332 | | { |
| | 5 | 333 | | if (_session.SelectedBlock == null && _session.SelectionLength > 1) |
| | 1 | 334 | | _session.SelectionLength--; |
| | 4 | 335 | | else if (_session.Editing && _session.SelectedBlock != null && _session.SelectionLength > 1) |
| | 2 | 336 | | _session.SelectionLength--; |
| | 5 | 337 | | } |
| | | 338 | | |
| | | 339 | | private void HandleEditStart() |
| | 11 | 340 | | { |
| | | 341 | | // Ctrl+E: edit the selected block's label, ticket, and length. Free the |
| | | 342 | | // block's slots so a live resize can reuse them; the abandon paths (Left/ |
| | | 343 | | // Right) re-fill them, and Enter re-derives occupancy via Replace. |
| | 11 | 344 | | if (_session.SelectedBlock == null || _session.Editing) |
| | 2 | 345 | | return; |
| | | 346 | | |
| | 9 | 347 | | var block = _session.SelectedBlock; |
| | 9 | 348 | | _session.Editing = true; |
| | 9 | 349 | | _session.CursorSlot = block.StartSlot; |
| | 9 | 350 | | _session.SelectionLength = block.Length; |
| | 9 | 351 | | Schedule.FreeSlots(block); |
| | 9 | 352 | | _session.InputBuffer.Clear(); |
| | 9 | 353 | | _session.InputBuffer.Append(block.Label); |
| | 9 | 354 | | _session.InputCursor = _session.InputBuffer.Length; |
| | 9 | 355 | | _session.TicketBuffer.Clear(); |
| | 9 | 356 | | _session.TicketBuffer.Append(block.Ticket); |
| | 9 | 357 | | _session.TicketCursor = _session.TicketBuffer.Length; |
| | 9 | 358 | | _session.ActiveField = 0; |
| | 11 | 359 | | } |
| | | 360 | | |
| | | 361 | | private void HandleEnter() |
| | 5 | 362 | | { |
| | 5 | 363 | | if (_session.Editing && _session.SelectedBlock != null && _session.InputBuffer.Length > 0) |
| | 1 | 364 | | { |
| | | 365 | | // Save the edited label, ticket, and (possibly resized) length. The |
| | | 366 | | // block's original slots were freed on Ctrl+E; Replace re-derives them. |
| | 1 | 367 | | var edited = _session.SelectedBlock with |
| | 1 | 368 | | { |
| | 1 | 369 | | Length = _session.SelectionLength, |
| | 1 | 370 | | Label = _session.InputBuffer.ToString(), |
| | 1 | 371 | | Ticket = _session.TicketBuffer.ToString(), |
| | 1 | 372 | | }; |
| | 1 | 373 | | _session.SelectedBlock = Schedule.Replace(_session.SelectedBlock, edited); |
| | 1 | 374 | | Save(); |
| | 1 | 375 | | _session.ResetInput(); |
| | 1 | 376 | | } |
| | 4 | 377 | | else if (_session.SelectedBlock == null && _session.InputBuffer.Length > 0) |
| | 2 | 378 | | { |
| | 2 | 379 | | var block = new TimeBlock(_session.CursorSlot, _session.SelectionLength, |
| | 2 | 380 | | _session.InputBuffer.ToString(), _session.TicketBuffer.ToString()); |
| | 2 | 381 | | Schedule.Add(block); |
| | 2 | 382 | | Save(); |
| | 2 | 383 | | _session.ResetInput(); |
| | 2 | 384 | | (_session.CursorSlot, _session.SelectionLength, _session.SelectedBlock) = |
| | 2 | 385 | | Schedule.AdvanceAfterAdd(_session.CursorSlot); |
| | 2 | 386 | | } |
| | 5 | 387 | | } |
| | | 388 | | |
| | | 389 | | private void ApplyTicketPick() |
| | 2 | 390 | | { |
| | | 391 | | // Write only the picked ticket onto the selected block; the label is left |
| | | 392 | | // untouched. The block keeps its slots, so Replace re-derives occupancy. |
| | 2 | 393 | | if (_session.SelectedBlock != null && _session.Tickets.Count > 0) |
| | 1 | 394 | | { |
| | 1 | 395 | | var picked = _session.Tickets[_session.TicketPickerCursor]; |
| | 1 | 396 | | var updated = _session.SelectedBlock with { Ticket = picked.Ticket }; |
| | 1 | 397 | | _session.SelectedBlock = Schedule.Replace(_session.SelectedBlock, updated); |
| | 1 | 398 | | Save(); |
| | 1 | 399 | | } |
| | 2 | 400 | | _session.ShowTicketPicker = false; |
| | 2 | 401 | | } |
| | | 402 | | |
| | | 403 | | private void HandleTextInput(ConsoleKeyInfo key) |
| | 65 | 404 | | { |
| | 65 | 405 | | if (key.KeyChar == '\0' || char.IsControl(key.KeyChar) || !_session.IsInputActive) |
| | 7 | 406 | | return; |
| | | 407 | | |
| | | 408 | | // Ticket numbers are auto-uppercased. |
| | 58 | 409 | | var ch = _session.ActiveField == 1 ? char.ToUpperInvariant(key.KeyChar) : key.KeyChar; |
| | 58 | 410 | | _session.ActiveBuffer.Insert(_session.ActiveCursor, ch); |
| | 58 | 411 | | _session.ActiveCursor++; |
| | 65 | 412 | | } |
| | | 413 | | |
| | | 414 | | private void DeleteSelected() |
| | 1 | 415 | | { |
| | 1 | 416 | | Schedule.Remove(_session.SelectedBlock!); |
| | 1 | 417 | | Save(); |
| | 1 | 418 | | _session.SelectedBlock = null; |
| | 1 | 419 | | _session.SelectionLength = 1; |
| | 1 | 420 | | _session.ResetInput(); |
| | 1 | 421 | | } |
| | | 422 | | |
| | | 423 | | private void AutoScrollToSelection() |
| | 170 | 424 | | { |
| | 170 | 425 | | var selected = _session.SelectedBlock; |
| | 170 | 426 | | if (selected == null) |
| | 92 | 427 | | return; |
| | | 428 | | |
| | 130 | 429 | | var sorted = Schedule.Blocks.OrderBy(b => b.StartSlot).ToList(); |
| | 177 | 430 | | var selIdx = sorted.FindIndex(b => b.StartSlot == selected.StartSlot && b.Length == selected.Length); |
| | 78 | 431 | | if (selIdx < 0) |
| | 0 | 432 | | return; |
| | | 433 | | |
| | | 434 | | // When scrolled, the "more above" indicator takes 1 line. |
| | 78 | 435 | | var visHeight = Math.Max(1, _windowHeight() - 10 - 2 - 1); |
| | 78 | 436 | | if (selIdx < _session.LogScrollOffset) |
| | 4 | 437 | | _session.LogScrollOffset = selIdx; |
| | 74 | 438 | | else if (selIdx >= _session.LogScrollOffset + visHeight) |
| | 2 | 439 | | _session.LogScrollOffset = selIdx - visHeight + 1; |
| | 170 | 440 | | } |
| | | 441 | | |
| | | 442 | | private void ClampScrollOffset() |
| | 170 | 443 | | { |
| | | 444 | | // When scrolled down, the "▲ more above" indicator takes 1 line, so only |
| | | 445 | | // viewHeight-1 block rows are visible at the bottom. |
| | 170 | 446 | | var viewHeight = Math.Max(1, _windowHeight() - 10 - 2); |
| | 170 | 447 | | var maxOff = Math.Max(0, Schedule.Count - (viewHeight - 1)); |
| | 170 | 448 | | _session.LogScrollOffset = Math.Clamp(_session.LogScrollOffset, 0, maxOff); |
| | 170 | 449 | | } |
| | | 450 | | |
| | 5 | 451 | | private void Save() => PunchStorage.Save(_session.WorkingDate, Schedule.Blocks); |
| | | 452 | | |
| | | 453 | | private void Render(LiveDisplayContext ctx) |
| | 0 | 454 | | { |
| | 0 | 455 | | _view.Render(_session, _confirming, _confirmingDelete); |
| | 0 | 456 | | ctx.Refresh(); |
| | 0 | 457 | | } |
| | | 458 | | } |