stitch

Note taking for messy minimalists

git clone git://mccd.space/stitch
commit be2bf751536850e7bdfbd876ef908c5d6cf00087
parent fde592dbbb97a89a498feb95f97bee674bd571e8
Author: Marc Coquand <marc@mccd.space>
Date:   Tue, 14 May 2024 10:46:28 -0500

Add search and tag search

Diffstat:
MREADME.org | 4++--
Mbin/main.ml | 17++++++++++++++++-
Mlib/grep.ml | 34++++++++++++++++++++++++++++++----
Mlib/headlines.ml | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Alib/input_screen.ml | 35+++++++++++++++++++++++++++++++++++
5 files changed, 159 insertions(+), 23 deletions(-)
diff --git a/README.org b/README.org
@@ -30,8 +30,8 @@ Set the environment variables:
 
 STICH_DIRECTORY
 STICH_GREP_CMD (default "grep")
-STITCH_HEADLINE_PATTERN (default "^\* ")
-STITCH_TAG_PATTERN (default ":[a-z]:")
+STITCH_HEADLINE_PATTERN (default "^\\* ")
+STITCH_TAG_PATTERN (default ":[a-z_-]+:")
 
 ** SPEED UP
 
diff --git a/bin/main.ml b/bin/main.ml
@@ -1,3 +1,18 @@
 open Stitch
+open Cmdliner
 
-let () = Headlines.start ()
+let tag_arg =
+  let doc = "Search entries for a given tag." in
+  Arg.(value & opt string "" & info [ "t"; "tag" ] ~docv:"TAG" ~doc)
+
+
+let headlines_t = Term.(const Headlines.start $ tag_arg $ const ())
+
+let headlines_cmd =
+  let doc = "Show titles in a condensed list" in
+  let man = [ `S Manpage.s_bugs; `P "Email bug reports to marc@mccd.space" ] in
+  let info = Cmd.info "headlines" ~version:"0.1" ~doc ~man in
+  Cmd.v info headlines_t
+
+
+let () = exit (Cmd.eval headlines_cmd)
diff --git a/lib/grep.ml b/lib/grep.ml
@@ -4,16 +4,42 @@ let execution_directory =
 
 let grep_cmd = Sys.getenv_opt "STICH_GREP_CMD" |> Option.value ~default:"ugrep"
 
+let tag_pattern =
+  Sys.getenv_opt "STITCH_TAG_PATTERN" |> Option.value ~default:":[a-z_-]+:"
+
+
+let headline_pattern =
+  Sys.getenv_opt "STITCH_HEADLINE_PATTERN" |> Option.value ~default:"^\\* "
+
+
 let run_print ~dir args =
   let open Shexp_process in
   let open Shexp_process.Infix in
   eval (chdir dir (call args |- read_all))
 
 
-let get_headlines () =
-  run_print
-    ~dir:execution_directory
-    [ grep_cmd; "^\\*"; "-H"; "-r"; "-n"; "--separator=|" ]
+let headline_args =
+  [ grep_cmd; "^\\*"; "-H"; "-r"; "-n"; "--separator=|"; "--no-messages" ]
+
+
+let get_headlines () = run_print ~dir:execution_directory headline_args
+
+let get_tagged_headlines tag () =
+  let open Shexp_process in
+  let open Shexp_process.Infix in
+  eval
+    (chdir
+       execution_directory
+       (call headline_args |- call [ grep_cmd; "-E"; "--no-messages"; tag ] |- read_all))
+
+
+let get_tags () =
+  let open Shexp_process in
+  let open Shexp_process.Infix in
+  eval
+    (chdir
+       execution_directory
+       (call headline_args |- call [ grep_cmd; "-E"; tag_pattern; "-o" ] |- read_all))
 
 
 exception Not_A_Tuple of string * string
diff --git a/lib/headlines.ml b/lib/headlines.ml
@@ -1,16 +1,27 @@
 module Grep = Grep
 module Common = Common
 open Notty
+module Input_screen = Input_screen
 
-let content = Grep.get_headlines () |> Grep.parse_headlines
-let content_pretty = content |> Grep.pretty_format
+type state =
+  { tag : string option
+  ; pos : int * int
+  ; scroll : int
+  ; content : (string * string) array
+  ; content_pretty : string array
+  }
 
-let rec headline_screen t (((x, y) as pos), scroll) =
+let rec headline_screen t ({ tag; pos; scroll; content; content_pretty } as state) =
+  print_endline (Option.value ~default:"No tag" tag);
+  let x, y = pos in
   let img =
-    let dot = I.string A.(fg black) ">" |> I.pad ~l:0 ~t:(y - scroll)
+    let dot = I.string A.(st bold) ">" |> I.pad ~l:0 ~t:(y - scroll)
     and elements =
       Array.mapi
