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 ClearToEOL = wg.cleartoeol
8local GetChar = wg.getchar
9local GotoXY = wg.gotoxy
10local SetBold = wg.setbold
11local SetBright = wg.setbright
12local SetReverse = wg.setreverse
13local SetNormal = wg.setnormal
14local GetStringWidth = wg.getstringwidth
15
16local menu_tab = {}
17local key_tab = {}
18local menu_stack = {}
19
20local function addmenu(n, m, menu)
21	local w = n:len()
22	menu = menu or {}
23	menu.label = n
24	menu.mks = {}
25
26	for i, _ in ipairs(menu) do
27		menu[i] = nil
28	end
29
30	for _, data in ipairs(m) do
31		if (data == "-") then
32			menu[#menu+1] = "-"
33		else
34			local item = {
35				id = data[1],
36				mk = data[2],
37				label = data[3],
38				ak = data[4],
39				fn = data[5]
40			}
41			menu[#menu+1] = item
42
43			if item.mk then
44				if menu.mks[item.mk] then
45					error("Duplicate menu action key "..item.mk)
46				end
47				menu.mks[item.mk] = item
48			end
49
50			if menu_tab[item.id] then
51				error("Dupicate menu ID "..item.id)
52			end
53			menu_tab[item.id] = item
54
55			if item.ak then
56				key_tab[item.ak] = item.id
57			end
58
59			if (item.label:len() > w) then
60				w = item.label:len()
61			end
62		end
63	end
64
65	menu.maxwidth = w
66	return menu
67end
68
69local function submenu(menu)
70	for _, item in ipairs(menu) do
71		menu_tab[item.id] = nil
72		if item.ak then
73			key_tab[item.ak] = nil
74		end
75	end
76end
77
78local DocumentsMenu = addmenu("Documents", {})
79local ParagraphStylesMenu = addmenu("Paragraph Styles", {})
80
81local cp = Cmd.Checkpoint
82
83local ImportMenu = addmenu("Import new document",
84{
85	{"FIodt",  "O", "Import ODT file...",        nil,         Cmd.ImportODTFile},
86	{"FIhtml", "H", "Import HTML file...",       nil,         Cmd.ImportHTMLFile},
87	{"FItxt",  "T", "Import text file...",       nil,         Cmd.ImportTextFile},
88})
89
90local ExportMenu = addmenu("Export current document",
91{
92	{"FEodt",  "O", "Export to ODT...",          nil,         Cmd.ExportODTFile},
93	{"FEhtml", "H", "Export to HTML...",         nil,         Cmd.ExportHTMLFile},
94	{"FEmd",   "M", "Export to Markdown...",     nil,         Cmd.ExportMarkdownFile},
95	{"FEtxt",  "T", "Export to plain text...",   nil,         Cmd.ExportTextFile},
96	{"FEtex",  "L", "Export to LaTeX...",        nil,         Cmd.ExportLatexFile},
97	{"FEtr",   "F", "Export to Troff...",        nil,         Cmd.ExportTroffFile},
98--	{"FErtf",  "R", "Export to Rtf...",          nil,         Cmd.ExportRTFFile},
99})
100
101local DocumentSettingsMenu = addmenu("Document settings",
102{
103    {"FSautosave",     "A", "Autosave...",           nil,         Cmd.ConfigureAutosave},
104    {"FSscrapbook",    "S", "Scrapbook...",          nil,         Cmd.ConfigureScrapbook},
105    {"FSHTMLExport",   "H", "HTML export...",        nil,         Cmd.ConfigureHTMLExport},
106	{"FSPageCount",    "P", "Page count...",         nil,         Cmd.ConfigurePageCount},
107	{"FSSmartquotes",  "Q", "Smart quotes...",       nil,         Cmd.ConfigureSmartQuotes},
108	{"FSSpellchecker", "K", "Spellchecker...",       nil,         Cmd.ConfigureSpellchecker},
109})
110
111local GlobalSettingsMenu = addmenu("Global settings",
112{
113	{"FSlookandfeel","L", "Change look and feel...",       nil,   Cmd.ConfigureLookAndFeel},
114	{"FSDictionary", "D", "Load new system dictionary...", nil,   Cmd.ConfigureSystemDictionary},
115	"-",
116	{"FSDebug",      "X", "Debugging options...",    nil,         Cmd.ConfigureDebug},
117})
118
119local FileMenu = addmenu("File",
120{
121	{"FN",         "N", "New document set",          nil,         Cmd.CreateBlankDocumentSet},
122	{"FO",         "O", "Load document set...",      nil,         Cmd.LoadDocumentSet},
123	{"FS",         "S", "Save document set",         "^S",        Cmd.SaveCurrentDocument},
124	{"FA",         "A", "Save document set as...",   nil,         Cmd.SaveCurrentDocumentAs},
125	"-",
126	{"FB",         "B", "Add new blank document",    nil,         Cmd.AddBlankDocument},
127	{"FI",         "I", "Import new document ▷",     nil,         ImportMenu},
128	{"FE",         "E", "Export current document ▷", nil,         ExportMenu},
129	{"Fdocman",    "D", "Manage documents...",       nil,         Cmd.ManageDocumentsUI},
130	"-",
131	{"Fsettings",  "T", "Document settings ▷",       nil,         DocumentSettingsMenu},
132	{"Fglobals",   "G", "Global settings ▷",         nil,         GlobalSettingsMenu},
133	"-",
134	{"Fabout",     "Z", "About WordGrinder...",      nil,         Cmd.AboutWordGrinder},
135	{"FQ",         "X", "Exit",                      "^Q",        Cmd.TerminateProgram}
136})
137
138local ScrapbookMenu = addmenu("Scrapbook",
139{
140	{"EScut",      "T", "Cut to scrapbook",          nil,         { cp, Cmd.CutToScrapbook }},
141	{"EScopy",     "C", "Copy to scrapbook",         nil,         Cmd.CopyToScrapbook},
142	{"ESpaste",    "P", "Paste to scrapbook",        nil,         { cp, Cmd.PasteToScrapbook }},
143})
144
145local SpellcheckMenu = addmenu("Spellchecker",
146{
147	{"ECfind",     "F", "Find next misspelt word",        "^L",   Cmd.FindNextMisspeltWord },
148	{"ECadd",      "A", "Add current word to dictionary", "^M",   { cp, Cmd.AddToUserDictionary }},
149})
150
151local EditMenu = addmenu("Edit",
152{
153	{"ET",         "T", "Cut",                       "^X",        { cp, Cmd.Cut }},
154	{"EC",         "C", "Copy",                      "^C",        Cmd.Copy},
155	{"EP",         "P", "Paste",                     "^V",        { cp, Cmd.Paste }},
156	{"ED",         "D", "Delete",                    nil,         { cp, Cmd.Delete }},
157	"-",
158	{"Eundo",      "U", "Undo",                      "^Z",        Cmd.Undo},
159	{"Eredo",      "E", "Redo",                      "^Y",        Cmd.Redo},
160	"-",
161	{"EF",         "F", "Find and replace...",       "^F",        Cmd.Find},
162	{"EN",         "N", "Find next",                 "^K",        Cmd.FindNext},
163	{"ER",         "R", "Replace then find",         "^R",        { cp, Cmd.ReplaceThenFind }},
164	{"Esq",        "Q", "Smartquotify selection",    nil,         Cmd.Smartquotify},
165	{"Eusq",       "W", "Unsmartquotify selection",  nil,         Cmd.Unsmartquotify},
166	"-",
167	{"EG",         "G", "Go to...",                  "^G",        Cmd.Goto},
168	{"Escrapbook", "S", "Scrapbook ▷",               nil,         ScrapbookMenu},
169	{"Espell",     "K", "Spellchecker ▷",            nil,         SpellcheckMenu},
170})
171
172local MarginMenu = addmenu("Margin",
173{
174	{"SM1",    "H", "Hide margin",                nil,         function() Cmd.SetViewMode(1) end},
175	{"SM2",    "S", "Show paragraph styles",      nil,         function() Cmd.SetViewMode(2) end},
176	{"SM3",    "N", "Show paragraph numbers",     nil,         function() Cmd.SetViewMode(3) end},
177	{"SM4",    "W", "Show paragraph word counts", nil,         function() Cmd.SetViewMode(4) end},
178})
179
180local StyleMenu = addmenu("Style",
181{
182	{"SI",     "I", "Set italic",                 "^I",        { cp, function() Cmd.SetStyle("i") end }},
183	{"SU",     "U", "Set underline",              "^U",        { cp, function() Cmd.SetStyle("u") end }},
184	{"SB",     "B", "Set bold",                   "^B",        { cp, function() Cmd.SetStyle("b") end }},
185	{"SO",     "O", "Set plain",                  "^O",        { cp, function() Cmd.SetStyle("o") end }},
186	"-",
187	{"SP",     "P", "Change paragraph style ▷",   "^P",        ParagraphStylesMenu},
188	{"SM",     "M", "Set margin mode ▷",          nil,         MarginMenu},
189	{"SS",     "S", "Toggle status bar",          nil,         Cmd.ToggleStatusBar},
190})
191
192local NavigationMenu = addmenu("Navigation",
193{
194	{"ZU",     nil, "Cursor up",                    "UP",        { Cmd.MoveWhileSelected, Cmd.GotoPreviousLine }},
195	{"ZR",     nil, "Cursor right",                 "RIGHT",     { Cmd.MoveWhileSelected, Cmd.GotoNextCharW }},
196	{"ZD",     nil, "Cursor down",                  "DOWN",      { Cmd.MoveWhileSelected, Cmd.GotoNextLine }},
197	{"ZL",     nil, "Cursor left",                  "LEFT",      { Cmd.MoveWhileSelected, Cmd.GotoPreviousCharW }},
198	{"ZSU",    nil, "Selection up",                 "SUP",       { Cmd.SetMark, Cmd.GotoPreviousLine }},
199	{"ZSR",    nil, "Selection right",              "SRIGHT",    { Cmd.SetMark, Cmd.GotoNextCharW }},
200	{"ZSD",    nil, "Selection down",               "SDOWN",     { Cmd.SetMark, Cmd.GotoNextLine }},
201	{"ZSL",    nil, "Selection left",               "SLEFT",     { Cmd.SetMark, Cmd.GotoPreviousCharW }},
202	{"ZSW",    nil, "Select word",                  "^W",        Cmd.SelectWord },
203	{"ZWL",    nil, "Goto previous word",           "^LEFT",     { Cmd.MoveWhileSelected, Cmd.GotoPreviousWordW }},
204	{"ZWR",    nil, "Goto next word",               "^RIGHT",    { Cmd.MoveWhileSelected, Cmd.GotoNextWordW }},
205	{"ZNP",    nil, "Goto next paragraph",          "^DOWN",     { Cmd.MoveWhileSelected, Cmd.GotoNextParagraphW }},
206	{"ZPP",    nil, "Goto previous paragraph",      "^UP",       { Cmd.MoveWhileSelected, Cmd.GotoPreviousParagraphW }},
207	{"ZSWL",   nil, "Select to previous word",      "S^LEFT",    { Cmd.SetMark, Cmd.GotoPreviousWordW }},
208	{"ZSWR",   nil, "Select to next word",          "S^RIGHT",   { Cmd.SetMark, Cmd.GotoNextWordW }},
209	{"ZSNP",   nil, "Select to next paragraph",     "S^DOWN",    { Cmd.SetMark, Cmd.GotoNextParagraphW }},
210	{"ZSPP",   nil, "Select to previous paragraph", "S^UP",      { Cmd.SetMark, Cmd.GotoPreviousParagraphW }},
211	{"ZH",     nil, "Goto beginning of line",       "HOME",      { Cmd.MoveWhileSelected, Cmd.GotoBeginningOfLine }},
212	{"ZE",     nil, "Goto end of line",             "END",       { Cmd.MoveWhileSelected, Cmd.GotoEndOfLine }},
213	{"ZSH",    nil, "Select to beginning of line",  "SHOME",     { Cmd.SetMark, Cmd.GotoBeginningOfLine }},
214	{"ZSE",    nil, "Select to end of line",        "SEND",      { Cmd.SetMark, Cmd.GotoEndOfLine }},
215	{"ZBD",    nil, "Goto beginning of document",   "^PGUP",     { Cmd.MoveWhileSelected, Cmd.GotoBeginningOfDocument }},
216	{"ZED",    nil, "Goto end of document",         "^PGDN",     { Cmd.MoveWhileSelected, Cmd.GotoEndOfDocument }},
217	{"ZSBD",   nil, "Select to beginning of document", "S^PGUP", { Cmd.SetMark, Cmd.GotoBeginningOfDocument }},
218	{"ZSED",   nil, "Select to end of document",    "S^PGDN",    { Cmd.SetMark, Cmd.GotoEndOfDocument }},
219	{"ZPGUP",  nil, "Page up",                      "PGUP",      { Cmd.MoveWhileSelected, Cmd.GotoPreviousPage }},
220	{"ZPGDN",  nil, "Page down",                    "PGDN",      { Cmd.MoveWhileSelected, Cmd.GotoNextPage }},
221	{"ZSPGUP", nil, "Selection page up",            "SPGUP",     { Cmd.SetMark, Cmd.GotoPreviousPage }},
222	{"ZSPGDN", nil, "Selection page down",          "SPGDN",     { Cmd.SetMark, Cmd.GotoNextPage }},
223	{"ZDPC",   nil, "Delete previous character",    "BACKSPACE", { cp, Cmd.DeleteSelectionOrPreviousChar }},
224	{"ZDNC",   nil, "Delete next character",        "DELETE",    { cp, Cmd.DeleteSelectionOrNextChar }},
225	{"ZDW",    nil, "Delete word",                  "^E",        { cp, Cmd.TypeWhileSelected, Cmd.DeleteWord }},
226	{"ZM",     nil, "Toggle mark",                  "^@",        Cmd.ToggleMark},
227})
228
229local MainMenu = addmenu("Main Menu",
230{
231	{"F",  "F", "File ▷",           nil,  FileMenu},
232	{"E",  "E", "Edit ▷",           nil,  EditMenu},
233	{"S",  "S", "Style ▷",          nil,  StyleMenu},
234	{"D",  "D", "Documents ▷",      nil,  DocumentsMenu},
235	{"Z",  "Z", "Navigation ▷",     nil,  NavigationMenu}
236})
237
238function IsMenu(m)
239	return (type(m) == "table") and (type(m[1]) ~= "function")
240end
241
242function RunMenuAction(ff)
243	if (type(ff) == "function") then
244		return ff()
245	elseif IsMenu(ff) then
246		Cmd.ActivateMenu(ff)
247	else
248		for _, f in ipairs(ff) do
249			local result, e = f()
250			if not result then
251				return false, e
252			end
253		end
254		return true
255	end
256end
257
258--- MENU DRIVER CLASS ---
259
260MenuClass = {
261	activate = function(self, menu)
262		menu = menu or MainMenu
263		self:runmenu(0, 0, menu)
264		QueueRedraw()
265		SetNormal()
266	end,
267
268	drawmenu = function(self, x, y, menu, n, top)
269		local akw = 0
270		for _, item in ipairs(menu) do
271			local ak = self.accelerators[item.id]
272			if ak then
273				local l = GetStringWidth(ak)
274				if (akw < l) then
275					akw = l
276				end
277			end
278		end
279		if (akw > 0) then
280			akw = akw + 1
281		end
282
283		local w = menu.maxwidth + 4 + akw
284		local visiblelen = min(#menu, ScreenHeight-y-3)
285		DrawTitledBox(x, y, w, visiblelen, menu.label)
286
287		if (visiblelen < #menu) then
288			local f1 = (top - 1) / #menu
289			local f2 = (top + visiblelen - 1) / #menu
290			local y1 = f1 * visiblelen + y + 1
291			local y2 = f2 * visiblelen + y + 1
292			SetBright()
293			for yy = y1, y2 do
294				Write(x+w+1, yy, "║")
295			end
296		end
297
298		for i = top, top+visiblelen-1 do
299			local item = menu[i]
300			local ak = self.accelerators[item.id]
301			local yy = y+i-top+1
302
303			if (item == "-") then
304				if (i == n) then
305					SetReverse()
306				end
307				SetBright()
308				Write(x+1, yy, string.rep("─", w))
309			else
310				SetNormal()
311				if (i == n) then
312					SetReverse()
313					Write(x+1, yy, string.rep(" ", w))
314				end
315
316				Write(x+4, yy, item.label)
317
318				SetBold()
319				SetBright()
320				if ak then
321					local l = GetStringWidth(ak)
322					Write(x+w-l, yy, ak)
323				end
324
325				if item.mk then
326					Write(x+2, yy, item.mk)
327				end
328			end
329
330			SetNormal()
331		end
332		GotoXY(ScreenWidth-1, ScreenHeight-1)
333
334		DrawStatusLine("^V rebinds a menu item; ^X unbinds it; ^R resets all bindings to default.")
335	end,
336
337	drawmenustack = function(self)
338		local osb = DocumentSet.statusbar
339		DocumentSet.statusbar = true
340		RedrawScreen()
341		DocumentSet.statusbar = osb
342
343		local o = 0
344		for _, m in ipairs(menu_stack) do
345			self:drawmenu(o*4, o*2, m.menu, m.n, m.top)
346			o = o + 1
347		end
348	end,
349
350	runmenu = function(self, x, y, menu)
351		local n = 1
352		local top = 1
353
354		while true do
355			local id
356
357			while true do
358				local visiblelen = min(#menu, ScreenHeight-y-3)
359				if (n < top) then
360					top = n
361				end
362				if (n > (top+visiblelen-1)) then
363					top = n - visiblelen + 1
364				end
365
366				self:drawmenu(x, y, menu, n, top)
367
368				local c = GetChar():upper()
369				if (c == "KEY_RESIZE") then
370					ResizeScreen()
371					RedrawScreen()
372					self:drawmenustack()
373				elseif (c == "KEY_UP") and (n > 1) then
374					n = n - 1
375				elseif (c == "KEY_DOWN") and (n < #menu) then
376					n = n + 1
377				elseif (c == "KEY_PGDN") then
378					n = int(min(n + visiblelen/2, #menu))
379				elseif (c == "KEY_PGUP") then
380					n = int(max(n - visiblelen/2, 1))
381				elseif (c == "KEY_RETURN") or (c == "KEY_RIGHT") then
382					if (type(menu[n]) ~= "string") then
383						id = menu[n].id
384						break
385					end
386				elseif (c == "KEY_LEFT") then
387					return nil
388				elseif (c == "KEY_ESCAPE") then
389					return false
390				elseif (c == "KEY_^C") then
391					return false
392				elseif (c == "KEY_^X") then
393					local item = menu[n]
394					if (type(item) ~= "string") then
395						local ak = self.accelerators[item.id]
396						if ak then
397							self.accelerators[ak] = nil
398							self.accelerators[item.id] = nil
399							self:drawmenustack()
400						end
401					end
402				elseif (c == "KEY_^V") then
403					local item = menu[n]
404					if (type(item) ~= "string") then
405						DrawStatusLine("Press new accelerator key for menu item.")
406
407						local oak = self.accelerators[item.id]
408						local ak = GetChar():upper()
409						if ak:match("^KEY_") then
410							ak = ak:gsub("^KEY_", "")
411							if self.accelerators[ak] then
412								NonmodalMessage("Sorry, "..ak.." is already bound elsewhere.")
413							elseif (ak == "ESCAPE") or (ak == "RESIZE") then
414								NonmodalMessage("You can't bind that key.")
415							else
416								if oak then
417									self.accelerators[oak] = nil
418								end
419
420								self.accelerators[ak] = item.id
421								self.accelerators[item.id] = ak
422							end
423							self:drawmenustack()
424						end
425					end
426				elseif (c == "KEY_^R") then
427					if PromptForYesNo("Reset menu keybindings?",
428						"Are you sure you want to reset all the menu "..
429						"keybindings back to their defaults?") then
430						DocumentSet.menu = CreateMenu()
431						DocumentSet:touch()
432						NonmodalMessage("All keybindings have been reset to their default settings.")
433						menu_stack = {}
434						return false
435					end
436					self:drawmenustack()
437				elseif menu.mks[c] then
438					id = menu.mks[c].id
439					break
440				end
441			end
442
443			local item = menu_tab[id]
444			local f = item.fn
445
446			if IsMenu(f) then
447				menu_stack[#menu_stack+1] = {
448					menu = menu,
449					n = n,
450					top = top
451				}
452
453				local r = self:runmenu(x+4, y+2, f)
454				menu_stack[#menu_stack] = nil
455
456				if (r == true) then
457					return true
458				elseif (r == false) then
459					return false
460				end
461
462				self:drawmenustack()
463			else
464				if not f then
465					ModalMessage("Not implemented yet", "Sorry, that feature isn't implemented yet. (This should never happen. Complain.)")
466				else
467					local _, msg = RunMenuAction(f)
468					if msg then
469						NonmodalMessage(msg)
470					end
471				end
472				menu_stack = {}
473				return true
474			end
475		end
476	end,
477
478	lookupAccelerator = function(self, c)
479		c = c:gsub("^KEY_", "")
480
481		-- Check the overrides table and only then the documentset keymap.
482
483		local id = CheckOverrideTable(c) or self.accelerators[c]
484		if not id then
485			return nil
486		end
487
488		-- Found something? Find out what function the menu ID corresponds to.
489		-- (Or maybe it's a raw function.)
490
491		local f
492		if (type(id) == "function") then
493			f = id
494		else
495			local item = menu_tab[id]
496			if not item then
497				f = function()
498					NonmodalMessage("Menu item with ID "..id.." not found.")
499				end
500			else
501				f = item.fn
502			end
503		end
504
505		return f
506	end,
507}
508
509function CreateMenu()
510	local my_key_tab = {}
511	for ak, id in pairs(key_tab) do
512		my_key_tab[ak] = id
513		my_key_tab[id] = ak
514	end
515
516	local m = {
517		accelerators = my_key_tab
518	}
519	setmetatable(m, {__index = MenuClass})
520	return m
521end
522
523function RebuildParagraphStylesMenu(styles)
524	submenu(ParagraphStylesMenu)
525
526	local m = {}
527
528	for id, style in ipairs(styles) do
529		local shortcut
530		if (id <= 10) then
531			shortcut = tostring(id - 1)
532		else
533			shortcut = string.char(id + 54)
534		end
535
536		m[#m+1] = {"SP"..id, shortcut, style.name..": "..style.desc, nil,
537			function()
538				Cmd.ChangeParagraphStyle(style.name)
539			end}
540	end
541
542	addmenu("Paragraph Styles", m, ParagraphStylesMenu)
543end
544
545function RebuildDocumentsMenu(documents)
546	-- Remember any accelerator keys and unhook the old menu.
547
548	local ak_tab = {}
549	for _, item in ipairs(DocumentsMenu) do
550		local ak = DocumentSet.menu.accelerators[item.id]
551		if ak then
552			ak_tab[item.label] = ak
553		end
554	end
555	submenu(DocumentsMenu)
556
557	-- Construct the new menu.
558
559	local m = {}
560	for id, document in ipairs(documents) do
561		local ak = ak_tab[document.name]
562		local shortcut
563		if (id <= 10) then
564			shortcut = tostring(id - 1)
565		else
566			shortcut = string.char(id + 54)
567		end
568
569		m[#m+1] = {"D"..id, shortcut, document.name, ak,
570			function()
571				Cmd.ChangeDocument(document.name)
572			end}
573	end
574
575	-- Hook it.
576
577	addmenu("Documents", m, DocumentsMenu)
578end
579
580function ListMenuItems()
581	local function list(menu)
582		for _, item in ipairs(menu) do
583			if IsMenu(item) then
584				io.stdout:write(
585					string.format("%15s %s\n", item.id, item.label))
586				if IsMenu(item.fn) then
587					list(item.fn)
588				end
589			end
590		end
591	end
592
593	io.stdout:write("All supported menu items:\n\n")
594	list(MainMenu)
595end
596
597