diff options
Diffstat (limited to 'src/jsMain/kotlin/moe/nea89/website')
-rw-r--r-- | src/jsMain/kotlin/moe/nea89/website/Colored.kt | 23 | ||||
-rw-r--r-- | src/jsMain/kotlin/moe/nea89/website/Command.kt | 11 | ||||
-rw-r--r-- | src/jsMain/kotlin/moe/nea89/website/KConsole.kt | 173 | ||||
-rw-r--r-- | src/jsMain/kotlin/moe/nea89/website/KFiles.kt | 161 | ||||
-rw-r--r-- | src/jsMain/kotlin/moe/nea89/website/ScrollIntoViewOptions.kt | 7 | ||||
-rw-r--r-- | src/jsMain/kotlin/moe/nea89/website/ShellExecutionContext.kt | 51 | ||||
-rw-r--r-- | src/jsMain/kotlin/moe/nea89/website/Styles.kt | 55 | ||||
-rw-r--r-- | src/jsMain/kotlin/moe/nea89/website/util.kt | 6 |
8 files changed, 487 insertions, 0 deletions
diff --git a/src/jsMain/kotlin/moe/nea89/website/Colored.kt b/src/jsMain/kotlin/moe/nea89/website/Colored.kt new file mode 100644 index 0000000..9918c85 --- /dev/null +++ b/src/jsMain/kotlin/moe/nea89/website/Colored.kt @@ -0,0 +1,23 @@ +package moe.nea89.website + +import kotlinx.css.* + +enum class CustomColor(val color: Color) { + BLACK(Color("#282a39")), + RED(Color("#ff4473")), + BLUE(Color("#00fefc")), + PURPLE(Color("#6064fe")), + GREEN(Color("#4ce080")), + WHITE(Color("#efefef")), +} + +data class ColoredElement( + val color: CustomColor, + val text: String +) + +fun red(text: String) = ColoredElement(CustomColor.RED, text) +fun blue(text: String) = ColoredElement(CustomColor.BLUE, text) +fun purple(text: String) = ColoredElement(CustomColor.PURPLE, text) +fun green(text: String) = ColoredElement(CustomColor.GREEN, text) + diff --git a/src/jsMain/kotlin/moe/nea89/website/Command.kt b/src/jsMain/kotlin/moe/nea89/website/Command.kt new file mode 100644 index 0000000..b8e4675 --- /dev/null +++ b/src/jsMain/kotlin/moe/nea89/website/Command.kt @@ -0,0 +1,11 @@ +package moe.nea89.website + +data class Command( + val name: String, + val aliases: Set<String>, + val runner: suspend ShellExecutionContext.() -> Unit, +) + + +fun command(name: String, vararg aliases: String, block: suspend ShellExecutionContext. () -> Unit) = + Command(name, aliases.toSet(), block)
\ No newline at end of file diff --git a/src/jsMain/kotlin/moe/nea89/website/KConsole.kt b/src/jsMain/kotlin/moe/nea89/website/KConsole.kt new file mode 100644 index 0000000..4a51219 --- /dev/null +++ b/src/jsMain/kotlin/moe/nea89/website/KConsole.kt @@ -0,0 +1,173 @@ +package moe.nea89.website + +import kotlinx.browser.document +import kotlinx.dom.addClass +import kotlinx.html.dom.append +import kotlinx.html.dom.create +import kotlinx.html.input +import kotlinx.html.js.p +import kotlinx.html.js.pre +import kotlinx.html.js.span +import org.w3c.dom.HTMLElement +import org.w3c.dom.HTMLParagraphElement +import org.w3c.dom.HTMLPreElement +import org.w3c.dom.events.KeyboardEvent +import styled.injectGlobal +import kotlin.collections.set + + +class KConsole( + val root: HTMLElement, + val text: HTMLPreElement, + val prompt: HTMLElement, + fileSystem: KFileSystem?, +) { + + + val fileAccessor = fileSystem?.let { FileAccessor(it) } + var PS1: KConsole.() -> String = { "$" } + + companion object { + + init { + injectGlobal(Styles.global) + } + + val shlexRegex = + """"([^"\\]+|\\.)+"|([^ "'\\]+|\\.)+|'([^'\\]+|\\.)+'""".toRegex() + + fun createFor(element: HTMLElement, fileSystem: KFileSystem? = null): KConsole { + val text = element.append.pre() + val prompt = text.append.p() + prompt.addClass(Styles.promptClass) + element.classList.add(Styles.consoleClass) + val console = KConsole(element, text, prompt, fileSystem) + val inp = element.append.input() + inp.hidden = true + inp.focus() + document.body!!.onkeydown = console::keydown + console.rerender() + return console + } + } + + enum class ConsoleState { + SHELLPROMPT, + IN_PROGRAM + } + + var state = ConsoleState.SHELLPROMPT + + var input: String = "" + + fun addLines(newLines: List<String>) { + newLines.forEach { addLine(it) } + } + + fun addMultilineText(text: String) { + addLines(text.split("\n")) + } + + fun addLine(vararg elements: Any) { + addLine(document.create.p().apply { + elements.forEach { + when (it) { + is HTMLElement -> append(it) + is ColoredElement -> append(document.create.span().also { el -> + el.style.color = it.color.color.toString() + el.append(it.text) + }) + + is String -> append(it) + else -> throw RuntimeException("Unknown element") + } + } + }) + } + + private fun addLine(element: HTMLParagraphElement) { + text.insertBefore(element, prompt) + } + + fun rerender() { + if (state == KConsole.ConsoleState.SHELLPROMPT) { + prompt.innerText = "${PS1.invoke(this)} $input" + } else { + prompt.innerText = "" + } + } + + fun scrollDown() { + text.lastElementChild?.scrollIntoView() + } + + fun registerCommand(command: Command) { + command.aliases.forEach { + commands[it] = command + } + commands[command.name] = command + } + + val commands = mutableMapOf<String, Command>() + + fun executeCommand(commandLine: String) { + val parts = shlex(commandLine) + if (parts == null) { + addLine("Syntax Error") + return + } + if (parts.isEmpty()) { + return + } + val command = parts[0] + println("Running command: $command") + val arguments = parts.drop(1) + val commandThing = commands[command] + if (commandThing == null) { + addLine("Unknown command") + return + } + ShellExecutionContext.run(this, commandThing, command, arguments) + scrollDown() + } + + fun shlex(command: String): List<String>? { + var i = 0 + val parts = mutableListOf<String>() + while (i < command.length) { + val match = shlexRegex.matchAt(command, i) + if (match == null) { + println("Could not shlex: $command") + return null + } + // TODO: Proper string unescaping + parts.add(match.groupValues.drop(1).firstOrNull { it != "" } ?: "") + i += match.value.length + while (command[i] == ' ' && i < command.length) + i++ + } + return parts + } + + fun keydown(event: KeyboardEvent) { + if (event.altKey || event.ctrlKey || event.metaKey) return + if (event.isComposing || event.keyCode == 229) return + if (state != ConsoleState.SHELLPROMPT) return + when (event.key) { + "Enter" -> { + val toExecute = input + addLine("${PS1.invoke(this)} $toExecute") + input = "" + executeCommand(toExecute) + } + + "Backspace" -> input = input.substring(0, input.length - 1) + else -> + if (event.key.length == 1 || event.key.any { it !in 'a'..'z' && it !in 'A'..'Z' }) + input += event.key + } + event.preventDefault() + rerender() + scrollDown() + } +} diff --git a/src/jsMain/kotlin/moe/nea89/website/KFiles.kt b/src/jsMain/kotlin/moe/nea89/website/KFiles.kt new file mode 100644 index 0000000..aad3036 --- /dev/null +++ b/src/jsMain/kotlin/moe/nea89/website/KFiles.kt @@ -0,0 +1,161 @@ +package moe.nea89.website + +sealed class KFile { + /** + * Only be empty for the root fs + * */ + var parent: Directory? = null + private set + + val name: List<String> + get() = + parent?.let { it.name + it.files.filter { it.value == this }.keys.first() } ?: emptyList() + + fun linkTo(parent: Directory) { + if (this.parent == null) + this.parent = parent + } + + val fileType: String + get() = when (this) { + is Directory -> "directory" + is Download -> "download" + is Image -> "image" + is Text -> "text file" + } + + data class Text(val text: String) : KFile() + data class Image(val url: String) : KFile() + data class Download(val url: String) : KFile() + data class Directory(val files: Map<String, KFile>) : KFile() +} + +data class KFileSystem(val root: KFile.Directory) { + init { + if (!verifyHierarchy(root)) { + throw RuntimeException("File system had missing links. Use linkTo with the primary parent directory") + } + } + + private fun verifyHierarchy(el: KFile.Directory): Boolean = + el.files.values.all { + it.parent == el && (it !is KFile.Directory || verifyHierarchy(it)) + } + + + /** + * Uses normalized paths + * */ + fun resolve(parts: List<String>): KFile? = + parts.fold<String, KFile?>(root) { current, part -> + if (part == "." || part == "") + current + else if (part == "..") + current?.parent + else if (current is KFile.Directory) { + current.files[part] + } else + null + } +} + + +enum class FSError { + ENOENT, EISNOTDIR +} + +class FileAccessor(val fileSystem: KFileSystem, var implicitPushD: Boolean = false) { // TODO implicit pushd support + val dirStack = mutableListOf<List<String>>() + var currentDir = listOf<String>() + + fun cd(path: String): FSError? { + val file = resolve(path) ?: return FSError.ENOENT + return when (file) { + !is KFile.Directory -> FSError.EISNOTDIR + else -> { + currentDir = file.name + null + } + } + } + + fun resolve(path: String): KFile? { + val parts = path.split("/").filter { it.isNotEmpty() && it != "." } + return if (path.startsWith("/")) { + fileSystem.resolve(parts) + } else { + fileSystem.resolve(currentDir + parts) + } + } + + fun pushD() { + dirStack.add(currentDir) + } + + fun useD(block: () -> Unit) { + val d = currentDir + try { + block() + } finally { + currentDir = d + } + } + + fun popD(): Boolean { + currentDir = dirStack.removeLastOrNull() ?: return false + return true + } +} + +@DslMarker +annotation class KFileDsl + +fun fileSystem(block: FileSystemBuilder.() -> Unit): KFileSystem = + KFileSystem(FileSystemBuilder().also(block).build()) + + +@KFileDsl +class FileSystemBuilder { + private val files = mutableMapOf<String, KFile>() + + fun addNode(name: String, file: KFile): FileSystemBuilder { + val parts = name.split("/", limit = 2) + if (parts.size != 1) { + return addNode(parts[0], FileSystemBuilder().addNode(parts[1], file).build()) + } + if (files.containsKey(name)) { + throw RuntimeException("Tried to double set file: $name") + } + files[name] = file + return this + } + + infix fun String.text(rawText: String) { + addNode(this, KFile.Text(rawText)) + } + + infix fun String.image(dataUrl: String) { + addNode(this, KFile.Image(dataUrl)) + } + + infix fun String.download(url: String) { + addNode(this, KFile.Download(url)) + } + + operator fun String.invoke(block: FileSystemBuilder.() -> Unit) { + addNode(this, FileSystemBuilder().also(block).build()) + } + + fun build() = KFile.Directory(files).also { dir -> + files.values.forEach { file -> file.linkTo(dir) } + } +} + +suspend fun ShellExecutionContext.requireFileAccessor(): FileAccessor { + val fa = console.fileAccessor + if (fa == null) { + console.addLine("There is no file accessor present :(") + exit() + } + return fa +} diff --git a/src/jsMain/kotlin/moe/nea89/website/ScrollIntoViewOptions.kt b/src/jsMain/kotlin/moe/nea89/website/ScrollIntoViewOptions.kt new file mode 100644 index 0000000..aab14e2 --- /dev/null +++ b/src/jsMain/kotlin/moe/nea89/website/ScrollIntoViewOptions.kt @@ -0,0 +1,7 @@ +package moe.nea89.website + +interface ScrollIntoViewOptions { + var behavior: String + var block: String + var inline: String +}
\ No newline at end of file diff --git a/src/jsMain/kotlin/moe/nea89/website/ShellExecutionContext.kt b/src/jsMain/kotlin/moe/nea89/website/ShellExecutionContext.kt new file mode 100644 index 0000000..bd72421 --- /dev/null +++ b/src/jsMain/kotlin/moe/nea89/website/ShellExecutionContext.kt @@ -0,0 +1,51 @@ +package moe.nea89.website + +import kotlinx.browser.window +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.* +import kotlin.time.Duration +import kotlin.time.DurationUnit + +class ShellExecutionContext( + val console: KConsole, + val name: String, + val args: List<String>, +) { + + suspend fun wait(duration: Duration) { + suspendCancellableCoroutine<Unit> { + window.setTimeout({ + it.resume(Unit) + }, timeout = duration.toInt(DurationUnit.MILLISECONDS)) + } + } + + suspend fun exit(): Nothing { + suspendCancellableCoroutine<Unit> { + it.cancel() + console.state = KConsole.ConsoleState.SHELLPROMPT + console.rerender() + } + throw RuntimeException("THIs shOULDNT EXIST") + } + + companion object { + fun run( + console: KConsole, command: Command, name: String, args: List<String> + ) { + console.state = KConsole.ConsoleState.IN_PROGRAM + val se = ShellExecutionContext(console, name, args) + window.requestAnimationFrame { + command.runner.createCoroutine(se, object : Continuation<Unit> { + override val context: CoroutineContext + get() = EmptyCoroutineContext + + override fun resumeWith(result: Result<Unit>) { + console.state = KConsole.ConsoleState.SHELLPROMPT + console.rerender() + } + }).resume(Unit) + } + } + } +} diff --git a/src/jsMain/kotlin/moe/nea89/website/Styles.kt b/src/jsMain/kotlin/moe/nea89/website/Styles.kt new file mode 100644 index 0000000..e1470d7 --- /dev/null +++ b/src/jsMain/kotlin/moe/nea89/website/Styles.kt @@ -0,0 +1,55 @@ +package moe.nea89.website + +import kotlinx.css.* +import kotlinx.css.properties.IterationCount +import kotlinx.css.properties.Timing +import kotlinx.css.properties.s +import styled.StyleSheet +import styled.animation + + +object Styles : StyleSheet("DefaultConsoleStyles") { + val consoleClass = "Console" + val promptClass = "prompt" + + val bgColor = CustomColor.BLACK.color + val fgColor = CustomColor.WHITE.color + val monospacedFont = "monospace" + + val global by css { + "*" { + padding(0.px) + margin(0.px) + boxSizing = BoxSizing.borderBox + } + + ".$promptClass" { + width = LinearDimension.fitContent + borderRightColor = fgColor + borderRightWidth = 2.px + paddingRight = 2.px + borderRightStyle = BorderStyle.solid + animation(1.s, Timing.stepStart, iterationCount = IterationCount.infinite) { + 0 { + borderRightStyle = BorderStyle.solid + } + 50 { + borderRightStyle = BorderStyle.none + } + } + } + + ".$consoleClass" { + width = 100.pct + height = 100.pct + backgroundColor = bgColor + color = fgColor + fontFamily = monospacedFont + width = 100.pct + height = 100.pct + pre { + fontFamily = monospacedFont + } + } + } +}
\ No newline at end of file diff --git a/src/jsMain/kotlin/moe/nea89/website/util.kt b/src/jsMain/kotlin/moe/nea89/website/util.kt new file mode 100644 index 0000000..47c7843 --- /dev/null +++ b/src/jsMain/kotlin/moe/nea89/website/util.kt @@ -0,0 +1,6 @@ +package moe.nea89.website + +fun <T> dyn(init: T.() -> Unit): dynamic = js("{}").also(init) + + + |