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.
- checksums.yaml +7 -0
- data/.gitignore +57 -0
- data/Gemfile +8 -0
- data/LICENSE +674 -0
- data/README.md +97 -0
- data/Rakefile +4 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/exe/rodo +10 -0
- data/lib/rodo.rb +825 -0
- data/lib/rodo/commands.rb +29 -0
- data/lib/rodo/curses_util.rb +287 -0
- data/lib/rodo/rodolib.rb +438 -0
- data/lib/rodo/version.rb +5 -0
- data/plan.md +267 -0
- data/rodo.gemspec +38 -0
- metadata +106 -0
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
|
+

|
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
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
data/exe/rodo
ADDED
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
|