1-- © 2008 David Given.
2-- WordGrinder is licensed under the MIT open source license. See the COPYING
3-- file in this distribution for the full text.
4
5local int = math.floor
6local Write = wg.write
7local GotoXY = wg.gotoxy
8local GetStringWidth = wg.getstringwidth
9local GetBoundedString = wg.getboundedstring
10local GetBytesOfCharacter = wg.getbytesofcharacter
11local SetNormal = wg.setnormal
12local SetBold = wg.setbold
13local SetBright = wg.setbright
14local SetUnderline = wg.setunderline
15local SetReverse = wg.setreverse
16local string_rep = string.rep
17
18Form = {}
19
20-- Atoms.
21
22Form.Left = {}
23Form.Right = {}
24Form.Centre = {}
25Form.Center = Form.Centre
26
27Form.Large = {}
28
29local function min(a, b)
30	if (a < b) then
31		return a
32	else
33		return b
34	end
35end
36
37local function max(a, b)
38	if (a > b) then
39		return a
40	else
41		return b
42	end
43end
44
45local function makewidgetclass(class)
46	return function(table)
47		setmetatable(table, {__index = class})
48		table.class = class
49		return table
50	end
51end
52
53Form.Divider = makewidgetclass
54{
55	draw = function(self)
56		Write(self.realx1, self.realy1, string_rep("─", self.realwidth))
57	end
58}
59
60Form.WrappedLabel = makewidgetclass
61{
62	draw = function(self)
63		local words = ParseStringIntoWords(self.value)
64		local paragraph = CreateParagraph(nil, words)
65		local lines = paragraph:wrap(self.realwidth)
66
67		local s = SpellcheckerOff()
68		for i = 1, #lines do
69			paragraph:renderLine(lines[i], self.realx1, self.realy1+i-1)
70		end
71		SpellcheckerRestore(s)
72	end,
73
74	calculate_height = function(self)
75		local words = ParseStringIntoWords(self.value)
76		local paragraph = CreateParagraph(nil, words)
77		local lines = paragraph:wrap(self.realwidth)
78		return #lines
79	end,
80}
81
82Form.Label = makewidgetclass {
83	align = Form.Centre,
84
85	draw = function(self)
86		local xo
87		if (self.align == Form.Centre) then
88			xo = int((self.realwidth - GetStringWidth(self.value)) / 2)
89		elseif (self.align == Form.Left) then
90			xo = 0
91		elseif (self.align == Form.Right) then
92			xo = self.realwidth - GetStringWidth(self.value)
93		end
94
95		Write(self.realx1, self.realy1, string_rep(" ", self.realwidth))
96		Write(self.realx1 + xo, self.realy1, self.value)
97	end
98}
99
100local checkbox_toggle = function(self, key)
101	self.value = not self.value
102	self:draw()
103end
104
105Form.Checkbox = makewidgetclass {
106	value = false,
107	label = "Checkbox",
108	focusable = true,
109
110	draw = function(self)
111		local s
112		if self.value then
113			s = "> YES"
114		else
115			s = "> NO "
116		end
117
118		Write(self.realx1, self.realy1, GetBoundedString(self.label, self.realwidth - 2))
119
120		SetBright()
121		Write(self.realx2, self.realy1, s)
122		SetNormal()
123
124		if self.focus then
125			GotoXY(self.realx2, self.realy1)
126		end
127	end,
128
129	[" "] = checkbox_toggle
130}
131
132Form.TextField = makewidgetclass {
133	focusable = true,
134
135	init = function(self)
136		self.cursor = self.cursor or (self.value:len() + 1)
137		self.offset = self.offset or 1
138	end,
139
140	draw = function(self)
141		SetBright()
142		Write(self.realx1, self.realy1 + 1, string_rep("▔", self.realwidth))
143		Write(self.realx1, self.realy1, string_rep(" ", self.realwidth))
144		SetNormal()
145
146		-- If the cursor is to the left of the visible area, adjust.
147
148		if (self.cursor < self.offset) then
149			self.offset = self.cursor
150		end
151
152		-- If the cursor is to the right of the visible area, adjust. (This is
153		-- very crude, but I'm not sure there's a more elegant way of doing
154		-- it.)
155
156		while true do
157			local xo = GetStringWidth(self.value:sub(self.offset, self.cursor))
158			if (xo <= self.realwidth) then
159				break
160			end
161
162			local b = GetBytesOfCharacter(self.value:byte(self.offset))
163			self.offset = self.offset + b
164		end
165
166		-- Draw the visible bit of the string.
167
168		local s = GetBoundedString(self.value:sub(self.offset), self.realwidth)
169		SetBright()
170		Write(self.realx1, self.realy1, s)
171		SetNormal()
172
173		if self.focus then
174			GotoXY(self.realx1 + GetStringWidth(s:sub(1, self.cursor-self.offset)), self.realy1)
175		end
176	end,
177
178	["KEY_LEFT"] = function(self, key)
179		if (self.cursor > 1) then
180			while true do
181				self.cursor = self.cursor - 1
182				if (GetBytesOfCharacter(self.value:byte(self.cursor)) ~= 0) then
183					break
184				end
185			end
186			self:draw()
187		end
188
189		return "nop"
190	end,
191
192	["KEY_RIGHT"] = function(self, key)
193		if (self.cursor <= self.value:len()) then
194			self.cursor = self.cursor + GetBytesOfCharacter(self.value:byte(self.cursor))
195			self:draw()
196		end
197
198		return "nop"
199	end,
200
201	["KEY_HOME"] = function(self, key)
202		self.cursor = 1
203		self:draw()
204
205		return "nop"
206	end,
207
208	["KEY_END"] = function(self, key)
209		self.cursor = self.value:len() + 1
210		self:draw()
211
212		return "nop"
213	end,
214
215	["KEY_BACKSPACE"] = function(self, key)
216		if (self.cursor > 1) then
217			local w
218			while true do
219				self.cursor = self.cursor - 1
220				w = GetBytesOfCharacter(self.value:byte(self.cursor))
221				if (w ~= 0) then
222					break
223				end
224			end
225
226			self.value = self.value:sub(1, self.cursor - 1) ..
227				self.value:sub(self.cursor + w)
228			self:draw()
229		end
230
231		return "nop"
232	end,
233
234	["KEY_DELETE"] = function(self, key)
235		local v = self.value:byte(self.cursor)
236		if v then
237			local w = GetBytesOfCharacter(self.value:byte(self.cursor))
238			self.value = self.value:sub(1, self.cursor - 1) ..
239				self.value:sub(self.cursor + w)
240			self:draw()
241		end
242
243		return "nop"
244	end,
245
246	["KEY_^U"] = function(self, key)
247		self.cursor = 1
248		self.offset = 1
249		self.value = ""
250		self:draw()
251
252		return "nop"
253	end,
254
255	key = function(self, key)
256		if not key:match("^KEY_") then
257			self.value = self.value:sub(1, self.cursor-1) .. key .. self.value:sub(self.cursor)
258			self.cursor = self.cursor + GetBytesOfCharacter(key:byte(1))
259			self:draw()
260
261			return "nop"
262		end
263	end,
264}
265
266Form.Browser = makewidgetclass {
267	focusable = true,
268
269	init = function(self)
270		self.cursor = self.cursor or 1
271		self.offset = self.offset or 0
272	end,
273
274	_adjustOffset = function(self)
275		local h = self.realheight
276
277		if (self.offset == 0) then
278			self.offset = self.cursor - int(h/2)
279		end
280
281		self.offset = min(self.offset, self.cursor)
282		self.offset = max(self.offset, self.cursor - (h-2))
283		self.offset = min(self.offset, #self.data - (h-2))
284		self.offset = max(self.offset, 1)
285	end,
286
287	changed = function(self)
288		return "nop"
289	end,
290
291	draw = function(self)
292		local x = self.realx1
293		local y = self.realy1
294		local w = self.realwidth
295		local h = self.realheight
296
297		-- Draw the box.
298
299		do
300			local border = string_rep("─", w - 2)
301			SetBright()
302			Write(x, y, "┌")
303			Write(x+1, y, border)
304			Write(x+w-1, y, "┐")
305			for i = 1, h-1 do
306				Write(x, y+i, "│")
307				Write(x+w-1, y+i, "│")
308			end
309			Write(x, y+h, "└")
310			Write(x+1, y+h, border)
311			Write(x+w-1, y+h, "┘")
312			SetNormal()
313		end
314
315		self:_adjustOffset()
316
317		-- Draw the data.
318
319		local space = string_rep(" ", w - 2)
320		for i = 0, h-2 do
321			local index = self.offset + i
322			local item = self.data[index]
323			if not item then
324				break
325			end
326
327			if (index == self.cursor) then
328				SetReverse()
329			else
330				SetNormal()
331			end
332
333			Write(x+1, y+1+i, space)
334			local s = GetBoundedString(item.label, w-4)
335			Write(x+2, y+1+i, s)
336
337			if (#self.data > (h-2)) then
338				SetNormal()
339				SetBright()
340				s = "│"
341				local yf = (i+1) * #self.data / (h-1)
342				if (yf >= self.offset) and (yf <= (self.offset + h-2)) then
343					s = "║"
344				end
345				Write(x+w-1, y+1+i, s)
346			end
347			SetNormal()
348		end
349		SetNormal()
350	end,
351
352	["KEY_UP"] = function(self, key)
353		if (self.cursor > 1) then
354			self.cursor = self.cursor - 1
355			self:draw()
356			return self:changed()
357		end
358
359		return "nop"
360	end,
361
362	["KEY_DOWN"] = function(self, key)
363		if (self.cursor < #self.data) then
364			self.cursor = self.cursor + 1
365			self:draw()
366			return self:changed()
367		end
368
369		return "nop"
370	end,
371
372	["KEY_PGUP"] = function(self, key)
373		local oldcursor = self.cursor
374		self.cursor = oldcursor - int(self.realheight/2)
375		if (self.cursor < 1) then
376			self.cursor = 1
377		end
378
379		if (self.cursor ~= oldcursor) then
380			self:draw()
381			return self:changed()
382		end
383		return "nop"
384	end,
385
386	["KEY_PGDN"] = function(self, key)
387		local oldcursor = self.cursor
388		self.cursor = oldcursor + int(self.realheight/2)
389		if (self.cursor > #self.data) then
390			self.cursor = #self.data
391		end
392
393		if (self.cursor ~= oldcursor) then
394			self:draw()
395			return self:changed()
396		end
397		return "nop"
398	end,
399}
400
401local standard_actions =
402{
403	["KEY_UP"] = function(dialogue, key)
404		if dialogue.focus then
405			local f = dialogue.focus - 1
406			while (f ~= dialogue.focus) do
407				if (f == 0) then
408					f = #dialogue
409				end
410
411				local widget = dialogue[f]
412				if widget.focusable then
413					dialogue.focus = f
414					return "redraw"
415				end
416
417				f = f - 1
418			end
419		end
420
421		return "nop"
422	end,
423
424	["KEY_DOWN"] = function(dialogue, key)
425		if dialogue.focus then
426			local f = dialogue.focus + 1
427			while (f ~= dialogue.focus) do
428				if (f > #dialogue) then
429					f = 1
430				end
431
432				local widget = dialogue[f]
433				if widget.focusable then
434					dialogue.focus = f
435					return "redraw"
436				end
437
438				f = f + 1
439			end
440		end
441
442		return "nop"
443	end
444}
445
446local function resolvesize(size, bound)
447	if (size < 0) then
448		return size + bound
449	else
450		return size
451	end
452end
453
454local function findaction(table, object, key)
455	local action = table[key]
456	if action and (type(action) == "function") then
457		action = action(object, key)
458	end
459	if not action and table.key then
460		action = table.key(object, key)
461	end
462	return action
463end
464
465function Form.Run(dialogue, redraw, helptext)
466	-- Ensure the screen is properly sized.
467
468	ResizeScreen()
469
470	-- Find a widget to give the focus to.
471
472	if not dialogue.focus then
473		for i, widget in ipairs(dialogue) do
474			if widget.focusable then
475				dialogue.focus = i
476				break
477			end
478		end
479	end
480
481	-- Initialise any widgets that need it.
482
483	for _, widget in ipairs(dialogue) do
484		if widget.init then
485			widget:init()
486		end
487	end
488
489	-- Redraw the backdrop.
490
491	if redraw then
492		redraw()
493	end
494
495	-- Size the dialogue.
496
497	if (dialogue.width == Form.Large) then
498		dialogue.realwidth = int(ScreenWidth * 6/7)
499	else
500		dialogue.realwidth = dialogue.width
501	end
502
503	if (dialogue.height == Form.Large) then
504		dialogue.realheight = int(ScreenHeight * 5/6)
505	else
506		dialogue.realheight = dialogue.height
507	end
508
509	-- Is this a stretchy dialogue?
510
511	if dialogue.stretchy then
512		-- Automatically scale the height depending on a 'stretchy' widget.
513
514		for _, widget in ipairs(dialogue) do
515			if (widget.y1 > 0) and (widget.y2 < 0) then
516				widget.realx1 = resolvesize(widget.x1, dialogue.realwidth)
517				widget.realx2 = resolvesize(widget.x2, dialogue.realwidth)
518				widget.realwidth = widget.realx2 - widget.realx1
519
520				local h = 1
521				if widget.calculate_height then
522					h = widget:calculate_height()
523				end
524
525				dialogue.realheight = dialogue.height + h
526				break
527			end
528		end
529	end
530
531	-- Place the dialogue.
532
533	dialogue.realx = int(ScreenWidth/2 - dialogue.realwidth/2)
534	dialogue.realy = int(ScreenHeight/2 - dialogue.realheight/2)
535
536	-- Place all widgets in the dialogue.
537
538	for _, widget in ipairs(dialogue) do
539		widget.realx1 = resolvesize(widget.x1, dialogue.realwidth) + dialogue.realx
540		widget.realy1 = resolvesize(widget.y1, dialogue.realheight) + dialogue.realy
541		widget.realx2 = resolvesize(widget.x2, dialogue.realwidth) + dialogue.realx
542		widget.realy2 = resolvesize(widget.y2, dialogue.realheight) + dialogue.realy
543		widget.realwidth = widget.realx2 - widget.realx1
544		widget.realheight = widget.realy2 - widget.realy1
545	end
546
547	-- Draw the dialogue itself.
548
549	do
550		local sizeadjust = 0
551		if helptext then
552			sizeadjust = 1
553		end
554		DrawTitledBox(dialogue.realx - 1, dialogue.realy - 1,
555			dialogue.realwidth, dialogue.realheight + sizeadjust,
556			dialogue.title)
557
558		if helptext then
559			CentreInField(dialogue.realx, dialogue.realy + dialogue.realheight,
560				dialogue.realwidth, "<"..helptext..">")
561		end
562	end
563
564	-- Draw the widgets.
565
566	GotoXY(ScreenWidth-1, ScreenHeight-1)
567	for i, widget in ipairs(dialogue) do
568		widget.focus = (i == dialogue.focus)
569		widget:draw()
570	end
571
572	-- Process keys.
573
574	while true do
575		local key = wg.getchar()
576
577		if (key == "KEY_RESIZE") then
578			ResizeScreen()
579			return Form.Run(dialogue, redraw, helptext)
580		end
581
582		local action = nil
583		if dialogue.focus then
584			local w = dialogue[dialogue.focus]
585			action = findaction(w, w, key)
586		end
587
588		if not action then
589			action = findaction(dialogue, dialogue, key) or
590				findaction(standard_actions, dialogue, key)
591		end
592
593		if (action == "redraw") then
594			return Form.Run(dialogue, redraw, helptext)
595		elseif (action == "cancel") then
596			return false
597		elseif (action == "confirm") then
598			return true
599		end
600	end
601end
602
603-- Test code
604
605function Form.Test()
606	FileBrowser("Title", "Load file:", false)
607end
608