diff --git a/build.gradle b/build.gradle index f4919227b..0be21ae8c 100644 --- a/build.gradle +++ b/build.gradle @@ -51,8 +51,9 @@ dependencies { shade 'org.squiddev:Cobalt:0.5.1-SNAPSHOT' - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.1.0' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.1.0' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.7.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0' modRuntime "me.shedaniel:RoughlyEnoughItems-api:5.2.10" modRuntime "me.shedaniel:RoughlyEnoughItems:5.2.10" diff --git a/patchwork.md b/patchwork.md index ea1606f6f..cc9b68710 100644 --- a/patchwork.md +++ b/patchwork.md @@ -343,7 +343,6 @@ Cleanup examples for the various modules ``` Ignored Documentation Changes, these are locate -``` ``` 9a749642d294506095e697a3a4345dfe260bd68c @@ -396,3 +395,30 @@ Try to handle a turtle being broken while ticked Hopefully fixes #585. Hopefully. ``` + +``` +511eea39a11956c82e2c11a47b2e7cad27f9887e + +Remove s in usages +``` + +``` +826797cbd579e867f0f35f0be44b6a28c8c094a9 + +Added documentation for global functions (#592) +``` +Didn't port the docs over. + +``` +d83a68f3ff6e3833278a38798d06215293656e85 + +Allow $private HTTP rule to block any private IP +``` +The config still uses a `blacklist` and `whitelist` array. + +``` +24d3777722812f975d2bc4594437fbbb0431d910 + +Added improved help viewer (#595) +``` +Didn't port the lua tests over. diff --git a/src/main/java/dan200/computercraft/ComputerCraft.java b/src/main/java/dan200/computercraft/ComputerCraft.java index 68d49e580..2ee0ffa0c 100644 --- a/src/main/java/dan200/computercraft/ComputerCraft.java +++ b/src/main/java/dan200/computercraft/ComputerCraft.java @@ -10,12 +10,12 @@ import static dan200.computercraft.shared.ComputerCraftRegistry.ModBlocks; import static dan200.computercraft.shared.ComputerCraftRegistry.init; import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; import java.util.EnumSet; import java.util.List; -import java.util.Objects; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import java.util.stream.Stream; + import dan200.computercraft.api.turtle.event.TurtleAction; import dan200.computercraft.core.apis.http.options.Action; @@ -32,20 +32,10 @@ import dan200.computercraft.shared.data.PlayerCreativeLootCondition; import dan200.computercraft.shared.media.recipes.DiskRecipe; import dan200.computercraft.shared.media.recipes.PrintoutRecipe; import dan200.computercraft.shared.peripheral.monitor.MonitorRenderer; -import dan200.computercraft.shared.pocket.peripherals.PocketModem; -import dan200.computercraft.shared.pocket.peripherals.PocketSpeaker; import dan200.computercraft.shared.pocket.recipes.PocketComputerUpgradeRecipe; import dan200.computercraft.shared.proxy.ComputerCraftProxyCommon; import dan200.computercraft.shared.turtle.recipes.TurtleRecipe; import dan200.computercraft.shared.turtle.recipes.TurtleUpgradeRecipe; -import dan200.computercraft.shared.turtle.upgrades.TurtleAxe; -import dan200.computercraft.shared.turtle.upgrades.TurtleCraftingTable; -import dan200.computercraft.shared.turtle.upgrades.TurtleHoe; -import dan200.computercraft.shared.turtle.upgrades.TurtleModem; -import dan200.computercraft.shared.turtle.upgrades.TurtleShovel; -import dan200.computercraft.shared.turtle.upgrades.TurtleSpeaker; -import dan200.computercraft.shared.turtle.upgrades.TurtleSword; -import dan200.computercraft.shared.turtle.upgrades.TurtleTool; import dan200.computercraft.shared.util.Config; import dan200.computercraft.shared.util.ImpostorRecipe; import dan200.computercraft.shared.util.ImpostorShapelessRecipe; @@ -65,15 +55,6 @@ import net.fabricmc.loader.api.FabricLoader; public final class ComputerCraft implements ModInitializer { public static final String MOD_ID = "computercraft"; // Configuration options - public static final String[] DEFAULT_HTTP_WHITELIST = new String[] {"*"}; - public static final String[] DEFAULT_HTTP_BLACKLIST = new String[] { - "127.0.0.0/8", - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "fd00::/8", - "0.0.0.0/8" - }; public static final int terminalWidth_computer = 51; public static final int terminalHeight_computer = 19; public static final int terminalWidth_turtle = 39; @@ -122,17 +103,10 @@ public final class ComputerCraft implements ModInitializer { public static int monitorHeight = 6; public static double monitorDistanceSq = 4096; - public static List httpRules = buildHttpRulesFromConfig(DEFAULT_HTTP_BLACKLIST, DEFAULT_HTTP_WHITELIST); - - public static List buildHttpRulesFromConfig(String[] blacklist, String[] whitelist) { - return Stream.concat(Stream.of(blacklist) - .map( x -> AddressRule.parse( x, null, Action.DENY.toPartial())) - .filter(Objects::nonNull), - Stream.of(whitelist) - .map( x -> AddressRule.parse( x, null, Action.ALLOW.toPartial())) - .filter(Objects::nonNull)) - .collect(Collectors.toList()); - } + public static List httpRules = Collections.unmodifiableList( Arrays.asList( + AddressRule.parse( "$private", null, Action.DENY.toPartial() ), + AddressRule.parse( "*", null, Action.ALLOW.toPartial() ) + ) ); @Override public void onInitialize() { diff --git a/src/main/java/dan200/computercraft/core/apis/http/options/AddressPredicate.java b/src/main/java/dan200/computercraft/core/apis/http/options/AddressPredicate.java new file mode 100644 index 000000000..9109a3457 --- /dev/null +++ b/src/main/java/dan200/computercraft/core/apis/http/options/AddressPredicate.java @@ -0,0 +1,148 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.core.apis.http.options; + +import com.google.common.net.InetAddresses; +import dan200.computercraft.ComputerCraft; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.regex.Pattern; + +/** + * A predicate on an address. Matches against a domain and an ip address. + * + * @see AddressRule#apply(Iterable, String, InetSocketAddress) for the actual handling of this rule. + */ +interface AddressPredicate +{ + default boolean matches( String domain ) + { + return false; + } + + default boolean matches( InetAddress socketAddress ) + { + return false; + } + + final class HostRange implements AddressPredicate + { + private final byte[] min; + private final byte[] max; + + HostRange( byte[] min, byte[] max ) + { + this.min = min; + this.max = max; + } + + @Override + public boolean matches( InetAddress address ) + { + byte[] entry = address.getAddress(); + if( entry.length != min.length ) return false; + + for( int i = 0; i < entry.length; i++ ) + { + int value = 0xFF & entry[i]; + if( value < (0xFF & min[i]) || value > (0xFF & max[i]) ) return false; + } + + return true; + } + + public static HostRange parse( String addressStr, String prefixSizeStr ) + { + int prefixSize; + try + { + prefixSize = Integer.parseInt( prefixSizeStr ); + } + catch( NumberFormatException e ) + { + ComputerCraft.log.error( + "Malformed http whitelist/blacklist entry '{}': Cannot extract size of CIDR mask from '{}'.", + addressStr + '/' + prefixSizeStr, prefixSizeStr + ); + return null; + } + + InetAddress address; + try + { + address = InetAddresses.forString( addressStr ); + } + catch( IllegalArgumentException e ) + { + ComputerCraft.log.error( + "Malformed http whitelist/blacklist entry '{}': Cannot extract IP address from '{}'.", + addressStr + '/' + prefixSizeStr, prefixSizeStr + ); + return null; + } + + // Mask the bytes of the IP address. + byte[] minBytes = address.getAddress(), maxBytes = address.getAddress(); + int size = prefixSize; + for( int i = 0; i < minBytes.length; i++ ) + { + if( size <= 0 ) + { + minBytes[i] &= 0; + maxBytes[i] |= 0xFF; + } + else if( size < 8 ) + { + minBytes[i] &= 0xFF << (8 - size); + maxBytes[i] |= ~(0xFF << (8 - size)); + } + + size -= 8; + } + + return new HostRange( minBytes, maxBytes ); + } + } + + final class DomainPattern implements AddressPredicate + { + private final Pattern pattern; + + DomainPattern( Pattern pattern ) + { + this.pattern = pattern; + } + + @Override + public boolean matches( String domain ) + { + return pattern.matcher( domain ).matches(); + } + + @Override + public boolean matches( InetAddress socketAddress ) + { + return pattern.matcher( socketAddress.getHostAddress() ).matches(); + } + } + + + final class PrivatePattern implements AddressPredicate + { + static final PrivatePattern INSTANCE = new PrivatePattern(); + + @Override + public boolean matches( InetAddress socketAddress ) + { + return socketAddress.isAnyLocalAddress() + || socketAddress.isLoopbackAddress() + || socketAddress.isLinkLocalAddress() + || socketAddress.isSiteLocalAddress(); + } + } + +} diff --git a/src/main/java/dan200/computercraft/core/apis/http/options/AddressRule.java b/src/main/java/dan200/computercraft/core/apis/http/options/AddressRule.java index c8d2017b8..20f32a67e 100644 --- a/src/main/java/dan200/computercraft/core/apis/http/options/AddressRule.java +++ b/src/main/java/dan200/computercraft/core/apis/http/options/AddressRule.java @@ -6,6 +6,7 @@ package dan200.computercraft.core.apis.http.options; +import java.net.Inet4Address; import java.net.Inet6Address; import java.net.InetAddress; import java.net.InetSocketAddress; @@ -15,7 +16,10 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import com.google.common.net.InetAddresses; -import dan200.computercraft.ComputerCraft; + +import dan200.computercraft.core.apis.http.options.AddressPredicate.DomainPattern; +import dan200.computercraft.core.apis.http.options.AddressPredicate.HostRange; +import dan200.computercraft.core.apis.http.options.AddressPredicate.PrivatePattern; /** * A pattern which matches an address, and controls whether it is accessible or not. @@ -25,18 +29,12 @@ public final class AddressRule { public static final long MAX_UPLOAD = 4 * 1024 * 1024; public static final int TIMEOUT = 30_000; public static final int WEBSOCKET_MESSAGE = 128 * 1024; - private final HostRange ip; - private final Pattern domainPattern; + + private final AddressPredicate predicate; private final Integer port; private final PartialOptions partial; - private AddressRule( - @Nullable HostRange ip, - @Nullable Pattern domainPattern, - @Nullable Integer port, - @Nonnull PartialOptions partial ) - { - this.ip = ip; - this.domainPattern = domainPattern; + private AddressRule( @Nonnull AddressPredicate predicate, @Nullable Integer port, @Nonnull PartialOptions partial ) { + this.predicate = predicate; this.partial = partial; this.port = port; } @@ -47,55 +45,29 @@ public final class AddressRule { if (cidr >= 0) { String addressStr = filter.substring(0, cidr); String prefixSizeStr = filter.substring(cidr + 1); - - int prefixSize; - try { - prefixSize = Integer.parseInt(prefixSizeStr); - } catch (NumberFormatException e) { - ComputerCraft.log.error("Malformed http whitelist/blacklist entry '{}': Cannot extract size of CIDR mask from '{}'.", - filter, - prefixSizeStr); - return null; - } - - InetAddress address; - try { - address = InetAddresses.forString(addressStr); - } catch (IllegalArgumentException e) { - ComputerCraft.log.error("Malformed http whitelist/blacklist entry '{}': Cannot extract IP address from '{}'.", filter, prefixSizeStr); - return null; - } - - // Mask the bytes of the IP address. - byte[] minBytes = address.getAddress(), maxBytes = address.getAddress(); - int size = prefixSize; - for (int i = 0; i < minBytes.length; i++) { - if (size <= 0) { - minBytes[i] &= 0; - maxBytes[i] |= 0xFF; - } else if (size < 8) { - minBytes[i] &= 0xFF << (8 - size); - maxBytes[i] |= ~(0xFF << (8 - size)); - } - - size -= 8; - } - - return new AddressRule(new HostRange(minBytes, maxBytes), null, port, partial); + HostRange range = HostRange.parse( addressStr, prefixSizeStr ); + return range == null ? null : new AddressRule( range, port, partial ); + } + else if( filter.equalsIgnoreCase( "$private" ) ) + { + return new AddressRule( PrivatePattern.INSTANCE, port, partial ); } else { - Pattern pattern = Pattern.compile("^\\Q" + filter.replaceAll("\\*", "\\\\E.*\\\\Q") + "\\E$"); - return new AddressRule(null, pattern, port, partial); + Pattern pattern = Pattern.compile( "^\\Q" + filter.replaceAll( "\\*", "\\\\E.*\\\\Q" ) + "\\E$", Pattern.CASE_INSENSITIVE ); + return new AddressRule( new DomainPattern( pattern ), port, partial ); } } - public static Options apply(Iterable rules, String domain, InetSocketAddress address) { + public static Options apply( Iterable rules, String domain, InetSocketAddress socketAddress ) { PartialOptions options = null; boolean hasMany = false; + int port = socketAddress.getPort(); + InetAddress address = socketAddress.getAddress(); + Inet4Address ipv4Address = address instanceof Inet6Address && InetAddresses.is6to4Address( (Inet6Address) address ) + ? InetAddresses.get6to4IPv4Address( (Inet6Address) address ) : null; + for (AddressRule rule : rules) { - if (!rule.matches(domain, address)) { - continue; - } + if( !rule.matches( domain, port, address, ipv4Address ) ) continue; if (options == null) { options = rule.partial; @@ -116,65 +88,16 @@ public final class AddressRule { /** * Determine whether the given address matches a series of patterns. * - * @param domain The domain to match - * @param socketAddress The address to check. + * @param domain The domain to match + * @param port The port of the address. + * @param address The address to check. + * @param ipv4Address An ipv4 version of the address, if the original was an ipv6 address. * @return Whether it matches any of these patterns. */ - private boolean matches(String domain, InetSocketAddress socketAddress) { - InetAddress address = socketAddress.getAddress(); - if( port != null && port != socketAddress.getPort() ) return false; - - if (this.domainPattern != null) { - if (this.domainPattern.matcher(domain) - .matches()) { - return true; - } - if (this.domainPattern.matcher(address.getHostName()) - .matches()) { - return true; - } - } - - // Match the normal address - if (this.matchesAddress(address)) { - return true; - } - - // If we're an IPv4 address in disguise then let's check that. - return address instanceof Inet6Address && InetAddresses.is6to4Address((Inet6Address) address) && this.matchesAddress(InetAddresses.get6to4IPv4Address((Inet6Address) address)); - } - - private boolean matchesAddress(InetAddress address) { - if (this.domainPattern != null && this.domainPattern.matcher(address.getHostAddress()) - .matches()) { - return true; - } - return this.ip != null && this.ip.contains(address); - } - - private static final class HostRange { - private final byte[] min; - private final byte[] max; - - private HostRange(byte[] min, byte[] max) { - this.min = min; - this.max = max; - } - - public boolean contains(InetAddress address) { - byte[] entry = address.getAddress(); - if (entry.length != this.min.length) { - return false; - } - - for (int i = 0; i < entry.length; i++) { - int value = 0xFF & entry[i]; - if (value < (0xFF & this.min[i]) || value > (0xFF & this.max[i])) { - return false; - } - } - - return true; - } + private boolean matches(String domain, int port, InetAddress address, Inet4Address ipv4Address) { + if( this.port != null && this.port != port ) return false; + return predicate.matches( domain ) + || predicate.matches( address ) + || (ipv4Address != null && predicate.matches( ipv4Address )); } } diff --git a/src/main/java/dan200/computercraft/core/apis/http/options/AddressRuleConfig.java b/src/main/java/dan200/computercraft/core/apis/http/options/AddressRuleConfig.java index a3685aef1..53f7648bf 100644 --- a/src/main/java/dan200/computercraft/core/apis/http/options/AddressRuleConfig.java +++ b/src/main/java/dan200/computercraft/core/apis/http/options/AddressRuleConfig.java @@ -8,7 +8,6 @@ package dan200.computercraft.core.apis.http.options; import com.electronwill.nightconfig.core.CommentedConfig; -import com.electronwill.nightconfig.core.Config; import com.electronwill.nightconfig.core.InMemoryCommentedFormat; import com.electronwill.nightconfig.core.UnmodifiableConfig; import dan200.computercraft.ComputerCraft; diff --git a/src/main/java/dan200/computercraft/shared/turtle/apis/TurtleAPI.java b/src/main/java/dan200/computercraft/shared/turtle/apis/TurtleAPI.java index 1c663fe3f..21f63deb9 100644 --- a/src/main/java/dan200/computercraft/shared/turtle/apis/TurtleAPI.java +++ b/src/main/java/dan200/computercraft/shared/turtle/apis/TurtleAPI.java @@ -505,7 +505,7 @@ public class TurtleAPI implements ILuaAPI { } /** - * Get the currently sleected slot. + * Get the currently selected slot. * * @return The current slot. * @see #select diff --git a/src/main/java/dan200/computercraft/shared/util/Config.java b/src/main/java/dan200/computercraft/shared/util/Config.java index 41c30f418..0cd64f846 100644 --- a/src/main/java/dan200/computercraft/shared/util/Config.java +++ b/src/main/java/dan200/computercraft/shared/util/Config.java @@ -3,7 +3,10 @@ package dan200.computercraft.shared.util; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Objects; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; import blue.endless.jankson.Comment; import blue.endless.jankson.Jankson; @@ -13,6 +16,8 @@ import com.google.common.base.CaseFormat; import com.google.common.base.Converter; import dan200.computercraft.ComputerCraft; import dan200.computercraft.api.turtle.event.TurtleAction; +import dan200.computercraft.core.apis.http.options.Action; +import dan200.computercraft.core.apis.http.options.AddressRule; import dan200.computercraft.core.apis.http.websocket.Websocket; public class Config { @@ -83,7 +88,13 @@ public class Config { // HTTP ComputerCraft.http_enable = config.http.enabled; ComputerCraft.http_websocket_enable = config.http.websocket_enabled; - ComputerCraft.httpRules = ComputerCraft.buildHttpRulesFromConfig(config.http.blacklist, config.http.whitelist); + ComputerCraft.httpRules = Stream.concat(Stream.of(config.http.blacklist) + .map( x -> AddressRule.parse( x, null, Action.DENY.toPartial())) + .filter(Objects::nonNull), + Stream.of(config.http.whitelist) + .map( x -> AddressRule.parse( x, null, Action.ALLOW.toPartial())) + .filter(Objects::nonNull)) + .collect(Collectors.toList()); ComputerCraft.httpTimeout = Math.max(0, config.http.timeout); ComputerCraft.httpMaxRequests = Math.max(1, config.http.max_requests); @@ -158,9 +169,9 @@ public class Config { ComputerCraft.http_websocket_enable; @Comment ("\nA list of wildcards for domains or IP ranges that can be accessed through the " + "\"http\" API on Computers.\n" + "Set this to " + - "\"*\" to access to the entire internet. Example: \"*.pastebin.com\" will restrict access to " + "just subdomains of pastebin.com.\n" + "You can use domain names (\"pastebin.com\"), wilcards (\"*.pastebin.com\") or CIDR notation (\"127.0.0.0/8\").") public String[] whitelist = ComputerCraft.DEFAULT_HTTP_WHITELIST.clone(); + "\"*\" to access to the entire internet. Example: \"*.pastebin.com\" will restrict access to " + "just subdomains of pastebin.com.\n" + "You can use domain names (\"pastebin.com\"), wilcards (\"*.pastebin.com\") or CIDR notation (\"127.0.0.0/8\").") public String[] whitelist = new String[] {"*"}; - @Comment ("\nA list of wildcards for domains or IP ranges that cannot be accessed through the " + "\"http\" API on Computers.\n" + "If this is " + "empty then all whitelisted domains will be accessible. Example: \"*.github.com\" will block " + "access to all subdomains of github" + ".com.\n" + "You can use domain names (\"pastebin.com\"), wilcards (\"*.pastebin.com\") or CIDR notation (\"127.0.0.0/8\").") public String[] blacklist = ComputerCraft.DEFAULT_HTTP_BLACKLIST.clone(); + @Comment ("\nA list of wildcards for domains or IP ranges that cannot be accessed through the " + "\"http\" API on Computers.\n" + "If this is " + "empty then all whitelisted domains will be accessible. Example: \"*.github.com\" will block " + "access to all subdomains of github" + ".com.\n" + "You can use domain names (\"pastebin.com\"), wilcards (\"*.pastebin.com\") or CIDR notation (\"127.0.0.0/8\").") public String[] blacklist = new String[] {"$private"}; @Comment ("\nThe period of time (in milliseconds) to wait before a HTTP request times out. Set to 0 for unlimited.") public int timeout = ComputerCraft.httpTimeout; diff --git a/src/main/resources/data/computercraft/lua/rom/modules/main/cc/completion.lua b/src/main/resources/data/computercraft/lua/rom/modules/main/cc/completion.lua index ce94ab81c..67a83bdbc 100644 --- a/src/main/resources/data/computercraft/lua/rom/modules/main/cc/completion.lua +++ b/src/main/resources/data/computercraft/lua/rom/modules/main/cc/completion.lua @@ -45,9 +45,9 @@ end -- @tparam string text The input string to complete. -- @tparam[opt] boolean add_space Whether to add a space after the completed name. -- @treturn { string... } A list of suffixes of matching peripherals. --- @usage ----- local completion = require "cc.completion" ----- read(nil, nil, completion.peripheral) +-- @usage +-- local completion = require "cc.completion" +-- read(nil, nil, completion.peripheral) local function peripheral_(text, add_space) expect(1, text, "string") expect(2, add_space, "boolean", "nil") @@ -61,9 +61,9 @@ local sides = redstone.getSides() -- @tparam string text The input string to complete. -- @tparam[opt] boolean add_space Whether to add a space after the completed side. -- @treturn { string... } A list of suffixes of matching sides. --- @usage ----- local completion = require "cc.completion" ----- read(nil, nil, completion.side) +-- @usage +-- local completion = require "cc.completion" +-- read(nil, nil, completion.side) local function side(text, add_space) expect(1, text, "string") expect(2, add_space, "boolean", "nil") @@ -75,9 +75,9 @@ end -- @tparam string text The input string to complete. -- @tparam[opt] boolean add_space Whether to add a space after the completed settings. -- @treturn { string... } A list of suffixes of matching settings. --- @usage ----- local completion = require "cc.completion" ----- read(nil, nil, completion.setting) +-- @usage +-- local completion = require "cc.completion" +-- read(nil, nil, completion.setting) local function setting(text, add_space) expect(1, text, "string") expect(2, add_space, "boolean", "nil") @@ -91,9 +91,9 @@ local command_list -- @tparam string text The input string to complete. -- @tparam[opt] boolean add_space Whether to add a space after the completed command. -- @treturn { string... } A list of suffixes of matching commands. --- @usage ----- local completion = require "cc.completion" ----- read(nil, nil, completion.command) +-- @usage +-- local completion = require "cc.completion" +-- read(nil, nil, completion.command) local function command(text, add_space) expect(1, text, "string") expect(2, add_space, "boolean", "nil") diff --git a/src/main/resources/data/computercraft/lua/rom/modules/main/cc/pretty.lua b/src/main/resources/data/computercraft/lua/rom/modules/main/cc/pretty.lua index 37a70a515..9a3aa7b0a 100644 --- a/src/main/resources/data/computercraft/lua/rom/modules/main/cc/pretty.lua +++ b/src/main/resources/data/computercraft/lua/rom/modules/main/cc/pretty.lua @@ -104,7 +104,7 @@ end -- -- @tparam Doc|string ... The documents to concatenate. -- @treturn Doc The concatenated documents. --- @usage +-- @usage -- local pretty = require "cc.pretty" -- local doc1, doc2 = pretty.text("doc1"), pretty.text("doc2") -- print(pretty.concat(doc1, " - ", doc2)) @@ -141,7 +141,7 @@ Doc.__concat = concat --- @local -- @tparam number depth The number of spaces with which the document should be indented. -- @tparam Doc doc The document to indent. -- @treturn Doc The nested document. --- @usage +-- @usage -- local pretty = require "cc.pretty" -- print(pretty.nest(2, pretty.text("foo\nbar"))) local function nest(depth, doc) 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 63ece3ab4..149e9f41e 100644 --- a/src/main/resources/data/computercraft/lua/rom/programs/help.lua +++ b/src/main/resources/data/computercraft/lua/rom/programs/help.lua @@ -15,12 +15,119 @@ end local sFile = help.lookup(sTopic) local file = sFile ~= nil and io.open(sFile) or nil -if file then - local sContents = file:read("*a") - file:close() - - local _, nHeight = term.getSize() - textutils.pagedPrint(sContents, nHeight - 3) -else - print("No help available") +if not file then + printError("No help available") + return end + +local contents = file:read("*a"):gsub("(\n *)[-*]( +)", "%1\7%2") +file:close() + +local width, height = term.getSize() +local buffer = window.create(term.current(), 1, 1, width, height, false) +local old_term = term.redirect(buffer) + +local print_height = print(contents) + 1 + +-- If we fit within the screen, just display without pagination. +if print_height <= height then + term.redirect(old_term) + print(contents) + return +end + +local function draw_buffer(width) + buffer.reposition(1, 1, width, print_height) + buffer.clear() + buffer.setCursorPos(1, 1) + print(contents) + term.redirect(old_term) +end + +local offset = 0 + +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(buffer.getLine(y + offset)) + end + end +end + +local function draw_menu() + term.setTextColor(colors.yellow) + term.setCursorPos(1, height) + term.clearLine() + + local tag = "Help: " .. sTopic + term.write("Help: " .. sTopic) + + if width >= #tag + 16 then + term.setCursorPos(width - 14, height) + term.write("Press Q to exit") + end +end + +draw_buffer(width) +draw() +draw_menu() + +while true do + local event, param = os.pullEvent() + if event == "key" then + if param == keys.up and offset > 0 then + offset = offset - 1 + draw() + elseif param == keys.down and offset < print_height - height then + offset = offset + 1 + draw() + elseif param == keys.pageUp and offset > 0 then + offset = math.max(offset - height + 2, 0) + draw() + elseif param == keys.pageDown and offset < print_height - height then + offset = math.min(offset + height - 2, print_height - height) + draw() + elseif param == keys.home then + offset = 0 + draw() + elseif param == keys["end"] then + offset = print_height - height + draw() + elseif param == keys.q then + sleep(0) -- Super janky, but consumes stray "char" events. + break + end + elseif event == "mouse_scroll" then + if param < 0 and offset > 0 then + offset = offset - 1 + draw() + elseif param > 0 and offset < print_height - height then + offset = offset + 1 + draw() + end + elseif event == "term_resize" then + local new_width, new_height = term.getSize() + + if new_width ~= width then + buffer.setCursorPos(1, 1) + buffer.reposition(1, 1, new_width, print_height) + term.redirect(buffer) + print_height = print(contents) + 1 + draw_buffer(new_width) + end + + width, height = new_width, new_height + offset = math.max(math.min(offset, print_height - height), 0) + draw() + draw_menu() + end +end + +term.redirect(old_term) +term.setCursorPos(1, 1) +term.clear()