mirror of
https://github.com/SquidDev-CC/CC-Tweaked
synced 2024-12-04 15:29:58 +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:
parent
8644c4ebf6
commit
d71bf225cc
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)
|
||||
|
Loading…
Reference in New Issue
Block a user