forked from keithfancher/todo-indicator
-
Notifications
You must be signed in to change notification settings - Fork 1
/
todo_indicator.py
executable file
·260 lines (217 loc) · 9.79 KB
/
todo_indicator.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
#!/usr/bin/env python
# Copyright 2012,2013 Keith Fancher
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# William Blanchard
# 3/6/2017 - Updated to accept a filter allowing a user to select either a context
# 3/7/2017 - Updated to work with either python2 or python3
# or a project
import argparse
import fileinput
import os
import pyinotify
import sys
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject
gi.require_version('AppIndicator3', '0.1')
from gi.repository import AppIndicator3 as appindicator
from os.path import expanduser
PANEL_ICON = "todo-indicator"
DEFAULT_EDITOR = "xdg-open"
class TodoIndicator(object):
def __init__(self, todo_filename, text_editor=None, task_filter=None):
"""Sets the filename, loads the list of items from the file, builds the
indicator."""
if text_editor:
self.text_editor = text_editor
else:
self.text_editor = DEFAULT_EDITOR
if task_filter:
self.task_filter = task_filter
else:
self.task_filter = ""
# Check to see if the user included a todo_filename. If not, check to
# see if one exists at the default location (~/todo.txt). If there isn't
# one at the default location, create one.
home = expanduser("~")
if todo_filename == "todo.txt":
if os.path.isfile(home + "/" + todo_filename):
self.todo_filename = home + "/" + todo_filename
else:
open(home + "/" + todo_filename, "w+").close()
self.todo_filename = home + "/todo.txt"
else:
if os.path.isfile(todo_filename):
self.todo_filename = todo_filename
else:
print("Error opening file:\n" + todo_filename)
sys.exit(1)
# Menu items (aside from the todo items themselves). An association of
# text and callback functions. Can't use a dict because we need to
# preserve order.
self.menu_items = [ ('Edit todo.txt', self.edit_handler),
('Clear completed', self.clear_completed_handler),
('Refresh', self.refresh_handler),
('Quit', self.quit_handler) ]
GObject.threads_init() # necessary for threaded notifications
self.list_updated_flag = False # does the GUI need to catch up?
self.todo_path = os.path.dirname(self.todo_filename) # useful
self.build_indicator() # creates self.ind
# Watch for modifications of the todo file with pyinotify. We have to
# watch the entire path, since inotify is very inconsistent about what
# events it catches for a single file.
self.wm = pyinotify.WatchManager()
self.notifier = pyinotify.ThreadedNotifier(self.wm,
self.process_inotify_event)
self.notifier.start()
# The IN_MOVED_TO watch catches Dropbox updates, which don't trigger
# normal IN_MODIFY events.
self.wm.add_watch(self.todo_path,
pyinotify.IN_MODIFY | pyinotify.IN_MOVED_TO)
# Add timeout function, allows threading to not fart all over itself.
# Can't use Gobject.idle_add() since it rudely 100%s the CPU.
GObject.timeout_add(500, self.update_if_todo_file_changed)
def update_if_todo_file_changed(self):
"""This will be called by the main GTK thread every half second or so.
If the self.list_updated_flag is False, it will immediately return. If
it's True, it will rebuild the GUI with the updated list and reset the
flag. This is necessary since threads + GTK are wonky as fuck."""
if self.list_updated_flag:
self.build_indicator() # rebuild
self.list_updated_flag = False # reset flag
# if we don't explicitly return True here the callback will be removed
# from the queue after one call and will never be called again
return True
def process_inotify_event(self, event):
"""This callback is typically an instance of the ProcessEvent class,
but after digging through the pyinotify source it looks like it can
also be a function? This makes things much easier in our case, avoids
nested classes, etc.
This function can't explicitly update the GUI since it's on a different
thread, so it just flips a flag and lets another function called by the
GTK main loop do the real work."""
if event.pathname == self.todo_filename:
self.list_updated_flag = True
def load_todo_file(self):
"""Populates the list of todo items from the todo file."""
try:
str_list = list()
with open(self.todo_filename, 'r+') as f:
for line in f:
if line and line.strip():
str_list.append(line.strip("\n"))
self.todo_list = sorted(filter(self.check_item_for_filter, str_list),
key=lambda a: 'z' * 10 + a if a[:2] == 'x ' else a)
except IOError:
print("Error opening file:\n" + self.todo_filename)
sys.exit(1)
def check_off_item_with_label(self, label):
"""Matches the given todo item, finds it in the file, and "checks it
off" by adding "x " to the beginning of the string. If you have
multiple todo items that are exactly the same, this will check them all
off. Also, you're stupid for doing that."""
for line in fileinput.input(self.todo_filename, inplace=1):
if line.strip() == label.strip():
print("x %s" % line.strip("\n"))
else:
print("%s" % line.strip("\n"))
def remove_checked_off_items(self):
"""Remove checked items from the file itself."""
for line in fileinput.input(self.todo_filename, inplace=1):
if line[:2] == 'x ':
pass
else:
print("%s" % line.strip("\n"))
def check_off_handler(self, menu_item):
"""Callback to check items off the list."""
self.check_off_item_with_label(menu_item.get_label()) # write file
self.build_indicator() # rebuild!
def edit_handler(self, menu_item):
"""Opens the todo.txt file with selected editor."""
os.system(self.text_editor + " " + self.todo_filename)
def clear_completed_handler(self, menu_item):
"""Remove checked off items, rebuild list menu."""
self.remove_checked_off_items()
self.build_indicator()
def refresh_handler(self, menu_item):
"""Manually refreshes the list."""
# TODO: gives odd warning about removing a child...
self.build_indicator() # rebuild indicator
def quit_handler(self, menu_item):
"""Quits our fancy little program."""
self.notifier.stop() # stop watching the file!
Gtk.main_quit()
def build_indicator(self):
"""Builds the Indicator object."""
if not hasattr(self, 'ind'): # self.ind needs to be created
self.ind = appindicator.Indicator.new("todo-txt-indicator",
PANEL_ICON, appindicator.IndicatorCategory.OTHER)
self.ind.set_status(appindicator.IndicatorStatus.ACTIVE)
# make sure the list is loaded
self.load_todo_file()
# create todo menu items
menu = Gtk.Menu()
if self.todo_list:
for todo_item in self.todo_list:
menu_item = Gtk.MenuItem(todo_item)
if todo_item[0:2] == 'x ': # gray out completed items
menu_item.set_sensitive(False)
menu_item.connect("activate", self.check_off_handler)
menu_item.show()
menu.append(menu_item)
# empty list
else:
menu_item = Gtk.MenuItem('[ No items. Click \'Edit\' to add some! ]')
menu_item.set_sensitive(False)
menu_item.show()
menu.append(menu_item)
# add a separator
menu_item = Gtk.SeparatorMenuItem()
menu_item.show()
menu.append(menu_item)
# our menu
for text, callback in self.menu_items:
menu_item = Gtk.MenuItem(text)
menu_item.connect("activate", callback)
menu_item.show()
menu.append(menu_item)
# do it!
self.ind.set_menu(menu)
def check_item_for_filter(self, list_item):
if list_item.find(self.task_filter) == -1:
return False
else:
return True
def main(self):
"""The indicator's main loop."""
Gtk.main()
def get_args():
"""Gets and parses command line arguments."""
parser = argparse.ArgumentParser()
parser.add_argument('-e', '--editor', action='store',
help='your favorite text editor')
parser.add_argument('-f', '--filter', action='store',
help='your filter')
parser.add_argument('todo_filename', action='store',
help='your todo.txt file')
return parser.parse_args()
def main():
"""My main() man."""
args = get_args()
ind = TodoIndicator(args.todo_filename, args.editor, args.filter)
ind.main()
if __name__ == "__main__":
main()