stitch

Note taking for messy minimalists

git clone git://mccd.space/stitch
commit 3f696169ab1a560d94d169c1a5b744346da4c081
parent 961339f0bd28c0f30bdb3c995a27927def8a991e
Author: Marc Coquand <marc@mccd.space>
Date:   Wed, 15 May 2024 14:05:03 -0500

Add done view + visual

Diffstat:
Alib/done.ml | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/grep.ml | 12+++++++++++-
Mlib/headlines.ml | 23+++++++++++++++--------
Mlib/help_screen.ml | 11+++++++----
Mlib/stitch.ml | 35+++++++++++++++++++++++++++++++++--
Mlib/stitched_article.ml | 15++++++++++-----
Mlib/todos.ml | 36+++++++++++++++++++++++-------------
7 files changed, 240 insertions(+), 33 deletions(-)
diff --git a/lib/done.ml b/lib/done.ml
@@ -0,0 +1,141 @@
+module Grep = Grep
+module Common = Common
+open Notty
+module Help_screen = Help_screen
+
+type state =
+  { pos : int * int
+  ; scroll : int
+  ; content : (string * string) array
+  ; content_pretty : string array
+  ; goto_headlines : (unit -> unit) -> unit
+  ; goto_todo : (unit -> unit) -> unit
+  }
+
+let title = I.strf ~attr:A.(st bold) "%s" "Done View" |> I.pad ~l:0 ~t:0
+let content_start = 2
+
+let init ~goto_todo ~goto_headlines =
+  let content = Grep.get_done () |> Grep.parse_todo_string in
+  let content_pretty = Grep.pretty_format_todo content in
+  { pos = 0, content_start
+  ; scroll = 0
+  ; content = content |> Array.of_list
+  ; content_pretty = content_pretty |> Array.of_list
+  ; goto_headlines
+  ; goto_todo
+  }
+
+
+let load_done () =
+  let done_content = Grep.get_done () |> Grep.parse_todo_string in
+  let done_pretty = Grep.pretty_format_todo done_content in
+  done_content, done_pretty
+
+
+let rec render
+  t
+  ({ pos; scroll; content; content_pretty; goto_headlines; goto_todo } as state)
+  =
+  let x, y = pos in
+  let content_position = y - content_start in
+  let img =
+    let dot = I.string A.(st bold) ">" |> I.pad ~l:0 ~t:(y - scroll)
+    and elements =
+      Array.mapi
+        (fun i el ->
+          if i == y - scroll
+          then I.strf "%s" el |> I.pad ~l:2 ~t:(i + content_start)
+          else I.strf "%s" el |> I.pad ~l:2 ~t:(i + content_start))
+        (Array.to_seq content_pretty |> Seq.drop scroll |> Array.of_seq)
+    in
+    let open I in
+    Array.fold_left (fun sum el -> el </> sum) (title </> dot) elements
+  in
+  let _, size_y = Common.Term.size t in
+  Common.Term.image t img;
+  let content_end = Array.length content_pretty + (content_start - 1) in
+  let scroll_up () =
+    let scroll = if y - content_start - scroll = 0 then max (scroll - 1) 0 else scroll in
+    render t { state with pos = x, max (y - 1) content_start; scroll }
+  in
+  let scroll_down () =
+    let scroll = if y - scroll >= size_y - 1 then scroll + 1 else scroll in
+    render t { state with pos = x, min (y + 1) content_end; scroll }
+  in
+  match Common.Term.event t with
+  | `End | `Key (`Escape, []) | `Key (`ASCII 'q', []) | `Key (`ASCII 'C', [ `Ctrl ]) -> ()
+  | `Mouse (`Press (`Scroll s), _, _) ->
+    (match s with
+     | `Down -> scroll_down ()
+     | `Up -> scroll_up ())
+  | `Resize _ -> render t state
+  | `Mouse ((`Press _ | `Drag), (_, y), _) ->
+    render t { state with pos = 0, min y content_end }
+  | `Key (`ASCII '?', []) -> Help_screen.render t { go_back = (fun () -> render t state) }
+  | `Key (`ASCII 's', []) ->
+    let (input_state : Input_screen.state) =
+      { screen = img
+      ; user_input = ""
+      ; prompt = "GREP: "
+      ; on_enter =
+          (fun tag ->
+            let content = Grep.get_tagged_headlines tag () |> Grep.parse_headlines in
+            let content_pretty = Grep.pretty_format content in
+            Common.Term.cursor t None;
+            render t { state with content; content_pretty })
+      ; on_cancel =
+          (fun _ ->
+            Common.Term.cursor t None;
+            render t state)
+      }
+    in
+    Input_screen.render t input_state
+  | `Key (`ASCII '1', []) -> goto_headlines (fun () -> render t state)
+  | `Key (`ASCII '2', []) -> goto_todo (fun () -> render t state)
+  | `Key (`ASCII 'j', []) | `Key (`ASCII 'N', [ `Ctrl ]) -> scroll_down ()
+  | `Key (`ASCII 'k', []) | `Key (`ASCII 'P', [ `Ctrl ]) -> scroll_up ()
+  | `Key (`ASCII 't', []) ->
+    let selected_file, _ = Array.get content (y - content_start) in
+    let _ = Grep.toggle_todo selected_file in
+    let content, content_pretty = load_done () in
+    render
+      t
+      { state with
+        content = content |> Array.of_list
+      ; content_pretty = Array.of_list content_pretty
+      }
+  | `Key (`Arrow d, _) ->
+    (match d with
+     | `Up -> scroll_up ()
+     | `Down -> scroll_down ()
+     | _ -> render t state)
+  | `Key (`ASCII 'e', []) | `Key (`Enter, []) ->
+    (* Editor might be set with extra args, in that case we need to separate these *)
+    let[@warning "-8"] (editor :: args) =
+      String.split_on_char ' ' (Sys.getenv "EDITOR")
+    in
+    let selected_file, _ = Array.get content content_position in
+    let full_path_file = Grep.execution_directory ^ "/" ^ selected_file in
+    let full_args = Array.append (Array.of_list args) [| full_path_file |] in
+    Common.Term.cursor t (Some (0, 0));
+    let _ =
+      Unix.create_process_env
+        editor
+        full_args
+        (Unix.environment ())
+        Unix.stdin
+        Unix.stdout
+        Unix.stderr
+    in
+    let rec run_editor () =
+      match Unix.wait () with
+      | _, _ ->
+        Common.Term.cursor t None;
+        render t state
+      (* Capture resizing events *)
+      | exception Unix.Unix_error (Unix.EINTR, _, _) -> run_editor ()
+      | exception Unix.Unix_error (_, _, _) -> failwith "ERROR"
+    in
+    run_editor ()
+  | _ -> render t state
diff --git a/lib/grep.ml b/lib/grep.ml
@@ -52,6 +52,14 @@ let pad str n =
 exception Not_A_Tuple of string * string
 
 (* todo parsing *)
+let done_get_args = [ grep_cmd; done_pattern_regexp; "-H"; "-r"; "-n"; "--no-messages" ]
+
+let get_done () =
+  let open Shexp_process in
+  let open Shexp_process.Infix in
+  run_calls (call done_get_args |- call [ "sort"; "-n"; "-r" ])
+
+
 let todo_get_args = [ grep_cmd; todo_pattern_regexp; "-H"; "-r"; "-n"; "--no-messages" ]
 
 let get_todos () =
@@ -158,6 +166,8 @@ let get_tagged_headlines tag () =
        (find_sort_name ()
         |- call headline_args
         |- call [ grep_cmd; "--no-messages"; "-E"; tag ]
+        |- call filter_todos_args
+        |- call filter_done_args
         |- call [ "sort"; "-n"; "-r" ]
         |- read_all))
 
@@ -231,6 +241,6 @@ let pretty_print_parsed_content parsed_files =
   List.concat_map
     (fun (file_name, line_number, line_content, _) ->
       if line_number == 0
-      then [ Bold ("------- " ^ file_name); Normal line_content ]
+      then [ Bold ("--------- " ^ file_name); Normal line_content ]
       else [ Normal (padding ^ line_content) ])
     parsed_files
diff --git a/lib/headlines.ml b/lib/headlines.ml
@@ -11,9 +11,10 @@ type state =
   ; content : (string * string) array
   ; content_pretty : string array
   ; goto_todos_view : (unit -> unit) -> unit
+  ; goto_done_view : (unit -> unit) -> unit
   }
 
-let init ~goto_todos_view ~regexp =
+let init ~goto_done_view ~goto_todos_view ~regexp =
   let tag = if String.equal regexp "" then None else Some regexp in
   let content =
     match tag with
@@ -26,15 +27,19 @@ let init ~goto_todos_view ~regexp =
     exit 0)
   else (
     let content_pretty = content |> Grep.pretty_format in
-    { pos = 0, 2; scroll = 0; content; content_pretty; goto_todos_view })
+    { pos = 0, 2; scroll = 0; content; content_pretty; goto_done_view; goto_todos_view })
 
 
+let title = I.strf ~attr:A.(st bold) "%s" "Note View" |> I.pad ~l:0 ~t:0
+
 (* TODO: Add page title *)
-let rec render t ({ pos; scroll; content; content_pretty; goto_todos_view } as state) =
-  let title = I.strf ~attr:A.(st underline) "%s" "Note View" |> I.pad ~l:0 ~t:0 in
+let rec render
+  t
+  ({ pos; scroll; content; content_pretty; goto_todos_view; goto_done_view } as state)
+  =
   let content_start = 2 in
   let x, y = pos in
-  let content_position = y - content_start + 1 in
+  let content_position = y - content_start in
   let img =
     let dot = I.string A.(st bold) ">" |> I.pad ~l:0 ~t:(y - scroll)
     and elements =
@@ -56,7 +61,6 @@ let rec render t ({ pos; scroll; content; content_pretty; goto_todos_view } as s
     render t { state with pos = x, max (y - 1) content_start; scroll }
   in
   let scroll_down () =
-    print_endline (Int.to_string y);
     let scroll = if y - scroll >= size_y - 1 then scroll + 1 else scroll in
     render t { state with pos = x, min (y + 1) content_end; scroll }
   in
@@ -71,6 +75,7 @@ let rec render t ({ pos; scroll; content; content_pretty; goto_todos_view } as s
     render t { state with pos = 0, min y content_end }
   | `Key (`ASCII '?', []) -> Help_screen.render t { go_back = (fun () -> render t state) }
   | `Key (`ASCII '2', []) -> goto_todos_view (fun () -> render t state)
+  | `Key (`ASCII '3', []) -> goto_done_view (fun () -> render t state)
   | `Key (`ASCII 's', []) ->
     let content =
       Array.map
@@ -84,7 +89,7 @@ let rec render t ({ pos; scroll; content; content_pretty; goto_todos_view } as s
     in
     Stitched_article.render
       t
-      { pos = 0, 0
+      { pos = 0, Stitched_article.content_start
       ; content = full_content |> Array.of_list
       ; content_pretty = full_content_pretty
       ; scroll = 0
@@ -101,7 +106,9 @@ let rec render t ({ pos; scroll; content; content_pretty; goto_todos_view } as s
             let content = Grep.get_tagged_headlines tag () |> Grep.parse_headlines in
             let content_pretty = Grep.pretty_format content in
             Common.Term.cursor t None;
-            render t { state with content; content_pretty; pos = 0, 0; scroll = 0 })
+            render
+              t
+              { state with content; content_pretty; pos = 0, content_start; scroll = 0 })
       ; on_cancel =
           (fun _ ->
             Common.Term.cursor t None;
diff --git a/lib/help_screen.ml b/lib/help_screen.ml
@@ -4,15 +4,18 @@ open Notty
 type state = { go_back : unit -> unit }
 
 let render_info =
-  let title = I.strf ~attr:A.(st bold ++ st underline) "%s" "Stitch" |> I.pad ~l:2 ~t:0 in
+  let title = I.strf ~attr:A.(st bold) "%s" "Stitch" |> I.pad ~l:0 ~t:0 in
   let description =
     I.strf
       ~attr:A.(st bold)
       "%s"
-      "Minimal Note Composer. Run with stitch --help for more info."
-    |> I.pad ~l:2 ~t:1
+      "Minimal Note Composer. Run stitch --help for more info."
+    |> I.pad ~l:2 ~t:2
+  in
+  let license =
+    I.strf "%s" "Made by Marc Coquand (https://mccd.space). Licensed under BSD-3."
+    |> I.pad ~l:2 ~t:4
   in
-  let license = I.strf "%s" "Licensed under BSD-3" |> I.pad ~l:2 ~t:3 in
   let open I in
   5, title </> description </> license
 
diff --git a/lib/stitch.ml b/lib/stitch.ml
@@ -5,7 +5,8 @@ module Headlines = Headlines
 
 let start (tag : string) () =
   (* This is a rather funky state management that isn't maybe entirely functional.
-     What we do is store a function for each view that restores it's state.
+     What we do is store a function for each view that restores it's state. Since the render function is
+     void -> void
 
      This allows us to remember the state of the view and restore it as we travel between different views.
 
@@ -13,17 +14,47 @@ let start (tag : string) () =
   *)
   let term = Common.Term.create () in
   let restore_headline_state = ref (fun () -> ()) in
+  let restore_done_state = ref (fun () -> ()) in
   let restore_todo_state = ref (fun () -> ()) in
+  (* DONE *)
+  let goto_todo_from_done new_done_state =
+    restore_done_state := new_done_state;
+    !restore_todo_state ()
+  in
+  let goto_headlines_from_done new_done_state =
+    restore_done_state := new_done_state;
+    !restore_headline_state ()
+  in
+  (restore_done_state
+   := fun () ->
+        let done_state =
+          Done.init
+            ~goto_headlines:goto_headlines_from_done
+            ~goto_todo:goto_todo_from_done
+        in
+        Done.render term done_state);
+  (* TODO *)
+  let goto_done_from_todo new_todo_state =
+    restore_todo_state := new_todo_state;
+    !restore_done_state ()
+  in
   let goto_headline_from_todo new_todo_state =
     restore_todo_state := new_todo_state;
     !restore_headline_state ()
   in
   (restore_todo_state
    := fun () ->
-        let todo = Todos.init ~goto_headlines:goto_headline_from_todo in
+        let todo =
+          Todos.init
+            ~goto_headlines:goto_headline_from_todo
+            ~goto_done:goto_done_from_todo
+        in
         Todos.render term todo);
   let headline =
     Headlines.init
+      ~goto_done_view:(fun new_state ->
+        restore_headline_state := new_state;
+        !restore_done_state ())
       ~goto_todos_view:(fun new_state ->
         restore_headline_state := new_state;
         !restore_todo_state ())
diff --git a/lib/stitched_article.ml b/lib/stitched_article.ml
@@ -13,6 +13,9 @@ type state =
   ; goto_todos_view : (unit -> unit) -> unit
   }
 
+let title = I.strf ~attr:A.(st bold) "%s" "Note View" |> I.pad ~l:0 ~t:0
+let content_start = 1
+
 (* TODO: Use grep -l to filter notes by regexp and rerender those files*)
 let rec render
   t
@@ -23,18 +26,18 @@ let rec render
   let img =
     let elements =
       Array.mapi
-        (fun i el -> Compontent.current_line size_x y scroll el i)
+        (fun i el -> Compontent.current_line size_x y scroll el (i + content_start))
         (* TODO: Fix this ugly slow conversion *)
         (Array.to_seq content_pretty |> Seq.drop scroll |> Array.of_seq)
     in
     let open I in
-    Array.fold_left (fun sum el -> el </> sum) I.empty elements
+    Array.fold_left (fun sum el -> el </> sum) title elements
   in
   Common.Term.image t img;
   let content_length = Array.length content_pretty in
   let scroll_up () =
-    let scroll = if y - scroll = 0 then max (scroll - 1) 0 else scroll in
-    render t @@ { state with pos = x, max (y - 1) 0; scroll }
+    let scroll = if y - content_start - scroll = 0 then max (scroll - 1) 0 else scroll in
+    render t @@ { state with pos = x, max (y - 1) content_start; scroll }
   in
   let scroll_down () =
     let scroll = if y - scroll >= size_y - 1 then scroll + 1 else scroll in
@@ -59,7 +62,9 @@ let rec render
     let[@warning "-8"] (editor :: args) =
       String.split_on_char ' ' (Sys.getenv "EDITOR")
     in
-    let selected_file, line_number, _, file_number_offset = Array.get content y in
+    let selected_file, line_number, _, file_number_offset =
+      Array.get content (y - content_start)
+    in
     let full_path_file = Grep.execution_directory ^ "/" ^ selected_file in
     (* Because each file title consists of two lines, we need to account for the offset
        it adds by removing the file_number *)
diff --git a/lib/todos.ml b/lib/todos.ml
@@ -9,16 +9,21 @@ type state =
   ; content : (string * string) array
   ; content_pretty : string array
   ; goto_headlines : (unit -> unit) -> unit
+  ; goto_done : (unit -> unit) -> unit
   }
 
-let init ~goto_headlines =
+let title = I.strf ~attr:A.(st bold) "%s" "Todo View" |> I.pad ~l:0 ~t:0
+let content_start = 2
+
+let init ~goto_done ~goto_headlines =
   let content = Grep.get_todos () |> Grep.parse_todo_string in
   let content_pretty = Grep.pretty_format_todo content in
-  { pos = 0, 0
+  { pos = 0, content_start
   ; scroll = 0
   ; content = content |> Array.of_list
   ; content_pretty = content_pretty |> Array.of_list
   ; goto_headlines
+  ; goto_done
   }
 
 
@@ -28,31 +33,35 @@ let load_todos () =
   todo_content, todo_pretty
 
 
-let rec render t ({ pos; scroll; content; content_pretty; goto_headlines } as state) =
+let rec render
+  t
+  ({ pos; scroll; content; content_pretty; goto_headlines; goto_done } as state)
+  =
   let x, y = pos in
+  let content_position = y - content_start in
   let img =
     let dot = I.string A.(st bold) ">" |> I.pad ~l:0 ~t:(y - scroll)
     and elements =
       Array.mapi
         (fun i el ->
           if i == y - scroll
-          then I.strf ~attr:A.(st underline) "%s" el |> I.pad ~l:2 ~t:i
-          else I.strf "%s" el |> I.pad ~l:2 ~t:i)
+          then I.strf "%s" el |> I.pad ~l:2 ~t:(i + content_start)
+          else I.strf "%s" el |> I.pad ~l:2 ~t:(i + content_start))
         (Array.to_seq content_pretty |> Seq.drop scroll |> Array.of_seq)
     in
     let open I in
-    Array.fold_left (fun sum el -> el </> sum) dot elements
+    Array.fold_left (fun sum el -> el </> sum) (title </> dot) elements
   in
   let _, size_y = Common.Term.size t in
   Common.Term.image t img;
-  let content_length = Array.length content_pretty in
+  let content_end = Array.length content_pretty + (content_start - 1) in
   let scroll_up () =
-    let scroll = if y - scroll = 0 then max (scroll - 1) 0 else scroll in
-    render t @@ { state with pos = x, max (y - 1) 0; scroll }
+    let scroll = if y - content_start - scroll = 0 then max (scroll - 1) 0 else scroll in
+    render t { state with pos = x, max (y - 1) content_start; scroll }
   in
   let scroll_down () =
     let scroll = if y - scroll >= size_y - 1 then scroll + 1 else scroll in
-    render t @@ { state with pos = x, min (y + 1) (content_length - 1); scroll }
+    render t { state with pos = x, min (y + 1) content_end; scroll }
   in
   match Common.Term.event t with
   | `End | `Key (`Escape, []) | `Key (`ASCII 'q', []) | `Key (`ASCII 'C', [ `Ctrl ]) -> ()
@@ -62,7 +71,7 @@ let rec render t ({ pos; scroll; content; content_pretty; goto_headlines } as st
      | `Up -> scroll_up ())
   | `Resize _ -> render t state
   | `Mouse ((`Press _ | `Drag), (_, y), _) ->
-    render t { state with pos = 0, min y content_length }
+    render t { state with pos = 0, min y content_end }
   | `Key (`ASCII '?', []) -> Help_screen.render t { go_back = (fun () -> render t state) }
   | `Key (`ASCII 's', []) ->
     let (input_state : Input_screen.state) =
@@ -83,10 +92,11 @@ let rec render t ({ pos; scroll; content; content_pretty; goto_headlines } as st
     in
     Input_screen.render t input_state
   | `Key (`ASCII '1', []) -> goto_headlines (fun () -> render t state)
+  | `Key (`ASCII '3', []) -> goto_done (fun () -> render t state)
   | `Key (`ASCII 'j', []) | `Key (`ASCII 'N', [ `Ctrl ]) -> scroll_down ()
   | `Key (`ASCII 'k', []) | `Key (`ASCII 'P', [ `Ctrl ]) -> scroll_up ()
   | `Key (`ASCII 't', []) ->
-    let selected_file, _ = Array.get content y in
+    let selected_file, _ = Array.get content (y - content_start) in
     let _ = Grep.toggle_done selected_file in
     let content, content_pretty = load_todos () in
     render
@@ -105,7 +115,7 @@ let rec render t ({ pos; scroll; content; content_pretty; goto_headlines } as st
     let[@warning "-8"] (editor :: args) =
       String.split_on_char ' ' (Sys.getenv "EDITOR")
     in
-    let selected_file, _ = Array.get content y in
+    let selected_file, _ = Array.get content content_position in
     let full_path_file = Grep.execution_directory ^ "/" ^ selected_file in
     let full_args = Array.append (Array.of_list args) [| full_path_file |] in
     Common.Term.cursor t (Some (0, 0));