Merge branch 'master' of codezen.org:/var/www/codezen.org/src/canto-curses
[canto-curses.git] / canto_curses / tag.py
1 # -*- coding: utf-8 -*-
2 #Canto-curses - ncurses RSS reader
3 #   Copyright (C) 2010 Jack Miller <jack@codezen.org>
4 #
5 #   This program is free software; you can redistribute it and/or modify
6 #   it under the terms of the GNU General Public License version 2 as 
7 #   published by the Free Software Foundation.
8
9 from canto_next.hooks import call_hook, on_hook, remove_hook
10
11 from parser import parse_conditionals, eval_theme_string
12 from theme import FakePad, WrapPad, theme_print
13 from story import Story
14
15 import traceback
16 import logging
17 import curses
18
19 log = logging.getLogger("TAG")
20
21 # The Tag class manages stories. Externally, it looks
22 # like a Tag takes IDs from the backend and renders an ncurses pad. No class
23 # other than Tag actually touches Story objects directly.
24
25 DEFAULT_TAG_FSTRING = "%?{sel}(%R:)%?{c}([+]:[-])%?{en}([%{to}]:)%?{aen}([%{vto}]:) %t [%B%2%n%1%b]%?{sel}(%r:)"
26
27 class Tag(list):
28     def __init__(self, tag, callbacks):
29         list.__init__(self)
30         self.tag = tag
31         self.pad = None
32
33         # Note that Tag() is only given the top-level CantoCursesGui
34         # callbacks as it shouldn't be doing input / refreshing
35         # itself.
36
37         self.callbacks = callbacks.copy()
38
39         # Modify our own callbacks so that *_tag_opt assumes
40         # the current tag.
41
42         self.callbacks["get_tag_opt"] =\
43                 lambda x : callbacks["get_tag_opt"](self, x)
44         self.callbacks["set_tag_opt"] =\
45                 lambda x, y : callbacks["set_tag_opt"](self, x, y)
46
47         # This could be implemented as a generic, top-level hook but then N
48         # tags would have access to story objects they shouldn't have and
49         # would have to check every items membership in self, which would be
50         # pointless and time-consuming.
51
52         self.callbacks["item_state_change"] =\
53                 self.on_item_state_change
54
55         # Are there changes pending?
56         self.changed = True
57
58         self.selected = False
59         self.marked = False
60
61         # Information from last refresh
62         self.lines = 0
63         self.width = 0
64
65         # Global indices (for enumeration)
66         self.item_offset = 0
67         self.visible_tag_offset = 0
68         self.tag_offset = 0
69         self.sel_offset = 0
70
71         on_hook("opt_change", self.on_opt_change)
72         on_hook("tag_opt_change", self.on_tag_opt_change)
73
74         # Upon creation, this Tag adds itself to the
75         # list of all tags.
76
77         callbacks["get_var"]("alltags").append(self)
78
79     def die(self):
80         # Reset so items get die() called and everything
81         # else is notified about items disappearing.
82
83         self.reset()
84         remove_hook("opt_change", self.on_opt_change)
85         remove_hook("tag_opt_change", self.on_tag_opt_change)
86
87     def on_item_state_change(self, item):
88         self.need_redraw()
89
90     def on_opt_change(self, opts):
91         if "taglist.tags_enumerated" in opts or \
92                 "taglist.tags_enumerated_absolute" in opts:
93             self.need_redraw()
94
95     def on_tag_opt_change(self, tag, opts):
96         if tag == self:
97             self.need_redraw()
98
99     # We override eq so that empty tags don't evaluate
100     # as equal and screw up things like enumeration.
101
102     def __eq__(self, other):
103         if not hasattr(other, "tag") or self.tag != other.tag:
104             return False
105         return list.__eq__(self, other)
106
107     # Create Story from ID before appending to list.
108
109     def add_items(self, ids):
110         added = []
111         for id in ids:
112             s = Story(id, self.callbacks)
113             self.append(s)
114             added.append(s)
115
116             rel = len(self) - 1
117             s.set_rel_offset(rel)
118             s.set_offset(self.item_offset + rel)
119             s.set_sel_offset(self.sel_offset + rel)
120
121         call_hook("items_added", [ self, added ] )
122
123     # Take a list of ordered ids and reorder ourselves, without generating any
124     # unnecessary add/remove hooks.
125
126     def reorder(self, ids):
127         cur_stories = [ s for s in self ]
128
129         # Perform the actual reorder.
130         stories = [ self.get_id(id) for id in ids ]
131
132         del self[:]
133         list.extend(self, stories)
134
135         # Deal with items that aren't listed. Usually this happens if the item
136         # would be filtered, but is protected for some reason (like selection)
137
138         # NOTE: This is bad behavior, but if we don't retain these items, other
139         # code will crap-out expecting this item to exist. Built-in transforms
140         # are hardened to never discard items with the filter-immune reason,
141         # like selection, so this is just for bad user transforms.
142
143         for s in cur_stories:
144             if s not in self:
145                 log.warn("Warning: A filter is filtering filter-immune items.")
146                 log.warn("Compensating. This may cause items to jump unexpectedly.")
147                 list.append(self, s)
148
149         log.debug("Self: %s" % [ s for s in self ])
150
151         # Handle updating story information.
152         for i, story in enumerate(self):
153             story.set_rel_offset(i)
154             story.set_offset(self.item_offset + i)
155             story.set_sel_offset(self.sel_offset + i)
156
157     # Remove Story based on ID
158
159     def remove_items(self, ids):
160         removed = []
161
162         # Copy self so we can remove from self
163         # without screwing up iteration.
164
165         for idx, item in enumerate(self[:]):
166             if item.id in ids:
167                 log.debug("removing: %s" % (item.id,))
168
169                 list.remove(self, item)
170                 item.die()
171                 removed.append(item)
172
173         # Update indices of items.
174         for i, story in enumerate(self):
175             story.set_rel_offset(i)
176             story.set_offset(self.item_offset + i)
177             story.set_sel_offset(self.sel_offset + i)
178
179         call_hook("items_removed", [ self, removed ] )
180
181     # Remove all stories from this tag.
182
183     def reset(self):
184         for item in self:
185             item.die()
186
187         call_hook("items_removed", [ self, self[:] ])
188         del self[:]
189
190     def get_id(self, id):
191         for item in self:
192             if item.id == id:
193                 return item
194         return None
195
196     def get_ids(self):
197         return [ s.id for s in self ]
198
199     # Inform the tag of global index of it's first item.
200     def set_item_offset(self, offset):
201         if self.item_offset != offset:
202             self.item_offset = offset
203             for i, item in enumerate(self):
204                 item.set_offset(offset + i)
205
206     # Note that this cannot be short-cut (i.e.
207     # copout if sel_offset is already equal)
208     # because it's possible that it's the same
209     # without the items having ever been updated.
210
211     # Alternatively, we could reset them in
212     # on_tag_opt_change, but since the sel
213     # offset does not cause a redraw, there's
214     # no point.
215
216     def set_sel_offset(self, offset):
217         self.sel_offset = offset
218
219         if not self.callbacks["get_tag_opt"]("collapsed"):
220             for i, item in enumerate(self):
221                 item.set_sel_offset(offset + i)
222
223     def set_visible_tag_offset(self, offset):
224         if self.visible_tag_offset != offset:
225             self.visible_tag_offset = offset
226             self.need_redraw()
227
228     def set_tag_offset(self, offset):
229         if self.tag_offset != offset:
230             self.tag_offset = offset
231             self.need_redraw()
232
233     def select(self):
234         if not self.selected:
235             self.selected = True
236             self.need_redraw()
237
238     def unselect(self):
239         if self.selected:
240             self.selected = False
241             self.need_redraw()
242
243     def need_redraw(self):
244         self.changed = True
245         self.callbacks["set_var"]("needs_redraw", True)
246
247     def do_changes(self, width):
248         if width != self.width or self.changed:
249             self.refresh(width)
250
251     def refresh(self, width):
252         self.width = width
253
254         lines = self.render_header(width, FakePad(width))
255
256         self.pad = curses.newpad(lines, width)
257         self.render_header(width, WrapPad(self.pad))
258
259         self.lines = lines
260         self.changed = False
261
262     def render_header(self, width, pad):
263         fstring = self.callbacks["get_opt"]("tag.format")
264         enumerated = self.callbacks["get_opt"]("taglist.tags_enumerated")
265         enumerated_absolute =\
266             self.callbacks["get_opt"]("taglist.tags_enumerated_absolute")
267         collapsed = self.callbacks["get_tag_opt"]("collapsed")
268
269         # Make sure to strip out the category from category:name
270         tag = self.tag.split(':', 1)[1]
271
272         unread = len([s for s in self\
273                 if "canto-state" not in s.content or\
274                 "read" not in s.content["canto-state"]])
275
276         # These are escapes that are handled in the theme_print
277         # lower in the function and should remain present after
278         # evaluation.
279
280         passthru = {}
281         for c in "RrDdUuBbSs012345678":
282             passthru[c] = "%" + c
283
284
285         try:
286             parsed = parse_conditionals(fstring)
287         except Exception, e:
288             log.warn("Failed to parse conditionals in fstring: %s" %
289                     fstring)
290             log.warn("\n" + "".join(traceback.format_exc(e)))
291             log.warn("Falling back to default.")
292             parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
293
294         values = { 'en' : enumerated,
295                     'aen' : enumerated_absolute,
296                     'c' : collapsed,
297                     't' : tag,
298                     'sel' : self.selected,
299                     'n' : unread,
300                     'to' : self.tag_offset,
301                     'vto' : self.visible_tag_offset,
302                     'tag' : self}
303
304         values.update(passthru)
305
306         try:
307             s = eval_theme_string(parsed, values)
308         except Exception, e:
309             log.warn("Failed to evaluate fstring: %s" % fstring)
310             log.warn("\n" + "".join(traceback.format_exc(e)))
311             log.warn("Falling back to default")
312
313             parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
314             s = eval_theme_string(parsed, values)
315
316         s += u"\n"
317
318         lines = 0
319
320         while s:
321             s = theme_print(pad, s, width, u"", u"")
322             lines += 1
323
324         return lines