View Javadoc
1   package com.guinetik.hexafun.examples.tasks.tui;
2   
3   import static com.guinetik.hexafun.examples.tui.Ansi.*;
4   
5   import com.guinetik.hexafun.examples.tasks.Task;
6   import com.guinetik.hexafun.examples.tasks.TaskApp;
7   import com.guinetik.hexafun.examples.tasks.TaskStatus;
8   import com.guinetik.hexafun.examples.tui.HexaTerminal;
9   import com.guinetik.hexafun.examples.tui.View;
10  import com.guinetik.hexafun.examples.tui.Widgets;
11  import com.guinetik.hexafun.fun.Result;
12  import java.io.BufferedReader;
13  import java.io.InputStreamReader;
14  import java.util.List;
15  import java.util.function.Function;
16  import java.util.function.UnaryOperator;
17  
18  /**
19   * Functional TUI for the Tasks application.
20   *
21   * <p>Demonstrates functional composition in a terminal UI using the
22   * shared {@link com.guinetik.hexafun.examples.tui} package:
23   * <ul>
24   *   <li>{@link State} - Immutable state record</li>
25   *   <li>{@link View} - Pure function S → String (from shared tui package)</li>
26   *   <li>{@link Views} - Composable view components</li>
27   *   <li>{@link Widgets} - Reusable TUI widgets (from shared tui package)</li>
28   *   <li>Single {@link #render(State)} side effect</li>
29   * </ul>
30   *
31   * <p>The pattern: {@code render(views.screen().apply(state))}
32   */
33  public class TasksTUI {
34  
35      // ═══════════════════════════════════════════════════════════════════
36      //  IMMUTABLE STATE
37      // ═══════════════════════════════════════════════════════════════════
38  
39      /**
40       * Immutable TUI state. All state transitions return new State instances.
41       */
42      public record State(
43          TaskApp app,
44          int width,
45          String status,
46          String statusColor,
47          boolean running
48      ) {
49          private static final int MIN_WIDTH = 60;
50          private static final int KANBAN_MIN_WIDTH = 80;
51  
52          /** Create initial state */
53          public static State initial(TaskApp app) {
54              return new State(app, detectWidth(), "", GREEN, true);
55          }
56  
57          /** State transitions - all return new State */
58          public State withStatus(String msg, String color) {
59              return new State(app, width, msg, color, running);
60          }
61  
62          public State withWidth(int w) {
63              return new State(
64                  app,
65                  Math.max(MIN_WIDTH, w),
66                  status,
67                  statusColor,
68                  running
69              );
70          }
71  
72          public State refreshWidth() {
73              return withWidth(detectWidth());
74          }
75  
76          public State stop() {
77              return new State(app, width, "Goodbye!", CYAN, false);
78          }
79  
80          /** Derived state */
81          public boolean isKanban() {
82              return width >= KANBAN_MIN_WIDTH;
83          }
84  
85          public List<Task> tasks() {
86              return app.listTasks();
87          }
88  
89          public List<Task> byStatus(TaskStatus s) {
90              return tasks()
91                  .stream()
92                  .filter(t -> t.status() == s)
93                  .toList();
94          }
95  
96          /** Terminal width detection - delegates to shared Terminal utility */
97          private static int detectWidth() {
98              return HexaTerminal.detectWidth(MIN_WIDTH, 120);
99          }
100     }
101 
102     // ═══════════════════════════════════════════════════════════════════
103     //  VIEWS - PURE VIEW COMPONENTS (using shared View<S> interface)
104     // ═══════════════════════════════════════════════════════════════════
105 
106     /**
107      * Pure view functions using the shared {@link View} interface.
108      * Each transforms State → String with no side effects.
109      */
110     public static class Views {
111 
112         /** The complete screen - composed from all views */
113         public static View<State> screen() {
114             return clear()
115                 .andThen(header())
116                 .andThen(stats())
117                 .andThen(View.when(State::isKanban, kanban(), tasks()))
118                 .andThen(menu())
119                 .andThen(status())
120                 .andThen(prompt());
121         }
122 
123         // ─────────────────────────────────────────────────────────────
124         //  Individual view components
125         // ─────────────────────────────────────────────────────────────
126 
127         public static View<State> clear() {
128             return state -> CLEAR + CURSOR_HOME;
129         }
130 
131         public static View<State> header() {
132             return state -> {
133                 int w = state.width();
134                 int headerWidth = w - 4;
135                 String title = ICON_TASK + "  TASK MANAGER";
136                 String mode = state.isKanban() ? "KANBAN" : "LIST";
137                 String hint = state.isKanban()
138                     ? ""
139                     : " (<" + State.KANBAN_MIN_WIDTH + " cols)";
140                 String subtitle =
141                     "HexaFun Demo  [" + w + "w " + mode + hint + "]";
142 
143                 return lines(
144                     "",
145                     color(
146                         "  " +
147                             DBOX_TOP_LEFT +
148                             repeat(DBOX_HORIZONTAL, headerWidth) +
149                             DBOX_TOP_RIGHT,
150                         CYAN
151                     ),
152                     color("  " + DBOX_VERTICAL, CYAN) +
153                         color(center(title, headerWidth), BOLD, BRIGHT_WHITE) +
154                         color(DBOX_VERTICAL, CYAN),
155                     color("  " + DBOX_VERTICAL, CYAN) +
156                         color(center(subtitle, headerWidth), DIM) +
157                         color(DBOX_VERTICAL, CYAN),
158                     color(
159                         "  " +
160                             DBOX_BOTTOM_LEFT +
161                             repeat(DBOX_HORIZONTAL, headerWidth) +
162                             DBOX_BOTTOM_RIGHT,
163                         CYAN
164                     ),
165                     ""
166                 );
167             };
168         }
169 
170         public static View<State> stats() {
171             return state -> {
172                 List<Task> tasks = state.tasks();
173                 long total = tasks.size();
174                 long todo = state.byStatus(TaskStatus.TODO).size();
175                 long doing = state.byStatus(TaskStatus.DOING).size();
176                 long done = state.byStatus(TaskStatus.DONE).size();
177                 int percent = total > 0 ? (int) ((done * 100) / total) : 0;
178 
179                 // Fluid segment widths
180                 int available = state.width() - 6;
181                 int segW = available / 3;
182                 int extra = available % 3;
183 
184                 String todoSeg = color(
185                     center(BULLET + " " + todo + " TODO", segW + extra),
186                     BG_YELLOW,
187                     BLACK,
188                     BOLD
189                 );
190                 String doingSeg = color(
191                     center(ICON_FLAME + " " + doing + " DOING", segW),
192                     BG_MAGENTA,
193                     BRIGHT_WHITE,
194                     BOLD
195                 );
196                 String doneSeg = color(
197                     center(CHECK + " " + done + " DONE", segW),
198                     BG_GREEN,
199                     BLACK,
200                     BOLD
201                 );
202 
203                 // Progress bar
204                 int barW = state.width() - 8;
205                 int filled = (barW * percent) / 100;
206                 int empty = barW - filled;
207 
208                 return lines(
209                     "  " +
210                         todoSeg +
211                         color(PL_LEFT, YELLOW, BG_MAGENTA) +
212                         doingSeg +
213                         color(PL_LEFT, MAGENTA, BG_GREEN) +
214                         doneSeg +
215                         color(PL_LEFT, GREEN),
216                     "",
217                     "  " +
218                         color(repeat(BLOCK_FULL, filled), GREEN) +
219                         color(repeat(BLOCK_LIGHT, empty), BRIGHT_BLACK) +
220                         color(String.format(" %3d%%", percent), DIM),
221                     ""
222                 );
223             };
224         }
225 
226         public static View<State> tasks() {
227             return state -> {
228                 int w = state.width();
229                 List<Task> tasks = state.tasks();
230                 int lineW = w - 4 - 9;
231 
232                 StringBuilder sb = new StringBuilder();
233                 sb
234                     .append(
235                         color(
236                             "  " +
237                                 BOX_HORIZONTAL +
238                                 " TASKS " +
239                                 repeat(BOX_HORIZONTAL, lineW),
240                             DIM
241                         )
242                     )
243                     .append("\n\n");
244 
245                 if (tasks.isEmpty()) {
246                     sb
247                         .append(
248                             color(
249                                 "  " +
250                                     center(
251                                         "No tasks yet. Press [a] to add one!",
252                                         w - 4
253                                     ),
254                                 DIM,
255                                 ITALIC
256                             )
257                         )
258                         .append("\n\n");
259                 } else {
260                     int idx = 1;
261                     for (Task task : tasks) {
262                         sb.append(taskCard(task, idx++, w));
263                     }
264                 }
265                 return sb.toString();
266             };
267         }
268 
269         private static String taskCard(Task task, int index, int width) {
270             String icon = switch (task.status()) {
271                 case TODO -> BULLET;
272                 case DOING -> ICON_FLAME;
273                 case DONE -> CHECK;
274             };
275             String iconColor = switch (task.status()) {
276                 case TODO -> YELLOW;
277                 case DOING -> MAGENTA;
278                 case DONE -> GREEN;
279             };
280             String titleColor = task.completed() ? DIM : BRIGHT_WHITE;
281             String suffix = switch (task.status()) {
282                 case DONE -> color("  (done)", DIM, GREEN);
283                 case DOING -> color("  (wip)", DIM, MAGENTA);
284                 default -> "";
285             };
286 
287             int suffixLen = task.status() == TaskStatus.DONE
288                 ? 8
289                 : (task.status() == TaskStatus.DOING ? 7 : 0);
290             int available = width - 8 - suffixLen - 2;
291             String title = task.title();
292             if (title.length() > available) {
293                 title = title.substring(0, Math.max(0, available - 3)) + "...";
294             }
295 
296             StringBuilder sb = new StringBuilder();
297             sb
298                 .append("    ")
299                 .append(color(index + " ", BRIGHT_BLACK))
300                 .append(color(icon + " ", iconColor))
301                 .append(color(title, titleColor))
302                 .append(suffix)
303                 .append("\n");
304 
305             if (task.description() != null && !task.description().isBlank()) {
306                 int descW = width - 10;
307                 String desc = task.description();
308                 if (desc.length() > descW) desc =
309                     desc.substring(0, Math.max(0, descW - 3)) + "...";
310                 sb.append(color("        " + desc, DIM)).append("\n");
311             }
312             sb.append("\n");
313             return sb.toString();
314         }
315 
316         public static View<State> kanban() {
317             return state -> {
318                 List<Task> todo = state.byStatus(TaskStatus.TODO);
319                 List<Task> doing = state.byStatus(TaskStatus.DOING);
320                 List<Task> done = state.byStatus(TaskStatus.DONE);
321 
322                 int totalW = state.width() - 4;
323                 int innerW = totalW - 2; // gutters
324                 int colW = innerW / 3;
325                 int extra = innerW % 3;
326                 int c1 = colW + extra,
327                     c2 = colW,
328                     c3 = colW;
329 
330                 StringBuilder sb = new StringBuilder();
331 
332                 // Top borders
333                 sb
334                     .append("  ")
335                     .append(
336                         color(
337                             BOX_TOP_LEFT +
338                                 repeat(BOX_HORIZONTAL, c1 - 2) +
339                                 BOX_TOP_RIGHT,
340                             YELLOW
341                         )
342                     )
343                     .append(" ")
344                     .append(
345                         color(
346                             BOX_TOP_LEFT +
347                                 repeat(BOX_HORIZONTAL, c2 - 2) +
348                                 BOX_TOP_RIGHT,
349                             MAGENTA
350                         )
351                     )
352                     .append(" ")
353                     .append(
354                         color(
355                             BOX_TOP_LEFT +
356                                 repeat(BOX_HORIZONTAL, c3 - 2) +
357                                 BOX_TOP_RIGHT,
358                             GREEN
359                         )
360                     )
361                     .append("\n");
362 
363                 // Headers
364                 sb
365                     .append("  ")
366                     .append(color(BOX_VERTICAL, YELLOW))
367                     .append(
368                         color(
369                             center(
370                                 BULLET + " TODO (" + todo.size() + ")",
371                                 c1 - 2
372                             ),
373                             BOLD,
374                             YELLOW
375                         )
376                     )
377                     .append(color(BOX_VERTICAL, YELLOW))
378                     .append(" ")
379                     .append(color(BOX_VERTICAL, MAGENTA))
380                     .append(
381                         color(
382                             center(
383                                 ICON_FLAME + " DOING (" + doing.size() + ")",
384                                 c2 - 2
385                             ),
386                             BOLD,
387                             MAGENTA
388                         )
389                     )
390                     .append(color(BOX_VERTICAL, MAGENTA))
391                     .append(" ")
392                     .append(color(BOX_VERTICAL, GREEN))
393                     .append(
394                         color(
395                             center(
396                                 CHECK + " DONE (" + done.size() + ")",
397                                 c3 - 2
398                             ),
399                             BOLD,
400                             GREEN
401                         )
402                     )
403                     .append(color(BOX_VERTICAL, GREEN))
404                     .append("\n");
405 
406                 // Separators
407                 sb
408                     .append("  ")
409                     .append(
410                         color(
411                             BOX_T_RIGHT +
412                                 repeat(BOX_HORIZONTAL, c1 - 2) +
413                                 BOX_T_LEFT,
414                             YELLOW
415                         )
416                     )
417                     .append(" ")
418                     .append(
419                         color(
420                             BOX_T_RIGHT +
421                                 repeat(BOX_HORIZONTAL, c2 - 2) +
422                                 BOX_T_LEFT,
423                             MAGENTA
424                         )
425                     )
426                     .append(" ")
427                     .append(
428                         color(
429                             BOX_T_RIGHT +
430                                 repeat(BOX_HORIZONTAL, c3 - 2) +
431                                 BOX_T_LEFT,
432                             GREEN
433                         )
434                     )
435                     .append("\n");
436 
437                 // Task rows
438                 int maxRows = Math.max(
439                     todo.size(),
440                     Math.max(doing.size(), done.size())
441                 );
442                 if (maxRows == 0) maxRows = 1;
443 
444                 int todoIdx = 1,
445                     doingIdx = todo.size() + 1,
446                     doneIdx = todo.size() + doing.size() + 1;
447 
448                 for (int i = 0; i < maxRows; i++) {
449                     sb.append("  ").append(color(BOX_VERTICAL, YELLOW));
450                     sb.append(
451                         i < todo.size()
452                             ? color(
453                                   kanbanCard(todo.get(i), todoIdx++, c1 - 2),
454                                   BRIGHT_WHITE
455                               )
456                             : repeat(" ", c1 - 2)
457                     );
458                     sb.append(color(BOX_VERTICAL, YELLOW)).append(" ");
459 
460                     sb.append(color(BOX_VERTICAL, MAGENTA));
461                     sb.append(
462                         i < doing.size()
463                             ? color(
464                                   kanbanCard(doing.get(i), doingIdx++, c2 - 2),
465                                   BRIGHT_MAGENTA
466                               )
467                             : repeat(" ", c2 - 2)
468                     );
469                     sb.append(color(BOX_VERTICAL, MAGENTA)).append(" ");
470 
471                     sb.append(color(BOX_VERTICAL, GREEN));
472                     sb.append(
473                         i < done.size()
474                             ? color(
475                                   kanbanCard(done.get(i), doneIdx++, c3 - 2),
476                                   DIM
477                               )
478                             : repeat(" ", c3 - 2)
479                     );
480                     sb.append(color(BOX_VERTICAL, GREEN)).append("\n");
481                 }
482 
483                 // Bottom borders
484                 sb
485                     .append("  ")
486                     .append(
487                         color(
488                             BOX_BOTTOM_LEFT +
489                                 repeat(BOX_HORIZONTAL, c1 - 2) +
490                                 BOX_BOTTOM_RIGHT,
491                             YELLOW
492                         )
493                     )
494                     .append(" ")
495                     .append(
496                         color(
497                             BOX_BOTTOM_LEFT +
498                                 repeat(BOX_HORIZONTAL, c2 - 2) +
499                                 BOX_BOTTOM_RIGHT,
500                             MAGENTA
501                         )
502                     )
503                     .append(" ")
504                     .append(
505                         color(
506                             BOX_BOTTOM_LEFT +
507                                 repeat(BOX_HORIZONTAL, c3 - 2) +
508                                 BOX_BOTTOM_RIGHT,
509                             GREEN
510                         )
511                     )
512                     .append("\n\n");
513 
514                 return sb.toString();
515             };
516         }
517 
518         private static String kanbanCard(Task task, int index, int width) {
519             String title = task.title();
520             int maxLen = width - 4;
521             if (title.length() > maxLen) title =
522                 title.substring(0, Math.max(0, maxLen - 2)) + "..";
523             return pad(" " + index + " " + title, width);
524         }
525 
526         public static View<State> menu() {
527             return state ->
528                 lines(
529                     color(
530                         "  " + repeat(BOX_HORIZONTAL, state.width() - 4),
531                         DIM
532                     ),
533                     "",
534                     "  " +
535                         color("[a]", CYAN, BOLD) +
536                         color(" " + ICON_ADD + " Add  ", CYAN) +
537                         color("[v]", BLUE, BOLD) +
538                         color(" " + ICON_EYE + " View  ", BLUE) +
539                         color("[s]", MAGENTA, BOLD) +
540                         color(" " + ICON_FLAME + " Start  ", MAGENTA) +
541                         color("[c]", GREEN, BOLD) +
542                         color(" " + CHECK + " Done  ", GREEN) +
543                         color("[d]", RED, BOLD) +
544                         color(" " + ICON_TRASH + " Del  ", RED) +
545                         color("[q]", BRIGHT_BLACK, BOLD) +
546                         color(" " + CROSS + " Quit", BRIGHT_BLACK),
547                     ""
548                 );
549         }
550 
551         public static View<State> status() {
552             return state ->
553                 state.status().isEmpty()
554                     ? "\n"
555                     : color(
556                           "  " + ARROW_RIGHT + " " + state.status(),
557                           state.statusColor()
558                       ) +
559                       "\n\n";
560         }
561 
562         public static View<State> prompt() {
563             return state -> color("  > ", CYAN, BOLD);
564         }
565 
566     }
567 
568     // ═══════════════════════════════════════════════════════════════════
569     //  ACTIONS - STATE TRANSITIONS VIA RESULT
570     // ═══════════════════════════════════════════════════════════════════
571 
572     /**
573      * Actions transform State → Result<State>.
574      * Success = new state, Failure = error message to display.
575      */
576     @FunctionalInterface
577     public interface Action extends Function<State, Result<State>> {
578         /** Chain actions: if first succeeds, apply second */
579         default Action andThen(Action next) {
580             return state -> this.apply(state).flatMap(next);
581         }
582 
583         /** Action that always succeeds with given state transform */
584         static Action of(UnaryOperator<State> transform) {
585             return state -> Result.ok(transform.apply(state));
586         }
587 
588         /** Action from a Result-returning operation */
589         static Action fromResult(Function<State, Result<State>> f) {
590             return f::apply;
591         }
592     }
593 
594     /**
595      * Action implementations for each command.
596      */
597     public static class Actions {
598 
599         public static Result<State> create(
600             State state,
601             String title,
602             String desc
603         ) {
604             return state
605                 .app()
606                 .createTask(title, desc)
607                 .map(task ->
608                     state.withStatus("Created: " + task.title(), GREEN)
609                 );
610         }
611 
612         public static Result<State> start(State state, String taskId) {
613             return state
614                 .app()
615                 .startTask(taskId)
616                 .map(task ->
617                     state.withStatus("Started: " + task.title(), MAGENTA)
618                 );
619         }
620 
621         public static Result<State> complete(State state, String taskId) {
622             return state
623                 .app()
624                 .completeTask(taskId)
625                 .map(task ->
626                     state.withStatus("Completed: " + task.title(), GREEN)
627                 );
628         }
629 
630         public static Result<State> delete(State state, String taskId) {
631             return state
632                 .app()
633                 .deleteTask(taskId)
634                 .map(ok -> state.withStatus("Deleted task", RED));
635         }
636 
637         /** Quick action: advances task through workflow based on current status */
638         public static Result<State> quickAction(State state, int index) {
639             List<Task> tasks = state.tasks();
640             if (index < 1 || index > tasks.size()) {
641                 return Result.fail("Invalid task number: " + index);
642             }
643 
644             Task task = tasks.get(index - 1);
645             return switch (task.status()) {
646                 case TODO -> start(state, task.id());
647                 case DOING -> complete(state, task.id());
648                 case DONE -> Result.ok(
649                     state.withStatus(
650                         "Already completed: " + task.title(),
651                         YELLOW
652                     )
653                 );
654             };
655         }
656     }
657 
658     // ═══════════════════════════════════════════════════════════════════
659     //  RUNTIME - THE ONLY SIDE EFFECTS
660     // ═══════════════════════════════════════════════════════════════════
661 
662     private final BufferedReader reader;
663     private final View<State> screen;
664 
665     public TasksTUI() {
666         this.reader = new BufferedReader(new InputStreamReader(System.in));
667         this.screen = Views.screen();
668     }
669 
670     /** The ONE place where we print to console */
671     private void render(State state) {
672         System.out.print(screen.apply(state));
673         System.out.flush();
674     }
675 
676     /** Read a line of input */
677     private String readLine() {
678         try {
679             return reader.readLine();
680         } catch (Exception e) {
681             return null;
682         }
683     }
684 
685     /** Prompt for additional input during an action */
686     private String prompt(String message) {
687         System.out.print(color("  " + message + ": ", CYAN));
688         System.out.flush();
689         return readLine();
690     }
691 
692     /** Main loop: render → read → process → repeat */
693     public void run(TaskApp app) {
694         State state = State.initial(app);
695 
696         // Enter alternate screen buffer (doesn't pollute scrollback)
697         System.out.print(ALT_SCREEN_ON);
698         System.out.flush();
699 
700         // Ensure cleanup on exit
701         Runtime.getRuntime().addShutdownHook(new Thread(this::restoreTerminal));
702 
703         try {
704             while (state.running()) {
705                 state = state.refreshWidth();
706                 render(state);
707 
708                 String input = readLine();
709                 state = processInput(state, input);
710             }
711 
712             render(state); // Final render with goodbye message
713         } finally {
714             restoreTerminal();
715         }
716     }
717 
718     /** Restore terminal to normal state */
719     private void restoreTerminal() {
720         System.out.print(ALT_SCREEN_OFF);
721         System.out.flush();
722     }
723 
724     /** Process input and return new state */
725     private State processInput(State state, String input) {
726         if (
727             input == null ||
728             input.equalsIgnoreCase("q") ||
729             input.equalsIgnoreCase("quit")
730         ) {
731             return state.stop();
732         }
733 
734         String cmd = input.toLowerCase().trim();
735 
736         // Handle commands
737         Result<State> result = switch (cmd) {
738             case "a", "add" -> handleAdd(state);
739             case "v", "view" -> handleView(state);
740             case "s", "start" -> handleStart(state);
741             case "c", "complete" -> handleComplete(state);
742             case "d", "delete" -> handleDelete(state);
743             case "" -> Result.ok(state); // Just refresh
744             default -> {
745                 if (cmd.matches("\\d+")) {
746                     yield Actions.quickAction(state, Integer.parseInt(cmd));
747                 }
748                 yield Result.ok(
749                     state.withStatus("Unknown command: " + input, RED)
750                 );
751             }
752         };
753 
754         // Fold result back to state
755         return result.fold(
756             error -> state.withStatus("Error: " + error, RED),
757             newState -> newState
758         );
759     }
760 
761     // ─────────────────────────────────────────────────────────────────
762     //  Interactive handlers (minimal I/O, delegate to Actions)
763     // ─────────────────────────────────────────────────────────────────
764 
765     private Result<State> handleAdd(State state) {
766         System.out.println();
767         String title = prompt("Title");
768         if (title == null || title.isBlank()) {
769             return Result.ok(
770                 state.withStatus("Cancelled - title required", YELLOW)
771             );
772         }
773         String desc = prompt("Description (optional)");
774         return Actions.create(state, title, desc != null ? desc : "");
775     }
776 
777     private Result<State> handleView(State state) {
778         List<Task> tasks = state.tasks();
779         if (tasks.isEmpty()) {
780             return Result.ok(state.withStatus("No tasks to view!", YELLOW));
781         }
782 
783         System.out.println();
784         System.out.println(color("  Select task to view:", DIM));
785         for (int i = 0; i < tasks.size(); i++) {
786             Task t = tasks.get(i);
787             String icon = switch (t.status()) {
788                 case TODO -> color(BULLET, YELLOW);
789                 case DOING -> color(ICON_FLAME, MAGENTA);
790                 case DONE -> color(CHECK, GREEN);
791             };
792             System.out.println(
793                 color("    [" + (i + 1) + "] ", BRIGHT_BLACK) + icon + " " + t.title()
794             );
795         }
796 
797         String input = prompt("Task number");
798         if (input == null || input.isBlank()) {
799             return Result.ok(state.withStatus("Cancelled", YELLOW));
800         }
801 
802         try {
803             int num = Integer.parseInt(input.trim());
804             if (num < 1 || num > tasks.size()) {
805                 return Result.fail("Invalid task number");
806             }
807             Task task = tasks.get(num - 1);
808 
809             // Display task details
810             System.out.println();
811             int w = state.width() - 4;
812             System.out.println(color("  " + repeat(BOX_HORIZONTAL, w), CYAN));
813             System.out.println();
814 
815             // Title with status icon
816             String statusIcon = switch (task.status()) {
817                 case TODO -> color(BULLET + " TODO", YELLOW, BOLD);
818                 case DOING -> color(ICON_FLAME + " DOING", MAGENTA, BOLD);
819                 case DONE -> color(CHECK + " DONE", GREEN, BOLD);
820             };
821             System.out.println("  " + color(task.title(), BRIGHT_WHITE, BOLD));
822             System.out.println("  " + statusIcon);
823             System.out.println();
824 
825             // Description
826             if (task.description() != null && !task.description().isBlank()) {
827                 System.out.println(color("  Description:", DIM));
828                 // Word wrap description
829                 String desc = task.description();
830                 int maxWidth = w - 4;
831                 while (desc.length() > maxWidth) {
832                     int breakPoint = desc.lastIndexOf(' ', maxWidth);
833                     if (breakPoint <= 0) breakPoint = maxWidth;
834                     System.out.println("    " + desc.substring(0, breakPoint));
835                     desc = desc.substring(breakPoint).trim();
836                 }
837                 if (!desc.isEmpty()) {
838                     System.out.println("    " + desc);
839                 }
840             } else {
841                 System.out.println(color("  No description", DIM, ITALIC));
842             }
843 
844             System.out.println();
845             System.out.println(color("  " + repeat(BOX_HORIZONTAL, w), CYAN));
846             System.out.println();
847             prompt("Press Enter to continue");
848 
849             return Result.ok(state.withStatus("Viewed: " + task.title(), BLUE));
850         } catch (NumberFormatException e) {
851             return Result.fail("Invalid number");
852         }
853     }
854 
855     private Result<State> handleStart(State state) {
856         List<Task> todo = state.byStatus(TaskStatus.TODO);
857         if (todo.isEmpty()) {
858             return Result.ok(
859                 state.withStatus("No TODO tasks to start!", YELLOW)
860             );
861         }
862 
863         System.out.println();
864         System.out.println(color("  TODO tasks:", DIM));
865         for (int i = 0; i < todo.size(); i++) {
866             System.out.println(
867                 color("    [" + (i + 1) + "] ", BRIGHT_BLACK) +
868                     todo.get(i).title()
869             );
870         }
871 
872         String input = prompt("Task number to start");
873         if (input == null || input.isBlank()) {
874             return Result.ok(state.withStatus("Cancelled", YELLOW));
875         }
876 
877         try {
878             int num = Integer.parseInt(input.trim());
879             if (num < 1 || num > todo.size()) {
880                 return Result.fail("Invalid task number");
881             }
882             return Actions.start(state, todo.get(num - 1).id());
883         } catch (NumberFormatException e) {
884             return Result.fail("Invalid number");
885         }
886     }
887 
888     private Result<State> handleComplete(State state) {
889         List<Task> doing = state.byStatus(TaskStatus.DOING);
890         List<Task> completable = !doing.isEmpty()
891             ? doing
892             : state.byStatus(TaskStatus.TODO);
893 
894         if (completable.isEmpty()) {
895             return Result.ok(state.withStatus("No tasks to complete!", YELLOW));
896         }
897 
898         System.out.println();
899         String label = !doing.isEmpty() ? "DOING tasks:" : "TODO tasks:";
900         System.out.println(color("  " + label, DIM));
901         for (int i = 0; i < completable.size(); i++) {
902             System.out.println(
903                 color("    [" + (i + 1) + "] ", BRIGHT_BLACK) +
904                     completable.get(i).title()
905             );
906         }
907 
908         String input = prompt("Task number to complete");
909         if (input == null || input.isBlank()) {
910             return Result.ok(state.withStatus("Cancelled", YELLOW));
911         }
912 
913         try {
914             int num = Integer.parseInt(input.trim());
915             if (num < 1 || num > completable.size()) {
916                 return Result.fail("Invalid task number");
917             }
918             return Actions.complete(state, completable.get(num - 1).id());
919         } catch (NumberFormatException e) {
920             return Result.fail("Invalid number");
921         }
922     }
923 
924     private Result<State> handleDelete(State state) {
925         List<Task> tasks = state.tasks();
926         if (tasks.isEmpty()) {
927             return Result.ok(state.withStatus("No tasks to delete!", YELLOW));
928         }
929 
930         System.out.println();
931         System.out.println(color("  All tasks:", DIM));
932         for (int i = 0; i < tasks.size(); i++) {
933             Task t = tasks.get(i);
934             String icon = t.completed()
935                 ? color(CHECK, GREEN)
936                 : color(BULLET, YELLOW);
937             System.out.println(
938                 color("    [" + (i + 1) + "] ", BRIGHT_BLACK) +
939                     icon +
940                     " " +
941                     t.title()
942             );
943         }
944 
945         String input = prompt("Task number to delete");
946         if (input == null || input.isBlank()) {
947             return Result.ok(state.withStatus("Cancelled", YELLOW));
948         }
949 
950         try {
951             int num = Integer.parseInt(input.trim());
952             if (num < 1 || num > tasks.size()) {
953                 return Result.fail("Invalid task number");
954             }
955             return Actions.delete(state, tasks.get(num - 1).id());
956         } catch (NumberFormatException e) {
957             return Result.fail("Invalid number");
958         }
959     }
960 
961     // ═══════════════════════════════════════════════════════════════════
962     //  MAIN
963     // ═══════════════════════════════════════════════════════════════════
964 
965     public static void main(String[] args) {
966         TaskApp app = TaskApp.withInMemoryRepo();
967 
968         // Sample data in different states
969         app.createTask(
970             "Learn HexaFun",
971             "Study the fluent DSL and port registry"
972         );
973         app.createTask("Write tests", "Ensure everything works correctly");
974 
975         Result<Task> tui = app.createTask(
976             "Build a TUI",
977             "Create a terminal user interface"
978         );
979         tui.map(t -> app.startTask(t.id()));
980 
981         Result<Task> docs = app.createTask(
982             "Read the docs",
983             "Check out the documentation"
984         );
985         docs.map(t ->
986             app.startTask(t.id()).map(started -> app.completeTask(started.id()))
987         );
988 
989         new TasksTUI().run(app);
990     }
991 }