stitch

Note taking for messy minimalists

git clone git://mccd.space/stitch
commit 47892663040f7e295cc4052438cf804b040f0389
parent 7038e0073e7f7f672644ca949eeda399d6a533fb
Author: Marc Coquand <marc@mccd.space>
Date:   Wed, 15 May 2024 10:45:54 -0500

Add TODOs, update help, rebind keys

Diffstat:
ALICENSE | 30++++++++++++++++++++++++++++++
MREADME.org | 4++++
Mlib/grep.ml | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mlib/headlines.ml | 17++++++++++++++---
Mlib/help_screen.ml | 62++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mlib/input_screen.ml | 15+++++++++------
Mlib/stitched_article.ml | 3++-
Alib/todos.ml | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 337 insertions(+), 38 deletions(-)
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,29 @@
+BSD-3-Clause
+Copyright 2024 Marc Coquand
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+1. Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+“AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+\ No newline at end of file
diff --git a/README.org b/README.org
@@ -36,6 +36,10 @@ STICH_GREP_CMD (default "grep")
 STITCH_HEADLINE_PATTERN_REGEXP (default "^\\* ")
 STITCH_HEADLINE_PATTERN (default "* ")
 STITCH_TAG_PATTERN (default ":[a-z-]+:", matches :a-tag:)
+STITCH_TODO (default "* TODO")
+STITCH_TODO_REGEXP (default "^\\* TODO")
+STITCH_DONE (default "* DONE")
+STITCH_DONE_REGEXP (default "^\\* DONE")
 
 ** SPEED UP
 
diff --git a/lib/grep.ml b/lib/grep.ml
@@ -16,6 +16,96 @@ let headline_pattern =
   Sys.getenv_opt "STITCH_HEADLINE_PATTERN" |> Option.value ~default:"* "
 
 
+let todo_pattern = Sys.getenv_opt "STITCH_TODO" |> Option.value ~default:"* TODO"
+
+let todo_pattern_regexp =
+  Sys.getenv_opt "STITCH_TODO_REGEXP" |> Option.value ~default:"^\\* TODO"
+
+
+let done_pattern = Sys.getenv_opt "STITCH_DONE" |> Option.value ~default:"* DONE"
+
+let done_pattern_regexp =
+  Sys.getenv_opt "STITCH_DONE_REGEXP" |> Option.value ~default:"^\\* DONE"
+
+
+(* Utils *)
+
+let run_calls calls =
+  let open Shexp_process in
+  let open Shexp_process.Infix in
+  eval (chdir execution_directory (calls |- read_all))
+
+
+let get_padding_arr arr =
+  Array.fold_left (fun n (file_name, _) -> Int.max n (String.length file_name)) 0 arr
+
+
+let get_padding_list list =
+  List.fold_left (fun n (file_name, _) -> Int.max n (String.length file_name)) 0 list
+
+
+let pad str n =
+  let padding = n - String.length str in
+  String.concat "" [ str; String.make padding ' ' ]
+
+
+exception Not_A_Tuple of string * string
+
+(* todo parsing *)
+let todo_get_args = [ grep_cmd; todo_pattern_regexp; "-H"; "-r"; "-n"; "--no-messages" ]
+
+let get_todos () =
+  let open Shexp_process in
+  let open Shexp_process.Infix in
+  run_calls (call todo_get_args |- call [ "sort"; "-n"; "-r" ])
+
+
+let parse_todo_files files =
+  List.concat
+  @@ List.mapi
+       (fun file_number ((file_name : string), content) ->
+         let content = String.split_on_char '\n' content in
+         List.mapi
+           (fun line_number line -> file_name, line_number, line, file_number)
+           content)
+       files
+
+
+let parse_todo_string s =
+  String.split_on_char '\n' s
+  |> List.filter_map (fun message ->
+    if String.equal message ""
+    then None
+    else (
+      let split = Str.bounded_split (Str.regexp ":[0-9]+:") message 2 in
+      match split with
+      (* file, line, content *)
+      | [ file_name; content ] -> Some (file_name, content)
+      | _ -> raise (Not_A_Tuple (String.concat " SPLIT " split, message))))
+
+
+let pretty_format_todo parsed_headlines =
+  let padding = get_padding_list parsed_headlines in
+  List.map
+    (fun (file_name, content) -> String.concat " | " [ pad file_name padding; content ])
+    parsed_headlines
+
+
+let toggle_done file_name =
+  let open Shexp_process in
+  run_calls
+    (call
+       [ "sed"; "-i"; "s/" ^ todo_pattern_regexp ^ "/" ^ done_pattern ^ "/g"; file_name ])
+
+
+let toggle_todo file_name =
+  let open Shexp_process in
+  run_calls
+    (call
+       [ "sed"; "-i"; "s/" ^ done_pattern_regexp ^ "/" ^ todo_pattern ^ "/g"; file_name ])
+
+
+(* Headline parsing *)
 let headline_pattern_length = String.length headline_pattern
 
 let find_sort_modification () =
