From 47892663040f7e295cc4052438cf804b040f0389 Mon Sep 17 00:00:00 2001 From: Marc Coquand Date: Wed, 15 May 2024 10:45:54 -0500 Subject: Add TODOs, update help, rebind keys --- LICENSE | 29 +++++++++++ README.org | 4 ++ lib/grep.ml | 108 +++++++++++++++++++++++++++++++++----- lib/headlines.ml | 17 ++++-- lib/help_screen.ml | 62 +++++++++++++++++----- lib/input_screen.ml | 15 +++--- lib/stitched_article.ml | 3 +- lib/todos.ml | 136 ++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 336 insertions(+), 38 deletions(-) create mode 100644 LICENSE create mode 100644 lib/todos.ml 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 }) -- cgit v1.2.3