%C escape suspends colors as well as attributes
[canto-curses.git] / canto_curses / theme.py
1 # -*- coding: utf-8 -*-
2 #Canto-curses - ncurses RSS reader
3 #   Copyright (C) 2014 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.encoding import encoder, locale_enc
10 from .widecurse import waddch, wcwidth
11
12 import curses
13
14 import logging
15
16 log = logging.getLogger("WIDECURSE")
17
18 attr_count = { "B" : 0,
19                "D" : 0,
20                "R" : 0,
21                "S" : 0,
22                "U" : 0 }
23
24 attr_map = { "B" : curses.A_BOLD,
25              "D" : curses.A_DIM,
26              "R" : curses.A_REVERSE,
27              "S" : curses.A_STANDOUT,
28              "U" : curses.A_UNDERLINE }
29
30 # theme_print handles attribute codes and escaping:
31 #   %1 - %8 turns on color pairs 1 - 8
32 #   %0      turns on the previously enabled color
33
34 color_stack = []
35 color_stack_suspended = []
36
37 # Return length of next string of non-space characters
38 # or 0 if next character *is* a space.
39
40 def len_next_word(uni):
41     if ' ' in uni:
42         return theme_len(uni.split(' ', 1)[0])
43     return theme_len(uni)
44
45 class FakePad():
46     def __init__(self, width):
47         self.x = 0
48         self.y = 0
49         self.width = width
50
51     def attron(self, attr):
52         pass
53
54     def attroff(self, attr):
55         pass
56
57     def clrtoeol(self):
58         pass
59
60     def waddch(self, ch):
61         cwidth = wcwidth(ch)
62         if cwidth < 0 and not ch.is_space():
63             return
64
65         self.x += cwidth
66         if self.x >= self.width:
67             self.y += 1
68             self.x -= self.width
69
70     def getyx(self):
71         return (self.y, self.x)
72
73     def move(self, y, x):
74         self.y = y
75         self.x = x
76
77 class WrapPad():
78     def __init__(self, pad):
79         self.pad = pad
80
81     def attron(self, attr):
82         self.pad.attron(attr)
83
84     def attroff(self, attr):
85         self.pad.attroff(attr)
86
87     def clrtoeol(self):
88         self.pad.clrtoeol()
89
90     def waddch(self, ch):
91         waddch(self.pad, ch)
92
93     def getyx(self):
94         return self.pad.getyx()
95
96     def move(self, x, y):
97         return self.pad.move(x, y)
98
99 def attr_debug(fn):
100     def debug_wrapper(*args):
101         log.debug("args: %s" % (args,))
102         log.debug("cstack: %s" % (color_stack,))
103         log.debug("attr_cnt: %s" % (attr_count,))
104
105         ret = fn(*args)
106
107         log.debug("args: %s" % (args,))
108         log.debug("cstack: %s" % (color_stack,))
109         log.debug("attr_cnt: %s" % (attr_count,))
110
111         return ret
112     return debug_wrapper
113
114 def theme_print_one(pad, uni, width):
115     global color_stack
116     global attr_count
117     global attr_map
118
119     max_width = width
120     escaped = False
121     code = False
122
123     long_code = False
124     lc = ""
125
126     for i, c in enumerate(uni):
127         ec = encoder(c)
128         cwidth = wcwidth(ec)
129         if cwidth < 0 and not ec.isspace():
130             continue
131
132         if escaped:
133             # No room
134             if cwidth > width:
135                 return "\\" + uni[i:]
136
137             try:
138                 pad.waddch(ec)
139             except:
140                 log.debug("Can't print escaped ec: %s in: %s" % (ec, uni))
141
142             width -= cwidth
143             escaped = False
144         elif code:
145             # Turn on color 1 - 8
146             if c in "12345678":
147                 if len(color_stack):
148                     pad.attroff(curses.color_pair(color_stack[-1]))
149                 color_stack.append(ord(c) - ord('0'))
150                 pad.attron(curses.color_pair(color_stack[-1]))
151             # Return to previous color
152             elif c == '0':
153                 if len(color_stack):
154                     pad.attroff(curses.color_pair(color_stack[-1]))
155
156                 if len(color_stack) >= 2:
157                     pad.attron(curses.color_pair(color_stack[-2]))
158                     color_stack = color_stack[0:-1]
159                 else:
160                     pad.attron(curses.color_pair(0))
161                     color_stack = []
162
163             # Turn attributes on / off
164             elif c in "BbDdRrSsUu":
165                 if c.isupper():
166                     attr_count[c] += 1
167                 else:
168                     c = c.upper()
169                     attr_count[c] -= 1
170
171                 if attr_count[c]:
172                     pad.attron(attr_map[c])
173                 else:
174                     pad.attroff(attr_map[c])
175
176             # Suspend attributes
177             elif c == "C":
178                 for attr in attr_map:
179                     pad.attroff(attr_map[attr])
180                 for color in reversed(color_stack):
181                     pad.attroff(curses.color_pair(color))
182                 pad.attron(curses.color_pair(0))
183                 color_stack_suspended = color_stack
184                 color_stack = []
185
186             # Restore attributes
187             elif c == "c":
188                 for attr in attr_map:
189                     if attr_count[attr]:
190                         pad.attron(attr_map[attr])
191                 color_stack = color_stack_suspended
192                 color_stack_suspended = []
193                 if color_stack:
194                     pad.attron(curses.color_pair(color_stack[-1]))
195                 else:
196                     pad.attron(curses.color_pair(0))
197             elif c == "[":
198                 long_code = True
199             code = False
200         elif long_code:
201             if c == "]":
202                 try:
203                     long_color = int(lc)
204                 except:
205                     log.error("Unknown long code: %s! Ignoring..." % lc)
206                 else:
207                     if long_color < 1 or long_color > 256:
208                         log.error("long color code must be >= 1 and <= 256")
209                     else:
210                         try:
211                             pad.attron(curses.color_pair(long_color))
212                             color_stack.append(long_color)
213                         except:
214                             log.error("Could not set pair. Perhaps need to set TERM='xterm-256color'?")
215                 long_code = False
216                 lc = ""
217             else:
218                 lc += c
219         elif c == "\\":
220             escaped = True
221         elif c == "%":
222             code = True
223         elif c == "\n":
224             return uni[i + 1:]
225         else:
226             if c == " ":
227                 # Word too long
228                 wwidth = len_next_word(uni[i + 1:])
229
230                 # >= to account for current character
231                 if wwidth <= max_width and wwidth >= width:
232                     return uni[i + 1:]
233
234             # Character too long (should be handled above).
235             if cwidth > width:
236                 return uni[i:]
237
238             try:
239                 pad.waddch(ec)
240             except Exception as e:
241                 log.debug("Can't print ec: %s in: %s" % (ec, repr(encoder(uni))))
242                 log.debug("Exception: %s" % e)
243
244             width -= cwidth
245
246     return None
247
248 def theme_print(pad, uni, mwidth, pre = "", post = "", cursorbash=True, clear=True):
249     prel = theme_len(pre)
250     postl = theme_len(post)
251     y = pad.getyx()[0]
252
253     theme_print_one(pad, pre, prel)
254
255     width = (mwidth - prel) - postl
256     if width <= 0:
257         raise Exception("theme_print: NO ROOM!")
258
259     r = theme_print_one(pad, uni, width)
260
261     if clear:
262         pad.clrtoeol()
263
264     if post:
265         try:
266             pad.move(y, (mwidth - postl))
267         except:
268             log.debug("move error: %d %d" % (y, mwidth - postl))
269         theme_print_one(pad, post, postl)
270
271     if cursorbash:
272         try:
273             pad.move(y + 1, 0)
274         except:
275             pass
276
277     if r == uni:
278         raise Exception("theme_print: didn't advance!")
279
280     return r
281
282 # Returns the effective, printed length of a string, taking
283 # escapes and wide characters into account.
284
285 def theme_len(uni):
286     escaped = False
287     code = False
288     length = 0
289
290     for c in uni:
291         ec = encoder(c)
292
293         cwidth = wcwidth(ec)
294         if cwidth < 0 and not ec.isspace():
295             continue
296
297         if escaped:
298             length += cwidth
299             escaped = False
300         elif code:
301             code = False
302         elif c == "\\":
303             escaped = True
304         elif c == "%":
305             code = True
306         else:
307             width = cwidth
308             if width >= 0:
309                 length += width
310     return length
311
312 # This is useful when a themed string needs to get truncated, so that color and
313 # attribute settings can be processed, despite the last part of the string not
314 # being displayed.
315
316 def theme_process(pad, uni):
317     only_codes = ""
318     escaped = False
319     code = False
320
321     for c in uni:
322         if escaped:
323             escaped = False
324             continue
325         elif code:
326             only_codes += c
327             code = False
328         elif c == "\\":
329             escaped = True
330         elif c == "%":
331             code = True
332             only_codes += "%"
333
334     # NOTE: len works because codes never use widechars.
335     theme_print(pad, only_codes, len(only_codes), "", "", False)
336
337 # Strip more than two newlines from the front of the input, processing escapes
338 # as we discard characters.
339
340 def theme_lstrip(pad, uni):
341     newlines = 0
342     codes = ""
343     escaped = False
344
345     for i, c in enumerate(uni):
346         # Discard
347         if c in " \t\v":
348             continue
349
350         if c == "\n":
351             newlines = 1
352         elif c == "%":
353             escaped = True
354             codes += "%"
355         elif escaped:
356             escaped = False
357             codes += c
358         else:
359             r = uni[i:]
360             break
361
362     # No content found.
363     else:
364         newlines = 0
365         r = ""
366
367     # Process dangling codes.
368     if codes:
369         theme_process(pad, codes)
370
371     return (newlines * "\n") + r
372
373 def theme_reset():
374     for key in attr_count:
375         attr_count[key] = 0
376     color_stack = []
377
378 utf_chars = { "ls" : "│",
379               "rs" : "│",
380               "ts" : "─",
381               "bs" : "─",
382               "tl" : "┌",
383               "tr" : "┐",
384               "bl" : "└",
385               "br" : "┘" }
386
387 ascii_chars = { "ls" : "|",
388                 "rs" : "|",
389                 "ts" : "-",
390                 "bs" : "-",
391                 "tl" : "+",
392                 "tr" : "+",
393                 "bl" : "+",
394                 "br" : "+" }
395
396 def theme_border(code):
397     if "UTF-8" in locale_enc:
398         return utf_chars[code]
399     return ascii_chars[code]