Template repo for tiny cross-platform apps that can be modified on phone, tablet or computer.
1-- Line wrapping
2local my_utf8 = require 'my_utf8'
3
4local wrap = {}
5local I = {}
6wrap.internal = I
7
8-- include width for each codepoint
9function I.widths(s, startpos, font)
10 local next_char = my_utf8.chars(s, startpos)
11 return function()
12 local p, cp, char = next_char()
13 if not p then return nil end
14 return p, cp, char, font:getWidth(char)
15 end
16end
17
18local WHITESPACE = {
19 [0x0009] = true, [0x000A] = true, [0x000B] = true, [0x000C] = true,
20 [0x000D] = true, [0x0020] = true, [0x0085] = true, [0x00A0] = true,
21 [0x1680] = true, [0x2000] = true, [0x2001] = true, [0x2002] = true,
22 [0x2003] = true, [0x2004] = true, [0x2005] = true, [0x2006] = true,
23 [0x2007] = true, [0x2008] = true, [0x2009] = true, [0x200A] = true,
24 [0x2028] = true, [0x2029] = true, [0x202F] = true, [0x205F] = true,
25 [0x3000] = true,
26}
27
28local WORD_START = 1
29local WORD_END = 2
30
31-- tag codepoints at WORD_START (after space) and WORD_END (before space)
32function I.words(s, startpos, font)
33 local next_char = I.widths(s, startpos, font)
34 local was_space = true
35 return function()
36 local p, cp, char, w = next_char()
37 if not p then return nil end
38 local space = WHITESPACE[cp]
39 local state = nil
40 if not space and was_space then
41 state = WORD_START
42 elseif space and not was_space then
43 state = WORD_END
44 end
45 was_space = space
46 return p, cp, char, w, state
47 end
48end
49
50-- tag each WORD_START codepoint with width of word (WORD_START to WORD_END)
51function I.wordwidth(s, startpos, font)
52 local next_char = I.words(s, startpos, font)
53 return coroutine.wrap(function()
54 local pos, cp, char, w, state
55 while true do
56 pos, cp, char, w, state = next_char()
57 if not pos then return end
58 if state ~= WORD_START then
59 coroutine.yield(pos, cp, char, w, state, nil)
60 else
61 break
62 end
63 end
64 while pos do
65 assert(state == WORD_START)
66 -- buffer to next WORD_START
67 local buf = {{pos, cp, char, w, state}}
68 local word_w = w
69 while true do
70 pos, cp, char, w, state = next_char()
71 if not pos then break end
72 table.insert(buf, {pos, cp, char, w, state})
73 if state == WORD_END then break end
74 word_w = word_w + w
75 end
76 -- emit buffer
77 table.insert(buf[1], word_w) -- just to the first char of word
78 for _,e in ipairs(buf) do
79 coroutine.yield(unpack(e))
80 end
81 -- emit spaces to next word
82 if not pos then break end
83 assert(state == WORD_END)
84 while true do
85 pos, cp, char, w, state = next_char()
86 if not pos then break end
87 if state == WORD_START then break end
88 coroutine.yield(pos, cp, char, w, state)
89 end
90 end
91 end)
92end
93
94-- tag each WORD_START codepoint with total width of word+spaces (WORD_START to WORD_START)
95function I.totalwordwidth(s, startpos, font)
96 local next_char = I.wordwidth(s, startpos, font)
97 return coroutine.wrap(function()
98 local pos, cp, char, w, state, word_w
99 while true do
100 pos, cp, char, w, state, word_w = next_char()
101 if not pos then return end
102 if state ~= WORD_START then
103 coroutine.yield(pos, cp, char, w, state, word_w, nil)
104 else
105 break
106 end
107 end
108 while pos do
109 assert(state == WORD_START)
110 -- buffer to next WORD_START
111 local buf = {{pos, cp, char, w, state, word_w}}
112 local total_w = w
113 while true do
114 pos, cp, char, w, state, word_w = next_char()
115 if state == WORD_START or not pos then
116 break
117 end
118 table.insert(buf, {pos, cp, char, w, state, word_w})
119 total_w = total_w + w
120 end
121 -- emit buffer
122 table.insert(buf[1], total_w) -- just to the first char of word
123 for _,e in ipairs(buf) do
124 coroutine.yield(unpack(e))
125 end
126 end
127 end)
128end
129
130function wrap.wrap(s, startpos, min_width, max_width, font)
131 local next_char = I.totalwordwidth(s, startpos, font)
132 local x = 0
133 local limit = max_width
134 return function()
135 while true do
136 local p, cp, char, w, state, word_w, total_w = next_char()
137 local is_wrap
138 if not p then return nil end
139 if state == WORD_START then
140 limit = max_width
141 if x + total_w > max_width and x >= min_width then
142 -- Wrap at word boundary
143 x = 0
144 is_wrap = {word_wrap=true}
145 elseif x + word_w >= min_width and x + word_w < max_width and x + total_w > max_width then
146 -- We can't word wrap just yet, and the word also wouldn't fit
147 -- without pushing trailing spaces to the next line.
148 -- Plan to truncate word at min_width so the next line starts with
149 -- non-spaces.
150 limit = min_width
151 end
152 end
153 if x > 0 and x + w > limit then
154 -- overflow
155 x = 0
156 is_wrap = {}
157 assert(char ~= ' ')
158 limit = max_width
159 end
160 local char_x = x
161 x = x + w
162 return p, char, w, char_x, is_wrap
163 end
164 end
165end
166
167-- like wrap, but can indent the next line after wrapping
168function wrap.indented_wrap(s, startpos, min_width, max_width, font, wrap_indent, debug)
169 local next_char = I.totalwordwidth(s, startpos, font)
170 local x = 0 -- first line starts at 0
171 local limit = max_width
172 return function()
173 while true do
174 local p, cp, char, w, state, word_w, total_w = next_char()
175 local is_wrap
176 if not p then return nil end
177 if state == WORD_START then
178 limit = max_width
179 if x + total_w > max_width and x >= min_width then
180 -- Wrap at word boundary
181 if debug then
182 print_to_output(('word wrap instead of printing to %d because next word including trailing whitespace would take us to %d which is greater than %d. Line is longer than %d.'):format(x, x+total_w, max_width, min_width))
183 end
184 is_wrap = {word_wrap=true, unwrapped_x=x}
185 x = wrap_indent
186 elseif x + word_w >= min_width and x + word_w < max_width and x + total_w > max_width then
187 -- We can't word wrap just yet, and the word also wouldn't fit
188 -- without pushing trailing spaces to the next line.
189 -- Plan to truncate word at min_width so the next line starts with
190 -- non-spaces.
191 if debug then
192 print_to_output(('Line length %d < min width %d and next word including trailing whitespace would take us to %d > max width %d. But just the next word would take us to %d < %d. So word wrap would put whitespace at the start of the next wrapped line. Instead truncate at %d.'):format(x, min_width, x + total_w, max_width, x + word_w, max_width, min_width))
193 end
194 limit = min_width
195 end
196 end
197 if x > 0 and x + w > limit then
198 -- overflow
199 if debug then
200 print_to_output(('wrap at %d because next character would put us at %d > limit %d'):format(x, x+w, limit))
201 end
202 is_wrap = {unwrapped_x=x}
203 x = wrap_indent
204 assert(char ~= ' ')
205 limit = max_width
206 end
207 local char_x = x
208 x = x + w
209 return p, char, w, char_x, is_wrap
210 end
211 end
212end
213
214return wrap