@@ -38,7 +128,9 @@ let run_print ~dir args =
   eval (chdir dir (call args |- read_all))
 
 
-let headline_args = [ "xargs"; grep_cmd; "^\\*"; "-H"; "-r"; "-n"; "--no-messages" ]
+let headline_args =
+  [ "xargs"; grep_cmd; headline_pattern_regexp; "-H"; "-r"; "-n"; "--no-messages" ]
+
 
 let get_headlines () =
   let open Shexp_process in
@@ -71,8 +163,6 @@ let get_tags () =
        (call headline_args |- call [ grep_cmd; "-E"; tag_pattern; "-o" ] |- read_all))
 
 
-exception Not_A_Tuple of string * string
-
 (** Returns a tuple of file name and Content *)
 let parse_headlines s =
   String.split_on_char '\n' s
@@ -89,19 +179,9 @@ let parse_headlines s =
   |> Array.of_list
 
 
-(** Used for pretty printing *)
-let get_padding list =
-  Array.fold_left (fun n (file_name, _) -> Int.max n (String.length file_name)) 0 list
-
-
-let pad str n =
-  let padding = n - String.length str in
-  String.concat "" [ str; String.make padding ' ' ]
-
-
 (** Turns "2024-03-05.org:* Hello world" into "2024-03-05    | * Hello world" *)
 let pretty_format parsed_headlines =
-  let padding = get_padding parsed_headlines in
+  let padding = get_padding_arr parsed_headlines in
   Array.map
     (fun (file_name, content) -> String.concat " | " [ pad file_name padding; content ])
     parsed_headlines
diff --git a/lib/headlines.ml b/lib/headlines.ml
@@ -4,6 +4,7 @@ open Notty
 module Input_screen = Input_screen
 module Stitched_article = Stitched_article
 module Help_screen = Help_screen
+module Todos = Todos
 
 type state =
   { pos : int * int
@@ -12,6 +13,7 @@ type state =
   ; content_pretty : string array
   }
 
+(* TODO: Add page title *)
 let rec render t ({ pos; scroll; content; content_pretty } as state) =
   let x, y = pos in
   let img =
