rodo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # Rodo
2
+ Rodo is terminal-based todo manager written in Ruby with a inbox-zero mentality. It takes inspiration from bullet journalling.
3
+
4
+ ## Screenshot
5
+
6
+ ![Screenshot of Rodo used on plan.md from this repository](https://user-images.githubusercontent.com/12127567/121207217-67065b80-c879-11eb-8976-f8ba3d162341.png)
7
+
8
+ ## Detail Description
9
+
10
+ Rodo is a todo/task manager with a terminal-based user interface (TUI). It uses `markdown` as the underlying file format, which allows you to also edit the resulting todo files with any other text editor. Rodo structures your todos primarily using dates ("What do I need to do today?") and only then using sub-sections for projects.
11
+
12
+ Rodo is a text editor, but with a rich command vocabulary which perform actions related to todo and task management. It is similar to `vi` in that by default you are not in a mode where you can `edit`, but in a command mode where keystroke are interpreted as commands. For examples, pressing <kbd>A</kbd> will append a todo to the currently selected day and enter `single line edit mode`.
13
+
14
+ Rodo suggests that every day you start at a blank page and take over the relevant entries from the previous days. This idea originates from bullet journalling (e.g. https://bulletjournal.com/pages/learn), but gets software support in Rodo:
15
+
16
+ - You can either press <kbd>T</kbd> (today) to create a new entry for the current date and copy all tasks which are unfinished over automatically.
17
+ - Or you can move individual entries over to the next day by pressing <kbd>P</kbd> (postpone).
18
+
19
+ Rodo is programmed in Ruby and uses `ncurses` under the hood.
20
+
21
+ ## Features
22
+
23
+ Rodo is work in progress but can already be used for basic todo tracking. Currently the following features are supported:
24
+
25
+ - Command mode, single line edit mode, journalling mode.
26
+ - Append, Insert, Edit, Kill, <kbd>⭾TAB</kbd> and <kbd>⇧Shift+⭾TAB</kbd></kbd> to indent and unindent, Save+Quit <kbd>Q</kbd>
27
+ - CTRL+C will exit without saving
28
+ - Bracketed paste support allows to paste from the clipboard without seeing indentation artifacts.
29
+ - Backup before save (stored to `_bak\`)
30
+
31
+ ## Things not working currently
32
+
33
+ Rodo comes with no warranty and is still rough. The most notable things missing in the release 0.1.0:
34
+
35
+ - No undo/redo
36
+ - No autosave
37
+ - No scrolling
38
+ - No mouse interaction
39
+ - No special handling for any markdown except unordered lists, headings and todos.
40
+
41
+ ## Installation & First Run
42
+
43
+ Install the `rodo` gem locally
44
+
45
+ $ gem install rodo
46
+
47
+ Then run it:
48
+
49
+ $ rodo
50
+
51
+ Starting rodo without arguments will use '~/plan.md' as the markdown file.
52
+
53
+ Note: Rodo requires Ruby 2.7 and higher due to using the pattern matching `case` statement.
54
+
55
+ Note: When running with `rbenv` you need to `rbenv rehash` after you installed the gem for the command line wrapper to become visible on the path.
56
+
57
+ ## Usage
58
+
59
+ To run:
60
+
61
+ $ rodo [file]
62
+
63
+ By default rodo is in `scroll` mode, with the following keys supported:
64
+
65
+ - <kbd>cursor keys</kbd> Up/Down select particular line, Left/Right go to previous/next day
66
+ - <kbd>Q</kbd> Quit with Save
67
+ - <kbd>CTRL+C</kbd> Quit without Save
68
+ - <kbd>A</kbd> Append new todo below with same indent as current line
69
+ - <kbd>I</kbd> Insert new todo before the current lien with same indent as current line
70
+ - <kbd>K</kbd> Kill current line
71
+ - <kbd>P</kbd> Postpone current line to tomorrow
72
+ - <kbd>W</kbd> Mark current todo as waiting for reply/other person (put a reminder in 7 days)
73
+ - <kbd>T</kbd> Create a new entry for today's date and move all unfinished todos over.
74
+ - <kbd>X</kbd> Toggle current line as Complete/Incomplete
75
+ - <kbd>⭾TAB</kbd> and <kbd>⇧Shift+⭾TAB</kbd></kbd> to indent and unindent
76
+ - <kbd>ENTER</kbd> Start editing the current line. Finish editing with another `ENTER`.
77
+ - <kbd>E</kbd> Enter editing mode. ENTER will create a new line in this mode.
78
+ - <kbd>F1</kbd> See a command palette.
79
+
80
+ In `editing` mode most keys will just create resulting in typing, except:
81
+
82
+ - <kbd>CTRL+A</kbd>, <kbd>CTRL+E</kbd> Put cursor at start of line, end of line.
83
+ - <kbd>⭾TAB</kbd> and <kbd>CTRL+RIGHT</kbd> Move to beginning/end of next/previous word.
84
+
85
+ ## Development
86
+
87
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
88
+
89
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
90
+
91
+ ## License
92
+
93
+ Rodo is licensed under GPL-v3 or later.
94
+
95
+ ## Contributing
96
+
97
+ Bug reports and pull requests are welcome on GitHub at https://github.com/coezbek/rodo.
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "rodo"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/exe/rodo ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'warning'
5
+ Warning.ignore(/Pattern matching is experimental/)
6
+
7
+ require "bundler/setup"
8
+ require "rodo"
9
+
10
+ Rodo.new.main_loop
data/lib/rodo.rb ADDED
@@ -0,0 +1,825 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rodo/version"
4
+
5
+ #
6
+ # rodo.rb
7
+ #
8
+ # Keybindings:
9
+ #
10
+ # n = New Todo
11
+ # <space> or . = Toggle Todo
12
+ # <up> <down> go to next in same hierarchy
13
+ # <left, right> go to subtasks
14
+ # <enter> edit current todo
15
+ # p <n> = Postpone for <n> days. If <n> is left blank, postpone to tomorrow (config_postpone_days_default)
16
+ # w <n> = Mark this entry as waiting for a response. This will mark this todo complete
17
+ # but create a todo in the future (<n> days) to check for the result actually being complete
18
+ # If <n> is left blank will postpone until next week (7 days, config_waiting_days_default)
19
+ # t (today) = Create a new journal entry for the current date (today), marks all unfinished todos of the current day as [u] and copies them as
20
+ # as empty todos [ ] to today.
21
+ #
22
+
23
+ require 'curses'
24
+ require 'fileutils'
25
+ require_relative 'rodo/curses_util'
26
+ require_relative 'rodo/rodolib'
27
+ require_relative 'rodo/commands'
28
+
29
+ CTRLC = 3
30
+ ENTER = 13
31
+ ESC = 27
32
+
33
+ class Rodo
34
+
35
+ include Commands
36
+
37
+ attr_accessor :file_name
38
+ attr_accessor :win1, :win1b
39
+ attr_accessor :cursor_line, :cursor_x, :current_day_index, :newly_added_line
40
+ attr_accessor :journal
41
+ attr_accessor :mode
42
+ attr_accessor :debug
43
+
44
+ def init
45
+ Curses.ESCDELAY = 50 # 50 milliseconds (ESC is always a key, never a sequence until automatic)
46
+ Curses.raw
47
+ Curses.noecho # Don't print user input
48
+ Curses.nonl
49
+ Curses.stdscr.keypad = true # Control keys should be returned as keycodes
50
+ Curses.init_screen
51
+ Curses.curs_set(0) # Invisible cursor
52
+ Curses.bracketed_paste # From CursesUtils: Enable Bracketed Paste Mode
53
+
54
+ if !Curses.has_colors?
55
+ Curses.abort "Curses doesn't have color support in this TTY."
56
+ else
57
+ Curses.start_color
58
+ Curses.colors.times { |i|
59
+ Curses.init_pair(i, i, 0)
60
+ }
61
+ end
62
+
63
+ # If no file is given, assume the user wants to edit "~/plan.md"
64
+ if ARGV.empty?
65
+ @file_name = "plan.md"
66
+ Dir.chdir Dir.home
67
+ else
68
+ @file_name = ARGV[0]
69
+ end
70
+ File.write(@file_name, "# #{Time.now.strftime("%Y-%m-%d")}\n") if !File.exist?(@file_name)
71
+
72
+ @journal = Journal.from_s(File.read(@file_name))
73
+
74
+ @cursor = Cursor.new(@journal)
75
+ @cursor.day = @journal.most_recent_index
76
+ @mode = :scroll
77
+ @newly_added_line = nil
78
+ @debug = $DEBUG || $VERBOSE # || ENV["BUNDLE_BIN_PATH"]
79
+ end
80
+
81
+ def main_loop
82
+
83
+ status = init()
84
+ return if status == :close
85
+
86
+ begin
87
+
88
+ build_windows()
89
+
90
+ loop do
91
+ render_windows()
92
+
93
+ char = @win1::get_char3
94
+
95
+ if char == Curses::KEY_RESIZE
96
+ sleep(0.5)
97
+ build_windows
98
+ next
99
+ end
100
+
101
+ case char
102
+ in paste:
103
+ process_paste(paste)
104
+ else
105
+ status = process_input(char)
106
+ return if status == :close
107
+ end
108
+ end
109
+
110
+ ensure
111
+ Curses.close_screen
112
+ end
113
+ end
114
+
115
+ def build_windows
116
+
117
+ @win1.close if @win1
118
+ if Curses.debug_win
119
+ Curses.debug_win.close
120
+ Curses.debug_win = nil
121
+ end
122
+
123
+ # Building a static window
124
+ @win1 = Curses::Window.new(Curses.lines, Curses.cols / (@debug ? 2 : 1), 0, 0)
125
+ @win1.keypad = true
126
+ @win1b = @win1.subwin(@win1.maxy - 2, @win1.maxx - 3, 1, 2)
127
+
128
+ if @debug
129
+ debug_win = Curses::Window.new(Curses.lines, Curses.cols / 2, 0, Curses.cols / 2)
130
+ debug_win.box
131
+ debug_win.caption "Debug information"
132
+ debug_win.refresh
133
+ Curses.debug_win = debug_win.inset
134
+ end
135
+ end
136
+
137
+ def get_cmd(win, pos_y, pos_x, command_prototyp_list)
138
+
139
+ size_y = 10
140
+ size_x = [win.maxx - win.begx - 2 * pos_x - 2, 10].max
141
+
142
+ cmd_win = Curses::Window.new(size_y + 2, size_x + 2, win.begy + pos_y, win.begx + pos_x)
143
+ cmd_win.keypad = true
144
+ cmd_win.box
145
+ cmd_win.refresh
146
+ inset = cmd_win.inset(1)
147
+ inset.keypad = true
148
+ result = inset.gets("> ".dup) { |s|
149
+
150
+ if s =~ /^\s*\>\s*(.*)$/
151
+ cmd = $1 # Remove prompt
152
+ list = command_prototyp_list.dup
153
+ if cmd.strip != ""
154
+ list.select! { |c|
155
+ if c.instance_of? Hash
156
+ if c.has_key?(:regex)
157
+ c[:regex] =~ cmd
158
+ elsif c.has_key?(:description)
159
+ c[:description].include?(cmd)
160
+ else
161
+ true
162
+ end
163
+ else
164
+ c.include?(cmd)
165
+ end
166
+ }
167
+ end
168
+ #Curses.debug_win.puts cmd
169
+ #Curses.debug_win.puts list.inspect
170
+ #Curses.debug_win.refresh
171
+ list.push "No matching commands" if list.empty?
172
+ list.each { |c|
173
+ if c.instance_of? Hash
174
+ if c.has_key?(:description)
175
+ inset.puts c[:description]
176
+ end
177
+ else
178
+ inset.puts c
179
+ end
180
+ }
181
+ end
182
+
183
+ }
184
+
185
+ # Curses.debug_win.puts "Result: #{result}"
186
+
187
+ return result
188
+ end
189
+
190
+ def render_windows
191
+
192
+ current_day = @journal.days[@cursor.day]
193
+
194
+ @win1.box
195
+
196
+ if Curses.debug_win
197
+ Curses.debug_win.setpos(0, 0)
198
+ Curses.debug_win.puts "Cursor @ #{@cursor.line}"
199
+ Curses.debug_win.puts "Mode: #{@mode}"
200
+ Curses.debug_win.refresh
201
+ end
202
+
203
+ # Next / prev Navigation
204
+ has_next_day = @cursor.day > 0
205
+ has_prev_day = @cursor.day < @journal.days.size - 1
206
+
207
+ nav_str = []
208
+ nav_str << "❮ #{@journal.days[@cursor.day + 1].date_name}" if has_prev_day
209
+ nav_str << "#{@journal.days[@cursor.day - 1].date_name} ❯" if has_next_day
210
+
211
+ nav_str = nav_str.join(" ")
212
+ @win1b.setpos(0, @win1b.maxx - nav_str.length - 1)
213
+ @win1b.addstr(nav_str)
214
+
215
+ # Contents of current day:
216
+ lines = current_day.lines
217
+ overflows = 0
218
+ lines.each_with_index do |line, i|
219
+ Curses.abort("Line #{i} is nil") if line == nil
220
+
221
+ @win1b.setpos(i + overflows + 1, 0)
222
+ if @cursor.line == i
223
+ @win1b.attron(Curses.color_pair(255))
224
+ else
225
+ @win1b.attron(Curses.color_pair(246))
226
+ end
227
+ if (%i[scroll move].include?(@mode) && (@cursor.line != i || line.size != 0)) ||
228
+ (%i[edit journalling].include?(@mode) && @cursor.line != i)
229
+ then
230
+ @win1b.puts line
231
+ else
232
+ line = line + " "
233
+
234
+ if Curses.debug_win
235
+ Curses.debug_win.puts "Before Cursor: '#{line[...@cursor.x]}'"
236
+ Curses.debug_win.puts "Cursor_x: '#{@cursor.x}'"
237
+ Curses.debug_win.refresh
238
+ end
239
+
240
+ @cursor.x = 0 if ![:edit, :journalling].include?(@mode)
241
+ @cursor.x = line.size - 1 if @cursor.x >= line.size
242
+
243
+ @win1b.addstr line[...@cursor.x] if @cursor.x > 0
244
+ @win1b.attron(Curses::A_REVERSE)
245
+ @win1b.addstr line[@cursor.x]
246
+ @win1b.attroff(Curses::A_REVERSE)
247
+ @win1b.addstr(line[(@cursor.x+1)..]) if @cursor.x < line.size
248
+ @win1b.addstr("\n")
249
+ end
250
+
251
+ overflows += line.length / @win1b.maxx
252
+ end
253
+
254
+ # At the end switch color to normal again
255
+ @win1b.attron(Curses.color_pair(246))
256
+ end
257
+
258
+ def process_paste(pasted)
259
+
260
+ pasted.gsub! /\r\n?/, "\n"
261
+ # Curses.debug "Pasted: #{pasted.inspect}"
262
+
263
+ current_day = @journal.days[@cursor.day]
264
+ lines = current_day.lines
265
+
266
+ # Todo Clean-up Pasted Special characters (bullets)
267
+ pasted.gsub! /^\t/, " " # Replace initial tabs with 1 space
268
+ pasted.gsub! /\t/, " " # Replace other tabs with 2 spaces
269
+ pasted.gsub! /^(\s*)[•□○®◊§] /, "\\1- "
270
+
271
+ case @mode
272
+ when :journalling, :edit
273
+
274
+ # When in journalling or edit mode, then pasting will split the current line
275
+
276
+ pasted_lines = pasted.lines
277
+
278
+ left = lines[@cursor.line][0...@cursor.x]
279
+ right = lines[@cursor.line][@cursor.x..-1]
280
+
281
+ pasted_lines.each_with_index { |line, i|
282
+ # On the first line, append to text left of cursor
283
+ if i == 0
284
+ lines[@cursor.line] = left + line
285
+ else
286
+ lines.insert(@cursor.line, line)
287
+ end
288
+
289
+ # On the last line, append existing text right of cursor
290
+ if i == pasted_lines.size - 1
291
+ @cursor.x = lines[@cursor.line].length
292
+ lines[@cursor.line] += right
293
+ else
294
+ @cursor.line += 1
295
+ end
296
+ }
297
+
298
+ when :scroll, :move
299
+ # Insert each line after the current line
300
+ pasted.each_line { |l|
301
+ @cursor.line += 1
302
+ lines.insert(@cursor.line, l)
303
+ }
304
+ @cursor.x = lines[@cursor.line].size
305
+
306
+ else
307
+ Curses.abort("Case not handled: #{@mode}");
308
+ end
309
+ end
310
+
311
+ def process_input(char)
312
+
313
+ current_day = @journal.days[@cursor.day]
314
+ lines = current_day.lines
315
+
316
+ case @mode
317
+
318
+ when :journalling
319
+
320
+ case char
321
+
322
+ when CTRLC, CTRLC.chr
323
+ return :close
324
+
325
+ when Curses::KEY_UP
326
+ @cursor.line -= 1 if @cursor.line > 0
327
+
328
+ when Curses::KEY_DOWN
329
+ @cursor.line += 1 if @cursor.line < lines.size - 1
330
+
331
+ when "\u0001" # CTRL+A
332
+
333
+ @cursor.x = 0
334
+
335
+ when "\u0005" # CTRL+E
336
+
337
+ @cursor.x = lines[@cursor.line].length
338
+
339
+ when Curses::KEY_LEFT
340
+
341
+ if @cursor.x == 0
342
+ if @cursor.line > 0
343
+ @cursor.line -= 1
344
+ @cursor.x = lines[@cursor.line].length
345
+ end
346
+ else
347
+ @cursor.x -= 1 if @cursor.x > 0
348
+ end
349
+
350
+ when Curses::KEY_RIGHT
351
+
352
+ if @cursor.x >= lines[@cursor.line].length - 1
353
+ if @cursor.line < lines.size - 1
354
+ @cursor.line += 1
355
+ @cursor.x = 0
356
+ end
357
+ else
358
+ @cursor.x += 1 if @cursor.x < lines[@cursor.line].length - 1
359
+ end
360
+
361
+ when Curses::KEY_CTRL_LEFT
362
+
363
+ if @cursor.x == 0
364
+ if @cursor.line > 0
365
+ @cursor.line -= 1
366
+ @cursor.x = lines[@cursor.line].length
367
+ end
368
+ end
369
+
370
+ left = lines[@cursor.line][0...@cursor.x]
371
+ if /\b?((^|\S+)\s*)$/ =~ left
372
+ @cursor.x -= $1.length
373
+ end
374
+
375
+ when Curses::KEY_CTRL_RIGHT
376
+
377
+ if @cursor.x >= lines[@cursor.line].length - 1
378
+ if @cursor.line < lines.size - 1
379
+ @cursor.line += 1
380
+ @cursor.x = 0
381
+ end
382
+ end
383
+ right = lines[@cursor.line][@cursor.x..-1]
384
+ if /^(\s*\S+\b?\s*)/ =~ right
385
+ @cursor.x += $1.length
386
+ end
387
+
388
+ when Curses::KEY_DC, "\u0004" # DEL, CTRL+D
389
+
390
+ if @cursor.x >= lines[@cursor.line].size
391
+ if @cursor.line < lines.size - 1
392
+ lines[@cursor.line] += lines[@cursor.line + 1]
393
+ lines.delete_at(@cursor.line + 1)
394
+ end
395
+ else
396
+ lines[@cursor.line].slice!(@cursor.x)
397
+ end
398
+ win1b.clear
399
+
400
+ when Curses::KEY_BACKSPACE
401
+
402
+ if @cursor.x == 0 && @cursor.line > 0
403
+ @cursor.line -= 1
404
+ @cursor.x = lines[@cursor.line].length
405
+ lines[@cursor.line] += lines[@cursor.line + 1]
406
+ lines.delete_at(@cursor.line + 1)
407
+ elsif @cursor.x > 0
408
+ lines[@cursor.line].slice!(@cursor.x - 1)
409
+ @cursor.x -= 1
410
+ end
411
+
412
+ when "\v" # CTRL K
413
+ if lines.size > 1
414
+ lines.delete_at(@cursor.line)
415
+ @cursor.line -= 1 if @cursor.line >= lines.size
416
+ else
417
+ lines[0] = "".dup
418
+ end
419
+ @win1b.clear
420
+
421
+ when ENTER, ENTER.chr
422
+
423
+ left = lines[@cursor.line][0...@cursor.x]
424
+ right = lines[@cursor.line][@cursor.x..-1]
425
+
426
+ # If line to the left of cursor starts with "- [ ]" or with a star or dash
427
+ if /^(?<lead>\s+[*-])(?<option>\s\[.\]\s?)?(?<rest>.*?)$/ =~ left && !(right =~ /^\s+[*-]/)
428
+
429
+ if rest.strip.length == 0 and right.strip.length == 0
430
+ # line is empty, except for */-/[ ]
431
+ right = nil
432
+ # unindent
433
+ if /^\s\s(?<lead2>.*)$/ =~ lead
434
+ lead = lead2
435
+ else
436
+ lead = ""
437
+ option = ""
438
+ end
439
+ left = lead + option
440
+ else
441
+ if option =~ /^\s\[.\]/
442
+ option = " [ ]"
443
+ end
444
+ end
445
+
446
+ option = "" if !option
447
+ lead = lead + option.rstrip + " "
448
+ right = lead + right.lstrip if right
449
+ @cursor.x = lead.length
450
+ else
451
+ @cursor.x = 0
452
+ end
453
+
454
+ lines[@cursor.line] = left
455
+ if right != nil
456
+ lines.insert(@cursor.line + 1, right)
457
+ @cursor.line += 1
458
+ end
459
+ when ESC, ESC.chr
460
+
461
+ @mode = :scroll
462
+ @journal.days[@cursor.day] = TodoDay.new(lines) # Reparse day after edit
463
+
464
+ when /[[:print:]]/
465
+
466
+ lines[@cursor.line].insert(@cursor.x, char)
467
+ @cursor.x += 1
468
+
469
+ when "\t", "\t".ord
470
+ if lines[@cursor.line] =~ /\s*[-+*]/
471
+ lines[@cursor.line].sub!(/^/, " ")
472
+ @cursor.x += 2
473
+ end
474
+
475
+ when Curses::KEY_BTAB
476
+ if lines[@cursor.line] =~ /( |\t)\s*[-+*]/
477
+ lines[@cursor.line].sub!(/^( |\t)/, "")
478
+ @cursor.x -= 2
479
+ @cursor.x = 0 if @cursor.x < 0
480
+ end
481
+
482
+ else
483
+ if Curses.debug_win
484
+ Curses.debug_win.puts "Char not handled: " + Curses::char_info(char)
485
+ Curses.debug_win.refresh
486
+ end
487
+ end
488
+
489
+ when :edit
490
+
491
+ case char
492
+
493
+ when CTRLC, CTRLC.chr
494
+ return :close
495
+
496
+ #when CTRLA then buffer.beginning_of_line
497
+ #when CTRLE then buffer.end_of_line
498
+ #when Curses::KEY_UP
499
+ # @cursor.line -= 1 if @cursor.line > 0
500
+ #when Curses::KEY_DOWN
501
+ # @cursor.line += 1 if @cursor.line < lines.size - 1
502
+
503
+ when "\u0001" # CTRL+A
504
+
505
+ @cursor.x = 0
506
+
507
+ when "\u0005" # CTRL+E
508
+
509
+ @cursor.x = lines[@cursor.line].length
510
+
511
+ when Curses::KEY_LEFT
512
+
513
+ @cursor.x -= 1 if @cursor.x > 0
514
+
515
+ when Curses::KEY_RIGHT
516
+
517
+ @cursor.x += 1 if @cursor.x < lines[@cursor.line].length - 1
518
+
519
+ when Curses::KEY_CTRL_LEFT
520
+
521
+ left = lines[@cursor.line][0...@cursor.x]
522
+ if /((^|\w+?|\W+?)\s*)$/ =~ left
523
+ @cursor.x -= $1.length
524
+ end
525
+
526
+ when Curses::KEY_CTRL_RIGHT
527
+
528
+ right = lines[@cursor.line][@cursor.x..-1]
529
+ if /^(\s*|\S+\s*)/ =~ right
530
+ @cursor.x += $1.length
531
+ end
532
+
533
+ when Curses::KEY_DC, "\u0004" # DEL, CTRL+D
534
+
535
+ if @cursor.x < lines[@cursor.line].size
536
+ lines[@cursor.line].slice!(@cursor.x)
537
+ end
538
+
539
+ when Curses::KEY_BACKSPACE
540
+ if @cursor.x > 0
541
+ lines[@cursor.line].slice!(@cursor.x - 1)
542
+ @cursor.x -= 1
543
+ end
544
+
545
+ when ENTER, ENTER.chr
546
+ @mode = :scroll
547
+ @newly_added_line = nil
548
+ @journal.days[@cursor.day] = TodoDay.new(lines) # Reparse day after edit
549
+
550
+ when ESC, ESC.chr
551
+ # When pressing ESC after an insert, which didn't change anything then undo the insertion
552
+ if @newly_added_line && lines[@cursor.line] == @newly_added_line
553
+ lines.delete_at(@cursor.line)
554
+ @cursor.line -= 1 if @cursor.line >= lines.size
555
+ @win1b.clear
556
+ end
557
+ # Debug:
558
+ # Curses.debug "Lines[@cursor.line] == #{lines[@cursor.line].inspect }, @newly_added_line == #{@newly_added_line.inspect}"
559
+
560
+ @mode = :scroll
561
+ @newly_added_line = nil
562
+ @journal.days[@cursor.day] = TodoDay.new(lines) # Reparse day after edit
563
+
564
+ when /[[:print:]]/
565
+
566
+ lines[@cursor.line].insert(@cursor.x, char)
567
+ # Curses.setpos(@cursor.line + 2, lines[@cursor.line].length + 2)
568
+ @cursor.x += 1
569
+
570
+ when "\t", "\t".ord
571
+ if lines[@cursor.line] =~ /\s*[-+*]/
572
+ lines[@cursor.line].sub!(/^/, " ")
573
+ @cursor.x += 2
574
+ end
575
+
576
+ when Curses::KEY_BTAB
577
+ if lines[@cursor.line] =~ /( |\t)\s*[-+*]/
578
+ lines[@cursor.line].sub!(/^( |\t)/, "")
579
+ @cursor.x -= 2
580
+ @cursor.x = 0 if @cursor.x < 0
581
+ end
582
+
583
+ else
584
+ if Curses.debug_win
585
+ Curses.debug_win.puts "Char not handled: " + Curses::char_info(char)
586
+ Curses.debug_win.refresh
587
+ end
588
+ end
589
+
590
+ when :scroll
591
+
592
+ case char
593
+ when 'q'
594
+ FileUtils.mkdir_p "_bak"
595
+ FileUtils.cp(@file_name, File.join(File.dirname(@file_name), "_bak", File.basename(@file_name) + "-#{Time.now.strftime("%Y-%m-%dT%H-%M-%S")}.bak"))
596
+ File.write(@file_name, @journal.to_s)
597
+
598
+ return :close
599
+
600
+ when '~'
601
+ @debug = !@debug
602
+ build_windows
603
+
604
+ when CTRLC, CTRLC.chr
605
+ return :close
606
+
607
+ #when CTRLA then buffer.beginning_of_line
608
+ #when CTRLE then buffer.end_of_line
609
+ when Curses::KEY_UP
610
+ @cursor.line -= 1 if @cursor.line > 0
611
+
612
+ when Curses::KEY_DOWN
613
+ @cursor.line += 1 if @cursor.line < lines.size - 1
614
+
615
+ when Curses::KEY_RIGHT then
616
+ @cursor.day -= 1 if @cursor.day > 0
617
+ @win1b.clear
618
+
619
+ when Curses::KEY_LEFT then
620
+ @cursor.day += 1 if @cursor.day < @journal.days.size - 1
621
+ @win1b.clear
622
+
623
+ when "\t", "\t".ord
624
+ if lines[@cursor.line] =~ /\s*[-+*]/
625
+ lines[@cursor.line].sub!(/^/, " ")
626
+ end
627
+
628
+ when Curses::KEY_F1
629
+
630
+ cmd = get_cmd(@win1b, 1, 1, @command_prototyp_list)
631
+ if cmd == :close
632
+ # do nothing, because user closed window
633
+ elsif cmd =~ /^\s*>\s*(.*)$/
634
+
635
+ cmd = $1 # Remove prompt
636
+
637
+ # Search list of available commands for a match and run the cmd
638
+ @command_prototyp_list.find { |command_prototype|
639
+ case command_prototype
640
+ in regex: r, do_cmd: c
641
+ if r =~ cmd
642
+ c.call(cmd, lines, current_day)
643
+ next true
644
+ else
645
+ next false
646
+ end
647
+
648
+ in description: d
649
+ if d.start_with? cmd.strip
650
+ Curses.unget_char(cmd.strip)
651
+ next true
652
+ end
653
+ next false
654
+
655
+ in String
656
+ if command_prototype.start_with? cmd.strip
657
+ Curses.unget_char(cmd.strip)
658
+ next true
659
+ end
660
+ next false
661
+ else
662
+ if Curses.debug_win
663
+ Curses.debug_win.puts "Unknown command enter from F1: #{cmd}"
664
+ Curses.debug_win.refresh
665
+ end
666
+ end
667
+ }
668
+ end
669
+
670
+ when Curses::KEY_BTAB
671
+ if lines[@cursor.line] =~ /( |\t)\s*[-+*]/
672
+ lines[@cursor.line].sub!(/^( |\t)/, "")
673
+ end
674
+
675
+ #when Curses::KEY_BACKSPACE then
676
+ # buffer.remove_char_before_cursor
677
+ #when ENTER then buffer.new_line
678
+ when '.', 'x'
679
+ if lines[@cursor.line] =~ /\[\s\]/
680
+ lines[@cursor.line].gsub!(/\[\s\]/, "[x]")
681
+ elsif lines[@cursor.line] =~ /\[[xX]\]/
682
+ lines[@cursor.line].gsub!(/\[[xX]\]/, "[ ]")
683
+ end
684
+ #when /[[:print:]]/ then buffer.add_char(char)
685
+
686
+ when '2'
687
+ # ★
688
+
689
+ when 'e' # Edit
690
+ @mode = :journalling
691
+ @cursor.x = lines[@cursor.line].length
692
+
693
+ when 'm' # Move
694
+ @mode = :move
695
+
696
+ when 'i' # Insert
697
+ @cursor.line = 1 if @cursor.line == 0
698
+ @newly_added_line = current_day.line_prototype(@cursor.line)
699
+ lines.insert(@cursor.line, @newly_added_line.dup)
700
+ @mode = :edit
701
+ @cursor.x = lines[@cursor.line].size
702
+
703
+ when ENTER, ENTER.chr
704
+ @mode = :edit
705
+ @cursor.x = lines[@cursor.line].size
706
+
707
+ when 'a' # append
708
+
709
+ @newly_added_line = current_day.line_prototype(@cursor.line)
710
+ @cursor.line += 1
711
+ lines.insert(@cursor.line, @newly_added_line.dup)
712
+ @mode = :edit
713
+ @cursor.x = lines[@cursor.line].size
714
+
715
+ when 't' # t(oday)
716
+
717
+ @cursor.day = @journal.close(current_day)
718
+ @current_day = @journal.days[@cursor.day]
719
+ @win1b.clear
720
+
721
+ when 'k' # kill
722
+ if lines.size > 1
723
+ lines.delete_at(@cursor.line)
724
+ @cursor.line -= 1 if @cursor.line >= lines.size
725
+ else
726
+ lines[0] = "".dup
727
+ end
728
+ @win1b.clear
729
+
730
+ when 'w' # waiting
731
+
732
+ if lines[@cursor.line] =~ /\[\s\]/
733
+
734
+ line_to_wait_for = lines[@cursor.line].dup
735
+
736
+ if !(line_to_wait_for =~ / - ⌛ since \d\d\d\d-\d\d-\d\d$/) && current_day.date
737
+ line_to_wait_for = line_to_wait_for.rstrip + " - ⌛ since #{current_day.date_name}"
738
+ end
739
+
740
+ # Get target day (and create if it doesn't exist) and add there
741
+ postpone_day = @journal.postpone_day(current_day, 7)
742
+ postpone_day.lines.insert(1, line_to_wait_for)
743
+
744
+ # Adjust @cursor.day if a new day was created
745
+ @cursor.day += 1 if current_day != @journal.days[@cursor.day]
746
+
747
+ # Add hourclass here
748
+ lines[@cursor.line].gsub!(/\[\s\]/, "[⌛]")
749
+
750
+ end
751
+
752
+ when 'p' # postpone
753
+
754
+ postpone(lines, current_day, 1)
755
+
756
+ else
757
+ if Curses.debug_win
758
+ Curses.debug_win.puts "Char not handled: " + Curses::char_info(char)
759
+ Curses.debug_win.refresh
760
+ end
761
+ end
762
+
763
+ when :move
764
+
765
+ case char
766
+ when 'q'
767
+ FileUtils.mkdir_p "_bak"
768
+ FileUtils.cp(@file_name, "_bak/" + @file_name + "-#{Time.now.strftime("%Y-%m-%dT%H-%M-%S")}.bak")
769
+ File.write(@file_name, @journal.to_s)
770
+
771
+ return :close
772
+
773
+ when CTRLC, CTRLC.chr
774
+ return :close
775
+
776
+ when Curses::KEY_UP
777
+
778
+ if @cursor.line > 0
779
+ lines[@cursor.line], lines[@cursor.line - 1] = lines[@cursor.line - 1], lines[@cursor.line]
780
+ @cursor.line -= 1
781
+ end
782
+
783
+ when Curses::KEY_DOWN
784
+
785
+ if @cursor.line < lines.size - 1
786
+ lines[@cursor.line], lines[@cursor.line + 1] = lines[@cursor.line + 1], lines[@cursor.line]
787
+ @cursor.line += 1
788
+ end
789
+
790
+ when "\t", "\t".ord, Curses::KEY_RIGHT
791
+ if lines[@cursor.line] =~ /\s*[-+*]/
792
+ lines[@cursor.line].sub!(/^/, " ")
793
+ end
794
+
795
+ when Curses::KEY_BTAB, Curses::KEY_LEFT
796
+ if lines[@cursor.line] =~ /( |\t)\s*[-+*]/
797
+ lines[@cursor.line].sub!(/^( |\t)/, "")
798
+ end
799
+
800
+ when ENTER, ENTER.chr, ESC, ESC.chr
801
+ @mode = :scroll
802
+
803
+ else
804
+ Curses.debug "Char not handled: " + Curses::char_info(char)
805
+ end
806
+ else
807
+ Curses.abort "Mode #{@mode} not handled"
808
+ end
809
+
810
+ return nil
811
+ end
812
+
813
+ def postpone(lines, current_day, n)
814
+
815
+ target_day = @journal.postpone_line(current_day, @cursor.line, n)
816
+
817
+ # Adjust @cursor.day if a new day was created
818
+ @cursor.day += 1 if current_day != @journal.days[@cursor.day]
819
+ end
820
+
821
+ end
822
+
823
+ if __FILE__==$0
824
+ Rodo.new.main_loop
825
+ end