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