@@ -48,7 +50,16 @@ let rec render t ({ pos; scroll; content; content_pretty } as state) =
   | `Mouse ((`Press _ | `Drag), (_, y), _) ->
     render t { state with pos = 0, min y content_length }
   | `Key (`ASCII '?', []) -> Help_screen.render t { go_back = (fun () -> render t state) }
-  | `Key (`ASCII '@', []) ->
+  | `Key (`ASCII 't', []) ->
+    let todo_content = Grep.get_todos () |> Grep.parse_todo_string in
+    let todo_pretty = Grep.pretty_format_todo todo_content in
+    let todo_state =
+      Todos.init
+        ~content:(todo_content |> Array.of_list)
+        ~content_pretty:(todo_pretty |> Array.of_list)
+    in
+    Todos.render t todo_state
+  | `Key (`ASCII 's', []) ->
     let content =
       Array.map
         (fun (file_name, _) -> Grep.get_full_file_content_content file_name)
@@ -67,11 +78,11 @@ let rec render t ({ pos; scroll; content; content_pretty } as state) =
       ; scroll = 0
       ; go_back = (fun () -> render t state)
       }
-  | `Key (`ASCII 's', []) ->
+  | `Key (`ASCII 'r', []) ->
     let (input_state : Input_screen.state) =
       { screen = img
       ; user_input = ""
-      ; prompt = "GREP: "
+      ; prompt = "REGEXP: "
       ; on_enter =
           (fun tag ->
             let content = Grep.get_tagged_headlines tag () |> Grep.parse_headlines in
diff --git a/lib/help_screen.ml b/lib/help_screen.ml
@@ -3,25 +3,29 @@ open Notty
 
 type state = { go_back : unit -> unit }
 
-let info = [ "STITCH"; "Note composing tool" ]
-
 let render_info =
-  let title = I.strf ~attr:A.(st bold) "%s" "STITCH" |> I.pad ~l:2 ~t:0 in
+  let title = I.strf ~attr:A.(st bold ++ st underline) "%s" "Stitch" |> I.pad ~l:2 ~t:0 in
   let description =
-    I.strf ~attr:A.(st bold) "%s" "Small Note Composer" |> I.pad ~l:2 ~t:1
+    I.strf
+      ~attr:A.(st bold)
+      "%s"
+      "Minimal Note Composer. Run with stitch --help for more info."
+    |> I.pad ~l:2 ~t:1
   in
-  let keybindings = I.strf ~attr:A.(st bold) "%s" "Keybindings" |> 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
-  title </> description </> keybindings
+  5, title </> description </> license
 
 
-let help_menu =
+let general_help_menu =
   [ "Toggle this menu", "?"
   ; "Exit", "Ctrl-c, q, Esc"
-  ; "Toggle collapsed view", "@"
   ; "Down", "Ctrl-n, j"
   ; "Up", "Ctrl-p, k"
-  ; "Grep", "s"
+  ; "Regexp", "r"
+  ; "Note view", "1"
+  ; "Todo view", "2"
+  ; "Done view", "3"
   ; "Edit", "Enter, e"
   ]
 
@@ -31,22 +35,52 @@ let pad str n =
   String.concat "" [ str; String.make padding ' ' ]
 
 
-let render_help_menu start_y =
+let render_menu ~menu ~title ~start_y =
+  let keybindings = I.strf ~attr:A.(st bold) "%s" title |> I.pad ~l:2 ~t:start_y in
   let elements =
     List.mapi
       (fun i (explanation, keybinding) ->
         let padding = pad explanation 27 in
-        I.strf "%s%s" padding keybinding |> I.pad ~l:2 ~t:(i + start_y))
-      help_menu
+        I.strf "%s%s" padding keybinding |> I.pad ~l:2 ~t:(i + start_y + 1))
+      menu
   in
   let open I in
-  List.fold_left (fun sum el -> el </> sum) I.empty elements
+  1 + List.length elements, List.fold_left (fun sum el -> el </> sum) keybindings elements
+
 
+let note_view_menu = [ "Toggle Stitch", "s" ]
+let todo_view_menu = [ "Toggle Done", "t" ]
+let done_view_menu = [ "Toggle Todo", "t" ]
 
 let rec render t ({ go_back } as state) =
   let img =
     let open I in
-    render_info </> render_help_menu 5
+    let info_length, info_img = render_info in
+    let general_length, general_img =
+      render_menu
+        ~menu:general_help_menu
+        ~title:"Keybindings (General)"
+        ~start_y:(info_length + 1)
+    in
+    let note_length, note_img =
+      render_menu
+        ~menu:note_view_menu
+        ~title:"Note View"
+        ~start_y:(general_length + info_length + 2)
+    in
+    let todo_length, todo_img =
+      render_menu
+        ~menu:todo_view_menu
+        ~title:"Todo View"
+        ~start_y:(note_length + general_length + info_length + 3)
+    in
+    let _, done_img =
+      render_menu
+        ~menu:done_view_menu
+        ~title:"Done View"
+        ~start_y:(todo_length + note_length + general_length + info_length + 4)
+    in
+    info_img </> general_img </> note_img </> todo_img </> done_img
   in
   Common.Term.image t img;
   match Common.Term.event t with
diff --git a/lib/input_screen.ml b/lib/input_screen.ml
@@ -22,12 +22,15 @@ let rec render t ({ user_input; on_enter; on_cancel; screen; prompt } as state) 
   | `End | `Key (`ASCII 'G', [ `Ctrl ]) | `Key (`ASCII 'C', [ `Ctrl ]) -> on_cancel ()
   | `Key (`Enter, []) -> on_enter user_input
   | `Key (`Backspace, []) ->
-    let state =
-      { state with
-        user_input = String.sub user_input 0 (max (String.length user_input - 1) 0)
-      }
-    in
-    render t state
+    if String.equal "" user_input
+    then on_cancel ()
+    else (
+      let state =
+        { state with
+          user_input = String.sub user_input 0 (max (String.length user_input - 1) 0)
+        }
+      in
+      render t state)
   | `Resize _ -> render t state
   | `Key (`ASCII c, []) ->
     let state = { state with user_input = user_input ^ String.make 1 c } in
diff --git a/lib/stitched_article.ml b/lib/stitched_article.ml
@@ -28,6 +28,7 @@ let render_line size_x y scroll (el : Grep.display_type) i =
     else I.strf "%s" el |> I.pad ~l:0 ~t:i
 
 
+(* TODO: Use grep -l to filter notes by regexp and rerender those files*)
 let rec render t ({ pos; scroll; content_pretty; go_back; content } as state) =
   let size_x, size_y = Common.Term.size t in
   let x, y = pos in
@@ -58,7 +59,7 @@ let rec render t ({ pos; scroll; content_pretty; go_back; content } as state) =
      | `Down -> scroll_down ()
      | `Up -> scroll_up ())
   | `Resize _ -> render t state
-  | `Key (`ASCII '@', []) -> go_back ()
+  | `Key (`ASCII 's', []) -> go_back ()
   | `Mouse ((`Press _ | `Drag), (_, y), _) ->
     render t { state with pos = 0, min y content_length }
   | `Key (`ASCII 'j', []) | `Key (`ASCII 'N', [ `Ctrl ]) -> scroll_down ()
diff --git a/lib/todos.ml b/lib/todos.ml
@@ -0,0 +1,136 @@
+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
+  }
+
+let init ~content ~content_pretty = { pos = 0, 0; scroll = 0; content; content_pretty }
+
+let load_todos () =
+  let todo_content = Grep.get_todos () |> Grep.parse_todo_string in
+  let todo_pretty = Grep.pretty_format_todo todo_content in
+  todo_content, todo_pretty
+
+
+let rec render t ({ pos; scroll; content; content_pretty } as state) =
+  let x, y = pos 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)
+        (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
+  in
+  let _, size_y = Common.Term.size t 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 }
+  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 }
+  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_length }
+  | `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 { content; content_pretty; pos = 0, 0; scroll = 0 })
+      ; on_cancel =
+          (fun _ ->
+            Common.Term.cursor t None;
+            render t state)
+      }
+    in
+    Input_screen.render t input_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 _ = Grep.toggle_done selected_file in
+    let content, content_pretty = load_todos () 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 y 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
+
+
+let start (tag : string) () =
+  let tag = if String.equal tag "" then None else Some tag in
+  let content =
+    match tag with
+    | None -> Grep.get_headlines () |> Grep.parse_headlines
+    | Some tag -> Grep.get_tagged_headlines tag () |> Grep.parse_headlines
+  in
+  if Array.length content == 0
+  then (
+    print_endline "No entry for tag";
+    exit 0)
+  else (
+    let content_pretty = content |> Grep.pretty_format in
+    render (Common.Term.create ()) { pos = 0, 0; scroll = 0; content; content_pretty })