1#!/usr/bin/python
2# -*- coding: utf-8 -*-
3#
4# Urwid Text Layout classes
5#    Copyright (C) 2004-2011  Ian Ward
6#
7#    This library is free software; you can redistribute it and/or
8#    modify it under the terms of the GNU Lesser General Public
9#    License as published by the Free Software Foundation; either
10#    version 2.1 of the License, or (at your option) any later version.
11#
12#    This library is distributed in the hope that it will be useful,
13#    but WITHOUT ANY WARRANTY; without even the implied warranty of
14#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15#    Lesser General Public License for more details.
16#
17#    You should have received a copy of the GNU Lesser General Public
18#    License along with this library; if not, write to the Free Software
19#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
20#
21# Urwid web site: http://excess.org/urwid/
22
23from __future__ import division, print_function
24
25from urwid.util import calc_width, calc_text_pos, calc_trim_text, is_wide_char, \
26    move_prev_char, move_next_char
27from urwid.compat import bytes, PYTHON3, B, xrange
28
29class TextLayout:
30    def supports_align_mode(self, align):
31        """Return True if align is a supported align mode."""
32        return True
33    def supports_wrap_mode(self, wrap):
34        """Return True if wrap is a supported wrap mode."""
35        return True
36    def layout(self, text, width, align, wrap ):
37        """
38        Return a layout structure for text.
39
40        :param text: string in current encoding or unicode string
41        :param width: number of screen columns available
42        :param align: align mode for text
43        :param wrap: wrap mode for text
44
45        Layout structure is a list of line layouts, one per output line.
46        Line layouts are lists than may contain the following tuples:
47
48        * (column width of text segment, start offset, end offset)
49        * (number of space characters to insert, offset or None)
50        * (column width of insert text, offset, "insert text")
51
52        The offset in the last two tuples is used to determine the
53        attribute used for the inserted spaces or text respectively.
54        The attribute used will be the same as the attribute at that
55        text offset.  If the offset is None when inserting spaces
56        then no attribute will be used.
57        """
58        raise NotImplementedError("This function must be overridden by a real"
59            " text layout class. (see StandardTextLayout)")
60
61class CanNotDisplayText(Exception):
62    pass
63
64class StandardTextLayout(TextLayout):
65    def __init__(self):#, tab_stops=(), tab_stop_every=8):
66        pass
67        #"""
68        #tab_stops -- list of screen column indexes for tab stops
69        #tab_stop_every -- repeated interval for following tab stops
70        #"""
71        #assert tab_stop_every is None or type(tab_stop_every)==int
72        #if not tab_stops and tab_stop_every:
73        #    self.tab_stops = (tab_stop_every,)
74        #self.tab_stops = tab_stops
75        #self.tab_stop_every = tab_stop_every
76    def supports_align_mode(self, align):
77        """Return True if align is 'left', 'center' or 'right'."""
78        return align in ('left', 'center', 'right')
79    def supports_wrap_mode(self, wrap):
80        """Return True if wrap is 'any', 'space', 'clip' or 'ellipsis'."""
81        return wrap in ('any', 'space', 'clip', 'ellipsis')
82    def layout(self, text, width, align, wrap ):
83        """Return a layout structure for text."""
84        try:
85            segs = self.calculate_text_segments( text, width, wrap )
86            return self.align_layout( text, width, segs, wrap, align )
87        except CanNotDisplayText:
88            return [[]]
89
90    def pack(self, maxcol, layout):
91        """
92        Return a minimal maxcol value that would result in the same
93        number of lines for layout.  layout must be a layout structure
94        returned by self.layout().
95        """
96        maxwidth = 0
97        assert layout, "huh? empty layout?: "+repr(layout)
98        for l in layout:
99            lw = line_width(l)
100            if lw >= maxcol:
101                return maxcol
102            maxwidth = max(maxwidth, lw)
103        return maxwidth
104
105    def align_layout( self, text, width, segs, wrap, align ):
106        """Convert the layout segs to an aligned layout."""
107        out = []
108        for l in segs:
109            sc = line_width(l)
110            if sc == width or align=='left':
111                out.append(l)
112                continue
113
114            if align == 'right':
115                out.append([(width-sc, None)] + l)
116                continue
117            assert align == 'center'
118            out.append([((width-sc+1) // 2, None)] + l)
119        return out
120
121
122    def calculate_text_segments(self, text, width, wrap):
123        """
124        Calculate the segments of text to display given width screen
125        columns to display them.
126
127        text - unicode text or byte string to display
128        width - number of available screen columns
129        wrap - wrapping mode used
130
131        Returns a layout structure without alignment applied.
132        """
133        nl, nl_o, sp_o = "\n", "\n", " "
134        if PYTHON3 and isinstance(text, bytes):
135            nl = B(nl) # can only find bytes in python3 bytestrings
136            nl_o = ord(nl_o) # + an item of a bytestring is the ordinal value
137            sp_o = ord(sp_o)
138        b = []
139        p = 0
140        if wrap in ('clip', 'ellipsis'):
141            # no wrapping to calculate, so it's easy.
142            while p<=len(text):
143                n_cr = text.find(nl, p)
144                if n_cr == -1:
145                    n_cr = len(text)
146                sc = calc_width(text, p, n_cr)
147
148                # trim line to max width if needed, add ellipsis if trimmed
149                if wrap == 'ellipsis' and sc > width:
150                    trimmed = True
151                    spos, n_end, pad_left, pad_right = calc_trim_text(text, p, n_cr, 0, width-1)
152                    # pad_left should be 0, because the start_col parameter was 0 (no trimming on the left)
153                    # similarly spos should not be changed from p
154                    assert pad_left == 0
155                    assert spos == p
156                    sc = width - 1 - pad_right
157                else:
158                    trimmed = False
159                    n_end = n_cr
160                    pad_right = 0
161
162                l = []
163                if p!=n_end:
164                    l += [(sc, p, n_end)]
165                if trimmed:
166                    l += [(1, n_end, u'…'.encode("utf-8"))]
167                l += [(pad_right,n_end)]
168                b.append(l)
169                p = n_cr+1
170            return b
171
172
173        while p<=len(text):
174            # look for next eligible line break
175            n_cr = text.find(nl, p)
176            if n_cr == -1:
177                n_cr = len(text)
178            sc = calc_width(text, p, n_cr)
179            if sc == 0:
180                # removed character hint
181                b.append([(0,n_cr)])
182                p = n_cr+1
183                continue
184            if sc <= width:
185                # this segment fits
186                b.append([(sc,p,n_cr),
187                    # removed character hint
188                    (0,n_cr)])
189
190                p = n_cr+1
191                continue
192            pos, sc = calc_text_pos( text, p, n_cr, width )
193            if pos == p: # pathological width=1 double-byte case
194                raise CanNotDisplayText(
195                    "Wide character will not fit in 1-column width")
196            if wrap == 'any':
197                b.append([(sc,p,pos)])
198                p = pos
199                continue
200            assert wrap == 'space'
201            if text[pos] == sp_o:
202                # perfect space wrap
203                b.append([(sc,p,pos),
204                    # removed character hint
205                    (0,pos)])
206                p = pos+1
207                continue
208            if is_wide_char(text, pos):
209                # perfect next wide
210                b.append([(sc,p,pos)])
211                p = pos
212                continue
213            prev = pos
214            while prev > p:
215                prev = move_prev_char(text, p, prev)
216                if text[prev] == sp_o:
217                    sc = calc_width(text,p,prev)
218                    l = [(0,prev)]
219                    if p!=prev:
220                        l = [(sc,p,prev)] + l
221                    b.append(l)
222                    p = prev+1
223                    break
224                if is_wide_char(text,prev):
225                    # wrap after wide char
226                    next = move_next_char(text, prev, pos)
227                    sc = calc_width(text,p,next)
228                    b.append([(sc,p,next)])
229                    p = next
230                    break
231            else:
232                # unwrap previous line space if possible to
233                # fit more text (we're breaking a word anyway)
234                if b and (len(b[-1]) == 2 or ( len(b[-1])==1
235                        and len(b[-1][0])==2 )):
236                    # look for removed space above
237                    if len(b[-1]) == 1:
238                        [(h_sc, h_off)] = b[-1]
239                        p_sc = 0
240                        p_off = p_end = h_off
241                    else:
242                        [(p_sc, p_off, p_end),
243                               (h_sc, h_off)] = b[-1]
244                    if (p_sc < width and h_sc==0 and
245                        text[h_off] == sp_o):
246                        # combine with previous line
247                        del b[-1]
248                        p = p_off
249                        pos, sc = calc_text_pos(
250                            text, p, n_cr, width )
251                        b.append([(sc,p,pos)])
252                        # check for trailing " " or "\n"
253                        p = pos
254                        if p < len(text) and (
255                            text[p] in (sp_o, nl_o)):
256                            # removed character hint
257                            b[-1].append((0,p))
258                            p += 1
259                        continue
260
261
262                # force any char wrap
263                b.append([(sc,p,pos)])
264                p = pos
265        return b
266
267
268
269######################################
270# default layout object to use
271default_layout = StandardTextLayout()
272######################################
273
274
275class LayoutSegment:
276    def __init__(self, seg):
277        """Create object from line layout segment structure"""
278
279        assert type(seg) == tuple, repr(seg)
280        assert len(seg) in (2,3), repr(seg)
281
282        self.sc, self.offs = seg[:2]
283
284        assert type(self.sc) == int, repr(self.sc)
285
286        if len(seg)==3:
287            assert type(self.offs) == int, repr(self.offs)
288            assert self.sc > 0, repr(seg)
289            t = seg[2]
290            if type(t) == bytes:
291                self.text = t
292                self.end = None
293            else:
294                assert type(t) == int, repr(t)
295                self.text = None
296                self.end = t
297        else:
298            assert len(seg) == 2, repr(seg)
299            if self.offs is not None:
300                assert self.sc >= 0, repr(seg)
301                assert type(self.offs)==int
302            self.text = self.end = None
303
304    def subseg(self, text, start, end):
305        """
306        Return a "sub-segment" list containing segment structures
307        that make up a portion of this segment.
308
309        A list is returned to handle cases where wide characters
310        need to be replaced with a space character at either edge
311        so two or three segments will be returned.
312        """
313        if start < 0: start = 0
314        if end > self.sc: end = self.sc
315        if start >= end:
316            return [] # completely gone
317        if self.text:
318            # use text stored in segment (self.text)
319            spos, epos, pad_left, pad_right = calc_trim_text(
320                self.text, 0, len(self.text), start, end )
321            return [ (end-start, self.offs, bytes().ljust(pad_left) +
322                self.text[spos:epos] + bytes().ljust(pad_right)) ]
323        elif self.end:
324            # use text passed as parameter (text)
325            spos, epos, pad_left, pad_right = calc_trim_text(
326                text, self.offs, self.end, start, end )
327            l = []
328            if pad_left:
329                l.append((1,spos-1))
330            l.append((end-start-pad_left-pad_right, spos, epos))
331            if pad_right:
332                l.append((1,epos))
333            return l
334        else:
335            # simple padding adjustment
336            return [(end-start,self.offs)]
337
338
339def line_width( segs ):
340    """
341    Return the screen column width of one line of a text layout structure.
342
343    This function ignores any existing shift applied to the line,
344    represented by an (amount, None) tuple at the start of the line.
345    """
346    sc = 0
347    seglist = segs
348    if segs and len(segs[0])==2 and segs[0][1]==None:
349        seglist = segs[1:]
350    for s in seglist:
351        sc += s[0]
352    return sc
353
354def shift_line( segs, amount ):
355    """
356    Return a shifted line from a layout structure to the left or right.
357    segs -- line of a layout structure
358    amount -- screen columns to shift right (+ve) or left (-ve)
359    """
360    assert type(amount)==int, repr(amount)
361
362    if segs and len(segs[0])==2 and segs[0][1]==None:
363        # existing shift
364        amount += segs[0][0]
365        if amount:
366            return [(amount,None)]+segs[1:]
367        return segs[1:]
368
369    if amount:
370        return [(amount,None)]+segs
371    return segs
372
373
374def trim_line( segs, text, start, end ):
375    """
376    Return a trimmed line of a text layout structure.
377    text -- text to which this layout structure applies
378    start -- starting screen column
379    end -- ending screen column
380    """
381    l = []
382    x = 0
383    for seg in segs:
384        sc = seg[0]
385        if start or sc < 0:
386            if start >= sc:
387                start -= sc
388                x += sc
389                continue
390            s = LayoutSegment(seg)
391            if x+sc >= end:
392                # can all be done at once
393                return s.subseg( text, start, end-x )
394            l += s.subseg( text, start, sc )
395            start = 0
396            x += sc
397            continue
398        if x >= end:
399            break
400        if x+sc > end:
401            s = LayoutSegment(seg)
402            l += s.subseg( text, 0, end-x )
403            break
404        l.append( seg )
405    return l
406
407
408
409def calc_line_pos( text, line_layout, pref_col ):
410    """
411    Calculate the closest linear position to pref_col given a
412    line layout structure.  Returns None if no position found.
413    """
414    closest_sc = None
415    closest_pos = None
416    current_sc = 0
417
418    if pref_col == 'left':
419        for seg in line_layout:
420            s = LayoutSegment(seg)
421            if s.offs is not None:
422                return s.offs
423        return
424    elif pref_col == 'right':
425        for seg in line_layout:
426            s = LayoutSegment(seg)
427            if s.offs is not None:
428                closest_pos = s
429        s = closest_pos
430        if s is None:
431            return
432        if s.end is None:
433            return s.offs
434        return calc_text_pos( text, s.offs, s.end, s.sc-1)[0]
435
436    for seg in line_layout:
437        s = LayoutSegment(seg)
438        if s.offs is not None:
439            if s.end is not None:
440                if (current_sc <= pref_col and
441                    pref_col < current_sc + s.sc):
442                    # exact match within this segment
443                    return calc_text_pos( text,
444                        s.offs, s.end,
445                        pref_col - current_sc )[0]
446                elif current_sc <= pref_col:
447                    closest_sc = current_sc + s.sc - 1
448                    closest_pos = s
449
450            if closest_sc is None or ( abs(pref_col-current_sc)
451                    < abs(pref_col-closest_sc) ):
452                # this screen column is closer
453                closest_sc = current_sc
454                closest_pos = s.offs
455            if current_sc > closest_sc:
456                # we're moving past
457                break
458        current_sc += s.sc
459
460    if closest_pos is None or type(closest_pos) == int:
461        return closest_pos
462
463    # return the last positions in the segment "closest_pos"
464    s = closest_pos
465    return calc_text_pos( text, s.offs, s.end, s.sc-1)[0]
466
467def calc_pos( text, layout, pref_col, row ):
468    """
469    Calculate the closest linear position to pref_col and row given a
470    layout structure.
471    """
472
473    if row < 0 or row >= len(layout):
474        raise Exception("calculate_pos: out of layout row range")
475
476    pos = calc_line_pos( text, layout[row], pref_col )
477    if pos is not None:
478        return pos
479
480    rows_above = list(xrange(row-1,-1,-1))
481    rows_below = list(xrange(row+1,len(layout)))
482    while rows_above and rows_below:
483        if rows_above:
484            r = rows_above.pop(0)
485            pos = calc_line_pos(text, layout[r], pref_col)
486            if pos is not None: return pos
487        if rows_below:
488            r = rows_below.pop(0)
489            pos = calc_line_pos(text, layout[r], pref_col)
490            if pos is not None: return pos
491    return 0
492
493
494def calc_coords( text, layout, pos, clamp=1 ):
495    """
496    Calculate the coordinates closest to position pos in text with layout.
497
498    text -- raw string or unicode string
499    layout -- layout structure applied to text
500    pos -- integer position into text
501    clamp -- ignored right now
502    """
503    closest = None
504    y = 0
505    for line_layout in layout:
506        x = 0
507        for seg in line_layout:
508            s = LayoutSegment(seg)
509            if s.offs is None:
510                x += s.sc
511                continue
512            if s.offs == pos:
513                return x,y
514            if s.end is not None and s.offs<=pos and s.end>pos:
515                x += calc_width( text, s.offs, pos )
516                return x,y
517            distance = abs(s.offs - pos)
518            if s.end is not None and s.end<pos:
519                distance = pos - (s.end-1)
520            if closest is None or distance < closest[0]:
521                closest = distance, (x,y)
522            x += s.sc
523        y += 1
524
525    if closest:
526        return closest[1]
527    return 0,0
528