From d71bf225cc5e4d9f1a2ca050982489bab3c366ea Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Sat, 12 Jun 2021 19:48:41 +0100 Subject: [PATCH] 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 --- build.gradle | 12 +- .../data/computercraft/lua/rom/apis/help.lua | 26 ++- .../rom/help/{changelog.txt => changelog.md} | 0 .../rom/help/{whatsnew.txt => whatsnew.md} | 0 .../computercraft/lua/rom/programs/help.lua | 187 ++++++++++++++++-- .../test-rom/spec/apis/help_spec.lua | 5 + 6 files changed, 196 insertions(+), 34 deletions(-) rename src/main/resources/data/computercraft/lua/rom/help/{changelog.txt => changelog.md} (100%) rename src/main/resources/data/computercraft/lua/rom/help/{whatsnew.txt => whatsnew.md} (100%) diff --git a/build.gradle b/build.gradle index f89440827..867106ebf 100644 --- a/build.gradle +++ b/build.gradle @@ -417,31 +417,31 @@ task setupServer(type: Copy) { 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") diff --git a/src/main/resources/data/computercraft/lua/rom/apis/help.lua b/src/main/resources/data/computercraft/lua/rom/apis/help.lua index 777ab45bf..888c4e78c 100644 --- a/src/main/resources/data/computercraft/lua/rom/apis/help.lua +++ b/src/main/resources/data/computercraft/lua/rom/apis/help.lua @@ -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 diff --git a/src/main/resources/data/computercraft/lua/rom/help/changelog.txt b/src/main/resources/data/computercraft/lua/rom/help/changelog.md similarity index 100% rename from src/main/resources/data/computercraft/lua/rom/help/changelog.txt rename to src/main/resources/data/computercraft/lua/rom/help/changelog.md diff --git a/src/main/resources/data/computercraft/lua/rom/help/whatsnew.txt b/src/main/resources/data/computercraft/lua/rom/help/whatsnew.md similarity index 100% rename from src/main/resources/data/computercraft/lua/rom/help/whatsnew.txt rename to src/main/resources/data/computercraft/lua/rom/help/whatsnew.md diff --git a/src/main/resources/data/computercraft/lua/rom/programs/help.lua b/src/main/resources/data/computercraft/lua/rom/programs/help.lua index 497a6dff7..43ba258d4 100644 --- a/src/main/resources/data/computercraft/lua/rom/programs/help.lua +++ b/src/main/resources/data/computercraft/lua/rom/programs/help.lua @@ -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 diff --git a/src/test/resources/test-rom/spec/apis/help_spec.lua b/src/test/resources/test-rom/spec/apis/help_spec.lua index d7ecb923f..f8ebcdb5c 100644 --- a/src/test/resources/test-rom/spec/apis/help_spec.lua +++ b/src/test/resources/test-rom/spec/apis/help_spec.lua @@ -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)