stitch

Note taking for messy minimalists

git clone git://mccd.space/stitch

headlines.ml (9742B)

      1 module Grep = Grep
      2 module Common = Common
      3 open Notty
      4 module Input_prompt = Input_prompt
      5 module Stitched_article = Stitched_article
      6 module Help_screen = Help_screen
      7 
      8 type state =
      9   { pos : int * int
     10   ; scroll : int
     11   ; content : (string * string) array
     12   ; content_pretty : string array
     13   ; goto_todos_view : (unit -> unit) -> unit
     14   ; goto_done_view : (unit -> unit) -> unit
     15   ; output : string option
     16   ; tag : string
     17   ; hide_file_name : bool
     18   }
     19 
     20 let init ~goto_done_view ~goto_todos_view ~regexp =
     21   let content = Grep.get_tagged_headlines regexp () |> Option.map Grep.parse_headlines in
     22   match content with
     23   | None ->
     24     Error
     25       "There are no notes to load. Make sure to create some in order to view them in \
     26        stitch. Check stitch --help for how to get started."
     27   | Some content ->
     28     if Array.length content == 0
     29     then (
     30       print_endline "Unable to load any content. Check grep commands and make sure there are notes";
     31       exit 0)
     32     else (
     33       let hide_file_name = true in
     34       let content_pretty = content |> Grep.pretty_format ~hide_file_name in
     35       Ok
     36         { pos = 0, 2
     37         ; scroll = 0
     38         ; content
     39         ; content_pretty
     40         ; goto_done_view
     41         ; goto_todos_view
     42         ; output = None
     43         ; tag = regexp
     44         ; hide_file_name
     45         })
     46 
     47 
     48 let title ~tag =
     49   match tag with
     50   | "" -> I.strf ~attr:A.(st bold) "%s" "Notes" |> I.pad ~l:0 ~t:0
     51   | a -> I.strf ~attr:A.(st bold) "%s > %s" "Notes" a |> I.pad ~l:0 ~t:0
     52 
     53 
     54 let refresh ~hide_file_name regexp =
     55   let content =
     56     Grep.get_tagged_headlines regexp ()
     57     |> Option.map Grep.parse_headlines
     58     |> Option.value ~default:[||]
     59   in
     60   content, Grep.pretty_format ~hide_file_name content
     61 
     62 
     63 let rec render
     64   t
     65   ({ pos
     66    ; scroll
     67    ; content
     68    ; content_pretty
     69    ; goto_todos_view
     70    ; goto_done_view
     71    ; tag
     72    ; output
     73    ; hide_file_name
     74    } as state)
     75   =
     76   let content_start = 2 in
     77   let size_x, size_y = Common.Term.size t in
     78   let x, y = pos in
     79   let content_position = y - content_start in
     80   let img =
     81     let output_info =
     82       match output with
     83       | Some line ->
     84         I.strf "%s%s" (String.escaped line) (String.make size_x ' ')
     85         |> I.pad ~t:(size_y - 1)
     86       | None -> I.empty
     87     in
     88     let dot =
     89       if Array.length content_pretty = 0
     90       then I.empty
     91       else I.string A.(st bold) ">" |> I.pad ~l:0 ~t:(y - scroll)
     92     and elements =
     93       Array.mapi
     94         (fun i el ->
     95           if i + 1 == y - scroll
     96           then I.strf "%s" el |> I.pad ~l:2 ~t:(i + content_start)
     97           else I.strf "%s" el |> I.pad ~l:2 ~t:(i + content_start))
     98         (Basic.array_drop scroll content_pretty)
     99     in
    100     let open I in
    101     Array.fold_left
    102       (fun sum el -> sum </> el)
    103       (title ~tag </> dot </> output_info)
    104       elements
    105   in
    106   Common.Term.image t img;
    107   let content_end = Array.length content_pretty + (content_start - 1) in
    108   let scroll_up ?(amount = 1) () =
    109     let scroll =
    110       if y - content_start - scroll - amount < 0 then max (scroll - amount) 0 else scroll
    111     in
    112     render t { state with pos = x, max (y - amount) content_start; scroll; output = None }
    113   in
    114   let scroll_down ?(amount = 1) () =
    115     let scroll = if y - scroll >= size_y - amount then scroll + amount else scroll in
    116     render t { state with pos = x, min (y + amount) content_end; scroll; output = None }
    117   in
    118   match Common.Term.event t with
    119   | `End | `Key (`Escape, []) | `Key (`ASCII 'q', []) | `Key (`ASCII 'C', [ `Ctrl ]) -> ()
    120   | `Mouse (`Press (`Scroll s), _, _) ->
    121     (match s with
    122      | `Down -> scroll_down ()
    123      | `Up -> scroll_up ())
    124   | `Resize _ -> render t state
    125   | `Mouse ((`Press _ | `Drag), (_, y), _) ->
    126     render t { state with pos = 0, min y content_end; output = None }
    127   | `Key (`ASCII '?', []) -> Help_screen.render t { go_back = (fun () -> render t state) }
    128   | `Key (`ASCII '2', []) -> goto_todos_view (fun () -> render t state)
    129   | `Key (`ASCII 'o', []) ->
    130     let (input_state : Input_prompt.state) =
    131       { screen = img
    132       ; user_input_pre = ""
    133       ; user_input_post = ""
    134       ; prompt = "EXPORT TO"
    135       ; on_enter =
    136           (fun path ->
    137             Common.Term.cursor t None;
    138             if String.equal (String.trim path) String.empty |> not
    139             then (
    140               let export = String.concat "\n" (Array.to_list content_pretty) in
    141               let result = Export.to_path path ~content:export in
    142               render t { state with output = Some result })
    143             else render t { state with output = Some "Export Blank; skipping" })
    144       ; on_cancel =
    145           (fun _ ->
    146             Common.Term.cursor t None;
    147             render t { state with output = Some "Export Cancelled" })
    148       }
    149     in
    150     Input_prompt.render t input_state
    151   | `Key (`ASCII 'h', []) ->
    152     let hide_file_name = not hide_file_name in
    153     let content_pretty = content |> Grep.pretty_format ~hide_file_name in
    154     render t { state with hide_file_name; content_pretty }
    155   | `Key (`ASCII '3', []) -> goto_done_view (fun () -> render t state)
    156   | `Key (`ASCII 'f', []) ->
    157     let content =
    158       Array.map
    159         (fun (file_name, _) -> Grep.get_full_file_content_content file_name)
    160         content
    161       |> Array.to_list
    162     in
    163     let full_content = Grep.parse_full_content content in
    164     let full_content_pretty =
    165       Grep.pretty_print_parsed_content full_content |> Array.of_list
    166     in
    167     Stitched_article.render
    168       t
    169       { pos = 0, Stitched_article.content_start
    170       ; content = full_content |> Array.of_list
    171       ; content_pretty = full_content_pretty
    172       ; scroll = 0
    173       ; tag
    174       ; output = None
    175       ; go_back = (fun () -> render t state)
    176       ; goto_todos_view
    177       ; goto_done_view
    178       }
    179   | `Key (`ASCII 's', []) ->
    180     let (input_state : Input_prompt.state) =
    181       { screen = img
    182       ; user_input_post = ""
    183       ; user_input_pre = ""
    184       ; prompt = "REGEXP"
    185       ; on_enter =
    186           (fun tag ->
    187             let content =
    188               Grep.get_tagged_headlines tag ()
    189               |> Option.map Grep.parse_headlines
    190               |> Option.value ~default:[||]
    191             in
    192             let content_pretty = Grep.pretty_format ~hide_file_name content in
    193             Common.Term.cursor t None;
    194             render
    195               t
    196               { state with
    197                 content
    198               ; content_pretty
    199               ; pos = 0, content_start
    200               ; scroll = 0
    201               ; tag
    202               })
    203       ; on_cancel =
    204           (fun _ ->
    205             Common.Term.cursor t None;
    206             render t state)
    207       }
    208     in
    209     Input_prompt.render t input_state
    210   | `Key (`ASCII 'D', [ `Ctrl ]) | `Key (`Page `Down, []) ->
    211     scroll_down ~amount:(size_y / 2) ()
    212   | `Key (`ASCII 'U', [ `Ctrl ]) | `Key (`Page `Up, []) ->
    213     scroll_up ~amount:(size_y / 2) ()
    214   | `Key (`ASCII 'j', []) | `Key (`ASCII 'N', [ `Ctrl ]) -> scroll_down ()
    215   | `Key (`ASCII 'k', []) | `Key (`ASCII 'P', [ `Ctrl ]) -> scroll_up ()
    216   | `Key (`Arrow d, _) ->
    217     (match d with
    218      | `Up -> scroll_up ()
    219      | `Down -> scroll_down ()
    220      | _ -> render t state)
    221   | `Key (`ASCII '!', []) ->
    222     let selected_file, content = Array.get content content_position in
    223     let selected_file = Grep.execution_directory ^ "/" ^ selected_file in
    224     let (input_state : Input_prompt.state) =
    225       { screen = img
    226       ; user_input_post = ""
    227       ; user_input_pre = ""
    228       ; prompt = "COMMAND"
    229       ; on_enter =
    230           (fun command ->
    231             if String.equal (String.trim command) String.empty
    232             then (
    233               Common.Term.cursor t None;
    234               render t state)
    235             else
    236               Arbitrary_command.run
    237                 t
    238                 ~command:(Scanf.unescaped command)
    239                 ~content
    240                 ~selected_file
    241                 ~on_return:(fun result ->
    242                   let content =
    243                     Grep.get_tagged_headlines tag ()
    244                     |> Option.map Grep.parse_headlines
    245                     |> Option.value ~default:[||]
    246                   in
    247                   let content_pretty = Grep.pretty_format ~hide_file_name content in
    248                   Common.Term.cursor t None;
    249                   render
    250                     t
    251                     { state with
    252                       content
    253                     ; content_pretty
    254                     ; pos = 0, content_start
    255                     ; scroll = 0
    256                     ; output = Some result
    257                     }))
    258       ; on_cancel =
    259           (fun _ ->
    260             Common.Term.cursor t None;
    261             render t state)
    262       }
    263     in
    264     Input_prompt.render t input_state
    265   | `Key (`ASCII 'e', []) | `Key (`Enter, []) ->
    266     (* Editor might be set with extra args, in that case we need to separate these *)
    267     let[@warning "-8"] (editor :: args) =
    268       String.split_on_char ' ' (Sys.getenv "EDITOR")
    269     in
    270     let selected_file, _ = Array.get content content_position in
    271     let full_path_file = Grep.execution_directory ^ "/" ^ selected_file in
    272     let full_args = Array.append (Array.of_list args) [| "+1"; full_path_file |] in
    273     Common.Term.cursor t (Some (0, 0));
    274     let _ =
    275       Unix.create_process_env
    276         editor
    277         full_args
    278         (Unix.environment ())
    279         Unix.stdin
    280         Unix.stdout
    281         Unix.stderr
    282     in
    283     let rec run_editor () =
    284       match Unix.wait () with
    285       | _, _ ->
    286         Common.Term.cursor t None;
    287         let content, content_pretty = refresh ~hide_file_name tag in
    288         render t { state with content; content_pretty }
    289       (* Capture resizing events *)
    290       | exception Unix.Unix_error (Unix.EINTR, _, _) -> run_editor ()
    291       | exception Unix.Unix_error (_, _, _) -> failwith "ERROR"
    292     in
    293     run_editor ()
    294   | _ -> render t state