mirror of
				https://github.com/SquidDev-CC/CC-Tweaked
				synced 2025-10-30 21:23:00 +00:00 
			
		
		
		
	Add very simple markdown support to the help viewer (#819)
- Allow help files to use the ".md" suffix, and move changelog/whatsnew to use them. - When files end with ".md", the "help" program attempts to highlight them. This involves: - Colour code blocks with a lightGrey background. - Replace lists to use bullet points instead of "-"/"*". - Colours headings yellow. The implementation of this is a bit janky because a) I wrote this and b) we need to run this step before text wrapping, but preserve colours and section positions over wrapping (thanks to Jack for getting this working). - Add section navigation to the help viewer, with left/right to move to the next/previous section. Closes #569
This commit is contained in:
		
							
								
								
									
										12
									
								
								build.gradle
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								build.gradle
									
									
									
									
									
								
							| @@ -417,31 +417,31 @@ task checkRelease { | ||||
|     description "Verifies that everything is ready for a release" | ||||
|  | ||||
|     inputs.property "version", mod_version | ||||
|     inputs.file("src/main/resources/data/computercraft/lua/rom/help/changelog.txt") | ||||
|     inputs.file("src/main/resources/data/computercraft/lua/rom/help/whatsnew.txt") | ||||
|     inputs.file("src/main/resources/data/computercraft/lua/rom/help/changelog.md") | ||||
|     inputs.file("src/main/resources/data/computercraft/lua/rom/help/whatsnew.md") | ||||
|  | ||||
|     doLast { | ||||
|         def ok = true | ||||
|  | ||||
|         // Check we're targetting the current version | ||||
|         def whatsnew = new File(projectDir, "src/main/resources/data/computercraft/lua/rom/help/whatsnew.txt").readLines() | ||||
|         def whatsnew = new File(projectDir, "src/main/resources/data/computercraft/lua/rom/help/whatsnew.md").readLines() | ||||
|         if (whatsnew[0] != "New features in CC: Tweaked $mod_version") { | ||||
|             ok = false | ||||
|             project.logger.error("Expected `whatsnew.txt' to target $mod_version.") | ||||
|             project.logger.error("Expected `whatsnew.md' to target $mod_version.") | ||||
|         } | ||||
|  | ||||
|         // Check "read more" exists and trim it | ||||
|         def idx = whatsnew.findIndexOf { it == 'Type "help changelog" to see the full version history.' } | ||||
|         if (idx == -1) { | ||||
|             ok = false | ||||
|             project.logger.error("Must mention the changelog in whatsnew.txt") | ||||
|             project.logger.error("Must mention the changelog in whatsnew.md") | ||||
|         } else { | ||||
|             whatsnew = whatsnew.getAt(0 ..< idx) | ||||
|         } | ||||
|  | ||||
|         // Check whatsnew and changelog match. | ||||
|         def versionChangelog = "# " + whatsnew.join("\n") | ||||
|         def changelog = new File(projectDir, "src/main/resources/data/computercraft/lua/rom/help/changelog.txt").getText() | ||||
|         def changelog = new File(projectDir, "src/main/resources/data/computercraft/lua/rom/help/changelog.md").getText() | ||||
|         if (!changelog.startsWith(versionChangelog)) { | ||||
|             ok = false | ||||
|             project.logger.error("whatsnew and changelog are not in sync") | ||||
|   | ||||
| @@ -27,21 +27,24 @@ function setPath(_sPath) | ||||
|     sPath = _sPath | ||||
| end | ||||
|  | ||||
| local extensions = { "", ".md", ".txt" } | ||||
|  | ||||
| --- Returns the location of the help file for the given topic. | ||||
| -- | ||||
| -- @tparam string topic The topic to find | ||||
| -- @treturn string|nil The path to the given topic's help file, or `nil` if it | ||||
| -- cannot be found. | ||||
| -- @usage help.lookup("disk") | ||||
| function lookup(_sTopic) | ||||
|     expect(1, _sTopic, "string") | ||||
| function lookup(topic) | ||||
|     expect(1, topic, "string") | ||||
|     -- Look on the path variable | ||||
|     for sPath in string.gmatch(sPath, "[^:]+") do | ||||
|         sPath = fs.combine(sPath, _sTopic) | ||||
|         if fs.exists(sPath) and not fs.isDir(sPath) then | ||||
|             return sPath | ||||
|         elseif fs.exists(sPath .. ".txt") and not fs.isDir(sPath .. ".txt") then | ||||
|             return sPath .. ".txt" | ||||
|     for path in string.gmatch(sPath, "[^:]+") do | ||||
|         path = fs.combine(path, topic) | ||||
|         for _, extension in ipairs(extensions) do | ||||
|             local file = path .. extension | ||||
|             if fs.exists(file) and not fs.isDir(file) then | ||||
|                 return file | ||||
|             end | ||||
|         end | ||||
|     end | ||||
|  | ||||
| @@ -66,8 +69,11 @@ function topics() | ||||
|             for _, sFile in pairs(tList) do | ||||
|                 if string.sub(sFile, 1, 1) ~= "." then | ||||
|                     if not fs.isDir(fs.combine(sPath, sFile)) then | ||||
|                         if #sFile > 4 and sFile:sub(-4) == ".txt" then | ||||
|                             sFile = sFile:sub(1, -5) | ||||
|                         for i = 2, #extensions do | ||||
|                             local extension = extensions[i] | ||||
|                             if #sFile > #extension and sFile:sub(-#extension) == extension then | ||||
|                                 sFile = sFile:sub(1, -#extension - 1) | ||||
|                             end | ||||
|                         end | ||||
|                         tItems[sFile] = true | ||||
|                     end | ||||
|   | ||||
| @@ -14,16 +14,127 @@ if sTopic == "index" then | ||||
| end | ||||
|  | ||||
| local strings = require "cc.strings" | ||||
| local function word_wrap(text, width) | ||||
|     local lines = strings.wrap(text, width) | ||||
|  | ||||
| local function min_of(a, b, default) | ||||
|     if not a and not b then return default end | ||||
|     if not a then return b end | ||||
|     if not b then return a end | ||||
|     return math.min(a, b) | ||||
| end | ||||
|  | ||||
| --[[- Parse a markdown string, extracting headings and highlighting some basic | ||||
| constructs. | ||||
|  | ||||
| The implementation of this is horrible. SquidDev shouldn't be allowed to write | ||||
| parsers, especially ones they think might be "performance critical". | ||||
| ]] | ||||
| local function parse_markdown(text) | ||||
|     local len = #text | ||||
|     local oob = len + 1 | ||||
|  | ||||
|     -- Some patterns to match headers and bullets on the start of lines. | ||||
|     -- The `%f[^\n\0]` is some wonderful logic to match the start of a line /or/ | ||||
|     -- the start of the document. | ||||
|     local heading = "%f[^\n\0](#+ +)([^\n]*)" | ||||
|     local bullet = "%f[^\n\0]( *)[.*]( +)" | ||||
|     local code = "`([^`]+)`" | ||||
|  | ||||
|     local new_text, fg, bg = "", "", "" | ||||
|     local function append(txt, fore, back) | ||||
|         new_text = new_text .. txt | ||||
|         fg = fg .. (fore or "0"):rep(#txt) | ||||
|         bg = bg .. (back or "f"):rep(#txt) | ||||
|     end | ||||
|  | ||||
|     local next_header = text:find(heading) | ||||
|     local next_bullet = text:find(bullet) | ||||
|     local next_block = min_of(next_header, next_bullet, oob) | ||||
|  | ||||
|     local next_code, next_code_end = text:find(code) | ||||
|  | ||||
|     local sections = {} | ||||
|  | ||||
|     local start = 1 | ||||
|     while start <= len do | ||||
|         if start == next_block then | ||||
|             if start == next_header then | ||||
|                 local _, fin, head, content = text:find(heading, start) | ||||
|                 sections[#new_text + 1] = content | ||||
|                 append(head .. content, "4", "f") | ||||
|                 start = fin + 1 | ||||
|  | ||||
|                 next_header = text:find(heading, start) | ||||
|             else | ||||
|                 local _, fin, space, content = text:find(bullet, start) | ||||
|                 append(space .. "\7" .. content) | ||||
|                 start = fin + 1 | ||||
|  | ||||
|                 next_bullet = text:find(bullet, start) | ||||
|             end | ||||
|  | ||||
|             next_block = min_of(next_header, next_bullet, oob) | ||||
|         elseif next_code and next_code_end < next_block then | ||||
|             -- Basic inline code blocks | ||||
|             if start < next_code then append(text:sub(start, next_code - 1)) end | ||||
|             local content = text:match(code, next_code) | ||||
|             append(content, "0", "7") | ||||
|  | ||||
|             start = next_code_end + 1 | ||||
|             next_code, next_code_end = text:find(code, start) | ||||
|         else | ||||
|             -- Normal text | ||||
|             append(text:sub(start, next_block - 1)) | ||||
|             start = next_block | ||||
|  | ||||
|             -- Rescan for a new code block | ||||
|             if next_code then next_code, next_code_end = text:find(code, start) end | ||||
|         end | ||||
|     end | ||||
|  | ||||
|     return new_text, fg, bg, sections | ||||
| end | ||||
|  | ||||
| local function word_wrap_basic(text, width) | ||||
|     local lines, fg, bg = strings.wrap(text, width), {}, {} | ||||
|     local fg_line, bg_line = ("0"):rep(width), ("f"):rep(width) | ||||
|  | ||||
|     -- Normalise the strings suitable for use with blit. We could skip this and | ||||
|     -- just use term.write, but saves us a clearLine call. | ||||
|     for k, line in pairs(lines) do | ||||
|         lines[k] = strings.ensure_width(line, width) | ||||
|         fg[k] = fg_line | ||||
|         bg[k] = bg_line | ||||
|     end | ||||
|  | ||||
|     return lines | ||||
|     return lines, fg, bg, {} | ||||
| end | ||||
|  | ||||
| local function word_wrap_markdown(text, width) | ||||
|     -- Add in styling for Markdown-formatted text. | ||||
|     local text, fg, bg, sections = parse_markdown(text) | ||||
|  | ||||
|     local lines = strings.wrap(text, width) | ||||
|     local fglines, bglines, section_list, section_n = {}, {}, {}, 1 | ||||
|  | ||||
|     -- Normalise the strings suitable for use with blit. We could skip this and | ||||
|     -- just use term.write, but saves us a clearLine call. | ||||
|     local start = 1 | ||||
|     for k, line in pairs(lines) do | ||||
|         -- I hate this with a burning passion, but it works! | ||||
|         local pos = text:find(line, start, true) | ||||
|         lines[k], fglines[k], bglines[k] = | ||||
|             strings.ensure_width(line, width), | ||||
|             strings.ensure_width(fg:sub(pos, pos + #line), width), | ||||
|             strings.ensure_width(bg:sub(pos, pos + #line), width) | ||||
|  | ||||
|         if sections[pos] then | ||||
|             section_list[section_n], section_n = { content = sections[pos], offset = k - 1 }, section_n + 1 | ||||
|         end | ||||
|  | ||||
|         start = pos + 1 | ||||
|     end | ||||
|  | ||||
|     return lines, fglines, bglines, section_list | ||||
| end | ||||
|  | ||||
| local sFile = help.lookup(sTopic) | ||||
| @@ -33,31 +144,40 @@ if not file then | ||||
|     return | ||||
| end | ||||
|  | ||||
| local contents = file:read("*a"):gsub("(\n *)[-*]( +)", "%1\7%2") | ||||
| local contents = file:read("*a") | ||||
| file:close() | ||||
|  | ||||
| local word_wrap = sFile:sub(-3) == ".md" and word_wrap_markdown or word_wrap_basic | ||||
| local width, height = term.getSize() | ||||
| local lines = word_wrap(contents, width) | ||||
| local lines, fg, bg, sections = word_wrap(contents, width) | ||||
| local print_height = #lines | ||||
|  | ||||
| -- If we fit within the screen, just display without pagination. | ||||
| if print_height <= height then | ||||
|     print(contents) | ||||
|     local _, y = term.getCursorPos() | ||||
|     for i = 1, print_height do | ||||
|         if y + i - 1 > height then | ||||
|             term.scroll(1) | ||||
|             term.setCursorPos(1, height) | ||||
|         else | ||||
|             term.setCursorPos(1, y + i - 1) | ||||
|         end | ||||
|  | ||||
|         term.blit(lines[i], fg[i], bg[i]) | ||||
|     end | ||||
|     return | ||||
| end | ||||
|  | ||||
| local current_section = nil | ||||
| local offset = 0 | ||||
|  | ||||
| local function draw() | ||||
|     local fg, bg = ("0"):rep(width), ("f"):rep(width) | ||||
|     for y = 1, height - 1 do | ||||
|         term.setCursorPos(1, y) | ||||
|         if y + offset > print_height then | ||||
|             -- Should only happen if we resize the terminal to a larger one | ||||
|             -- than actually needed for the current text. | ||||
|             term.clearLine() | ||||
|         else | ||||
|             term.blit(lines[y + offset], fg, bg) | ||||
| --- Find the currently visible seciton, or nil if this document has no sections. | ||||
| -- | ||||
| -- This could potentially be a binary search, but right now it's not worth it. | ||||
| local function find_section() | ||||
|     for i = #sections, 1, -1 do | ||||
|         if sections[i].offset <= offset then | ||||
|             return i | ||||
|         end | ||||
|     end | ||||
| end | ||||
| @@ -68,7 +188,10 @@ local function draw_menu() | ||||
|     term.clearLine() | ||||
|  | ||||
|     local tag = "Help: " .. sTopic | ||||
|     term.write("Help: " .. sTopic) | ||||
|     if current_section then | ||||
|         tag = tag .. (" (%s)"):format(sections[current_section].content) | ||||
|     end | ||||
|     term.write(tag) | ||||
|  | ||||
|     if width >= #tag + 16 then | ||||
|         term.setCursorPos(width - 14, height) | ||||
| @@ -76,11 +199,31 @@ local function draw_menu() | ||||
|     end | ||||
| end | ||||
|  | ||||
|  | ||||
| local function draw() | ||||
|     for y = 1, height - 1 do | ||||
|         term.setCursorPos(1, y) | ||||
|         if y + offset > print_height then | ||||
|             -- Should only happen if we resize the terminal to a larger one | ||||
|             -- than actually needed for the current text. | ||||
|             term.clearLine() | ||||
|         else | ||||
|             term.blit(lines[y + offset], fg[y + offset], bg[y + offset]) | ||||
|         end | ||||
|     end | ||||
|  | ||||
|     local new_section = find_section() | ||||
|     if new_section ~= current_section then | ||||
|         current_section = new_section | ||||
|         draw_menu() | ||||
|     end | ||||
| end | ||||
|  | ||||
| draw() | ||||
| draw_menu() | ||||
|  | ||||
| while true do | ||||
|     local event, param = os.pullEvent() | ||||
|     local event, param = os.pullEventRaw() | ||||
|     if event == "key" then | ||||
|         if param == keys.up and offset > 0 then | ||||
|             offset = offset - 1 | ||||
| @@ -97,6 +240,12 @@ while true do | ||||
|         elseif param == keys.home then | ||||
|             offset = 0 | ||||
|             draw() | ||||
|         elseif param == keys.left and current_section and current_section > 1 then | ||||
|             offset = sections[current_section - 1].offset | ||||
|             draw() | ||||
|         elseif param == keys.right and current_section and current_section < #sections then | ||||
|             offset = sections[current_section + 1].offset | ||||
|             draw() | ||||
|         elseif param == keys["end"] then | ||||
|             offset = print_height - height | ||||
|             draw() | ||||
| @@ -124,6 +273,8 @@ while true do | ||||
|         offset = math.max(math.min(offset, print_height - height), 0) | ||||
|         draw() | ||||
|         draw_menu() | ||||
|     elseif event == "terminate" then | ||||
|         break | ||||
|     end | ||||
| end | ||||
|  | ||||
|   | ||||
| @@ -18,5 +18,10 @@ describe("The help library", function() | ||||
|             help.completeTopic("") | ||||
|             expect.error(help.completeTopic, nil):eq("bad argument #1 (expected string, got nil)") | ||||
|         end) | ||||
|  | ||||
|         it("completes topics without extensions", function() | ||||
|             expect(help.completeTopic("changel")):same { "og" } | ||||
|             expect(help.completeTopic("turt")):same { "le" } | ||||
|         end) | ||||
|     end) | ||||
| end) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Jonathan Coates
					Jonathan Coates