aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMarc Coquand <marc@mccd.space>2024-05-15 10:45:54 -0500
committerMarc Coquand <marc@mccd.space>2024-05-15 10:45:54 -0500
commit47892663040f7e295cc4052438cf804b040f0389 (patch)
treed299cb4f0741904b1a8d484a6fba365b41592bf5
parent7038e0073e7f7f672644ca949eeda399d6a533fb (diff)
downloadstitch-47892663040f7e295cc4052438cf804b040f0389.tar.gz
stitch-47892663040f7e295cc4052438cf804b040f0389.tar.bz2
stitch-47892663040f7e295cc4052438cf804b040f0389.zip
Add TODOs, update help, rebind keys
-rw-r--r--LICENSE29
-rw-r--r--README.org4
-rw-r--r--lib/grep.ml108
-rw-r--r--lib/headlines.ml17
-rw-r--r--lib/help_screen.ml62
-rw-r--r--lib/input_screen.ml15
-rw-r--r--lib/stitched_article.ml3
-rw-r--r--lib/todos.ml136
8 files changed, 336 insertions, 38 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c7b7be5
--- /dev/null
+++ 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
index 924a93f..9d767f3 100644
--- 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
index 9855b6f..ac09f80 100644
--- 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
index b30903d..0633a73 100644
--- 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
index 223a863..0726be3 100644
--- 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
index 3fae53c..45979aa 100644
--- 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
index c5c31d3..ceedc46 100644
--- 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
new file mode 100644
index 0000000..3162615
--- /dev/null
+++ 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 })