-        (fun i el -> I.strf ~attr:A.(fg black) "%s" el |> I.pad ~l:2 ~t:i)
+        (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
@@ -21,11 +32,11 @@ let rec headline_screen t (((x, y) as pos), scroll) =
   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
-    headline_screen t @@ ((x, max (y - 1) 0), scroll)
+    headline_screen 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
-    headline_screen t @@ ((x, min (y + 1) content_length), scroll)
+    headline_screen t @@ { state with pos = x, min (y + 1) content_length; scroll }
   in
   match Common.Term.event t with
   | `End | `Key (`Escape, []) | `Key (`ASCII 'q', []) | `Key (`ASCII 'C', [ `Ctrl ]) -> ()
@@ -33,18 +44,52 @@ let rec headline_screen t (((x, y) as pos), scroll) =
     (match s with
      | `Down -> scroll_down ()
      | `Up -> scroll_up ())
-  | `Resize _ -> headline_screen t (pos, scroll)
+  | `Resize _ -> headline_screen t state
   | `Mouse ((`Press _ | `Drag), (_, y), _) ->
-    headline_screen t ((0, min y content_length), scroll)
+    headline_screen t { state with pos = 0, min y content_length }
+  | `Key (`ASCII 't', []) ->
+    let (input_state : Input_screen.state) =
+      { screen = img
+      ; user_input = ""
+      ; prompt = "TAG: "
+      ; 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;
+            headline_screen
+              t
+              { state with content; content_pretty; pos = 0, 0; scroll = 0 })
+      ; on_cancel =
+          (fun _ ->
+            Common.Term.cursor t None;
+            headline_screen t state)
+      }
+    in
+    Input_screen.render t input_state
+  | `Key (`ASCII 's', []) ->
+    let (input_state : Input_screen.state) =
+      { screen = img
+      ; user_input = ""
+      ; prompt = "SEARCH: "
+      ; on_enter =
+          (fun _ ->
+            Common.Term.cursor t None;
+            headline_screen t { state with pos = 0, 0; scroll = 0 })
+      ; on_cancel =
+          (fun _ ->
+            Common.Term.cursor t None;
+            headline_screen 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 ]) ->
-    let scroll = if y - scroll = 0 then max (scroll - 1) 0 else scroll in
-    headline_screen t @@ ((x, max (y - 1) 0), scroll)
+  | `Key (`ASCII 'k', []) | `Key (`ASCII 'P', [ `Ctrl ]) -> scroll_up ()
   | `Key (`Arrow d, _) ->
     (match d with
      | `Up -> scroll_up ()
      | `Down -> scroll_down ()
-     | _ -> headline_screen t ((x, y), scroll))
+     | _ -> headline_screen 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) =
@@ -67,13 +112,28 @@ let rec headline_screen t (((x, y) as pos), scroll) =
       match Unix.wait () with
       | _, _ ->
         Common.Term.cursor t None;
-        headline_screen t ((x, y), scroll)
+        headline_screen t state
       (* Capture resizing events *)
       | exception Unix.Unix_error (Unix.EINTR, _, _) -> run_editor ()
       | exception Unix.Unix_error (_, _, _) -> failwith "ERROR"
     in
     run_editor ()
-  | _ -> headline_screen t (pos, scroll)
+  | _ -> headline_screen t state
 
 
-let start () = headline_screen (Common.Term.create ()) ((0, 0), 0)
+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
+    headline_screen
+      (Common.Term.create ())
+      { tag; pos = 0, 0; scroll = 0; content; content_pretty })
diff --git a/lib/input_screen.ml b/lib/input_screen.ml
@@ -0,0 +1,35 @@
+module Grep = Grep
+module Common = Common
+open Notty
+
+type state =
+  { user_input : string
+  ; on_enter : string -> unit
+  ; on_cancel : unit -> unit
+  ; prompt : string
+  ; screen : I.t
+  }
+
+let rec render t ({ user_input; on_enter; on_cancel; screen; prompt } as state) =
+  let _, size_y = Common.Term.size t in
+  Common.Term.cursor t (Some (String.length user_input + String.length prompt, size_y));
+  let img =
+    let open I in
+    I.strf "%s%s" prompt user_input |> I.pad ~l:0 ~t:(size_y - 1) </> screen
+  in
+  Common.Term.image t img;
+  match Common.Term.event t with
+  | `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
+  | `Resize _ -> render t state
+  | `Key (`ASCII c, []) ->
+    let state = { state with user_input = user_input ^ String.make 1 c } in
+    render t state
+  | _ -> render t state