diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 69c712ca..3a694373 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -28,7 +28,7 @@ jobs: - uses: jkl1337/gh-actions-lua@v11 with: # luaVersion: "5.3" - luaVersion: "luajit-2.1.0-beta3" + luaVersion: "luajit-git" - uses: jkl1337/gh-actions-luarocks@v5 with: luarocksVersion: "3.9.2" @@ -143,7 +143,7 @@ jobs: VER: ${{ github.ref_name }} run: echo VER_CODE="$(echo $VERSION | sed -e 's/^v//' -e 's/\.//g')" >> $GITHUB_ENV - name: Package for android - uses: aldum/love-actions-android@v0.2.1 + uses: compy-toys/love-actions-android@v0.2.1 with: love-ref: "loveputer" no-soft-keyboard: "enabled" diff --git a/.gitignore b/.gitignore index d874d04c..6b3b6f05 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ /.luarocks .history dist/* -doc/*.md +.debug/* +doc/scratch/ doc/parse *.lua_ *.odg# diff --git a/.gitmodules b/.gitmodules index 22a84574..a0dd30a9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "src/lib/metalua"] path = src/lib/metalua - url = https://github.com/aldum/metalua.git + url = https://github.com/compy-toys/metalua.git diff --git a/.luarc.json b/.luarc.json index d000cd7a..50f301a2 100644 --- a/.luarc.json +++ b/.luarc.json @@ -10,9 +10,12 @@ // project env "input_text", "input_code", + "validated_input", "stop", "continue", - "font1", + // luautils + "prequire", + // end of array "" ], "workspace.library": [ diff --git a/.vscode/settings.json b/.vscode/settings.json index ff0a6e3a..5752d653 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,4 +20,5 @@ "color": "#555", } ], + "reflowMarkdown.preferredLineLength" : 64, } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1383b76e..3e4951ff 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,10 +1,11 @@ -## Development +## Installing -To run the code, [LÖVE2D] is required. It's been tested and developed on version 11.4 (Mysterious Mysteries). +To run the code, [LÖVE2D] is required. It's been tested and +developed on version 11.4 (Mysterious Mysteries). -For unit tests, we are using the [busted] framework. -Also, we need to supplement a utf-8 library, which comes with LOVE, but -is not available for Lua 5.1 by default. +For unit tests, we are using the [busted] framework. Also, we +need to supply a [utf-8][luautf8] library, one of which comes +with LOVE, but is not available for Lua 5.1 / Luajit by default. The recommended way of installing these is with [LuaRocks]: @@ -13,44 +14,96 @@ luarocks --local --lua-version 5.1 install busted luarocks --local --lua-version 5.1 install luautf8 ``` -For information about installing [LÖVE2D] and [LuaRocks], visit their respective webpages. +For information about installing [LÖVE2D] and [LuaRocks], visit +their respective webpages. +## Development -### Running unit tests +### [OOP](doc/development/OOP.md) -```sh -busted tests +### `util/lua.lua` (luautils) + +The contents of this module will be put into the global +namespace (`_G`). However, the language server does not pick up +on this (yet), so usages will be littered with warnings unless +silenced. + +#### `prequire()` + +Analogous to `pcall()`, require a lua file that may or may not +exist. Example: + +```lua +--- @diagnostic disable-next-line undefined-global +local autotest = prequire('tests/autotest') +if autotest then + autotest(self) +end ``` -### Test mode +## Testing + +### Test modes -The game can be run with the `--test` flag, which causes it to launch in test mode. +#### normal + +The game can be run with the `--test` flag, which causes it to launch in test +mode. ```sh love src --test ``` -This is currently used for testing the canvas terminal, therefore it causes the terminal to be smaller (so overflows are clearly visible), and pre-fills it with characters. +This is currently used for testing the canvas terminal, therefore it causes the +terminal to be smaller (so overflows are clearly visible), and pre-fills it with +characters. + +#### autotest + +```sh +love src --autotest +``` + +#### drawtest + +```sh +love src --drawtest +``` + +### Running unit tests + +In project root: + +```sh +busted tests +``` + +## Environment variables ### Debug mode -Certain diagnostic key combinations are only available in debug mode, -to access this, run the project with the `DEBUG` environment variable set -(it's value doesn't matter, just that it's set): +Certain diagnostic key combinations are only available in debug +mode, to access this, run the project with the `DEBUG` +environment variable set (it's value doesn't matter, just that +it's set): + ```sh DEBUG=1 love src ``` -In this mode, a VT-100 terminal test can be activated with ^T (C-t, or Ctrl+t). +In this mode, a VT-100 terminal test can be activated with ^T +(C-t, or Ctrl+t). ### HiDPI -Similarly, to set double scaling, set the `HIDPI` variable to `true` +Similarly, to set double scaling, set the `HIDPI` variable to +`true`: + ```sh HIDPI=true love src ``` - [löve2d]: https://love2d.org [busted]: https://lunarmodules.github.io/busted/ -[LuaRocks]: https://luarocks.org/ +[luautf8]: https://github.com/starwing/luautf8 +[luarocks]: https://luarocks.org/ diff --git a/README.md b/README.md index 05bc7f72..dae37a20 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,22 @@ # loveputer + A console-based Lua-programmable computer for children based on [LÖVE2D] framework. ## Principles -* Command-line based UI -* Full control over each pixel of the display -* Ability to easily reset to initial state -* Impossible to damage with non-violent interaction -* Syntactic mistakes caught early, not accepted on input -* Possibility to test/try parts of program separately -* Share software in source package form -* Minimize frustration + +- Command-line based UI +- Full control over each pixel of the display +- Ability to easily reset to initial state +- Impossible to damage with non-violent interaction +- Syntactic mistakes caught early, not accepted on input +- Possibility to test/try parts of program separately +- Share software in source package form +- Minimize frustration # Usage Rather than the default LÖVE storage locations (save directory, cache, etc), the -application uses a folder under *Documents* to store projects. Ideally, this is +application uses a folder under _Documents_ to store projects. Ideally, this is located on removable storage to enable sharing programs the user writes. For simplicity and security reasons, the user is only allowed to access files @@ -23,92 +25,102 @@ first. ## Keys -| Command | Keymap | -| :--------------------------------- | :-------------------------------------------- | -| Clear terminal | Ctrl+L | -| Quit project | Ctrl+Shift+Q | -| Reset application to initial state | Ctrl+Shift+R | -| Exit application | Ctrl+Esc | -| **Input** | -| Move cursor | | -| Go back in command history | PageUp | -| Go forward in command history | PageDown | -| Move in history (if in first/last line) | | -| Jump to start | Home | -| Jump to end | End | -| Insert newline | Shift+Enter | -| Evaluate input | Enter | -| **Editor** | -|        _same as Input, except for:_ | -| Scroll up | PageUp | -| Scroll down | PageDown | -| Move selection (if in first/last line) | | -| Replace selection with input | Enter | -|        _additionally_ | -| Delete selected (line) | Ctrl+Delete | -| | Ctrl+Y | -| Replace input with selected content | Esc | -| Insert selected content into input | Shift+Esc | -| Scroll to start | Ctrl+PageUp | -| Scroll to end | Ctrl+PageDown | -| Move selection to start | Ctrl+Home | -| Move selecion to end | Ctrl+End | -| Quit editor (save work) | Ctrl+Shift+Q | +| Command | Keymap | +| :---------------------------------------------------------------- | :-------------------------------------------- | +| Clear terminal | Ctrl+L | +| Quit project | Ctrl+Shift+Q | +| Reset application to initial state | Ctrl+Shift+R | +| Exit application | Ctrl+Esc | +| Pause application | Ctrl+Pause | +| **Input** | +| Move cursor horizontally | | +| Move cursor vertically | | +| Go back in command history | PageUp | +| Go forward in command history | PageDown | +| Move in history (if in first/last line) | | +| Jump to start | Home | +| Jump to end | End | +| Jump to line start | Alt+Home | +| Jump to line end | Alt+End | +| Insert newline | Shift+Enter | +| Evaluate input | Enter | +| **Editor** | +|        _same as Input, except for:_ | +| Scroll up | PageUp | +| Scroll down | PageDown | +| Move selection (if in first/last line) | | +| Replace selection with input | Enter | +|        _additionally_ | +| Delete selected (line) | Ctrl+Delete | +| | Ctrl+Y | +| Replace input with selected content | Esc | +| Insert selected content into input | Shift+Esc | +| Scroll to start | Ctrl+PageUp | +| Scroll to end | Ctrl+PageDown | +| Scroll up by one line | Ctrl+PageUp | +| Scroll down by one line | Ctrl+PageDown | +| Move selection to start | Ctrl+Home | +| Move selecion to end | Ctrl+End | +| Quit editor (save work) | Ctrl+Shift+Q | ### Projects -A *project* is a folder in the application's storage which contains at least a +A _project_ is a folder in the application's storage which contains at least a `main.lua` file. Projects can be loaded and ran. At any time, pressing Ctrl-Shift-Q quits and returns to the console -* `list_projects()` +- `list_projects()` + + List available projects. - List available projects. +- `project(proj)` -* `project(proj)` + Open project _proj_ or create a new one if it doesn't exist. + New projects are supplied with example code to demonstrate the structure. - Open project *proj* or create a new one if it doesn't exist. - New projects are supplied with example code to demonstrate the structure. +- `current_project()` -* `current_project()` + Print the currently open project's name (if any). - Print the currently open project's name (if any). +- `run_project(proj?)` -* `run_project(proj?)` + Run either _proj_ or the currently open project if no arguments are passed. - Run either *proj* or the currently open project if no arguments are passed. +- `example_projects()` -* `example_projects()` + Copy the included example projects to the projects folder. - Copy the included example projects to the projects folder. +- `close_project()` -* `close_project()` + Close currently opened project. - Close currently opened project. +- `edit(file)` -* `edit(file)` + Open file in editor. If it does not exist yet, a new file will be created. + See [Editor mode](#editor) - Open file in editor. If it does not exist yet, a new file will be created. ### Files Once a project is open, file operations are available on it's contents. -* `list_contents()` +- `list_contents()` - List files in the project. + List files in the project. -* `readfile(file)` +- `readfile(file)` - Open *file* and display it's contents. + Open _file_ and display it's contents. -* `writefile(file, content)` +- `writefile(file, content)` - Write to *file* the text supplied as the *content* parameter. This can be - either a string, or an array of strings. + Write to _file_ the text supplied as the _content_ parameter. This can be + either a string, or an array of strings. -* `runfile(file)` +- `runfile(file)` - Run *file* if it's a lua script. + Run _file_ if it's a lua script. +### Editor +![editor_1](./doc/interface/editor_1.png) diff --git a/doc/development/OOP.md b/doc/development/OOP.md new file mode 100644 index 00000000..0b63be2d --- /dev/null +++ b/doc/development/OOP.md @@ -0,0 +1,63 @@ +### OOP + +Even though lua is not an object oriented language per se, it can approximate +some OO behaviors with clever use of metatables. + +See: + +- [http://lua-users.org/wiki/ObjectOrientedProgramming][oo1] +- [http://lua-users.org/wiki/ObjectOrientationTutorial][oo2] + +#### Class factory + +To automate this, a class factory utility was added. + +First, import it: + +```lua +local class = require('util.class') +``` + +Then it can be used in the following ways: + +- passing a constructor (record/dataclass pattern) + +```lua +A = class.create(function() + return { a = 'a' } +end +) +local a = A() --- results in an instance with the preset values, not very useful + +B = class.create(function(x, y) + return { x = x, y = y } +end) +local b = B(1, 2) --- results in a B instance where x = 1 and y = 2 + +``` + +For more advanced uses, it will probably be necessary to manually control the +metatable setup, this is achieved with the + +- `new()` method + +```lua +N = class.create() +N.new = function(cfg) + local width = cfg.width or 10 + local height = cfg.height or 5 + local self = setmetatable({ + label = 'meta', + width = width, + height = height, + area = width * height, + }, N) + + return self +end + +local n = N({width = 80, height = 25}) +``` + +[oo1]: https://archive.vn/B3buW +[oo2]: https://archive.vn/muhJx diff --git a/doc/mermaid/classes.md b/doc/mermaid/classes.md new file mode 100644 index 00000000..6d9fc636 --- /dev/null +++ b/doc/mermaid/classes.md @@ -0,0 +1,129 @@ +### Model + +```mermaid +classDiagram + +BufferModel --* EditorModel +EditorInterpreter --* EditorModel +EditorModel --* ConsoleModel +%% EditorInterpreter --|> InterpreterBase + +class ConsoleModel { + projects: ProjectService +} +class InputModel { + oneshot: boolean + entered: InputText + evaluator: EvalBase + type: InputType + cursor: Cursor + wrapped:_text WrappedText + selection: InputSelection + cfg: Config + custom_status: CustomStatus? + +} +class InterpreterModel { + cfg: Config + input: InputModel + history: table + evaluator: table + luaEval: LuaEval + textInput: InputEval + luaInput: InputEval + wrapped_error: string[]? + + get_entered_text() +} +InputModel --* InterpreterModel +CanvasModel --* ConsoleModel +InterpreterModel --* ConsoleModel +%% InterpreterModel --|> InterpreterBase +%% EvalBase --> InterpreterModel + + +%% class View { +%% prev_draw: function& +%% } +%% InputView --* InterpreterView +%% InterpreterView --* View +``` + +### View + +```mermaid +classDiagram + +Statusline --* InputView +InputView --* InterpreterView +InterpreterView --* ConsoleView +EditorView --* ConsoleView +CanvasView --* ConsoleView +BufferView --* EditorView +InputView --* EditorView +%% InterpreterView --* EditorView +``` + +### Controller + +```mermaid +classDiagram + +class InterpreterController { + model: InterpreterModel + input: InputController + + set_eval() + get_eval() + get_viewdata() + set_text() + add_text() + textinput() + keypressed() + clear() + get_input() + get_text() + set_custom_status() +} + +class EditorController { + model: EditorModel + interpreter: InterpreterController + view: EditorView | nil + + open() + close() + get_active_buffer() + update_status() + textinput() + keypressed() +} + +InputController --* ConsoleController +InputController --* InterpreterController +InterpreterController --* EditorController +EditorController --* ConsoleController + +class Controller { + <> +} +``` + +### MVC + +```mermaid +classDiagram + +Config --* ConsoleModel +Config --* ConsoleController +Config --* ConsoleView +ConsoleModel --> ConsoleController +ConsoleController --> ConsoleView + +class Controller { + <> +} +class View { + <> +} +``` diff --git a/doc/mermaid/editor.md b/doc/mermaid/editor.md new file mode 100644 index 00000000..25ae91d3 --- /dev/null +++ b/doc/mermaid/editor.md @@ -0,0 +1,201 @@ +### Editor data structures + +```mermaid +classDiagram + +class Empty { + tag: 'empty' + pos: Range +} +class Chunk { + tag: 'chunk' + pos: Range + lines: string[] +} +class Block { +<> + Chunk | Empty +} +Chunk -- Block +Empty -- Block + + +class Content { +<> + string[] | Block[] +} +Block -- Content +class ContentType { +<> + 'plain' | 'lua' +} + +class More { + up: bool + down: bool +} + +class VisibleBlock { + wrapped: WrappedText + highlight: SyntaxColoring + pos: Range + app_pos: Range +} +``` + +```mermaid +classDiagram + +class WrappedText { + text: string[] + wrap_w: integer + wrap_forward: integer[][] + wrap_reverse: integer[] + n_breaks: integer + + wrap() + get_text() + get_line() + get_text_length() +} + +class VisibleContent { + range: Range? + overscroll: integer + overscroll_max: integer + + set_range() + get_range() + move_range() + get_visible() + get_content_length() +} + +class VisibleStructuredContent { + text: string[] + blocks: Block[] + visible_blocks: Block[] + reverse_map: ReverseMap + + range: Range? + overscroll: integer + overscroll_max: integer + + set_range() + + get_range() + move_range() + get_visible() + get_content_length() +} + +WrappedText <|-- VisibleContent +WrappedText *-- VisibleStructuredContent +%% BufferModel --o BufferView + +class BufferModel { + name: string + content: Content + content_type: ContentType + selection: Selected + readonly: bool + revmap: table + + chunker(string[], boolean?): Block[] + highlighter(string[]): SyntaxColoring + printer(string[]): string[] + move_selection() + get_selection() + get_selected_text() + delete_selected_text() + replace_selected_text() +} + +class BufferView { + cfg: ViewConfig + + content: VisibleContent|VisibleStructuredContent + content_type: ContentType + buffer: BufferModel + + LINES: integer + SCROLL_BY: integer + w: integer + offset: integer + more: More + + open(b: BufferModel) + refresh() + draw() + follow_selection() + get_wrapped_selection() + + _scroll() + _calculate_end_range() + _update_visible() +} + +class EditorController { + model: EditorModel + interpreter: InterpreterController + view: EditorView | nil + + open(name: string, content: string[]) + close() + get_active_buffer() + update_status() + textinput() + keypressed() +} + +``` + +### Flow + +#### open + +```mermaid +sequenceDiagram + +participant Controller +Controller->>EditorController: open() +activate EditorController +participant EditorController + +create participant BufferView + +create participant BufferModel +EditorController->>BufferModel: new() + +EditorController->>BufferView: open() + +deactivate EditorController +EditorController->>EditorController: update_status() +``` + +#### submit + +```mermaid +sequenceDiagram + +participant Controller +participant EditorController +participant InputModel +participant BufferModel + +Controller->>EditorController: keypressed(k) +activate EditorController +EditorController->>InputModel: keypressed(k) +EditorController->>EditorController: handle_submit() +EditorController->>InputModel: get_text() +InputModel->>EditorController: text + +EditorController->>BufferModel: replace_selected_text(text) +EditorController->>InputModel: clear() +EditorController->>BufferView: refresh() + + +deactivate EditorController +EditorController->>EditorController: update_status() + +``` diff --git a/doc/mermaid/fsm.md b/doc/mermaid/fsm.md new file mode 100644 index 00000000..e69cc40c --- /dev/null +++ b/doc/mermaid/fsm.md @@ -0,0 +1,32 @@ +```mermaid +stateDiagram-v2 + direction LR + + classDef editorStyle fill:#FF8815, color:#000; + class editor editorStyle + classDef inspectStyle fill:#FF4315, color:#000; + class inspect inspectStyle + classDef runStyle fill:#5BFF15, color:#000; + class running runStyle + + %% linkStyle default stroke:red + %% linkStyle 0 stroke-width:4px,stroke:green + %% linkStyle 3 stroke:blue + %% linkStyle 4 stroke:blue + + state init { + [*] --> booting + booting --> title + title --> ready + } + ready --> project_open : project() + ready --> running : run_project() + running --> project_open : quit() + running --> inspect : stop() + inspect --> running : continue() + running --> ready + project_open --> ready : close_project() + project_open --> editor : edit() + inspect --> editor : edit() + editor --> inspect : finish_edit() +``` diff --git a/doc/mermaid/fsm_f.md b/doc/mermaid/fsm_f.md new file mode 100644 index 00000000..aef06a19 --- /dev/null +++ b/doc/mermaid/fsm_f.md @@ -0,0 +1,33 @@ +```mermaid +flowchart TD + id1(( )) --> booting + booting --> title + title --> ready + ready -- project() --> project_open + ready -- run_project() --> running + %% running -- asd ---> project_open + running -- stop() --> inspect + inspect{{inspect}} -- continue() --> running + project_open -- close_project() --> ready + project_open -- edit() --> editor + project_open -- run_project() ----> running + editor(editor) -- finish_edit() --> inspect + editor -- finish_edit() --> project_open + inspect -- edit() --> editor + running --> ready + + classDef editorStyle fill:#FF8815, color:#000; + classDef inspectStyle fill:#FF4315, color:#000; + classDef runStyle fill:#5BFF15, color:#000; + class editor editorStyle; + class inspect inspectStyle; + class running runStyle; + + linkStyle default stroke:white + linkStyle 4,9 stroke:green + linkStyle 5 stroke:red + linkStyle 6 stroke:firebrick + linkStyle 8,12 stroke:orange + linkStyle 10,11 stroke:darkorange + +``` diff --git a/res/android/drawable-hdpi/loveputer.png b/res/android/drawable-hdpi/compy.png similarity index 100% rename from res/android/drawable-hdpi/loveputer.png rename to res/android/drawable-hdpi/compy.png diff --git a/res/android/drawable-mdpi/loveputer.png b/res/android/drawable-mdpi/compy.png similarity index 100% rename from res/android/drawable-mdpi/loveputer.png rename to res/android/drawable-mdpi/compy.png diff --git a/res/android/drawable-xhdpi/loveputer.png b/res/android/drawable-xhdpi/compy.png similarity index 100% rename from res/android/drawable-xhdpi/loveputer.png rename to res/android/drawable-xhdpi/compy.png diff --git a/res/android/drawable-xxhdpi/loveputer.png b/res/android/drawable-xxhdpi/compy.png similarity index 100% rename from res/android/drawable-xxhdpi/loveputer.png rename to res/android/drawable-xxhdpi/compy.png diff --git a/res/android/drawable-xxxhdpi/loveputer.png b/res/android/drawable-xxxhdpi/compy.png similarity index 100% rename from res/android/drawable-xxxhdpi/loveputer.png rename to res/android/drawable-xxxhdpi/compy.png diff --git a/selene.toml b/selene.toml deleted file mode 100644 index abdcad02..00000000 --- a/selene.toml +++ /dev/null @@ -1,8 +0,0 @@ -std = "lua52" - -[lints] -unscoped_variables = "allow" -undefined_variable = "allow" -incorrect_standard_library_use = "allow" - -exclude = [".vscode-oss/*"] diff --git a/src/conf.lua b/src/conf.lua index 7e993087..d957eb10 100644 --- a/src/conf.lua +++ b/src/conf.lua @@ -1,6 +1,8 @@ +require('util.lua') + function love.conf(t) - t.identity = 'loveputer' - t.window.title = 'LÖVEputer' + t.identity = 'compy' + t.window.title = 'Compy' t.window.resizable = false local hidpi = os.getenv("HIDPI") if os.getenv("DEBUG") then @@ -25,7 +27,8 @@ function love.conf(t) -- Android: use SD card for storage t.externalstorage = true - pcall(function() - require('host').spec(t) - end) + local hostconf = prequire('host') + if hostconf then + hostconf.conf_love(t) + end end diff --git a/src/conf/colors.lua b/src/conf/colors.lua index 06dab861..e65afdc0 100644 --- a/src/conf/colors.lua +++ b/src/conf/colors.lua @@ -36,6 +36,7 @@ local syntax_i = { --- @class StatuslineColors --- @field bg RGB --- @field fg RGB +--- @field fg2 RGB? --- @field indicator RGB --- @class Colors @@ -63,8 +64,8 @@ return { fg = Color[Color.black + Color.bright], }, user = { - bg = Color[Color.black + Color.bright], - fg = Color[Color.white], + bg = Color[Color.white], + fg = Color[Color.black + Color.bright], }, inspect = { bg = Color[Color.white], @@ -100,6 +101,7 @@ return { }, editor = { fg = Color[Color.white + Color.bright], + fg2 = Color[Color.yellow + Color.bright], bg = Color[Color.blue], indicator = Color[Color.cyan + Color.bright], }, diff --git a/src/controller/consoleController.lua b/src/controller/consoleController.lua index 7f5c24af..4b3ad7b6 100644 --- a/src/controller/consoleController.lua +++ b/src/controller/consoleController.lua @@ -1,9 +1,12 @@ require("controller.inputController") +require("controller.interpreterController") require("controller.editorController") +local class = require('util.class') +require("util.lua") require("util.testTerminal") require("util.key") -require("util.eval") +local LANG = require("util.eval") require("util.table") --- @class ConsoleController @@ -14,19 +17,14 @@ require("util.table") --- @field base_env LuaEnv --- @field project_env LuaEnv --- @field input InputController +--- @field interpreter InterpreterController --- @field editor EditorController --- @field view ConsoleView? --- methods +--- @field cfg Config +--- methods --- @field edit function --- @field finish_edit function -ConsoleController = {} -ConsoleController.__index = ConsoleController - -setmetatable(ConsoleController, { - __call = function(cls, ...) - return cls.new(...) - end, -}) +ConsoleController = class.create() --- @param M Model function ConsoleController.new(M) @@ -34,12 +32,13 @@ function ConsoleController.new(M) local pre_env = table.clone(env) local config = M.cfg pre_env.font = config.view.font - local IC = InputController.new(M.interpreter.input) - local EC = EditorController.new(M.editor) + local IpC = InputController(M.interpreter.input) + local InC = InterpreterController(M.interpreter, IpC) + local EC = EditorController(M.editor) local self = setmetatable({ time = 0, model = M, - input = IC, + interpreter = InC, editor = EC, -- console runner env main_env = env, @@ -95,7 +94,7 @@ local function run_user_code(f, cc, project_path) G.pop() G.setCanvas() if not ok then - local e = LANG.parse_error(call_err) + local e = LANG.get_call_error(call_err) return false, e end return true @@ -255,7 +254,7 @@ function ConsoleController.prepare_env(cc) if love.state.app_state == 'inspect' or love.state.app_state == 'running' then - cc.model.interpreter:set_error("There's already a project running!", true) + cc.interpreter:set_error("There's already a project running!", true) return end local runner_env = cc:get_project_env() @@ -275,22 +274,34 @@ function ConsoleController.prepare_env(cc) print(err) end end + + prepared.run = prepared.run_project + + prepared.eval = LANG.eval + prepared.print_eval = LANG.print_eval + + prepared.quit = function() + love.event.quit() + end end --- API functions for the user --- @param cc ConsoleController function ConsoleController.prepare_project_env(cc) - local interpreter = cc.model.interpreter + require("controller.userInputController") + require("model.input.userInputModel") + require("view.input.userInputView") + local interpreter = cc.model.interpreter ---@type table - local project_env = cc:get_pre_env_c() - project_env.G = love.graphics + local project_env = cc:get_pre_env_c() + project_env.G = love.graphics --- @param msg string? - project_env.stop = function(msg) + project_env.stop = function(msg) cc:suspend_run(msg) end - project_env.continue = function() + project_env.continue = function() if love.state.app_state == 'inspect' then -- resume love.state.app_state = 'running' @@ -300,49 +311,57 @@ function ConsoleController.prepare_project_env(cc) end end - project_env.close_project = function() + project_env.close_project = function() close_project(cc) end - --- @param type InputType - --- @param result any - local input = function(type, result) + --- @param eval Evaluator + --- @param result table + local input = function(eval, result) if love.state.user_input then return -- there can be only one end local cfg = interpreter.cfg - local eval - if type == 'lua' then - eval = interpreter.luaInput - elseif type == 'text' then - eval = interpreter.textInput - else - Log('Invalid input type!') - return - end local cb = function(v) table.insert(result, 1, v) end - local input = InputModel:new(cfg, eval, true) - local controller = InputController.new(input, cb) - local view = InputView.new(cfg.view, controller) + + local input = UserInputModel(cfg, eval, true) + local controller = UserInputController(input, cb) + local view = UserInputView(cfg.view, controller) love.state.user_input = { M = input, C = controller, V = view } end - project_env.input_code = function(result) - return input('lua', result) + project_env.input_code = function(result) + return input(InputEvalLua, result) end - project_env.input_text = function(result) - return input('text', result) + project_env.input_text = function(result) + return input(InputEvalText, result) + end + + project_env.validated_input = function(result, filters) + if love.state.user_input then + return -- there can be only one + end + return input(ValidatedTextEval(filters), result) + end + + if love.debug then + project_env.astv_input = function(result) + return input(LuaEditorEval, result) + end end --- @param name string - project_env.edit = function(name) + project_env.edit = function(name) return cc:edit(name) end - local base = table.clone(project_env) - local project = table.clone(project_env) + project_env.eval = LANG.eval + project_env.print_eval = LANG.print_eval + + local base = table.clone(project_env) + local project = table.clone(project_env) cc:_set_base_env(base) cc:_set_project_env(project) end @@ -359,18 +378,15 @@ function ConsoleController:get_timestamp() end function ConsoleController:evaluate_input() - -- @type Model - -- local M = self.model - --- @type InterpreterModel - local interpreter = self.model.interpreter - local input = interpreter.input + --- @type InterpreterController + local inter = self.interpreter - local text = input:get_text() - local eval = input.evaluator + local text = inter:get_text() + local eval = inter:get_eval() - local eval_ok, res = interpreter:evaluate() + local eval_ok, res = inter:evaluate() - if eval.is_lua then + if eval.parser then if eval_ok then local code = string.unlines(text) local run_env = (function() @@ -383,18 +399,21 @@ function ConsoleController:evaluate_input() if f then local _, err = run_user_code(f, self) if err then - interpreter:set_error(err, true) + inter:set_error(err, true) end else -- this means that metalua failed to catch some invalid code - Log.error('Load error:', LANG.parse_error(load_err)) - interpreter:set_error(load_err, true) + Log.error('Load error:', LANG.get_call_error(load_err)) + inter:set_error(load_err, true) end else - local _, _, eval_err = interpreter:get_eval_error(res) - if string.is_non_empty_string(eval_err) then - orig_print(eval_err) - interpreter:set_error(eval_err, false) + local eval_err = res + if eval_err then + local msg = eval_err.msg + if string.is_non_empty_string(msg) then + orig_print(msg) + inter:set_error(msg, false) + end end end end @@ -406,7 +425,7 @@ end function ConsoleController:reset() self:quit_project() - self.model.interpreter:reset(true) -- clear history + self.interpreter:reset(true) -- clear history end ---@return LuaEnv @@ -450,7 +469,7 @@ function ConsoleController:suspend_run(msg) Log.info('Suspending project run') love.state.app_state = 'inspect' if msg then - self.model.interpreter:set_error(tostring(msg), true) + self.interpreter:set_error(tostring(msg), true) end self.model.output:invalidate_terminal() @@ -470,7 +489,7 @@ end function ConsoleController:quit_project() self.model.output:reset() - self.model.interpreter:reset() + self.interpreter:reset() nativefs.setWorkingDirectory(love.filesystem.getSourceBaseDirectory()) Controller.set_default_handlers(self, self.view) Controller.set_love_update(self) @@ -516,30 +535,30 @@ function ConsoleController:textinput(t) if love.state.app_state == 'editor' then self.editor:textinput(t) else - local interpreter = self.model.interpreter - if interpreter:has_error() then - interpreter:clear_error() + local inter = self.interpreter + if inter:has_error() then + inter:clear_error() else if Key.ctrl() and Key.shift() then return end - self.input:textinput(t) + inter:textinput(t) end end end --- @param k string function ConsoleController:keypressed(k) - local out = self.model.output - local interpreter = self.model.interpreter + local inter = self.interpreter local function terminal_test() + local out = self.model.output if not love.state.testing then love.state.testing = 'running' - interpreter:cancel() - TerminalTest:test(out.terminal) + inter:cancel() + TerminalTest.test(out.terminal) elseif love.state.testing == 'waiting' then - TerminalTest:reset(out.terminal) + TerminalTest.reset(out.terminal) love.state.testing = false end end @@ -555,31 +574,31 @@ function ConsoleController:keypressed(k) return end - if self.model.interpreter:has_error() then + if inter:has_error() then if k == 'space' or Key.is_enter(k) or k == "up" or k == "down" then - interpreter:clear_error() + inter:clear_error() end return end if k == "pageup" then - interpreter:history_back() + inter:history_back() end if k == "pagedown" then - interpreter:history_fwd() + inter:history_fwd() end - local limit = self.input:keypressed(k) + local limit = inter:keypressed(k) if limit then if k == "up" then - interpreter:history_back() + inter:history_back() end if k == "down" then - interpreter:history_fwd() + inter:history_fwd() end end if not Key.shift() and Key.is_enter(k) then - if not interpreter:has_error() then + if not inter:has_error() then self:evaluate_input() end end @@ -607,7 +626,37 @@ end --- @param k string function ConsoleController:keyreleased(k) - self.input:keyreleased(k) + self.interpreter:keyreleased(k) +end + +function ConsoleController:mousepressed(x, y, button) + if love.state.app_state == 'editor' then + if self.cfg.editor.mouse_enabled then + self.editor.input:mousepressed(x, y, button) + end + else + self.interpreter:mousepressed(x, y, button) + end +end + +function ConsoleController:mousereleased(x, y, button) + if love.state.app_state == 'editor' then + if self.cfg.editor.mouse_enabled then + self.editor.input:mousereleased(x, y, button) + end + else + self.interpreter:mousereleased(x, y, button) + end +end + +function ConsoleController:mousemoved(x, y, dx, dy) + if love.state.app_state == 'editor' then + if self.cfg.editor.mouse_enabled then + self.editor.input:mousemoved(x, y) + end + else + self.interpreter:mousemoved(x, y) + end end --- @return Terminal @@ -623,13 +672,14 @@ end --- @return ViewData function ConsoleController:get_viewdata() return { - w_error = self.model.interpreter:get_wrapped_error(), + w_error = self.interpreter:get_wrapped_error(), } end function ConsoleController:autotest() - local input = self.model.interpreter.input - input:add_text('list_projects()') - self:evaluate_input() - input:add_text('run_project("turtle")') + --- @diagnostic disable-next-line undefined-global + local autotest = prequire('tests/autotest') + if autotest then + autotest(self) + end end diff --git a/src/controller/controller.lua b/src/controller/controller.lua index 40525a8b..93f6e9b4 100644 --- a/src/controller/controller.lua +++ b/src/controller/controller.lua @@ -119,7 +119,7 @@ Controller = { if love.DEBUG then Log.info(string.format('click! {%d, %d}', x, y)) end - C.input:mousepressed(x, y, button) + C:mousepressed(x, y, button) end Controller._defaults.mousepressed = mousepressed @@ -128,7 +128,7 @@ Controller = { --- @param C ConsoleController set_love_mousereleased = function(C) local function mousereleased(x, y, button) - C.input:mousereleased(x, y, button) + C:mousereleased(x, y, button) end Controller._defaults.mousereleased = mousereleased @@ -137,7 +137,7 @@ Controller = { --- @param C ConsoleController set_love_mousemoved = function(C) local function mousemoved(x, y, dx, dy) - C.input:mousemoved(x, y) + C:mousemoved(x, y, dx, dy) end Controller._defaults.mousemoved = mousemoved @@ -184,7 +184,7 @@ Controller = { --------------- - -- draw -- + -- draw -- --------------- --- @param C ConsoleController --- @param CV ConsoleView @@ -325,7 +325,7 @@ Controller = { end end - handlers.userinput = function(input) + handlers.userinput = function() local user_input = get_user_input() if user_input then clear_user_input() diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 85f886ff..bb9663c3 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -1,59 +1,69 @@ +require("model.interpreter.eval.evaluator") require("controller.inputController") +require("controller.userInputController") +require("view.input.customStatus") + +local class = require('util.class') + +--- @param M EditorModel +local function new(M) + return { + input = UserInputController(M.input), + model = M, + view = nil, + } +end --- @class EditorController --- @field model EditorModel ---- @field input InputController +--- @field input UserInputController --- @field view EditorView? --- --- @field open fun(self, name: string, content: string[]?) --- @field close fun(self): string, string[] ---- @field get_active_buffer function +--- @field get_active_buffer fun(self): BufferModel --- @field update_status function --- @field textinput fun(self, string) --- @field keypressed fun(self, string) -EditorController = {} -EditorController.__index = EditorController - -setmetatable(EditorController, { - __call = function(cls, ...) - return cls.new(...) - end, -}) +EditorController = class.create(new) ---- @param M EditorModel -function EditorController.new(M) - local self = setmetatable({ - input = InputController.new(M.interpreter.input), - model = M, - view = nil, - }, EditorController) - - return self -end --- @param name string --- @param content string[]? function EditorController:open(name, content) - local input = self.input - local interpreter = self.model.interpreter - -- local is_lua = string.match(name, '.lua$') - -- if is_lua then - -- input:set_eval(interpreter.luaInput) - -- else - input:set_eval(interpreter.textInput) - -- end - local b = BufferModel(name, content) + local w = self.model.cfg.view.drawableChars + local is_lua = string.match(name, '.lua$') + local ch, hl, pp + if is_lua then + self.input:set_eval(LuaEditorEval) + local luaEval = LuaEval() + local parser = luaEval.parser + if not parser then return end + hl = parser.highlighter + --- @param t string[] + --- @param single boolean + ch = function(t, single) + return parser.chunker(t, w, single) + end + pp = function(t) + return parser.pprint(t, w) + end + else + self.input:set_eval(TextEval) + end + + local b = BufferModel(name, content, ch, hl, pp) self.model.buffer = b self.view.buffer:open(b) self:update_status() end --- @return string name ---- @return string[] content +--- @return Dequeue content function EditorController:close() local buf = self:get_active_buffer() - local content = buf.content self.input:clear() + local content = buf:get_text_content() return buf.name, content end @@ -63,23 +73,22 @@ function EditorController:get_active_buffer() end --- @private ---- @param sel Selected +--- @param sel integer --- @return CustomStatus function EditorController:_generate_status(sel) - local len = self:get_active_buffer():get_content_length() + 1 - local vrange = self.view.buffer.content:get_range() - local vlen = self.view.buffer.content:get_content_length() - local more = { - up = vrange.start > 1, - down = vrange.fin < vlen - } - local cs = { - line = sel[1], - buflen = len, - more = more, - } - cs.__tostring = function(t) - return 'L' .. t.line + --- @type BufferModel + local buffer = self:get_active_buffer() + local len = buffer:get_content_length() + 1 + local bufview = self.view.buffer + local more = bufview.content:get_more() + local cs + if bufview.content_type == 'plain' then + cs = CustomStatus(bufview.content_type, len, more, sel) + end + if bufview.content_type == 'lua' then + local range = bufview.content:get_block_app_pos(sel) + cs = CustomStatus( + bufview.content_type, len, more, sel, range) end return cs @@ -93,9 +102,9 @@ end --- @param t string function EditorController:textinput(t) - local interpreter = self.model.interpreter - if interpreter:has_error() then - interpreter:clear_error() + local input = self.model.input + if input:has_error() then + input:clear_error() else if Key.ctrl() and Key.shift() then return @@ -104,17 +113,62 @@ function EditorController:textinput(t) end end +function EditorController:get_input() + return self.input:get_input() +end + +--- @param go fun(nt: string[]|Block[]) +function EditorController:_handle_submit(go) + local inter = self.input + local raw = inter:get_text() + + local buf = self:get_active_buffer() + local ct = buf.content_type + if ct == 'lua' then + if not string.is_non_empty_string_array(raw) then + local sel = buf:get_selection() + local block = buf:get_content():get(sel) + if not block then return end + local ln = block.pos.start + if ln then go({ Empty(ln) }) end + else + local pretty = buf.printer(raw) + if pretty then + inter:set_text(pretty) + else + --- fallback to original in case of unparse-able input + pretty = raw + end + local ok, res = inter:evaluate() + local _, chunks = buf.chunker(pretty, true) + if ok then + go(chunks) + else + local eval_err = res + if eval_err then + inter:set_error(eval_err) + end + end + end + else + go(raw) + end +end + --- @param k string function EditorController:keypressed(k) - local vmove = self.input:keypressed(k) + local inter = self.input + + local vmove = inter:keypressed(k) --- @param dir VerticalDir --- @param by integer? --- @param warp boolean? local function move_sel(dir, by, warp) + if inter:has_error() then return end local m = self:get_active_buffer():move_selection(dir, by, warp) if m then - self.input:clear() + inter:clear() self.view.buffer:follow_selection() self:update_status() end @@ -122,27 +176,32 @@ function EditorController:keypressed(k) --- @param dir VerticalDir --- @param warp boolean? - local function scroll(dir, warp) - self.view.buffer:_scroll(dir, nil, warp) + --- @param by integer? + local function scroll(dir, warp, by) + self.view.buffer:scroll(dir, by, warp) self:update_status() end local function load_selection() local t = self:get_active_buffer():get_selected_text() - self.input:set_text(t) + inter:set_text(t) end --- handlers local function submit() if not Key.ctrl() and not Key.shift() and Key.is_enter(k) then - local newtext = self.input:get_input().text - local insert, n = self:get_active_buffer():replace_selected_text(newtext) - self.input:clear() - self.view:refresh() - move_sel('down', n) - load_selection() - self:update_status() + local function go(newtext) + local buf = self:get_active_buffer() + local _, n = buf:replace_selected_text(newtext) + inter:clear() + self.view:refresh() + move_sel('down', n) + load_selection() + self:update_status() + end + + self:_handle_submit(go) end end local function load() @@ -155,7 +214,7 @@ function EditorController:keypressed(k) Key.shift() and k == "escape" then local t = self:get_active_buffer():get_selected_text() - self.input:add_text(t) + inter:add_text(t) end end local function delete() @@ -183,16 +242,33 @@ function EditorController:keypressed(k) end -- scroll - if k == "pageup" then + if not Key.shift() + and k == "pageup" then scroll('up', Key.ctrl()) end - if k == "pagedown" then + if not Key.shift() + and k == "pagedown" then scroll('down', Key.ctrl()) end + if Key.shift() + and k == "pageup" then + scroll('up', false, 1) + end + if Key.shift() + and k == "pagedown" then + scroll('down', false, 1) + end end submit() load() delete() navigate() + + if love.debug then + if k == 'f5' then + local bufview = self.view.buffer + bufview:refresh() + end + end end diff --git a/src/controller/inputController.lua b/src/controller/inputController.lua index e11c0368..e926ffba 100644 --- a/src/controller/inputController.lua +++ b/src/controller/inputController.lua @@ -1,16 +1,10 @@ +local class = require('util.class') require("util.key") --- @class InputController --- @field model InputModel --- @field result function -InputController = {} -InputController.__index = InputController - -setmetatable(InputController, { - __call = function(cls, ...) - return cls.new(...) - end, -}) +InputController = class.create() --- @param M InputModel --- @param result function? @@ -31,7 +25,7 @@ function InputController:textinput(t) self.model:add_text(t) end ---- @param t string|string[] +--- @param t str function InputController:add_text(t) self.model:add_text(string.unlines(t)) end @@ -39,12 +33,12 @@ end ---------------- -- evaluation -- ---------------- ---- @param eval EvalBase +--- @param eval Evaluator function InputController:set_eval(eval) self.model:set_eval(eval) end ---- @param t string|string[] +--- @param t str function InputController:set_text(t) self.model:set_text(t) end @@ -105,12 +99,22 @@ function InputController:keypressed(k) input:cursor_right() end - if k == "home" then + if not Key.alt() + and k == "home" then input:jump_home() end - if k == "end" then + if not Key.alt() + and k == "end" then input:jump_end() end + if Key.alt() + and k == "home" then + input:jump_line_start() + end + if Key.alt() + and k == "end" then + input:jump_line_end() + end end local function newline() if Key.shift() then @@ -193,7 +197,7 @@ end function InputController:keyreleased(k) local input = self.model local function selection() - if Key.shift() then + if Key.is_shift(k) then input:release_selection() end end @@ -203,13 +207,7 @@ end --- @return InputDTO function InputController:get_input() - local im = self.model - return { - text = im:get_text(), - wrapped_text = im:get_wrapped_text(), - highlight = im:highlight(), - selection = im:get_ordered_selection(), - } + return self.model:get_input() end --- @return Status diff --git a/src/controller/interpreterController.lua b/src/controller/interpreterController.lua new file mode 100644 index 00000000..98213b99 --- /dev/null +++ b/src/controller/interpreterController.lua @@ -0,0 +1,149 @@ +local class = require('util.class') + +--- @class InterpreterController +--- @field model InterpreterModel +--- @field input InputController +--- +--- @field set_eval fun(self, Evaluator) +--- @field get_eval fun(self): Evaluator +--- @field get_viewdata fun(self): ViewData +--- @field set_text fun(self, t: str) +--- @field add_text fun(self, t: str) +--- @field textinput fun(self, t: string) +--- @field keypressed fun(self, k: string): boolean? +--- @field clear fun(self) +--- @field get_input fun(self): InputDTO +--- @field get_text fun(self): string[] +--- @field set_custom_status fun(self, CustomStatus) +InterpreterController = class.create() + +--- @param model InterpreterModel +--- @param input InputController +function InterpreterController.new(model, input) + local self = setmetatable({ + model = model, + input = input, + }, InterpreterController) + + return self +end + +function InterpreterController:set_eval(eval) + self.input:set_eval(eval) +end + +function InterpreterController:get_eval() + return self.input.model.evaluator +end + +--- @return ViewData +function InterpreterController:get_viewdata() + return { + w_error = self.model:get_wrapped_error(), + } +end + +--- @param t str +function InterpreterController:set_text(t) + self.input:set_text(t) +end + +--- @param t str +function InterpreterController:add_text(t) + self.input:add_text(t) +end + +function InterpreterController:clear() + self.input:clear() + self:clear_error() +end + +--- @return InputDTO +function InterpreterController:get_input() + return self.input:get_input() +end + +--- @return string[] +function InterpreterController:get_text() + return self:get_input().text +end + +--- @return boolean +function InterpreterController:has_error() + return self.model:has_error() +end + +function InterpreterController:clear_error() + self.model:clear_error() +end + +--- @param error string? +--- @param is_call_error boolean? +function InterpreterController:set_error(error, is_call_error) + self.model:set_error(error, is_call_error) +end + +--- @return string[]? +function InterpreterController:get_wrapped_error() + return self.model:get_wrapped_error() +end + +--- @return boolean +--- @return string|EvalError +function InterpreterController:evaluate() + return self.model:handle(true) +end + +function InterpreterController:cancel() + self.model:handle(false) +end + +--- @param history boolean? +function InterpreterController:reset(history) + self.model:reset(history) +end + +--- @param cs CustomStatus +function InterpreterController:set_custom_status(cs) + self.input:set_custom_status(cs) +end + +function InterpreterController:history_back() + self.model:history_back() +end + +function InterpreterController:history_fwd() + self.model:history_fwd() +end + +---------------------- +--- event handlers --- +---------------------- + +--- @param t string +function InterpreterController:textinput(t) + self.input:textinput(t) +end + +--- @param k string +--- @return boolean? +function InterpreterController:keypressed(k) + return self.input:keypressed(k) +end + +--- @param k string +function InterpreterController:keyreleased(k) + return self.input:keyreleased(k) +end + +function InterpreterController:mousepressed(x, y, btn) + self.input:mousepressed(x, y, btn) +end + +function InterpreterController:mousereleased(x, y, btn) + self.input:mousereleased(x, y, btn) +end + +function InterpreterController:mousemoved(x, y, dx, dy) + self.input:mousemoved(x, y, dx, dy) +end diff --git a/src/controller/userInputController.lua b/src/controller/userInputController.lua new file mode 100644 index 00000000..9adaec9b --- /dev/null +++ b/src/controller/userInputController.lua @@ -0,0 +1,346 @@ +local class = require('util.class') +require("util.key") + +--- @param model InputModel +--- @param result function? +local new = function(model, result) + return { + model = model, + result = result, + } +end + +--- @class UserInputController +--- @field model UserInputModel +--- @field result function +UserInputController = class.create(new) + +--------------- +-- entered -- +--------------- + +--- @param t str +function UserInputController:add_text(t) + self.model:add_text(string.unlines(t)) +end + +--- @return InputText +function UserInputController:get_text() + return self.model:get_text() +end + +--- @param t str +function UserInputController:set_text(t) + self.model:set_text(t) +end + +---------------- +-- evaluation -- +---------------- + +--- @param eval Evaluator +function UserInputController:set_eval(eval) + self.model:set_eval(eval) +end + +function UserInputController:clear() + self.model:clear_input() + self:clear_error() +end + +--- @param cs CustomStatus +function UserInputController:set_custom_status(cs) + self.model:set_custom_status(cs) +end + +--- @return InputDTO +function UserInputController:get_input() + return self.model:get_input() +end + +--- @return Status +function UserInputController:get_status() + return self.model:get_status() +end + +--- @return CursorInfo +function UserInputController:get_cursor_info() + return self.model:get_cursor_info() +end + +----------- +-- error -- +----------- +--- @return boolean +function UserInputController:has_error() + return self.model:has_error() +end + +function UserInputController:clear_error() + self.model:clear_error() +end + +--- @param error string[]? +function UserInputController:set_error(error) + self.model:set_error(error) +end + +--- @return string[]? +function UserInputController:get_wrapped_error() + return self.model:get_wrapped_error() +end + +--- @return boolean +--- @return EvalError[] +function UserInputController:evaluate() + return self.model:handle(true) +end + +function UserInputController:cancel() + self.model:handle(false) +end + +---------------------- +--- event handlers --- +---------------------- + +---------------- +-- keyboard -- +---------------- + +--- @param k string +--- @return boolean? limit +function UserInputController:keypressed(k) + local input = self.model + local ret + + if input:has_error() then + if Key.is_enter(k) + or k == "up" or k == "down" + then + input:clear_error() + end + return + end + + -- utility functions + local function paste() + input:paste(love.system.getClipboardText()) + input:clear_selection() + end + local function copy() + local t = input:get_selected_text() + love.system.setClipboardText(string.unlines(t)) + end + local function cut() + local t = input:pop_selected_text() + love.system.setClipboardText(string.unlines(t)) + end + + -- action categories + local function removers() + if k == "backspace" then + input:backspace() + end + if k == "delete" then + input:delete() + end + end + local function vertical() + if k == "up" then + local l = input:cursor_vertical_move('up') + ret = l + end + if k == "down" then + local l = input:cursor_vertical_move('down') + ret = l + end + end + local function horizontal() + if k == "left" then + input:cursor_left() + end + if k == "right" then + input:cursor_right() + end + + if not Key.alt() + and k == "home" then + input:jump_home() + end + if not Key.alt() + and k == "end" then + input:jump_end() + end + if Key.alt() + and k == "home" then + input:jump_line_start() + end + if Key.alt() + and k == "end" then + input:jump_line_end() + end + end + local function newline() + if Key.shift() then + if Key.is_enter(k) then + input:line_feed() + end + end + end + local function copypaste() + if Key.ctrl() then + if k == "v" then + paste() + end + if k == "c" or k == "insert" then + copy() + end + if k == "x" then + cut() + end + end + if Key.shift() then + if k == "insert" then + paste() + end + if k == "delete" then + cut() + end + end + end + local function selection() + if Key.shift() then + input:hold_selection() + end + end + + local function cancel() + if not Key.ctrl() and k == "escape" then + input:cancel() + end + end + local function submit() + if not Key.shift() and Key.is_enter(k) and input.oneshot then + local ok, evret = input:evaluate() + if ok then + local text = evret + local res = self.result + if type(res) == "function" then + local t = string.unlines(text) + res(t) + end + else + local err = evret + input:set_error(err) + end + end + end + + if love.state.app_state == 'editor' then + removers() + horizontal() + vertical() -- sets return + newline() + + copypaste() + selection() + + submit() + else + -- normal behavior + removers() + vertical() + horizontal() + newline() + + copypaste() + selection() + + cancel() + submit() + end + + + return ret +end + +--- @param t string +function UserInputController:textinput(t) + if self.model:has_error() then + return + end + if not self.result and love.state.app_state == 'running' then + return + end + self.model:add_text(t) +end + +--- @param k string +function UserInputController:keyreleased(k) + local input = self.model + + if input:has_error() then + if k == 'space' then + input:clear_error() + end + return + end + + local function selection() + if Key.is_shift(k) then + input:release_selection() + end + end + + selection() +end + +--------------- +-- mouse -- +--------------- + +function UserInputController:_translate_to_input_grid(x, y) + local cfg = self.model.cfg + local h = cfg.view.h + local fh = cfg.view.fh + local fw = cfg.view.fw + local line = math.floor((h - y) / fh) + local a, b = math.modf((x / fw)) + local char = a + 1 + if b > .5 then char = char + 1 end + return char, line +end + +function UserInputController:_handle_mouse(x, y, btn, handler) + if btn == 1 then + local im = self.model + local n_lines = im:get_wrapped_text():get_text_length() + local c, l = self:_translate_to_input_grid(x, y) + if l < n_lines then + handler(n_lines - l, c) + end + end +end + +function UserInputController:mousepressed(x, y, btn) + local im = self.model + self:_handle_mouse(x, y, btn, function(l, c) + im:mouse_click(l, c) + end) +end + +function UserInputController:mousereleased(x, y, btn) + local im = self.model + self:_handle_mouse(x, y, btn, function(l, c) + im:mouse_release(l, c) + end) + im:release_selection() +end + +function UserInputController:mousemoved(x, y, dx, dy) + local im = self.model + self:_handle_mouse(x, y, 1, function(l, c) + im:mouse_drag(l, c) + end) +end diff --git a/src/examples/valid/main.lua b/src/examples/valid/main.lua new file mode 100644 index 00000000..6c010116 --- /dev/null +++ b/src/examples/valid/main.lua @@ -0,0 +1,50 @@ +r = {} + +-- local length = + +min_length = function(n) + return function(s) + if string.len(s) > n then + return true + end + return false, 'too short!' + end +end + +max_length = function(n) + return function(s) + if string.len(s) < n then + return true + end + return false, 'too long!' + end +end + +is_upper = function(s) + local ret = true + for i = 1, string.ulen(s) do + local v = string.char_at(s, i) + if v ~= string.upper(v) then + ret = false + end + end + if ret then + return true + end + return false, 'should be all uppercase' +end + +is_number = function(s) + local n = tonumber(s) + if n then return true end + return false, 'NaN' +end + +function love.update() + if not r[1] then + validated_input(r, { min_length(2), is_number }) + else + print(r[1]) + r[1] = nil + end +end diff --git a/src/lib/error_explorer.lua b/src/lib/error_explorer.lua new file mode 100644 index 00000000..8a000f62 --- /dev/null +++ b/src/lib/error_explorer.lua @@ -0,0 +1,734 @@ +-- # love error explorer +-- +-- by kira +-- +-- version 0.0.7 +-- +-- an interactive error screen for the love2d game engine. +-- +-- on error, shows the stack, local variables, and the +-- source code when available. +-- +-- the newest version should be available +-- [here](https://github.com/snowkittykira/love-error-explorer). +-- +-- ## usage +-- +-- ```lua +-- require 'error_explorer' +-- ``` +-- +-- include `error_explorer.lua` in your project and +-- `require` it somewhere near the start of your program +-- +-- when an error happens, press `up` and `down` (or `k` and +-- `j`) to move up and down on the stack, click on tables +-- in the variable view to expand them, and scroll with the +-- mousewheel. +-- +-- you can provide an optional table when requiring error +-- explorer to provide options: +-- +-- ```lua +-- require 'error_explorer' { +-- -- change the limit of stack depth (default 20) +-- stack_limit = 20, +-- +-- -- provide custom font for error / stack trace / variables +-- error_font = love.graphics.newFont (16), +-- +-- -- provide custom font for source code +-- source_font = love.graphics.newFont (12), +-- +-- -- provide `open_editor` to run a command when +-- -- clicking a source line (disabled in fused builds, +-- -- and when running from a file ending in .love, but +-- -- it's safer to remove this when distributing) +-- open_editor = function (filename, line) +-- -- for example using neovim remote +-- io.popen ('nvr --nostart ' .. filename .. ' +' .. line) +-- end, +-- } +-- ``` +-- +-- ## version history +-- +-- version 0.0.7: +-- +-- - collapse multiline variable values to one line +-- +-- version 0.0.6: +-- +-- - fix issue when the mouse module isn't available +-- +-- version 0.0.5: +-- +-- - added options table for configuring: +-- - stack limit +-- - fonts +-- - optional "open in editor" action +-- - use less cpu when idle +-- +-- version 0.0.4: +-- +-- - fix for non-string keys and multiline keys +-- +-- version 0.0.3: +-- +-- - handle when source file isn't available +-- +-- version 0.0.2: +-- +-- - automatically select the right stack frame at start +-- - don't print full stack contents to terminal by default +-- +-- version 0.0.1: +-- +-- - initial release + +-- ## license +-- +-- Copyright 2024 Kira Boom +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the “Software”), to +-- deal in the Software without restriction, including without limitation the +-- rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +-- sell copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS +-- OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +-- THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +-- DEALINGS IN THE SOFTWARE. + +local utf8 = require("utf8") + +local print_stack_variables_to_terminal = false +local stack_limit = 20 +local open_editor +local error_font +local source_font + +-- util ------------------------------------------ + +local function is_build () + return love.filesystem.isFused () or + love.filesystem.getSource ():match ('%.love$') +end + +local function safe_tostring (value) + local success, value_string = pcall (tostring, value) + return success and value_string + or ('error during tostring: ' .. value_string) +end + +local function shorten (str) + local result = str:sub(1, 30) + if #result < #str then + result = result .. '...' + end + result = result:gsub ('\n', '\\n') + return result +end + +local function compare_keys (a, b) + local ta = type (a.key) + local tb = type (b.key) + if ta ~= tb then + return ta < tb + end + if ta == 'number' or ta == 'string' then + return a.key < b.key + else + return safe_tostring (a.key) < safe_tostring (b.key) + end +end + +local function approach (from, to) + local value = from + (to - from) * 0.25 + if math.abs (value - to) * source_font:getHeight() < 0.5 then + value = to + end + return value +end + +local function round (n) + return math.floor (n + 0.5) +end + +local function get_lines (text) + local lines = {} + for line in text:gmatch ("(.-)\r?\n") do + table.insert (lines, line) + end + local last_line = text:match ('([^\n]*)$') + if last_line and last_line ~= '' then + table.insert (lines, last_line) + end + return lines +end + +local function get_font_height () + local font = love.graphics.getFont () + return math.ceil (font:getHeight () * font:getLineHeight ()) +end + +local function draw_text (text, x, y) + text = text or '' + local font = love.graphics.getFont () + local w = font:getWidth (text) + local lines = 1 + for _ in text:gmatch ('\n') do + lines = lines + 1 + end + local h = get_font_height () * lines + love.graphics.print (text, x, y) + return w, h +end + +local function get_stack_info () + local stack_info = {} + local level = 5 + -- maximum stack frames + while #stack_info < stack_limit do + local raw = debug.getinfo (level) + -- if no more stack frames, stop + if not raw then break end + if raw.short_src:sub(1, 1) ~= '[' then + local info = { + raw = raw, + variables = {}, + line = raw.currentline, + source = raw.short_src, + fn_name = raw.name or raw.linedefined ~= 0 and (raw.short_src .. ':' .. tostring (raw.linedefined)) + } + table.insert (stack_info, info) + + -- local variables + local local_index = 1 + repeat + local name, value = debug.getlocal (level, local_index) + if name then + if name ~= '(*temporary)' then + table.insert (info.variables, { + key = name, + value = value + }) + end + local_index = local_index + 1 + end + until not name + + -- upvalues and env + --info.upvalues = {} + if raw.func then + local upvalue_index = 1 + repeat + local name, value = debug.getupvalue (raw.func, upvalue_index) + if name then + table.insert (info.variables, { + key = name, + value = value, + }) + upvalue_index = upvalue_index + 1 + end + until not name + + if rawget (_G, 'getfenv') then + local env = getfenv (raw.func) + table.insert (info.variables, { key = '_ENV', value = env }) + end + end + + end + level = level + 1 + end + return stack_info +end + +-- handle error ---------------------------------- + +local function handle_error (msg) + msg = tostring (msg) + + -- print error + print (debug.traceback ("Error: " .. msg, 5)) + + local stack_info = get_stack_info () + if print_stack_variables_to_terminal then + for i = 1, #stack_info do + local info = stack_info[i] + print (string.format ('%s:%d', info.source, info.line) .. + (info.fn_name and (' in function ' .. info.fn_name) or ' at top level')) + for j = 1, #info.variables do + print ('\t' .. tostring (info.variables[j].key) .. ': ' .. shorten (safe_tostring (info.variables[j].value))) + end + end + end + + -- do nothing if modules not available + if not love.window or not love.graphics or not love.event then + return + end + + -- open a window if needed + if not love.graphics.isCreated () or not love.window.isOpen () then + local success, status = pcall (love.window.setMode, 800, 600) + if not success or not status then + return + end + end + + -- reset mouse + if love.mouse then + love.mouse.setVisible (true) + love.mouse.setGrabbed (false) + love.mouse.setRelativeMode (false) + if love.mouse.isCursorSupported() then + love.mouse.setCursor() + end + end + + -- reset joystick vibration + if love.joystick then + for i, v in ipairs (love.joystick.getJoysticks ()) do + v:setVibration () + end + end + + -- stop audio + if love.audio then + love.audio.stop () + end + + -- reset graphics + love.graphics.reset () + if not error_font then + error_font = love.graphics.newFont (16) + error_font:setLineHeight (1.2) + end + if not source_font then + source_font = love.graphics.newFont (12) + source_font:setLineHeight (1.2) + end + love.graphics.setBackgroundColor (1/15, 1/15, 1/15) + love.graphics.setColor (1, 1, 1, 1) + love.graphics.clear (love.graphics.getBackgroundColor ()) + love.graphics.origin () + + -- colors + local c_verydark = {0.25, 0.25, 0.25} + local c_dark = {0.5, 0.5, 0.5} + local c_mid = {0.7, 0.7, 0.7} + local c_bright = {1.0, 1.0, 1.0} + local c_red = {1.0, 0.0, 0.0} + local c_clear = {0.0, 0.0, 0.0, 0.0} + + -- sanitize utf-8 + local sanitizedmsg = {} + for char in msg:gmatch(utf8.charpattern) do + table.insert(sanitizedmsg, char) + end + sanitizedmsg = table.concat(sanitizedmsg) + local invalid_utf8 = sanitizedmsg ~= msg + msg = sanitizedmsg + + -- get the backtrace + local trace = debug.traceback ('', 4) + + -- start error explorer + + -- stack view + local current_stack_index = 1 + local hovered_stack_index = false + local mouse_over_stack = false + local stack_max_scroll = 0 + local stack_scroll = 0 + local stack_scroll_smooth = 0 + + -- variables view + local hovered_variable = false + local variables_max_scroll = 0 + local variables_scroll = 0 + local variables_scroll_smooth = 0 + local mouse_over_variables = false + + -- source view + local hovered_source_line + + -- idle tracking + local mouse_moved_time = 0 + + -- what location does the error target + local target_file, target_linenum, msg_without_target = msg:match '^([^:]-%.lua):([^:]-): ?(.*)' + if target_file then + target_linenum = tonumber (target_linenum) + msg = msg_without_target + for i = 1, #stack_info do + if target_file == stack_info[i].source and target_linenum == stack_info[i].line then + current_stack_index = i + break + end + end + end + + -- source view + local source_lines + + local function refresh_source () + source_lines = nil + local frame = stack_info[current_stack_index] + local filename = frame.source + if filename then + pcall (function () + source_lines = get_lines (love.filesystem.read(filename)) + end) + end + + end + refresh_source () + + local function keypressed (key) + if key == 'up' or key == 'k' then + current_stack_index = math.max (1, current_stack_index - 1) + stack_scroll = math.min (current_stack_index-1, stack_scroll) + refresh_source () + end + if key == 'down' or key == 'j' then + current_stack_index = math.min (#stack_info, current_stack_index + 1) + stack_scroll = math.max (current_stack_index - (#stack_info - stack_max_scroll), stack_scroll) + refresh_source () + end + end + + local function mousepressed () + if hovered_stack_index then + current_stack_index = hovered_stack_index + refresh_source () + end + if hovered_variable and type (hovered_variable.value) == 'table' then + if hovered_variable.contents then + hovered_variable.contents = nil + else + local contents = {} + hovered_variable.contents = contents + for k,v in pairs (hovered_variable.value) do + table.insert (contents, { + key = k, + value = v, + }) + end + table.sort (contents, compare_keys) + end + end + if hovered_source_line then + local frame = stack_info[current_stack_index] + if frame then + open_editor (frame.source, hovered_source_line) + end + end + end + + local function wheelmoved (amount) + if mouse_over_stack then + stack_scroll = math.max (0, math.min (stack_scroll - amount * 2, stack_max_scroll)) + end + if mouse_over_variables then + variables_scroll = math.max (0, math.min (variables_scroll - amount * 2, variables_max_scroll)) + end + end + + local function update () + stack_scroll_smooth = approach (stack_scroll_smooth, stack_scroll) + variables_scroll_smooth = approach (variables_scroll_smooth, variables_scroll) + end + + local function is_idle () + if love.timer then + if love.timer.getTime() < mouse_moved_time + 1 then + return false + end + end + return stack_scroll_smooth == stack_scroll and + variables_scroll_smooth == variables_scroll + end + + local function draw () + local W = love.graphics.getWidth () + local H = love.graphics.getHeight () + local P = 50 + + local mx, my = -1, -1 + if love.mouse then + mx, my = love.mouse.getPosition () + end + local over_section = false + local sx, sy, sw, sh + local x, y + + local function section (new_sx, new_sy, new_sw, new_sh) + sx, sy = new_sx, new_sy + sw, sh = new_sw, new_sh + x, y = sx, sy + over_section = + mx >= sx and mx < sx + sw and + my >= sy and my < sy + sh + love.graphics.setScissor (sx, sy, sw, sh) + end + + local function print_horizontal (text, color) + if color then + love.graphics.setColor (color) + end + local dx, _dy = draw_text (text, x, y) + x = x + dx + end + + local function print_line (text, color) + if color then + love.graphics.setColor (color) + end + local _dx, dy = draw_text (text, x, y) + x = sx + y = y + dy + end + + local function draw_scrollbar (scroll, scroll_height, visible_height) + if scroll_height <= visible_height then + return + end + love.graphics.setColor (c_verydark) + love.graphics.rectangle ('fill', sx + sw - 2, sy, 2, sh, 2, 2, 2) + local scroll_y = scroll / scroll_height + local scroll_h = visible_height / scroll_height + love.graphics.setColor (c_dark) + love.graphics.rectangle ('fill', sx + sw - 2, sy + scroll_y * sh, 2, scroll_h*sh, 2, 2, 2) + end + + love.graphics.setFont (error_font) + local font_height = get_font_height () + + -- error message + section (P, P, W/2-2*P, H-2*P) + print_line ('error explorer', c_dark) + local _, wrapped_error = error_font:getWrap (msg, W/2-2*P) + for _, text in ipairs (wrapped_error) do + print_line (text, c_bright) + end + print_line () + local left_space_left = H-P - y + local left_section_height = math.floor ((left_space_left - font_height)/2) + + -- stack frames + --print_line ('stack', c_dark) + section (P, y, W/2-2*P, left_section_height) + mouse_over_stack = over_section + local stack_top_y = y + y = y - round (stack_scroll_smooth * font_height) + local last_hovered_stack_index = hovered_stack_index + hovered_stack_index = false + for i, frame in ipairs (stack_info) do + local light_color = c_mid + local dark_color = c_dark + if last_hovered_stack_index == i or current_stack_index == i then + light_color = c_bright + dark_color = c_bright + end + local y_before = y + print_horizontal (string.format ('%s:%d', frame.source, frame.line), light_color) + if frame.fn_name then + print_horizontal (' in function ', dark_color) + print_horizontal (string.format ('%s', frame.fn_name), light_color) + else + print_horizontal (' at top level', dark_color) + end + print_line () + + if over_section then + if my >= y_before and my < y then + hovered_stack_index = i + end + end + end + local stack_lines_shown = (sy + left_section_height - stack_top_y) / font_height + stack_max_scroll = #stack_info - stack_lines_shown + draw_scrollbar (stack_scroll_smooth, #stack_info, stack_lines_shown) + + local frame = stack_info [current_stack_index] + if not frame then + return + end + + -- variables + section (P, sy+left_section_height+font_height, W/2-2*P, left_section_height) + mouse_over_variables = over_section + --print_line ('variables', c_dark) + section (P, y, W/2 - 2*P, H-P-y) + local variables_top_y = y + y = y - round (variables_scroll_smooth * font_height) + local last_hovered_variable = hovered_variable + hovered_variable = false + local variable_count = 0 + local function draw_variable (variable, indent) + variable_count = variable_count + 1 + local hovered = variable == last_hovered_variable + local y_before = y + print_horizontal (indent .. shorten(safe_tostring (variable.key)), hovered and c_bright or c_mid) + print_horizontal (': ', variable == last_hovered_variable and c_bright or c_dark) + print_line (shorten (safe_tostring (variable.value))) + + if over_section and type (variable.value) == 'table' then + if mx >= 0 and mx < W/2 and my >= y_before and my < y then + hovered_variable = variable + end + end + + if variable.contents then + for _, v in ipairs (variable.contents) do + draw_variable (v, indent .. '\t') + end + end + end + for _, variable in ipairs (frame.variables) do + draw_variable (variable, '') + end + local variables_lines_shown = (H-P - variables_top_y) / font_height + variables_max_scroll = variable_count - variables_lines_shown + draw_scrollbar (variables_scroll_smooth, variable_count, variables_lines_shown) + + -- source + love.graphics.setFont (source_font) + section (W/2+P, P, W/2-2*P, H-2*P) + print_line (frame.source .. '\n', c_dark) + local prev_hovered_line = hovered_source_line + hovered_source_line = nil + if source_lines then + local source_height = H-P - y + local line = frame.line + local lines = math.floor (source_height / get_font_height ()) + local context = math.floor ((lines-1) / 2) + for i = line - context, line + context do + if source_lines [i] then + local y_before = y + local hovered = i == prev_hovered_line + local color = (hovered or i == line) and c_bright or c_dark + print_horizontal (string.format ('%d', i), color) + x = sx + print_horizontal (#source_lines .. ' ', c_clear) + print_line (source_lines [i], color) + if open_editor and over_section and y_before <= my and my < y then + hovered_source_line = i + end + end + end + else + print_line ('source unavailable') + end + end + + -- main loop + return function () + -- handle events + love.event.pump () + for e, a, b, c in love.event.poll () do + if e == "quit" or e == "keypressed" and a == "escape" then + return 1 + elseif e == "keypressed" then + keypressed (a) + elseif e == "mousepressed" and c == 1 then + mousepressed () + elseif e == "mousemoved" and love.timer then + mouse_moved_time = love.timer.getTime () + elseif e == "wheelmoved" and b ~= 0 then + wheelmoved (b) + elseif e == "touchpressed" then + local name = love.window.getTitle () + if #name == 0 or name == "Untitled" then + name = "Game" + end + local pressed = love.window.showMessageBox ( + "Quit " .. name .. "?", "", {"OK", "Cancel"}) + if pressed == 1 then + return + end + end + end + + update () + + -- draw + love.graphics.clear (love.graphics.getBackgroundColor ()) + draw () + love.graphics.setScissor() + love.graphics.present () + + -- wait + if love.timer then + if is_idle () then + love.timer.sleep (1/20) + else + love.timer.sleep (1/60) + end + end + end +end + +local errhand = love.errhand + +function love.errhand (msg) + local success, result = pcall (handle_error, msg) + if not success then + return errhand (tostring (msg) .. '\n\nerror during error handling: ' .. tostring (result)) + end + + local loop = result + local failed = false + + return function () + if failed then + return loop () + else + success, result = pcall (loop) + if not success then + failed = true + loop = errhand (tostring (msg) .. '\n\nerror during error handling: ' .. tostring (result)) + return loop () + end + return result + end + end +end + +return function (options) + if options.stack_limit then + if type (options.stack_limit) ~= 'number' then + error ('when provided, stack_limit must be a number') + end + stack_limit = math.floor (options.stack_limit) + end + if options.open_editor and not is_build () then + if type (options.open_editor) ~= 'function' then + error ('when provided, `open_editor` should be a function', 2) + end + open_editor = options.open_editor + end + if options.error_font then + if type(options.error_font) ~= 'userdata' or not options.error_font:typeOf 'Font' then + error('when provided, error_font must be a font', 2) + end + error_font = options.error_font + end + if options.source_font then + if type(options.source_font) ~= 'userdata' or not options.source_font:typeOf 'Font' then + error('when provided, source_font must be a font', 2) + end + source_font = options.source_font + end +end diff --git a/src/lib/metalua b/src/lib/metalua index bf3b9f40..471f968c 160000 --- a/src/lib/metalua +++ b/src/lib/metalua @@ -1 +1 @@ -Subproject commit bf3b9f40f1231115189dd8b236545f5ff057d517 +Subproject commit 471f968ca5768ae501860f791584af76d384218b diff --git a/src/main.lua b/src/main.lua index df9b01a2..7b834c77 100644 --- a/src/main.lua +++ b/src/main.lua @@ -1,32 +1,33 @@ -require("model.consoleModel") local redirect_to = require("model.io.redirect") +require("model.consoleModel") require("controller.controller") require("controller.consoleController") require("view.view") require("view.consoleView") + local colors = require("conf.colors") +local hostconf = prequire('host') require("util.key") require("util.debug") +require("lib/error_explorer") + G = love.graphics --- Find removable and user-writable storage --- Assumptions are made, which might be specific to the target platform/device ----@return boolean success ----@return string? path +--- @return boolean success +--- @return string? path local android_storage_find = function() + local OS = require("util.os") -- Yes, I know. We are working with the limitations of Android here. local quadhex = string.times('[0-9A-F]', 4) local uuid_regex = quadhex .. '-' .. quadhex local regex = '/dev/fuse /storage/' .. uuid_regex - local handle = io.popen(string.format("grep /proc/mounts -e '%s'", regex)) - if not handle then - return false - end - local result = handle:read("*a") - handle:close() - local lines = string.lines(result) + local grep = string.format("grep /proc/mounts -e '%s'", regex) + local _, result = OS.runcmd(grep) + local lines = string.lines(result or '') if not string.is_non_empty_string_array(lines) then return false end @@ -82,6 +83,7 @@ local config_view = function(sizedebug) -- this should lead to 16 lines visible by default on the -- console and the editor local lines = 16 + local input_max = 14 local font_labels = G.newFont( font_dir .. "PressStart2P-Regular.ttf", 12) @@ -100,6 +102,9 @@ local config_view = function(sizedebug) drawableWidth = drawableWidth * 2 end + local drawableChars = math.floor(drawableWidth / fw) + if love.DEBUG then drawableChars = drawableChars - 3 end + return { font = font_main, iconfont = font_icon, @@ -107,6 +112,7 @@ local config_view = function(sizedebug) fw = fw, lh = lh, lines = lines, + input_max = input_max, show_append_hl = false, labelfont = font_labels, @@ -122,7 +128,7 @@ local config_view = function(sizedebug) debugheight = debugheight, debugwidth = debugwidth, drawableWidth = drawableWidth, - drawableChars = math.floor(drawableWidth / fw), + drawableChars = drawableChars, } end @@ -202,16 +208,28 @@ function love.load(args) show_terminal = true, show_canvas = true, show_input = true, + once = 0 } end + local editorconf = { + --- TODO + mouse_enabled = false, + } + --- @class Config local baseconf = { view = viewconf, + editor = editorconf, autotest = autotest, drawtest = drawtest, sizedebug = sizedebug, } + + if hostconf then + hostconf.conf_app(viewconf) + end + --- MVC wiring local CM = ConsoleModel(baseconf) redirect_to(CM) diff --git a/src/model/canvasModel.lua b/src/model/canvasModel.lua index b84675d6..7b4b148f 100644 --- a/src/model/canvasModel.lua +++ b/src/model/canvasModel.lua @@ -1,6 +1,7 @@ require("util.dequeue") require("util.string") require("util.view") +local class = require('util.class') local Terminal = require("lib.terminal") local G = love.graphics @@ -17,14 +18,7 @@ local G = love.graphics --- @field get_canvas function --- @field draw_to function --- @field restore_main function -CanvasModel = {} -CanvasModel.__index = CanvasModel - -setmetatable(CanvasModel, { - __call = function(cls, ...) - return cls.new(...) - end, -}) +CanvasModel = class.create() --- @param cfg Config function CanvasModel.new(cfg) diff --git a/src/model/consoleModel.lua b/src/model/consoleModel.lua index 45c6f04b..22f142e1 100644 --- a/src/model/consoleModel.lua +++ b/src/model/consoleModel.lua @@ -1,3 +1,5 @@ +local class = require('util.class') + require("model.canvasModel") require("model.editor.editorModel") require("model.interpreter.interpreterModel") @@ -9,23 +11,12 @@ require("model.project.project") --- @field output CanvasModel --- @field projects ProjectService --- @field cfg Config -ConsoleModel = {} -ConsoleModel.__index = ConsoleModel - -setmetatable(ConsoleModel, { - __call = function(cls, ...) - return cls.new(...) - end, -}) - ---- @param cfg Config -function ConsoleModel.new(cfg) - local self = setmetatable({ +ConsoleModel = class.create(function(cfg) + return { interpreter = InterpreterModel(cfg), editor = EditorModel(cfg), output = CanvasModel(cfg), - projects = ProjectService:new(), + projects = ProjectService(), cfg = cfg - }, ConsoleModel) - return self -end + } +end) diff --git a/src/model/editor/bufferModel.lua b/src/model/editor/bufferModel.lua index 5842164f..79fe4915 100644 --- a/src/model/editor/bufferModel.lua +++ b/src/model/editor/bufferModel.lua @@ -1,43 +1,105 @@ +require("model.editor.content") + +local class = require('util.class') require('util.table') +require('util.range') +require('util.string') +require('util.dequeue') + +--- @alias Block Empty|Chunk +--- @alias Content Dequeue|Dequeue + +--- @alias Chunker fun(s: string[], s: boolean?): Dequeue +--- @alias Highlighter fun(c: str): SyntaxColoring +--- @alias Printer fun(c: string[]): string[]? + ---- @alias Content Dequeue ---- @alias Selected integer[] +--- @param name string +--- @param content string[] +--- @param chunker Chunker +--- @param highlighter Highlighter +--- @param printer function +--- @return BufferModel? +local function new(name, content, chunker, highlighter, printer) + local _content, sel, ct + local readonly = false + + if type(chunker) == "function" then + ct = 'lua' + local ok, blocks = chunker(content) + if ok then + local len = #blocks + sel = len + 1 + else + readonly = true + sel = 1 + end + _content = blocks + else + ct = 'plain' + _content = Dequeue(content, 'string') + sel = #_content + 1 + end + + return { + name = name or 'untitled', + content = _content, + content_type = ct, + chunker = chunker, + highlighter = highlighter, + printer = printer, + selection = sel, + readonly = readonly + } +end --- @class BufferModel --- @field name string ---- @field content Content ---- @field selection Selected +--- @field content Dequeue -- Content +--- @field content_type ContentType +--- @field selection integer +--- @field readonly boolean +--- @field revmap table --- +--- @field chunker Chunker +--- @field highlighter Highlighter +--- @field printer Printer --- @field move_selection function --- @field get_selection function --- @field get_selected_text function --- @field delete_selected_text function --- @field replace_selected_text function -BufferModel = {} -BufferModel.__index = BufferModel - -setmetatable(BufferModel, { - __call = function(cls, ...) - return cls.new(...) - end, -}) +--- @field render_content fun(self): string[] +BufferModel = class.create(new) ---- @param name string ---- @param content string[]? -function BufferModel.new(name, content) - local buffer = Dequeue(content) - local self = setmetatable({ - name = name or 'untitled', - content = buffer, - selection = { #buffer + 1 }, - }, BufferModel) +--- @return Dequeue +function BufferModel:get_content() + return self.content +end - return self +--- @return string[] +function BufferModel:get_text_content() + if self.content_type == 'lua' + then + return self:_render_blocks(self.content) + elseif self.content_type == 'plain' + then + return self.content + end + return {} end --- @return string[] -function BufferModel:get_content() - return self.content or {} +function BufferModel:_render_blocks(blocks) + local ret = Dequeue.typed('string') + for _, v in ipairs(blocks) do + if v.tag == 'chunk' then + ret:append_all(v.lines) + elseif v.tag == 'empty' then + ret:append('') + end + end + return ret end --- @return integer @@ -50,66 +112,157 @@ end --- @param warp boolean? --- @return boolean moved function BufferModel:move_selection(dir, by, warp) - -- TODO chunk selection + local last = self:get_content_length() + 1 if warp then if dir == 'up' then - self.selection[1] = 1 + self.selection = 1 return true end if dir == 'down' then - self.selection[1] = self:get_content_length() + 1 + self.selection = last return true end return false end - local cur = self.selection[1] + local cur = self.selection local by = by or 1 if dir == 'up' then if (cur - by) >= 1 then - self.selection[1] = cur - by + self.selection = cur - by return true end end if dir == 'down' then - if (cur + by) <= #(self.content) + 1 then - self.selection[1] = cur + by + if (cur + by) <= last then + self.selection = cur + by return true end end return false end ---- @return Selected +--- @return integer function BufferModel:get_selection() return self.selection end +--- @private +--- @return Block? +function BufferModel:_get_selected_block() + if self.content_type == 'plain' then return end + + local sel = self.selection + if sel == self:get_content_length() + 1 then + local ln = self.content:last().pos.fin + 1 + return Empty(ln) + end + return self.content[sel] +end + +--- @return integer? +function BufferModel:get_selection_start_line() + if self.content_type == 'lua' then + local b = self:_get_selected_block() + if b then + local ln = b.pos.start + return ln + end + else + return self.selection + end +end + --- @return string[] function BufferModel:get_selected_text() local sel = self.selection - -- continuous selection assumed - local si = sel[1] - local ei = sel[#sel] - return table.slice(self.content, si, ei) + if self.content_type == 'lua' then + --- @type Block + local s = self.content[sel] + if table.is_instance(s, 'chunk') then + return table.clone(s.lines) + else + return {} + end + else + return self.content[sel] or {} + end end function BufferModel:delete_selected_text() local sel = self.selection - -- continuous selection assumed - for i = #sel, 1, -1 do - self.content:remove(sel[i]) + if self.content_type == 'lua' then + local sb = self.content[sel] + if not sb then return end + + local l = sb.pos:len() + self.content:remove(sel) + for i = sel, self:get_content_length() do + local b = self.content[i] + local r = b.pos + b.pos = r:translate(-l) + end + else + self.content:remove(sel) end end ---- @param t string[] +--- @param t string[]|Block[] --- @return boolean insert ---- @return integer? +--- @return integer? inserted_lines function BufferModel:replace_selected_text(t) - local sel = self.selection - local clen = #(self.content) - if #sel == 1 then - local ti = sel[1] + if self.content_type == 'lua' then + local chunks = t + local n = #chunks + if n == 0 then + return false + end + local sel = self.selection + --- content start and original length + local cs, ol = (function() + local current = self.content[sel] + if current then + return current.pos.start, self.content[sel].pos:len() + end + local last = self.content:last() + if last then + return self.content:last().pos.fin + 1, 0 + else --- empty file + return 1, 0 + end + end)() + + if n == 1 then + local c = chunks[1] + local nr = c.pos:translate(cs - 1) + c.pos = nr + self.content[sel] = chunks[1] + else + --- remove old chunk + self.content:remove(sel) + --- insert new version of the chunk(s) + for i = #chunks, 1, -1 do + local c = chunks[i] + local nr = c.pos:translate(cs - 1) + c.pos = nr + self.content:insert(c, sel) + end + end + -- Log.debug(Debug.terse_array(self.content, sel)) + --- move subsequent chunks down + local diff = chunks[n].pos:len() - ol + if diff ~= 0 then + for i = sel + 1, self:get_content_length() do + local b = self.content[i] + b.pos = b.pos:translate(diff) + end + end + + return true, n + else + local sel = self.selection + local clen = #(self.content) + local ti = sel if #t == 1 then self.content[ti] = t[1] if ti > clen then @@ -122,8 +275,6 @@ function BufferModel:replace_selected_text(t) end return true, #t end - else - -- TODO multiine + return false end - return false end diff --git a/src/model/editor/content.lua b/src/model/editor/content.lua new file mode 100644 index 00000000..03d9421c --- /dev/null +++ b/src/model/editor/content.lua @@ -0,0 +1,48 @@ +local class = require('util.class') +require("util.range") + +--- @class Empty +--- @field tag 'empty' +--- @field pos Range +Empty = class.create(function(ln) + return { + tag = 'empty', + pos = Range.singleton(ln), + } +end) + +function Empty:__tostring() + return string.format('L%d: ', self.pos.start) +end + +--- @class Chunk +--- @field tag 'chunk' +--- @field lines string[] +--- @field hl SyntaxColoring +--- @field pos Range +Chunk = class.create() + +--- @param lines str +--- @return Chunk +function Chunk.new(lines, pos) + local ls = (function() + if type(lines) == 'string' then return { lines } end + return lines + end)() + local self = setmetatable({ + tag = 'chunk', + lines = ls, + pos = pos, + }, Chunk) + + return self +end + +function Chunk:__tostring() + local ret = '' + for i, l in ipairs(self.lines) do + ret = ret .. string.format('\nL%d:\t%s', + self.pos.start + i - 1, l) + end + return ret +end diff --git a/src/model/editor/editorModel.lua b/src/model/editor/editorModel.lua index ba329674..3a775b9f 100644 --- a/src/model/editor/editorModel.lua +++ b/src/model/editor/editorModel.lua @@ -1,26 +1,17 @@ require("model.editor.bufferModel") require("model.interpreter.interpreterModel") +require("model.input.userInputModel") + +local class = require('util.class') --- @class EditorModel ---- @field interpreter InterpreterModel +--- @field input UserInputModel --- @field buffer BufferModel? --- @field cfg Config -EditorModel = {} -EditorModel.__index = EditorModel - -setmetatable(EditorModel, { - __call = function(cls, ...) - return cls.new(...) - end, -}) - ---- @param cfg Config -function EditorModel.new(cfg) - local self = setmetatable({ - interpreter = InterpreterModel(cfg), -- EditorInterpreter? +EditorModel = class.create(function(cfg) + return { + input = UserInputModel(cfg), buffer = nil, cfg = cfg, - }, EditorModel) - - return self -end + } +end) diff --git a/src/model/input/cursor.lua b/src/model/input/cursor.lua index fd9d4114..a96917e5 100644 --- a/src/model/input/cursor.lua +++ b/src/model/input/cursor.lua @@ -1,23 +1,20 @@ +local class = require('util.class') + --- @class Cursor --- @field l number --- @field c number -Cursor = { - l = 0, - c = 0 -} - -function Cursor:new(l, c) +Cursor = class.create(function(l, c) local ll = l or 1 local cc = c or 1 - local cur = { l = ll, c = cc } - setmetatable(cur, self) - self.__index = self + return { l = ll, c = cc } +end) - return cur +function Cursor:__tostring() + return string.format('{l%d, %d}', self.l, self.c) end -function Cursor:inline(c) - return Cursor:new(1, c) +function Cursor.inline(c) + return Cursor(1, c) end function Cursor:compare(other) diff --git a/src/model/input/inputModel.lua b/src/model/input/inputModel.lua index 3389a321..2ed6d9a5 100644 --- a/src/model/input/inputModel.lua +++ b/src/model/input/inputModel.lua @@ -1,7 +1,9 @@ -require("model.interpreter.item") require("model.input.inputText") require("model.input.selection") +require("model.lang.error") +require("view.editor.visibleContent") +local class = require('util.class') require("util.wrapped_text") require("util.dequeue") require("util.string") @@ -10,10 +12,11 @@ require("util.debug") --- @class InputModel --- @field oneshot boolean --- @field entered InputText ---- @field evaluator EvalBase ---- @field type InputType +--- @field evaluator Evaluator +--- @field label string --- @field cursor Cursor --- @field wrapped_text WrappedText +--- @field visible VisibleContent --- @field selection InputSelection --- @field cfg Config --- @field custom_status CustomStatus? @@ -26,28 +29,38 @@ require("util.debug") --- @field get_text_line fun(self, integer): string --- @field get_n_text_lines fun(self): integer --- @field get_wrapped_text fun(self): WrappedText -InputModel = {} +InputModel = class.create() + --- @param cfg Config ---- @param eval EvalBase +--- @param eval Evaluator --- @param oneshot boolean? -function InputModel:new(cfg, eval, oneshot) - local im = { +function InputModel.new(cfg, eval, oneshot) + local w = cfg.view.drawableChars + local self = setmetatable({ oneshot = oneshot, - entered = InputText:new(), + entered = InputText(), evaluator = eval, - type = eval.kind, - cursor = Cursor:new(), - wrapped_text = WrappedText.new(cfg.view.drawableChars), - selection = InputSelection:new(), + label = eval.label, + cursor = Cursor(), + wrapped_text = WrappedText(w), + selection = InputSelection(), custom_status = nil, cfg = cfg, - } - setmetatable(im, self) - self.__index = self + }, InputModel) + + InputModel.init_visible(self, { '' }) + + return self +end - return im +--- @param text string[] +function InputModel:init_visible(text) + local w = self.cfg.view.drawableChars + local s = self.cfg.view.input_max + self.visible = VisibleContent(w, text, 1, s) + self.visible:set_default_range() end ---------------- @@ -86,7 +99,7 @@ function InputModel:add_text(text) end end ---- @param text string|string[] +--- @param text str --- @param keep_cursor boolean function InputModel:set_text(text, keep_cursor) self.entered = nil @@ -94,20 +107,23 @@ function InputModel:set_text(text, keep_cursor) local lines = string.lines(text) local n_added = #lines if n_added == 1 then - self.entered = InputText:new({ text }) + self.entered = InputText({ text }) end if not keep_cursor then self:_update_cursor(true) end elseif type(text) == 'table' then - self.entered = InputText:new(text) + self.entered = InputText(text) end - self:jump_end() self:text_change() + if not keep_cursor then + self:init_visible(self.entered) + end + self:jump_end() end --- @private ---- @param text string|string[] +--- @param text str --- @param ln integer --- @param keep_cursor boolean function InputModel:_set_text_line(text, ln, keep_cursor) @@ -119,7 +135,7 @@ function InputModel:_set_text_line(text, ln, keep_cursor) self:_update_cursor(true) end elseif type(text) == 'table' and ln == 1 then - self.entered = InputText:new(text) + self.entered = InputText(text) end end end @@ -151,7 +167,7 @@ end --- @return InputText function InputModel:get_text() - return self.entered or InputText:new() + return self.entered or InputText() end --- @param l integer @@ -256,11 +272,10 @@ function InputModel:delete() end function InputModel:clear_input() - self.entered = InputText:new() + self.entered = InputText() self:text_change() self:clear_selection() self:_update_cursor(true) - self.tokens = nil self.custom_status = nil end @@ -269,38 +284,26 @@ function InputModel:reset() end function InputModel:text_change() - local ev = self.evaluator - if ev.kind == 'lua' then - -- TODO enforce this kind-parser invariant in types - ---@diagnostic disable-next-line: undefined-field - local ts = ev.parser.tokenize(self:get_text()) - self.tokens = ts - end self.wrapped_text:wrap(self.entered) + self.visible:wrap(self.entered) + self.visible:check_range() + self:_follow_cursor() end --- @return Highlight? function InputModel:highlight() local ev = self.evaluator - if ev.highlight then - -- TODO enforce this highligh-parser invariant in types - ---@diagnostic disable-next-line: undefined-field - local p = ev.parser + local p = ev.parser + if p and p.highlighter then local text = self:get_text() - local lex = p.stream_tokens(text) - -- iterating over the stream exhausts it - local tokens = p.realize_stream(lex) - local ok, err = p.parse_prot(text) + local ok, err = p.parse(text) local parse_err if not ok then - local l, c, msg = p.get_error(err) - parse_err = { l = l, c = c, msg = msg } + parse_err = err end + local hl = p.highlighter(text) - return { - parse_err = parse_err, - hl = p.syntax_hl(tokens), - } + return { hl = hl, parse_err = parse_err } end end @@ -308,6 +311,19 @@ end -- cursor -- ---------------- +--- Follow cursor movement with visible range +--- @private +function InputModel:_follow_cursor() + local cl, cc = self:_get_cursor_pos() + local w = self.cfg.view.drawableChars + local acl = cl + (math.floor(cc / w) or 0) + local vrange = self.visible:get_range() + local diff = vrange:outside(acl) + if diff ~= 0 then + self.visible:move_range(diff) + end +end + --- @private --- @param replace_line boolean function InputModel:_update_cursor(replace_line) @@ -322,8 +338,8 @@ function InputModel:_update_cursor(replace_line) end --- @private ---- @param x integer ---- @param y integer +--- @param x integer? +--- @param y integer? function InputModel:_advance_cursor(x, y) local cur_l, cur_c = self:_get_cursor_pos() local move_x = x or 1 @@ -337,6 +353,9 @@ function InputModel:_advance_cursor(x, y) end end +--- @param y integer? +--- @param x integer? +--- @param selection 'keep'|'move'? function InputModel:move_cursor(y, x, selection) local prev_l, prev_c = self:_get_cursor_pos() local c, l @@ -363,6 +382,7 @@ function InputModel:move_cursor(y, x, selection) else self:clear_selection() end + self:_follow_cursor() end --- @private @@ -387,6 +407,17 @@ function InputModel:get_cursor_y() return self.cursor.l end +--- @return InputDTO +function InputModel:get_input() + return { + text = self:get_text(), + wrapped_text = self:get_wrapped_text(), + highlight = self:highlight(), + selection = self:get_ordered_selection(), + visible = self.visible, + } +end + --- @param dir VerticalDir --- @return boolean? limit function InputModel:cursor_vertical_move(dir) @@ -472,6 +503,7 @@ function InputModel:cursor_vertical_move(dir) else return end + if not limit then self:_follow_cursor() end return limit end @@ -527,6 +559,7 @@ function InputModel:jump_home() local nl, nc = 1, 1 self:end_selection(nl, nc) self:move_cursor(nl, nc, keep) + self:_follow_cursor() end function InputModel:jump_end() @@ -540,18 +573,46 @@ function InputModel:jump_end() end)() self:end_selection(last_line, last_char) self:move_cursor(last_line, last_char, keep) + self.visible:to_end() + self.visible:check_range() end --- @return Status function InputModel:get_status() return { - input_type = self.type, + label = self.label, cursor = self.cursor, n_lines = self:get_n_text_lines(), - custom = self.custom_status + custom = self.custom_status, + input_more = self.visible:get_more(), } end +function InputModel:jump_line_start() + local keep = (function() + if self.selection:is_held() then + return 'keep' + end + end)() + local l = self.cursor.l + local nc = 1 + self:end_selection(l, nc) + self:move_cursor(l, nc, keep) +end + +function InputModel:jump_line_end() + local ent = self:get_text() + local line = self.cursor.l + local char = string.ulen(ent[line]) + 1 + local keep = (function() + if self.selection:is_held() then + return 'keep' + end + end)() + self:end_selection(line, char) + self:move_cursor(line, char, keep) +end + --- @param cs CustomStatus function InputModel:set_custom_status(cs) if type(cs) == 'table' then @@ -567,7 +628,7 @@ end function InputModel:finish() local ent = self:get_text() --- @diagnostic disable-next-line: param-type-mismatch - if self.oneshot then love.event.push('userinput', ent) end + if self.oneshot then love.event.push('userinput') end return ent end @@ -575,10 +636,10 @@ function InputModel:cancel() self:clear_input() end ---- @param eval EvalBase +--- @param eval Evaluator function InputModel:set_eval(eval) self.evaluator = eval - self.type = eval.kind + self.label = eval.label or '' end ---------------- @@ -617,9 +678,9 @@ end function InputModel:start_selection(l, c) local start = (function() if l and c then - return Cursor:new(l, c) + return Cursor(l, c) else -- default to current cursor position - return Cursor:new(self:_get_cursor_pos()) + return Cursor(self:_get_cursor_pos()) end end)() self.selection.start = start @@ -629,9 +690,9 @@ function InputModel:end_selection(l, c) local start = self.selection.start local fin = (function() if l and c then - return Cursor:new(l, c) + return Cursor(l, c) else -- default to current cursor position - return Cursor:new(self:_get_cursor_pos()) + return Cursor(self:_get_cursor_pos()) end end)() local from, to = self:diff_cursors(start, fin) @@ -673,7 +734,7 @@ end function InputModel:get_ordered_selection() local sel = self.selection local s, e = self:diff_cursors(sel.start, sel.fin) - local ret = InputSelection:new() + local ret = InputSelection() ret.start = s ret.fin = e ret.text = sel.text @@ -700,7 +761,7 @@ function InputModel:pop_selected_text() end function InputModel:clear_selection() - self.selection = InputSelection:new() + self.selection = InputSelection() self:release_selection() end @@ -721,7 +782,8 @@ end function InputModel:mouse_drag(l, c) local li, ci = self:translate_grid_to_cursor(l, c) local sel = self:get_selection() - if sel.start and sel.held then + local held = sel.held and love.mouse.isDown(1) + if sel.start and held then self:end_selection(li, ci) self:move_cursor(li, ci, 'move') end diff --git a/src/model/input/inputText.lua b/src/model/input/inputText.lua index 68a65785..ac83590f 100644 --- a/src/model/input/inputText.lua +++ b/src/model/input/inputText.lua @@ -1,25 +1,33 @@ require("model.input.cursor") + +local class = require('util.class') require("util.dequeue") --- @class InputText: Dequeue --- @field new function --- @field traverse function -InputText = {} +InputText = class.create() --- @param values string[]? -function InputText:new(values) - local text = Dequeue(values) +--- @return InputText +function InputText.new(values) + --- @type InputText + --- @diagnostic disable-next-line: assign-type-mismatch + local self = Dequeue.typed('string', values) if not values or values == '' or (type(values) == "table" and #values == 0) then - text:append('') + self:append('') end - setmetatable(self, Dequeue) - setmetatable(text, self) - self.__index = self + setmetatable(self, { + __index = function(t, k) + local value = InputText[k] or Dequeue[k] + return value + end + }) - return text + return self end --- Traverses text between two cursor positions @@ -36,7 +44,7 @@ function InputText:traverse(from, to, options) local ce = to.c - 1 local lines = string.lines(self) - local ret = Dequeue() + local ret = Dequeue.typed('string') local defaults = { delete = false, } diff --git a/src/model/input/selection.lua b/src/model/input/selection.lua index c5016dac..fee0e4f2 100644 --- a/src/model/input/selection.lua +++ b/src/model/input/selection.lua @@ -1,25 +1,31 @@ require("model.input.cursor") +local class = require('util.class') + --- @class InputSelection --- @field start Cursor? --- @field fin Cursor? --- @field text string[] --- @field held boolean -InputSelection = {} - -function InputSelection:new() - local s = { +InputSelection = class.create(function() + return { + --- not a Range, because the ends are optional start = nil, fin = nil, text = { '' }, held = false, } - setmetatable(s, self) - self.__index = self - - return s -end +end) function InputSelection:is_held() return self.held end + +function InputSelection:__tostring() + local s = self.start + local e = self.fin + local held = (function() + if self.held then return 'v' else return '^' end + end)() + return string.format('{%d-%d}[%d]', s, e, held) +end diff --git a/src/model/input/userInputModel.lua b/src/model/input/userInputModel.lua new file mode 100644 index 00000000..11008756 --- /dev/null +++ b/src/model/input/userInputModel.lua @@ -0,0 +1,855 @@ +require("model.input.inputText") +require("model.input.selection") +require("model.lang.error") +require("view.editor.visibleContent") + +local class = require('util.class') +require("util.wrapped_text") +require("util.dequeue") +require("util.string") +require("util.debug") + +--- @class UserInputModel +--- @field oneshot boolean +--- @field entered InputText +--- @field evaluator Evaluator +--- @field label string +--- @field cursor Cursor +--- @field wrapped_text WrappedText +--- @field error string[]? +--- @field visible VisibleContent +--- @field selection InputSelection +--- @field cfg Config +--- @field custom_status CustomStatus? +--- methods +--- @field new function +--- @field add_text fun(self, string) +--- @field set_text fun(self, string, boolean) +--- @field line_feed fun(self) +--- @field get_text fun(self): InputText +--- @field get_text_line fun(self, integer): string +--- @field get_n_text_lines fun(self): integer +--- @field get_wrapped_text fun(self): WrappedText +--- @field get_wrapped_error fun(self): string[]? +UserInputModel = class.create() + + +--- @param cfg Config +--- @param eval Evaluator +--- @param oneshot boolean? +function UserInputModel.new(cfg, eval, oneshot) + local w = cfg.view.drawableChars + local self = setmetatable({ + oneshot = oneshot, + entered = InputText(), + evaluator = eval, + cursor = Cursor(), + wrapped_text = WrappedText(w), + selection = InputSelection(), + custom_status = nil, + + cfg = cfg, + }, UserInputModel) + + UserInputModel.init_visible(self, { '' }) + + return self +end + +function UserInputModel:get_label() + local eval = self.evaluator + if eval and eval.label then return eval.label end +end + +--- @param text string[] +function UserInputModel:init_visible(text) + local w = self.cfg.view.drawableChars + local s = self.cfg.view.input_max + self.visible = VisibleContent(w, text, 1, s) + self.visible:set_default_range() +end + +---------------- +-- entered -- +---------------- + +--- @param text string +function UserInputModel:add_text(text) + if type(text) == 'string' then + self:pop_selected_text() + local sl, cc = self:_get_cursor_pos() + local cur_line = self:get_text_line(sl) + local pre, post = string.split_at(cur_line, cc) + local lines = string.lines(text) + local n_added = #lines + if n_added == 1 then + local nval = string.interleave(pre, text, post) + self:_set_text_line(nval, sl, true) + self:_advance_cursor(string.ulen(text)) + else + for k, line in ipairs(lines) do + if k == 1 then + local nval = pre .. line + self:_set_text_line(nval, sl, true) + elseif k == n_added then + local nval = line .. post + local last_line_i = sl + k - 1 + self:_set_text_line(nval, last_line_i, true) + self:move_cursor(last_line_i, string.ulen(line) + 1) + else + self:_insert_text_line(line, sl + k - 1) + end + end + end + self:text_change() + end +end + +--- @param text str +--- @param keep_cursor boolean +function UserInputModel:set_text(text, keep_cursor) + self.entered = nil + if type(text) == 'string' then + local lines = string.lines(text) + local n_added = #lines + if n_added == 1 then + self.entered = InputText({ text }) + end + if not keep_cursor then + self:_update_cursor(true) + end + elseif type(text) == 'table' then + self.entered = InputText(text) + end + self:text_change() + if not keep_cursor then + self:init_visible(self.entered) + end + self:jump_end() +end + +--- @private +--- @param text str +--- @param ln integer +--- @param keep_cursor boolean +function UserInputModel:_set_text_line(text, ln, keep_cursor) + if type(text) == 'string' then + local ent = self:get_text() + if ent then + ent:update(text, ln) + if not keep_cursor then + self:_update_cursor(true) + end + elseif type(text) == 'table' and ln == 1 then + self.entered = InputText(text) + end + end +end + +--- @private +--- @param ln integer +function UserInputModel:_drop_text_line(ln) + self:get_text():remove(ln) +end + +--- @private +--- @param text string +--- @param li integer +function UserInputModel:_insert_text_line(text, li) + local l = li or self:get_cursor_y() + self.cursor.l = l + 1 + self:get_text():insert(text, l) +end + +function UserInputModel:line_feed() + local cl, cc = self:_get_cursor_pos() + local cur_line = self:get_text_line(cl) + local pre, post = string.split_at(cur_line, cc) + self:_set_text_line(pre, cl, true) + self:_insert_text_line(post, cl + 1) + self:move_cursor(cl + 1, 1) + self:text_change() +end + +--- @return InputText +function UserInputModel:get_text() + return self.entered or InputText() +end + +--- @param l integer +--- @return string +function UserInputModel:get_text_line(l) + local ent = self:get_text() + return ent:get(l) or '' +end + +--- @return integer +function UserInputModel:get_n_text_lines() + local ent = self:get_text() + return ent:length() +end + +--- @return WrappedText +function UserInputModel:get_wrapped_text() + return self.wrapped_text +end + +--- @param l integer +--- @return string +function UserInputModel:get_wrapped_text_line(l) + return self.wrapped_text:get_line(l) +end + +--- @private +--- @return string +function UserInputModel:_get_current_line() + local cl = self:get_cursor_y() or 1 + return self:get_text():get(cl) +end + +function UserInputModel:paste(text) + local sel = self:get_selection() + local start = sel.start + local fin = sel.fin + if start and start.l and fin and fin.l and fin.c then + local from, to = self:diff_cursors(start, fin) + self:get_text():traverse(from, to, { delete = true }) + self:move_cursor(from.l, from.c) + end + self:add_text(text) + self:clear_selection() +end + +function UserInputModel:backspace() + self:pop_selected_text() + local line = self:_get_current_line() + local cl, cc = self:_get_cursor_pos() + local newcl = cl - 1 + local pre, post + + if cc == 1 then + if cl == 1 then -- can't delete nothing + return + end + -- line merge + pre = self:get_text_line(newcl) + local pre_len = string.ulen(pre) + post = line + local nval = pre .. post + self:_set_text_line(nval, newcl, true) + self:move_cursor(newcl, pre_len + 1) + self:_drop_text_line(cl) + else + -- regular merge + pre = string.usub(line, 1, cc - 2) + post = string.usub(line, cc) + local nval = pre .. post + self:_set_text_line(nval, cl, true) + self:cursor_left() + end + self:text_change() +end + +function UserInputModel:delete() + self:pop_selected_text() + local line = self:_get_current_line() + local cl, cc = self:_get_cursor_pos() + local pre, post + + local n = self:get_n_text_lines() + + local llen = string.ulen(line) + if cc == llen + 1 then + if cl == n then + return + end + -- line merge + post = self:get_text_line(cl + 1) + pre = line + self:_drop_text_line(cl + 1) + else + -- regular merge + pre = string.usub(line, 1, cc - 1) + post = string.usub(line, cc + 1) + end + local nval = pre .. post + self:_set_text_line(nval, cl, true) + self:text_change() +end + +function UserInputModel:clear_input() + self.entered = InputText() + self:text_change() + self:clear_selection() + self:_update_cursor(true) + self.custom_status = nil +end + +function UserInputModel:reset() + self:clear_input() +end + +function UserInputModel:text_change() + self.wrapped_text:wrap(self.entered) + self.visible:wrap(self.entered) + self.visible:check_range() + self:_follow_cursor() +end + +--- @return Highlight? +function UserInputModel:highlight() + local ev = self.evaluator + local p = ev.parser + if p and p.highlighter then + local text = self:get_text() + local ok, err = p.parse(text) + local parse_err + if not ok then + parse_err = err + end + local hl = p.highlighter(text) + + return { hl = hl, parse_err = parse_err } + end +end + +---------------- +-- cursor -- +---------------- + +--- Follow cursor movement with visible range +--- @private +function UserInputModel:_follow_cursor() + local cl, cc = self:_get_cursor_pos() + local w = self.cfg.view.drawableChars + local acl = cl + (math.floor(cc / w) or 0) + local vrange = self.visible:get_range() + local diff = vrange:outside(acl) + if diff ~= 0 then + self.visible:move_range(diff) + end +end + +--- @private +--- @param replace_line boolean +function UserInputModel:_update_cursor(replace_line) + local cl = self:get_cursor_y() + local t = self:get_text() + if replace_line then + self.cursor.c = string.ulen(t[cl]) + 1 + self.cursor.l = #t + else + + end +end + +--- @private +--- @param x integer? +--- @param y integer? +function UserInputModel:_advance_cursor(x, y) + local cur_l, cur_c = self:_get_cursor_pos() + local move_x = x or 1 + local move_y = y or 0 + if move_y == 0 then + local next = cur_c + move_x + self.cursor.c = next + else + self.cursor.l = cur_l + move_y + -- TODO multiline + end +end + +--- @param y integer? +--- @param x integer? +--- @param selection 'keep'|'move'? +function UserInputModel:move_cursor(y, x, selection) + local prev_l, prev_c = self:_get_cursor_pos() + local c, l + local line_limit = self:get_n_text_lines() + 1 -- allow for line just being added + if y and y >= 1 and y <= line_limit then + l = y + else + l = prev_l + end + local llen = #(self:get_text_line(l)) + local char_limit = llen + 1 + if x and x >= 1 and x <= char_limit then + c = x + else + c = prev_c + end + self.cursor = { + c = c, + l = l + } + + if selection == 'keep' then + elseif selection == 'move' then + else + self:clear_selection() + end + self:_follow_cursor() +end + +--- @private +--- @return integer l +--- @return integer c +function UserInputModel:_get_cursor_pos() + return self.cursor.l, self.cursor.c +end + +--- @return CursorInfo +function UserInputModel:get_cursor_info() + return { + cursor = self.cursor, + } +end + +function UserInputModel:get_cursor_x() + return self.cursor.c +end + +function UserInputModel:get_cursor_y() + return self.cursor.l +end + +--- @return InputDTO +function UserInputModel:get_input() + return { + text = self:get_text(), + highlight = self:highlight(), + selection = self:get_ordered_selection(), + visible = self.visible, + wrapped_error = self:get_wrapped_error(), + } +end + +--- @param dir VerticalDir +--- @return boolean? limit +function UserInputModel:cursor_vertical_move(dir) + local cl, cc = self:_get_cursor_pos() + local w = self.wrapped_text.wrap_w + local n = self:get_n_text_lines() + local llen = string.ulen(self:get_text_line(cl)) + local full_lines = math.floor(llen / w) + + --- @param is_inline function + --- @param is_not_last_line function + --- @return boolean? limit + local function move(is_inline, is_not_last_line) + local keep = (function() + if self.selection:is_held() then + return 'keep' + end + end)() + local function sgn(back, fwd) + if dir == 'up' then + return back() + elseif dir == 'down' then + return fwd() + end + end + if llen > w and is_inline() then + local newc = sgn( + function() return math.max(cc - w, 0) end, + function() return math.min(cc + w, llen + 1) end + ) + self:move_cursor(cl, newc, keep) + if keep then self:end_selection() end + return + end + if is_not_last_line() then + local nl = sgn( + function() return cl - 1 end, + function() return cl + 1 end + ) + local target_line = self:get_text_line(nl) + local target_len = string.ulen(target_line) + local offset = math.fmod(cc, w) + local newc + if target_len > w then + local base = sgn( + function() return math.floor(target_len / w) * w end, + function() return 0 end + ) + local t_offset = sgn( + function() return math.fmod(target_len, w) + 1 end, + function() return math.fmod(w, target_len) end + ) + + local new_off = math.min(offset, t_offset) + newc = base + new_off + else + newc = math.min(offset, 1 + string.ulen(target_line)) + end + self:move_cursor(nl, newc, keep) + if keep then self:end_selection() end + else + if self:is_selection_held() then + sgn( + function() self:jump_home() end, + function() self:jump_end() end + ) + end + return true + end + end + + local limit + if dir == 'up' then + limit = move( + function() return cc - w > 0 end, + function() return cl > 1 end + ) + elseif dir == 'down' then + limit = move( + function() return cc <= full_lines * w end, + function() return cl < n end + ) + else + return + end + if not limit then self:_follow_cursor() end + return limit +end + +function UserInputModel:cursor_left() + local cl, cc = self:_get_cursor_pos() + local nl, nc = (function() + if cc > 1 then + local next = cc - 1 + return nil, next + elseif cl > 1 then + local cpl = cl - 1 + local pl = self:get_text_line(cpl) + local cpc = 1 + string.ulen(pl) + return cpl, cpc + end + end)() + + if self.selection:is_held() then + self:move_cursor(nl, nc, 'keep') + self:end_selection() + else + self:move_cursor(nl, nc) + end +end + +function UserInputModel:cursor_right() + local cl, cc = self:_get_cursor_pos() + local line = self:get_text_line(cl) + local len = string.ulen(line) + local next = cc + 1 + local nl, nc = (function() + if cc <= len then + return nil, next + elseif cl < self:get_n_text_lines() then + return cl + 1, 1 + end + end)() + + if self.selection:is_held() then + self:end_selection(cl, cc + 1) + self:move_cursor(nl, nc, 'keep') + else + self:move_cursor(nl, nc) + end +end + +function UserInputModel:jump_home() + local keep = (function() + if self.selection:is_held() then + return 'keep' + end + end)() + local nl, nc = 1, 1 + self:end_selection(nl, nc) + self:move_cursor(nl, nc, keep) + self:_follow_cursor() +end + +function UserInputModel:jump_end() + local ent = self:get_text() + local last_line = #ent + local last_char = string.ulen(ent[last_line]) + 1 + local keep = (function() + if self.selection:is_held() then + return 'keep' + end + end)() + self:end_selection(last_line, last_char) + self:move_cursor(last_line, last_char, keep) + self.visible:to_end() + self.visible:check_range() +end + +--- @return Status +function UserInputModel:get_status() + return { + label = self.label, + cursor = self.cursor, + n_lines = self:get_n_text_lines(), + custom = self.custom_status, + input_more = self.visible:get_more(), + } +end + +function UserInputModel:jump_line_start() + local keep = (function() + if self.selection:is_held() then + return 'keep' + end + end)() + local l = self.cursor.l + local nc = 1 + self:end_selection(l, nc) + self:move_cursor(l, nc, keep) +end + +function UserInputModel:jump_line_end() + local ent = self:get_text() + local line = self.cursor.l + local char = string.ulen(ent[line]) + 1 + local keep = (function() + if self.selection:is_held() then + return 'keep' + end + end)() + self:end_selection(line, char) + self:move_cursor(line, char, keep) +end + +--- @param cs CustomStatus +function UserInputModel:set_custom_status(cs) + if type(cs) == 'table' then + self.custom_status = cs + end +end + +---------------- +-- evaluation -- +---------------- + +--- @return boolean +--- @return string[] +function UserInputModel:evaluate() + return self:handle(true) +end + +function UserInputModel:cancel() + self:handle(false) +end + +--- @param eval boolean +--- @return boolean +--- @return string[]|EvalError[] +function UserInputModel:handle(eval) + local ent = self:get_text() + self.historic_index = nil + local ok, result + if string.is_non_empty_string_array(ent) then + local ev = self.evaluator + -- self:_remember(ent) + if eval then + ok, result = ev.apply(ent) + if ok then + if self.oneshot then + --- @diagnostic disable-next-line: param-type-mismatch + love.event.push('userinput') + end + else + return false, result + end + else + ok = true + end + end + + return ok, result +end + +---------------- +-- error -- +---------------- +function UserInputModel:clear_error() + self.error = nil +end + +--- @return string[]? +function UserInputModel:get_wrapped_error() + if self.error then + local we = string.wrap_array( + self.error, + self.wrapped_text.wrap_w) + table.insert(we, 1, 'Errors:') + return we + end +end + +--- @param errors EvalError[]? +function UserInputModel:set_error(errors) + self.error = {} + if type(errors) == "table" then + for _, e in ipairs(errors) do + table.insert(self.error, tostring(e)) + end + end +end + +--- @return boolean +function UserInputModel:has_error() + return string.is_non_empty_string_array(self.error) +end + +--- @param eval Evaluator +function UserInputModel:set_eval(eval) + self.evaluator = eval + self.label = eval.label or '' +end + +---------------- +-- selection -- +---------------- +function UserInputModel:translate_grid_to_cursor(l, c) + local wt = self.wrapped_text.wrap_reverse + local li = wt[l] or wt[#wt] + local line = self:get_wrapped_text_line(l) + local llen = string.ulen(line) + local c_offset = math.min(llen + 1, c) + local c_base = l - li + local ci = c_base * self.wrapped_text.wrap_w + c_offset + return li, ci +end + +function UserInputModel:diff_cursors(c1, c2) + if c1 and c2 then + local d = c1:compare(c2) + if d > 0 then + return c1, c2 + else + return c2, c1 + end + end +end + +function UserInputModel:text_between_cursors(from, to) + if from and to then + return self:get_text():traverse(from, to) + else + return { '' } + end +end + +function UserInputModel:start_selection(l, c) + local start = (function() + if l and c then + return Cursor(l, c) + else -- default to current cursor position + return Cursor(self:_get_cursor_pos()) + end + end)() + self.selection.start = start +end + +function UserInputModel:end_selection(l, c) + local start = self.selection.start + local fin = (function() + if l and c then + return Cursor(l, c) + else -- default to current cursor position + return Cursor(self:_get_cursor_pos()) + end + end)() + local from, to = self:diff_cursors(start, fin) + local sel = self:text_between_cursors(from, to) + self.selection.fin = fin + self.selection.text = sel +end + +function UserInputModel:hold_selection(is_mouse) + if not is_mouse then + local cur_start = self:get_selection().start + local cur_end = self:get_selection().fin + if cur_start and cur_start.l and cur_start.c then + self:start_selection(cur_start.l, cur_start.c) + else + self:start_selection() + end + if cur_end and cur_end.l and cur_end.c then + self:end_selection(cur_end.l, cur_end.c) + else + self:end_selection() + end + end + self.selection.held = true +end + +function UserInputModel:release_selection() + self.selection.held = false +end + +function UserInputModel:get_selection() + return self.selection +end + +function UserInputModel:is_selection_held() + return self.selection.held +end + +function UserInputModel:get_ordered_selection() + local sel = self.selection + local s, e = self:diff_cursors(sel.start, sel.fin) + local ret = InputSelection() + ret.start = s + ret.fin = e + ret.text = sel.text + ret.held = sel.held + return ret +end + +function UserInputModel:get_selected_text() + return self.selection.text +end + +function UserInputModel:pop_selected_text() + local t = self.selection.text + local start = self.selection.start + local fin = self.selection.fin + if start and fin then + local from, to = self:diff_cursors(start, fin) + self:get_text():traverse(from, to, { delete = true }) + self:text_change() + self:move_cursor(from.l, from.c) + self:clear_selection() + return t + end +end + +function UserInputModel:clear_selection() + self.selection = InputSelection() + self:release_selection() +end + +function UserInputModel:mouse_click(l, c) + local li, ci = self:translate_grid_to_cursor(l, c) + self:clear_selection() + self:start_selection(li, ci) + self:hold_selection(true) +end + +function UserInputModel:mouse_release(l, c) + local li, ci = self:translate_grid_to_cursor(l, c) + self:release_selection() + self:end_selection(li, ci) + self:move_cursor(li, ci, 'keep') +end + +function UserInputModel:mouse_drag(l, c) + local li, ci = self:translate_grid_to_cursor(l, c) + local sel = self:get_selection() + local held = sel.held and love.mouse.isDown(1) + if sel.start and held then + self:end_selection(li, ci) + self:move_cursor(li, ci, 'move') + end +end diff --git a/src/model/interpreter/eval/evalBase.lua b/src/model/interpreter/eval/evalBase.lua deleted file mode 100644 index 90990a65..00000000 --- a/src/model/interpreter/eval/evalBase.lua +++ /dev/null @@ -1,39 +0,0 @@ ---- @class EvalBase ---- @field kind string ---- @field is_lua boolean ---- @field highlight boolean ---- @field apply function -EvalBase = { - kind = '', - apply = function(input) - return input - end, - is_lua = false, - highlight = false, -} - ---- Create a new evaluator ----@param kind string ----@param evaluator function ----@param highlight boolean ----@return EvalBase -function EvalBase:inherit(kind, evaluator, highlight) - local e = { - kind = kind, - highlight = highlight, - } - setmetatable(e, self) - self.__index = self - - if type(evaluator) == 'function' then - e.eval = evaluator - else - e.eval = function() end - end - e.apply = function(...) - local args = { ... } - return pcall(e.eval, args) - end - - return e -end diff --git a/src/model/interpreter/eval/evaluator.lua b/src/model/interpreter/eval/evaluator.lua new file mode 100644 index 00000000..80e3447f --- /dev/null +++ b/src/model/interpreter/eval/evaluator.lua @@ -0,0 +1,125 @@ +require('model.interpreter.eval.filter') + +local class = require('util.class') + +--- @class Evaluator +--- @field label string +--- @field parser Parser? +--- @field apply function +--- @field validators ValidatorFilter[] +--- @field astValidators AstValidatorFilter[] +--- @field transformers TransformerFilter[] +Evaluator = class.create() + +--- @param label string +--- @param parser Parser? +--- @param filters Filters? +--- @param custom_apply function? +function Evaluator.new(label, parser, filters, custom_apply) + local f = filters or {} + local self = setmetatable({ + label = label, + parser = parser, + line_validators = f.line_validators or {}, + astValidators = f.astValidators or {}, + transformers = f.transformers or {}, + }, Evaluator) + + local default_apply = function(s) + local errors = {} + local valid = true + + --- run validations + for _, fv in ipairs(self.line_validators) do + if #s == 1 then + local ok, verr = fv(s[1]) + if not ok and verr then + valid = false + local e = EvalError.wrap(verr) + table.insert(errors, e) + end + else + for i, l in ipairs(s) do + local ok, verr = fv(l) + if not ok and verr then + valid = false + local e = EvalError.wrap(verr) + if e and not e.l then e.l = i end + table.insert(errors, e) + end + end + end + end + if valid then + if parser then + local ok, result = parser.parse(s) + if not ok then + table.insert(errors, result) + return false, errors + else + local ast = result + return true, ast + end + end + return true, s + else + return false, errors + end + end + self.apply = custom_apply or default_apply + + return self +end + +--- @param label string +--- @param filters Filters? +--- @param custom_apply function? +function Evaluator.plain(label, filters, custom_apply) + return Evaluator(label, nil, filters, custom_apply) +end + +TextEval = Evaluator.plain('text') + +local luaParser = require("model.lang.parser")() + +--- @param label string? +--- @param filters Filters? +--- @param custom_apply function? +LuaEval = function(label, filters, custom_apply) + local l = label or 'lua' + return Evaluator(l, luaParser, filters, custom_apply) +end + +InputEvalText = Evaluator.plain('text input') +InputEvalLua = Evaluator('lua input', luaParser) + +ValidatedTextEval = function(filter) + local ft = Filters.validators_only(filter) + return Evaluator.plain('plain', ft) +end + +LuaEditorEval = (function() + --- AST validations + local test = function(ast) + -- Log.info('AST', Debug.terse_ast(ast, true, 'lua')) + -- return false, EvalError('test', 1, 1) + return true + end + + --- text validations + local max_length = function(n) + return function(s) + if string.len(s) < n then + return true + end + return false, 'line too long!' + end + end + local line_length = max_length(64) + + local ft = { + validators = { line_length }, + astValidators = { test }, + } + return LuaEval(nil, ft) +end)() diff --git a/src/model/interpreter/eval/filter.lua b/src/model/interpreter/eval/filter.lua new file mode 100644 index 00000000..a6a7994e --- /dev/null +++ b/src/model/interpreter/eval/filter.lua @@ -0,0 +1,38 @@ +local class = require('util.class') + +--- AST scope, i.e. where a validation applies +--- @class Scope + +--- @alias ValidatorFilter fun(string): boolean, string|EvalError? +--- @alias AstValidatorFilter fun(AST): boolean, string|EvalError? + +--- @alias TransformerFilter fun(string): string + +--- @class Filters +--- @field line_validators ValidatorFilter[] +--- @field astValidators AstValidatorFilter[] +--- @field transformers TransformerFilter[] + +Filters = class.create(function(v, av, tf) + return { + line_validators = v, + astValidators = av, + transformers = tf, + } +end) + +--- @param flt function|function[] +function Filters.validators_only(flt) + local fs = {} + if type(flt) == 'function' then + fs = { flt } + end + if type(flt) == 'table' then + for _, v in ipairs(flt) do + if type(v) == 'function' then + table.insert(fs, v) + end + end + end + return Filters(fs) +end diff --git a/src/model/interpreter/eval/inputEval.lua b/src/model/interpreter/eval/inputEval.lua deleted file mode 100644 index ef3ef74c..00000000 --- a/src/model/interpreter/eval/inputEval.lua +++ /dev/null @@ -1,26 +0,0 @@ -require("model.interpreter.eval.evalBase") - ---- @class InputEval: EvalBase -InputEval = {} - ---- Create input evaluator ----@param highlight boolean ----@return InputEval -function InputEval:new(highlight) - local noop = function() end - local kind = 'input' - if highlight then - kind = kind .. ' lua' - end - --- @type InputEval - --- @diagnostic disable-next-line -- TODO - local ie = EvalBase:inherit(kind, noop, highlight) - if highlight then - local luaParser = require("model.lang.parser")('metalua') - - --- @diagnostic disable-next-line -- TODO - ie.parser = luaParser - end - - return ie -end diff --git a/src/model/interpreter/eval/luaEval.lua b/src/model/interpreter/eval/luaEval.lua deleted file mode 100644 index 72395845..00000000 --- a/src/model/interpreter/eval/luaEval.lua +++ /dev/null @@ -1,27 +0,0 @@ -require("model.interpreter.eval.evalBase") - -require("util.string") -require("util.debug") - ---- @class LuaEval: EvalBase ---- @field parser table -LuaEval = {} -LuaEval.__index = LuaEval - ---- Create a new evaluator ----@param parser string ----@return LuaEval -function LuaEval:new(parser) - local luaParser = require("model.lang.parser")(parser) - local eval = function(args) - return luaParser.parse(args[1]) - end - - --- @type LuaEval - --- @diagnostic disable-next-line -- TODO - local ev = EvalBase:inherit('lua', eval, true) - ev.parser = luaParser - ev.is_lua = true - - return ev -end diff --git a/src/model/interpreter/eval/textEval.lua b/src/model/interpreter/eval/textEval.lua deleted file mode 100644 index 4b4c8fe4..00000000 --- a/src/model/interpreter/eval/textEval.lua +++ /dev/null @@ -1,10 +0,0 @@ -require("model.interpreter.eval.evalBase") - -TextEval = {} - -function TextEval:new() - local ret = function(i) return i end - local te = EvalBase:inherit('text', ret, false) - - return te -end diff --git a/src/model/interpreter/interpreterModel.lua b/src/model/interpreter/interpreterModel.lua index 9c5bbe59..6875eb8f 100644 --- a/src/model/interpreter/interpreterModel.lua +++ b/src/model/interpreter/interpreterModel.lua @@ -1,9 +1,7 @@ -require("model.interpreter.eval.textEval") -require("model.interpreter.eval.luaEval") -require("model.interpreter.eval.inputEval") -require("model.interpreter.item") +require("model.interpreter.eval.evaluator") require("model.input.inputModel") +local class = require('util.class') require("util.dequeue") require("util.string") require("util.debug") @@ -13,45 +11,32 @@ require("util.debug") --- @field input InputModel --- @field history table --- @field evaluator table ---- @field luaEval LuaEval ---- @field textInput InputEval ---- @field luaInput InputEval +--- @field luaEval Evaluator +--- @field textInput Evaluator +--- @field luaInput Evaluator --- @field wrapped_error string[]? -- methods ---- @field new function ---- @field get_entered_text function ---- @todo -InterpreterModel = {} -InterpreterModel.__index = InterpreterModel - -setmetatable(InterpreterModel, { - __call = function(cls, ...) - return cls.new(...) - end, -}) - ---- @return InterpreterModel +--- @field reset fun(self, h: boolean?) +--- @field get_entered_text fun(self): InputText +InterpreterModel = class.create( --- @param cfg Config -function InterpreterModel.new(cfg) - local luaEval = LuaEval:new('metalua') - local textInput = InputEval:new(false) - local luaInput = InputEval:new(true) - local self = setmetatable({ - cfg = cfg, - input = InputModel:new(cfg, luaEval), - history = Dequeue(), - -- starter - evaluator = luaEval, - -- available options - luaEval = luaEval, - textInput = textInput, - luaInput = luaInput, - - wrapped_error = nil - }, InterpreterModel) - - return self -end +--- @return InterpreterModel + function(cfg) + local luaEval = LuaEval() + return { + cfg = cfg, + input = InputModel(cfg, luaEval), + history = Dequeue(), + -- starter + evaluator = luaEval, + -- available options + luaEval = luaEval, + textInput = InputEvalText, + luaInput = InputEvalLua, + + wrapped_error = nil + } + end) --- @param history boolean? function InterpreterModel:reset(history) @@ -70,17 +55,20 @@ end -- evaluation -- ---------------- +--- @return boolean +--- @return string|EvalError function InterpreterModel:evaluate() - return self:_handle(true) + return self:handle(true) end function InterpreterModel:cancel() - self:_handle(false) + self:handle(false) end ---- @private --- @param eval boolean -function InterpreterModel:_handle(eval) +--- @return boolean +--- @return string|EvalError +function InterpreterModel:handle(eval) local ent = self:get_entered_text() self.historic_index = nil local ok, result @@ -92,9 +80,11 @@ function InterpreterModel:_handle(eval) if ok then self.input:clear_input() else - local l, c, err = self:get_eval_error(result) - self.input:move_cursor(l, c + 1) - self.error = err + local perr = result[1] + if perr then + self.input:move_cursor(perr.l, perr.c + 1) + self.error = perr.msg + end end else self.input:clear_input() @@ -111,34 +101,30 @@ function InterpreterModel:clear_error() self.wrapped_error = nil end +--- @return string[]? function InterpreterModel:get_wrapped_error() return self.wrapped_error end +--- @return boolean function InterpreterModel:has_error() return string.is_non_empty_string_array(self.wrapped_error) end --- @param error string? ---- @param is_call_error boolean +--- @param is_call_error boolean? function InterpreterModel:set_error(error, is_call_error) if string.is_non_empty_string(error) then - self.error = error - self.wrapped_error = string.wrap_at(error, self.input.wrapped_text.wrap_w) + self.error = error or '' + self.wrapped_error = string.wrap_at( + self.error, + self.input.wrapped_text.wrap_w) if not is_call_error then self:history_back() end end end -function InterpreterModel:get_eval_error(errors) - local ev = self.evaluator - local t = self:get_entered_text() - if string.is_non_empty_string_array(t) then - return ev.parser.get_error(errors) - end -end - ---------------- -- history -- ---------------- @@ -206,6 +192,7 @@ function InterpreterModel:_get_history_entry(i) return self.history[i] end +--- @return string[] function InterpreterModel:_get_history_entries() return self.history:items() end diff --git a/src/model/interpreter/item.lua b/src/model/interpreter/item.lua deleted file mode 100644 index c63b55d7..00000000 --- a/src/model/interpreter/item.lua +++ /dev/null @@ -1,17 +0,0 @@ -Item = { - text = '', - kind = nil, -} - -function Item:new(text, kind) - local i = { - text = text, - } - if kind then - i.kind = kind - end - setmetatable(i, self) - self.__index = self - - return i -end diff --git a/src/model/lang/error.lua b/src/model/lang/error.lua new file mode 100644 index 00000000..105ebcc6 --- /dev/null +++ b/src/model/lang/error.lua @@ -0,0 +1,44 @@ +local class = require('util.class') + +--- Input evaluation error class holding an error message and +--- optionally the location of the error in the input. + +--- @class EvalError +--- @field msg string +--- @field c number? +--- @field l number +--- +--- @field wrap fun(e: string|EvalError): EvalError? +--- @field __tostring function + +--- @param msg string +--- @param c number? +--- @param l number? +local newe = function(msg, c, l) + return { msg = msg, c = c, l = l } +end + +--- @type EvalError +EvalError = class.create(newe) + +--- @return EvalError +function EvalError.wrap(e) + if type(e) == "string" then + return EvalError(e) + end + if type(e) == "table" and type(e.msg) == "string" then + return e + end + return EvalError(tostring(e)) +end + +function EvalError:__tostring() + local li = '' + if self.l then + li = li .. 'L' .. self.l .. ':' + if self.c then + li = li .. self.c .. ':' + end + end + return li .. self.msg +end diff --git a/src/model/lang/parser.lua b/src/model/lang/parser.lua index f7ddf36f..eaf78f1f 100644 --- a/src/model/lang/parser.lua +++ b/src/model/lang/parser.lua @@ -1,18 +1,50 @@ +require("model.lang.error") + +require("util.debug") require("util.string") require("util.dequeue") +--- @alias CPos 'first'|'last' + +--- @class Comment +--- @field text string +--- @field position CPos +--- @field idf integer +--- @field idl integer +--- @field first Cursor +--- @field last Cursor +--- @field multiline boolean +--- @field prepend_newline boolean + +--- type representing metalua AST +--- @alias AST token[] + +--- @alias ParseResult AST|EvalError + +--- @class Parser +--- @field parse fun(code: string[]): ParseResult +--- @field chunker fun(s: string[], integer, boolean?): Dequeue +--- @field highlighter fun(str): SyntaxColoring +--- @field pprint fun(c: string[], w: integer): string[]? +--- +--- @field tokenize fun(str): table +--- @field syntax_hl fun(table): SyntaxColoring + return function(lib) + local l = lib or 'metalua' local add_paths = { '', - 'lib/' .. lib .. '/?.lua', - 'lib/?.lua' + 'lib/' .. l .. '/?.lua', + 'lib/?.lua', + -- 'lib/lua/5.1/?' } if love and not TESTING then local love_paths = string.join(add_paths, ';') - love.filesystem.setRequirePath(love.filesystem.getRequirePath() .. love_paths) + love.filesystem.setRequirePath( + love.filesystem.getRequirePath() .. love_paths) else local lib_paths = string.join(add_paths, ';src/') - package.path = package.path .. lib_paths + package.path = lib_paths .. ';' .. package.path end local mlc = require('metalua.metalua.compiler').new() @@ -21,7 +53,7 @@ return function(lib) --- @param stream table --- @return table tokens local realize_stream = function(stream) - local tokens = Dequeue() + local tokens = Dequeue.typed('string') local n repeat n = stream:next() @@ -31,7 +63,7 @@ return function(lib) end --- Parses text table to lexstream - --- @param code table + --- @param code str --- @return table lexstream local stream_tokens = function(code) local c = string.unlines(code) @@ -40,7 +72,7 @@ return function(lib) end --- Parses text table to tokens - --- @param code table + --- @param code str --- @return table local tokenize = function(code) local stream = stream_tokens(code) @@ -49,36 +81,22 @@ return function(lib) --- Parses lexstream to AST --- @param stream table - --- @return table|string ast|errmsg + --- @return ParseResult local parse_stream = function(stream) return mlc:lexstream_to_ast(stream) end - - --- Parses code to AST - --- @param code table - --- @return boolean success - --- @return any result - --- @return any ... - local parse_prot = function(code) - local stream = stream_tokens(code) - -- return parse_stream_prot(stream) - return pcall(parse_stream, stream) - end - - --- Parses code to AST - --- @param code table - --- @return table|string ast|errmsg - local parse = function(code) - local stream = stream_tokens(code) - return parse_stream(stream) + --- @param ast token + --- @param ... any + --- @return Comment[] + local ast_extract_comments = function(ast, ...) + local a2s = mlc:a2s(...) + return a2s:extract_comments(ast) end --- Finds error location and message in parse result --- @param result string - --- @return number line - --- @return number char - --- @return string err_msg + --- @return EvalError local get_error = function(result) local err_lines = string.lines(result) local err_first_line = err_lines[1] @@ -89,29 +107,28 @@ return function(lib) local line = tonumber(match2() or '') or -1 local char = tonumber(match2() or '') or -1 local errmsg = string.trim(colons[4]) - return line, char, errmsg - end - - local pprint = function(code) - local pprinter = require('metalua.metalua.pprint') - local c = string.unlines(code) - return pprinter.tostring(c) + return EvalError(errmsg, char, line) end --- Read lexstream and determine highlighting --- @param tokens table - --- @return table + --- @return SyntaxColoring local syntax_hl = function(tokens) if not tokens then return {} end + --- @type SyntaxColoring local colored_tokens = {} setmetatable(colored_tokens, { __index = function(table, key) + --- default value is an empty array table[key] = {} return table[key] end }) + --- @param tag string + --- @param single boolean + --- @return TokenType? local function getType(tag, single) if tag == 'Keyword' then if single then @@ -130,12 +147,18 @@ return function(lib) end end - local function multiline(first, last, text, ttype, tl) + --- @param first Cursor + --- @param last Cursor + --- @param text string + --- @param lex_t LexType + --- @param tl integer + local function multiline(first, last, text, lex_t, tl) local ls = first.l local le = last.l local cs = first.c local ce = last.c local lines = string.lines(text) + local n_lines = #lines local till = le + 1 - ls -- if the first line has no text after the block starter, @@ -146,21 +169,23 @@ return function(lib) -- first line for i = cs, cs + string.ulen(lines[1]) + tl do - colored_tokens[ls][i] = ttype + colored_tokens[ls][i] = lex_t end for i = 2, till - 1 do local e = string.ulen(lines[i]) - for j = 1, e do - colored_tokens[ls + i - 1][j] = ttype + for j = 1, e + 2 do + colored_tokens[ls + i - 1][j] = lex_t end end -- last line for i = 1, ce do - colored_tokens[le][i] = ttype + colored_tokens[le][i] = lex_t end end - local colorize = function(t) + --- @param t token + --- @return SyntaxColoring? + local function colorize(t) local text = t[1] local tag = t.tag local lfi = t.lineinfo.first @@ -213,7 +238,7 @@ return function(lib) colored_tokens[l][i] = getType(tag, single) end else - local tl = 2 -- a string block starts with '[[' + local tl = 2 --- a string block starts with '[[' multiline(first, last, text, 'string', tl) end @@ -228,7 +253,7 @@ return function(lib) colored_tokens[ls][i] = 'comment' end else - local tl = 4 -- a block comment starts with '--[[' + local tl = 4 --- a block comment starts with '--[[' multiline(co.first, co.last, co.text, 'comment', tl) end end @@ -247,15 +272,173 @@ return function(lib) return colored_tokens end + -------------------- + --- Public --- + -------------------- + + --- @param ast token[] + --- @param ... any + --- @return string + local ast_to_src = function(ast, ...) + local a2s = mlc:a2s(...) + return a2s:run(ast) + end + + --- Parses code to AST + --- @param code str + --- @return boolean success + --- @return ParseResult + local parse = function(code) + local stream = stream_tokens(code) + local ok, res = pcall(parse_stream, stream) + local ret = res + if not ok then + ---@diagnostic disable-next-line: param-type-mismatch + ret = get_error(res) + end + return ok, ret + end + + --- @param code string[] + --- @return string[]? + local pprint = function(code, wrap) + local w = wrap or 80 + local ok, r = parse(code) + if ok then + local src = ast_to_src(r, {}, w) + return string.lines(src) + end + end + + --- Highlight string array + --- @param code str + --- @return SyntaxColoring + local highlighter = function(code) + return syntax_hl(tokenize(code)) + end + + --- @param text string[] + --- @param w integer + --- @param single boolean + --- @return boolean ok + --- @return Block[] + local chunker = function(text, w, single) + require("model.editor.content") + if string.is_non_empty_string_array(text) then + local wrap = w + local ret = Dequeue.typed('block') + local ok, r = parse(text) + local has_lines = false + if ok then + local idx = 1 -- block number + local last = 0 -- last line number + local comment_ids = {} + local add_comment_block = function(ctext, c, range) + ret:insert( + Chunk.new(ctext, range), + idx) + comment_ids[c.idf] = true + comment_ids[c.idl] = true + end + --- @param comments Comment[] + --- @param pos CPos + local get_comments = function(comments, pos) + for _, c in ipairs(comments) do + -- Log.warn('c', c.position) + -- Log.warn(Debug.terse_t(c, nil, nil, true)) + if c.position == pos + and not (comment_ids[c.idl] or comment_ids[c.idf]) + then + local cfl, cll = c.first.l, c.last.l + -- account for empty lines + if cfl > last + 1 then + ret:insert(Empty(last + 1), idx) + idx = idx + 1 + comment_ids[c.idf] = true + comment_ids[c.idl] = true + end + if cfl == cll then + local ctext = '--' .. c.text + add_comment_block(ctext, c, Range.singleton(cfl)) + idx = idx + 1 + last = cll + else + local lines = string.lines(c.text) + if c.multiline then + if c.prepend_newline then + table.insert(lines, 1, '') + end + local l1 = lines[1] or '' + if #lines == 1 then + lines[1] = '--[[' .. l1 .. ']]' + else + local llast = lines[#lines] or '' + lines[1] = '--[[' .. l1 + lines[#lines] = llast .. ']]' + end + local wrapped = string.wrap_array(lines, wrap) + local w_t = string.unlines(wrapped) + + add_comment_block(wrapped, c, Range(cfl, cll)) + idx = idx + 1 + last = cll + else + for i, l in ipairs(lines) do + local ln = cfl + i - 1 + local ctext = '--' .. l + add_comment_block(ctext, c, Range.singleton(ln)) + idx = idx + 1 + last = cll + end + end + end + end + end + end + + for _, v in ipairs(r) do + has_lines = true + local li = v.lineinfo + local fl, ll = li.first.line, li.last.line + + local comments = ast_extract_comments(v, {}, wrap) + + get_comments(comments, 'first') + + -- account for empty lines, including the zeroth + if fl > last + 1 then + ret:insert(Empty(last + 1), idx) + idx = idx + 1 + end + local tex = table.slice(text or {}, fl, ll) + local chunk = Chunk(tex, Range(fl, ll)) + ret:insert(chunk, idx) + idx = idx + 1 + last = ll + + get_comments(comments, 'last') + end + + if single or not has_lines then + local single_comment = ast_extract_comments(r, {}, wrap) + get_comments(single_comment, 'first') + end + + return true, ret + else + --- content is not valid lua + return false, Dequeue(text, 'string') + end + else + return true, Dequeue(Empty(1), 'block') + end + end + return { - stream_tokens = stream_tokens, - realize_stream = realize_stream, - tokenize = tokenize, - parse = parse, - parse_prot = parse_prot, - parse_stream = parse_stream, - pprint = pprint, - get_error = get_error, - syntax_hl = syntax_hl, + parse = parse, + pprint = pprint, + highlighter = highlighter, + ast_to_src = ast_to_src, + chunker = chunker, } end diff --git a/src/model/lang/tokenHighlighter.lua b/src/model/lang/syntaxHighlighter.lua similarity index 94% rename from src/model/lang/tokenHighlighter.lua rename to src/model/lang/syntaxHighlighter.lua index 9c2da440..ca190021 100644 --- a/src/model/lang/tokenHighlighter.lua +++ b/src/model/lang/syntaxHighlighter.lua @@ -15,7 +15,7 @@ local tokenHL = { colorize = function(t) local type = types[t] if not type then - return c.fg + return c.console.fg else return colors[t] end diff --git a/src/model/project/project.lua b/src/model/project/project.lua index 7c158710..4fa8f431 100644 --- a/src/model/project/project.lua +++ b/src/model/project/project.lua @@ -1,6 +1,6 @@ require("util.string") require("util.filesystem") - +local class = require('util.class') local function error_annot(base) return function(err) @@ -63,23 +63,12 @@ end --- @field contents function --- @field readfile function --- @field writefile function -Project = {} -Project.__index = Project - -setmetatable(Project, { - __call = function(cls, ...) - return cls.new(...) - end, -}) - -function Project.new(pname) - local self = setmetatable({ +Project = class.create(function(pname) + return { name = pname, path = string.join_path(love.paths.project_path, pname) - }, Project) - - return self -end + } +end) --- @return table function Project:contents() @@ -113,35 +102,31 @@ function Project:writefile(name, data) return FS.write(fp, data) end +local newps = function() + ProjectService.path = love.paths.project_path + ProjectService.messages = messages + return { + --- @type Project? + current = nil + } +end + --- @class ProjectService --- @field path string --- @field messages table ---- @field validate_filename function --- @field current Project +--- methods +--- @field validate_filename function --- @field create function --- @field list function --- @field open function --- @field close function --- @field deploy_examples function --- @field run function -ProjectService = {} +ProjectService = class.create(newps) ProjectService.MAIN = 'main.lua' ---- @return ProjectService -function ProjectService:new(M) - ProjectService.path = love.paths.project_path - ProjectService.messages = messages - local pc = { - --- @type Project? - current = nil - } - setmetatable(pc, self) - self.__index = self - - return pc -end - --- @param name string --- @return string? path --- @return string? error diff --git a/src/types.lua b/src/types.lua index 5e10cb09..74d7d908 100644 --- a/src/types.lua +++ b/src/types.lua @@ -1,3 +1,5 @@ +--- @alias str string|string[] + --- @class PathInfo table --- @field storage_path string --- @field project_path string @@ -13,6 +15,10 @@ ---| '"lua"' ---| '"text"' +---@alias ContentType +---| 'plain' +---| 'lua' + ---@alias Fac # scaling ---| 1 ---| 2 @@ -24,8 +30,9 @@ --- @field fw integer -- font width --- @field lh integer -- line height --- @field lines integer +--- @field input_max integer --- @field show_append_hl boolean ---- @field labelfont table +--- @field labelfont love.Font --- @field lfh integer -- font height --- @field lfw integer -- font width --- @field border integer @@ -47,28 +54,45 @@ --- @alias More {up: boolean, down: boolean} --- @class Status table ---- @field input_type string +--- @field label string --- @field cursor Cursor? --- @field n_lines integer +--- @field input_more More --- @field custom CustomStatus? --- @class InputDTO table ---- @field text table +--- @field text InputText --- @field wrapped_text WrappedText --- @field highlight Highlight ---- @field selection table +--- @field wrapped_error string[] +--- @field selection InputSelection +--- @field visible VisibleContent --- @class ViewData table --- @field w_error string[] --- @class Highlight table ---- @field parse_err table ---- @field hl table +--- @field parse_err EvalError +--- @field hl SyntaxColoring + +--- @alias TokenType +--- | 'kw_single' +--- | 'kw_multi' +--- | 'number' +--- | 'string' +--- | 'identifier' + +--- @alias LexType +--- | TokenType +--- | 'comment' +--- | 'error' + +--- @alias SyntaxColoring LexType[][] --- @class UserInput table ---- @field M InputModel ---- @field V InputView ---- @field C InputController +--- @field M UserInputModel +--- @field V UserInputView +--- @field C UserInputController ---@alias AppState ---| 'starting' @@ -91,5 +115,6 @@ --- @field show_terminal boolean --- @field show_canvas boolean --- @field show_input boolean +--- @field once integer --- @class LuaEnv : table diff --git a/src/util/class.lua b/src/util/class.lua new file mode 100644 index 00000000..a33b2818 --- /dev/null +++ b/src/util/class.lua @@ -0,0 +1,69 @@ +return { + --- Simple factory, to spare boilerplate + --- @param constructor function? + create = function(constructor) + local ret = {} + ret.__index = ret + local function new(...) + if constructor then + return constructor(...) + end + return {} + end + + setmetatable(ret, { + __call = function(cls, ...) + if type(cls.new) == "function" then + return cls.new(...) + else + local instance = new(...) + setmetatable(instance, cls) + return instance + end + end, + }) + + return ret + end, + + --- Class factory from lua-users wiki, archived here: + --- https://archive.vn/muhJx#selection-569.0-569.23 + --- Should be able to do (multiple) inheritance + --- @return table + newclass = function(...) + --- "cls" is the new class + local cls, bases = {}, { ... } + --- copy base class contents into the new class + for _, base in ipairs(bases) do + for k, v in pairs(base) do + cls[k] = v + end + end + --- set the class's __index, and start filling an "is_a" table that contains + --- this class and all of its bases + --- so you can do an "instance of" check using my_instance.is_a[MyClass] + cls.__index, cls.is_a = cls, { [cls] = true } + for _, base in ipairs(bases) do + for c in pairs(base.is_a or {}) do + cls.is_a[c] = true + end + cls.is_a[base] = true + end + + --- the class's __call metamethod + setmetatable(cls, { + __call = function(c, ...) + local instance = setmetatable({}, c) + --- TODO how to automate this + --- run the init method if it's there + -- local init = instance._init + -- if init then init(instance, ...) end + -- return instance + return c.new(...) + end + }) + -- return the new class table, that's ready to fill with methods + return cls + end, + +} diff --git a/src/util/debug.lua b/src/util/debug.lua index 76f70c64..53a24f3b 100644 --- a/src/util/debug.lua +++ b/src/util/debug.lua @@ -1,26 +1,146 @@ +--- @diagnostic disable: redefined-local + require("util.string") +require("util.table") local tc = require("util.termcolor") +local OS = require("util.os") -local INDENT = ' ' +local tab = ' ' -local seen = {} --- @param level integer --- @param starter string? local get_indent = function(level, starter) local indent = starter or '' for _ = 0, level do - indent = indent .. INDENT + indent = indent .. tab end return indent end local text = string.debug_text +local debugdebug = function(...) + if love and not TESTING + then + else + local args = { ... } + io.write(tc.to_control(6)) + for _, v in ipairs(args) do + local s = v + if type(v) == 'string' then s = text(v) end + io.write(s .. '\t') + end + print(tc.reset) + end +end + +local debugappend = function(res, str) + if love and not TESTING + then + else + io.write(tc.to_control(5)) + io.write(str .. '\t') + print(tc.reset) + return res .. str + end +end + +--- @param t table? +--- @param level integer? +--- @param prev_seen table? +--- @param jsonify boolean? +--- @return string +local function terse_hash(t, level, prev_seen, jsonify) + if not t then return '' end + + local seen = prev_seen or {} + local indent = level or 0 + local res = '' + local flat = true + if type(t) == 'table' then + res = res .. string.times(tab, indent) .. '{' + if seen[t] then return '' end + seen[t] = true + + for k, v in pairs(t) do + local dent = '' + if type(v) == 'table' then + flat = false + dent = '\n' .. string.times(tab, indent + 1) + end + + if type(k) == 'table' then + res = res .. dent .. Debug.terse_hash(k, nil, nil, jsonify) .. ': ' + else + res = res .. dent .. k .. ': ' -- .. '// [' .. type(v) .. '] ' + end + if type(v) == 'table' then + local table_text = Debug.terse_hash(v, indent + 1, seen, jsonify) + if string.is_non_empty_string(table_text, true) then + res = res .. '\n' .. tab .. table_text + else + res = res .. '{},' .. '\n' .. string.times(tab, indent + 1) + end + elseif type(v) == nil and jsonify then + res = res .. 'null, ' + elseif type(v) == 'boolean' and v == false then + res = res .. 'false, ' + else + res = res .. Debug.terse_hash(v, indent + 1, seen, jsonify) + end + end + local br = (function() + if flat then return '' else return '\n' end + end)() + local dent = br .. string.times(tab, indent) + res = res .. dent .. '}, ' + elseif type(t) == 'string' then + local t_ = (function() + if jsonify then + local l = string.lines(t) + return string.join(l, '\\n') + end + return t + end)() + res = res .. text(t_) .. ', ' --.. '// [' .. type(t) .. '] ' + elseif type(t) == 'function' then + res = res .. Debug.mem(t) .. ', ' --.. '// [' .. type(t) .. '] ' + else + res = res .. tostring(t) .. ', ' --.. '// [' .. type(t) .. '] ' + end + + return res +end + +--- @param a table? +--- @param skip integer? +local function terse_array(a, skip) + if type(a) == 'table' then + local res = '[' + if skip then + for i = skip, #a do + res = res .. i .. ': ' .. terse_hash(a[i], nil, nil, true) .. ', ' + end + res = res .. ']' + else + for i, v in ipairs(a) do + res = res .. string.format('\n/* %d */\n%s', i, terse_hash(v, 1, {})) + -- res = res .. string.format('\n/* %d */\n%s', i, '{ ... }') + end + res = res .. '\n]' + end + + return res + else + return '' + end +end + Debug = { --- @param t table --- @param tag string? - --- @param level integer + --- @param level integer? --- @param prev_seen table? --- @return string print_t = function(t, tag, level, prev_seen) @@ -62,12 +182,15 @@ Debug = { --- @param t string[]? --- @param no_ln boolean? + --- @param skip integer? --- @param trunc boolean? --- @return string - text_table = function(t, no_ln, trunc) + text_table = function(t, no_ln, skip, trunc) local res = '\n' if type(t) == 'table' then - for i, l in ipairs(t) do + local start = math.max(1, skip or 0) + for i = start, #t do + local l = t[i] local line = (function() if not no_ln then return string.format("#%02d: %s\n", i, text(l)) @@ -91,48 +214,117 @@ Debug = { return res end, - --- @param t table? - --- @param level integer? - --- @param prev_seen table? - ---@return string - terse_t = function(t, level, prev_seen) - if not t then return '' end + terse_hash = terse_hash, + terse_array = terse_array, + terse_t = function(t, ...) + if t and type(t) == "table" then + if table.is_array(t) then + return terse_array(t) + else + return terse_hash(t, ...) + end + end + end, - local seen = prev_seen or {} - local indent = level or 0 - local res = '' - local flat = true - if type(t) == 'table' then - res = res .. '{' - if seen[t] then return '' end - seen[t] = true - for k, v in pairs(t) do - local dent = '' - if type(v) == 'table' then - flat = false - dent = '\n' .. string.times(' ', indent + 1) + --- @alias dumpstyle + --- | 'lua' + --- | 'json5' + --- @param ast token[] + --- @param skip_lineinfo boolean? + --- @param style dumpstyle? + terse_ast = function(ast, skip_lineinfo, style) + local style = style or 'json5' + + --- @param t table? + --- @param omit any[]? + --- @param style dumpstyle? + --- @param level integer? + --- @param prev_seen table? + --- @return string + local function terse(t, omit, style, level, prev_seen) + if not t then return '' end + + local seen = prev_seen or {} + local omit = omit or {} + local indent = level or 0 + local res = '' + local flat = true + --- TODO: finish type display + local assign, cmt = (function() + if style == 'lua' then + return ' = ', { o = '--[[ ', c = ' ]] ' } end - if type(k) == table then - res = res .. dent .. Debug.terse_t(k) .. ': ' - else - res = res .. dent .. k .. ': ' + return ': ', { o = '/* ', c = ' */ ' } + end)() + if type(t) == 'table' then + res = res .. string.times(tab, indent) .. '{' + if seen[t] then return '' end + seen[t] = true + + for k, v in pairs(t) do + if not omit[k] then + local dent = '' + if type(v) == 'table' then + flat = false + dent = '\n' .. string.times(tab, indent + 1) + end + + if type(k) == 'table' then + res = res .. dent .. terse(k, omit, style) .. assign + elseif type(k) == 'number' and style == 'lua' then + -- skip index + else + res = res .. dent + -- .. cmt.o .. type(v) .. cmt.c + .. k .. assign + end + if type(v) == 'table' then + local table_text = + terse(v, omit, style, indent + 1, seen) + if string.is_non_empty_string(table_text, true) then + res = res .. '\n' .. tab .. table_text + else + res = res .. '{},' .. '\n' .. string.times(tab, indent + 1) + end + elseif type(v) == nil then + res = res .. 'null, ' + elseif type(v) == 'boolean' and v == false then + res = res .. 'false, ' + else + res = res .. Debug.terse_hash(v, indent + 1, seen) + end + end end - res = res .. Debug.terse_t(v, indent + 1, seen) + local br = (function() + if flat then return '' else return '\n' end + end)() + local dent = br .. string.times(tab, indent) + res = res .. dent .. '}' + res = res .. ', ' + elseif type(t) == 'string' then + local t_ = (function() + local l = string.lines(t) + return string.join(l, '\\n') + end)() + res = res .. string.format('%q', t_) + -- .. cmt.o .. '[' .. type(t) .. ']' .. cmt.o + .. ', ' + else + res = res .. tostring(t) + -- .. cmt .. '[' .. type(t) .. ']' .. cmt.o + .. ', ' end - local br = (function() - if flat then return '' else return '\n' end - end)() - local dent = br .. string.times(' ', indent) - res = res .. dent .. '}, ' - elseif type(t) == 'string' then - res = res .. text(t) .. ', ' - elseif type(t) == 'function' then - res = res .. Debug.mem(t) .. ', ' - else - res = res .. tostring(t) .. ', ' + + return res end - return res + local om = {} + -- local om = { 'source' } + if skip_lineinfo then + om.lineinfo = true + end + local res = terse(ast, om, style, nil, nil) + return string.gsub(res, ', ?$', '') end, --- @param o any @@ -185,6 +377,29 @@ Debug = { return string.unlines(lines) end end, + + --- @param content str + --- @param ext string? + --- @param fixname string? + write_tempfile = function(content, ext, fixname) + local function create_temp() + local cmd = 'mktemp -u -p .' + if string.is_non_empty_string(ext) then + cmd = string.format('%s --suffix .%s', cmd, ext) + end + local _, result = OS.runcmd(cmd) + return result + end + local name = + string.is_non_empty_string(fixname) + and fixname .. (ext and '.' .. ext or '') + or create_temp() + local path = string.join_path('./.debug', name) + + local data = string.unlines(content) + local FS = require('util.filesystem') + FS.write(path, data) + end, } local printer = (function() @@ -202,7 +417,7 @@ local annot = function(tag, color, args) local ret = tc.to_control(color) ret = ret .. tag .. ': ' for _, s in ipairs(args) do - ret = ret .. tostring(s) .. '\t' + ret = ret .. tostring(s or '') .. '\t' end ret = ret .. tc.reset return ret @@ -247,10 +462,13 @@ local function hash(s) return h end +local once_seen = {} +local once_color = Color.white + Color.bright + local function once(kh, args) - if not seen[kh] then - seen[kh] = true - local s = annot('ONCE ', Color.white, args) + if not once_seen[kh] then + once_seen[kh] = true + local s = annot('ONCE ', once_color, args) printer(s) end end @@ -274,11 +492,24 @@ Log = { end, once = function(...) + if not love.DEBUG then return end local args = { ... } - local key = string.join(args, '') + local key = love.debug.once .. string.join(args, '') local kh = hash(key) once(kh, args) end, + + fire_once = function() + if not love.DEBUG then return end + love.debug.once = love.debug.once + 1 + end, + --- @param color integer + set_once_color = function(color) + if color >= 0 and + color <= 15 then + once_color = color + end + end, } setmetatable(Log, { diff --git a/src/util/dequeue.lua b/src/util/dequeue.lua index e327eaf6..6b47e0d0 100644 --- a/src/util/dequeue.lua +++ b/src/util/dequeue.lua @@ -1,4 +1,6 @@ ---- @class Dequeue : table +local class = require('util.class') + +--- @class Dequeue: { [integer]: T } --- @field new function --- @field push_front function --- @field prepend function @@ -13,27 +15,71 @@ --- @field items function --- @field length function --- @field is_empty function -Dequeue = {} -Dequeue.__index = Dequeue +Dequeue = class.create() -setmetatable(Dequeue, { - __call = function(cls, ...) - return cls.new(...) - end, -}) +local tags = {} --- Create a new double-ended queue --- @param values table? -function Dequeue.new(values) - local self = setmetatable({}, Dequeue) +--- @param tag string? -- define type if created empty +--- @return Dequeue +function Dequeue.new(values, tag) + local ttag + if tag then + ttag = tag or '' + elseif values + and type(values) == 'table' + then + if + type(values[1]) == 'table' + then + local fv = values[1] + ttag = fv.tag or type(fv) or '' + else + ttag = type(values[1]) + end + end + + local mt = Dequeue + local self = setmetatable({}, mt) if values and type(values) == 'table' then for _, v in ipairs(values) do self:push_back(v) end end + local addr = tostring(self) + tags[addr] = ttag return self end +--- @param tag string +--- @param values table? +--- @return Dequeue +function Dequeue.typed(tag, values) + return Dequeue.new(values, tag) +end + +--- Return a string representation +--- @return string +function Dequeue:repr() + local res = '[' + for i, v in ipairs(self) do + res = res .. i .. ': ' .. tostring(v) .. ',\n' + end + res = res .. ']' + return res +end + +--- Return item type +--- @return string? +function Dequeue:type() + return tags[tostring(self)] +end + +function Dequeue:get_type() + return self:type() +end + --- Insert element at the start, reorganizing the array --- @param v any function Dequeue:push_front(v) @@ -66,6 +112,14 @@ function Dequeue:append(v) self:push_back(v) end +--- Insert element at the end +--- @param d Dequeue -- +function Dequeue:append_all(d) + for _, v in ipairs(d) do + self:push_back(v) + end +end + --- Insert element at the end --- @param v any function Dequeue:push(v) diff --git a/src/util/eval.lua b/src/util/eval.lua index 7fd0a205..c192ecae 100644 --- a/src/util/eval.lua +++ b/src/util/eval.lua @@ -1,8 +1,6 @@ require("util.string") -LANG = {} - -local function parse_error(err) +local function get_call_error(err) if string.is_non_empty_string(err) then local colons = string.split(err, ':') table.remove(colons, 1) @@ -11,6 +9,24 @@ local function parse_error(err) end end -LANG.parse_error = parse_error +local function eval(s) + local expr = loadstring('return ' .. s) + if not expr then return end + local ok, res = pcall(expr) + if ok then return res end +end + +local function print_eval(s) + local r = eval(s) + if r then + print(r) + end + return r +end + -return LANG +return { + get_call_error = get_call_error, + eval = eval, + print_eval = print_eval, +} diff --git a/src/util/filesystem.lua b/src/util/filesystem.lua index ebba6506..d6b6e5b1 100644 --- a/src/util/filesystem.lua +++ b/src/util/filesystem.lua @@ -1,5 +1,3 @@ -local nativefs = require("lib/nativefs") - require("util.string") FS = { @@ -13,149 +11,172 @@ FS = { } } ---- @param path string ---- @return boolean -function FS.exists(path) - if nativefs.getInfo(path) then return true end - return false -end +if love then + local nativefs = require("lib/nativefs") ---- @param path string ---- @return boolean success -function FS.mkdir(path) - return nativefs.createDirectory(path) -end + --- @param path string + --- @return boolean + function FS.exists(path) + if nativefs.getInfo(path) then return true end + return false + end + + --- @param path string + --- @return boolean success + function FS.mkdir(path) + return nativefs.createDirectory(path) + end ---- @param path string ---- @param filtertype love.FileType? ---- @param vfs boolean? ---- @return table -function FS.dir(path, filtertype, vfs) - local items = (function() - if vfs then - local items = {} - local ls = love.filesystem.getDirectoryItems(path) - for _, n in ipairs(ls) do - local fi = love.filesystem.getInfo(string.join_path(path, n), filtertype) - if fi then - fi.name = n - table.insert(items, fi) + --- @param path string + --- @param filtertype love.FileType? + --- @param vfs boolean? + --- @return table + function FS.dir(path, filtertype, vfs) + local items = (function() + if vfs then + local items = {} + local ls = love.filesystem.getDirectoryItems(path) + for _, n in ipairs(ls) do + local fi = love.filesystem.getInfo(string.join_path(path, n), filtertype) + if fi then + --- @diagnostic disable-next-line: inject-field + fi.name = n + table.insert(items, fi) + end end + return items end - return items - end - return nativefs.getDirectoryItemsInfo(path, filtertype) - end)() + return nativefs.getDirectoryItemsInfo(path, filtertype) + end)() - return items -end + return items + end ---- @param path string ---- @return table -function FS.lines(path) - local ret = {} - if FS.exists(path) then - for l in nativefs.lines(path) do - table.insert(ret, l) + --- @param path string + --- @return table + function FS.lines(path) + local ret = {} + if FS.exists(path) then + for l in nativefs.lines(path) do + table.insert(ret, l) + end end + return ret end - return ret -end ---- @param path string ---- @param data string ---- @return boolean success ---- @return string? error -function FS.write(path, data) - return nativefs.write(path, data) -end + --- @param path string + --- @param data string + --- @return boolean success + --- @return string? error + function FS.write(path, data) + return nativefs.write(path, data) + end ---- @param source string ---- @param target string ---- @param vfs boolean? ---- @return boolean success ---- @return string? error -function FS.cp(source, target, vfs) - local getInfo = (function() - if vfs then - return love.filesystem.getInfo + --- @param source string + --- @param target string + --- @param vfs boolean? + --- @return boolean success + --- @return string? error + function FS.cp(source, target, vfs) + local getInfo = (function() + if vfs then + return love.filesystem.getInfo + end + return nativefs.getInfo + end)() + local srcinfo = getInfo(source) + if not srcinfo or srcinfo.type ~= 'file' then + return false, FS.messages.enoent('source') end - return nativefs.getInfo - end)() - local srcinfo = getInfo(source) - if not srcinfo or srcinfo.type ~= 'file' then - return false, FS.messages.enoent('source') - end - local tgtinfo = nativefs.getInfo(target) - local to - if not tgtinfo or tgtinfo.type == 'file' then - to = target - end - if tgtinfo and tgtinfo.type == 'directory' then - local parts = string.split(source, '/') - local fn = parts[#parts] - to = string.join_path(target, fn) - end - if not to then - return false, FS.messages.enoent('target') - end + local tgtinfo = nativefs.getInfo(target) + local to + if not tgtinfo or tgtinfo.type == 'file' then + to = target + end + if tgtinfo and tgtinfo.type == 'directory' then + local parts = string.split(source, '/') + local fn = parts[#parts] + to = string.join_path(target, fn) + end + if not to then + return false, FS.messages.enoent('target') + end - local content, s_err = (function() - if vfs then return love.filesystem.read(source) end - return nativefs.read(source) - end)() - if not content then - return false, tostring(s_err) - end + local content, s_err = (function() + if vfs then return love.filesystem.read(source) end + return nativefs.read(source) + end)() + if not content then + return false, tostring(s_err) + end - local out, t_err = io.open(target, "w") - if not out then - return false, t_err + local out, t_err = io.open(target, "w") + if not out then + return false, t_err + end + out:write(content) + out:close() + return true end - out:write(content) - out:close() - return true -end ---- @param source string ---- @param target string ---- @param vfs boolean? ---- @return boolean success ---- @return string? error -function FS.cp_r(source, target, vfs) - local getInfo = (function() - if vfs then - return love.filesystem.getInfo + --- @param source string + --- @param target string + --- @param vfs boolean? + --- @return boolean success + --- @return string? error + function FS.cp_r(source, target, vfs) + local getInfo = (function() + if vfs then + return love.filesystem.getInfo + end + return nativefs.getInfo + end)() + local cp_ok = true + local cp_err + local srcinfo = getInfo(source) + local tgtinfo = nativefs.getInfo(target) + if not srcinfo or srcinfo.type ~= 'directory' then + return false, FS.messages.enoent('source', 'dir') end - return nativefs.getInfo - end)() - local cp_ok = true - local cp_err - local srcinfo = getInfo(source) - local tgtinfo = nativefs.getInfo(target) - if not srcinfo or srcinfo.type ~= 'directory' then - return false, FS.messages.enoent('source', 'dir') - end - if not tgtinfo then + if not tgtinfo then + FS.mkdir(target) + end + tgtinfo = nativefs.getInfo(target) + if not tgtinfo or tgtinfo.type ~= 'directory' then + return false, FS.messages.enoent('target', 'dir') + end + FS.mkdir(target) - end - tgtinfo = nativefs.getInfo(target) - if not tgtinfo or tgtinfo.type ~= 'directory' then - return false, FS.messages.enoent('target', 'dir') - end + local items = FS.dir(source, nil, vfs) + for _, i in pairs(items) do + local s = string.join_path(source, i.name) + local t = string.join_path(target, i.name) + local ok, err = FS.cp(s, t, vfs) + if not ok then + cp_ok = false + cp_err = err + end + end - FS.mkdir(target) - local items = FS.dir(source, nil, vfs) - for _, i in pairs(items) do - local s = string.join_path(source, i.name) - local t = string.join_path(target, i.name) - local ok, err = FS.cp(s, t, vfs) - if not ok then - cp_ok = false - cp_err = err + return cp_ok, cp_err + end +else + --- @param path string + --- @param data string + --- @return boolean success + --- @return string? error + function FS.write(path, data) + local f = io.open(path, 'w') + if f then + io.output(f) + io.write(data) + io.close(f) + io.output(io.stdout) + return true end + return false end - - return cp_ok, cp_err end + +return FS diff --git a/src/util/key.lua b/src/util/key.lua index 9ae109f9..16323568 100644 --- a/src/util/key.lua +++ b/src/util/key.lua @@ -1,19 +1,37 @@ +require("util.table") + +local shift_k = { "lshift", "rshift" } +local ctrl_k = { "lctrl", "rctrl" } +local alt_k = { "lalt", "ralt" } + --- @param k string --- @return boolean local function is_enter(k) return k == "return" or k == 'kpenter' end +--- @return boolean +local function is_shift(k) + return table.is_member(shift_k, k) +end --- @return boolean local function shift() return love.keyboard.isDown("lshift", "rshift") end +--- @return boolean +local function is_ctrl(k) + return table.is_member(ctrl_k, k) +end --- @return boolean local function ctrl() return love.keyboard.isDown("lctrl", "rctrl") end +--- @return boolean +local function is_alt(k) + return table.is_member(alt_k, k) +end --- @return boolean local function alt() return love.keyboard.isDown("lalt", "ralt") @@ -22,6 +40,9 @@ end Key = { is_enter = is_enter, shift = shift, + is_shift = is_shift, ctrl = ctrl, + is_ctrl = is_ctrl, alt = alt, + is_alt = is_alt, } diff --git a/src/util/lua.lua b/src/util/lua.lua new file mode 100644 index 00000000..ca6b1a46 --- /dev/null +++ b/src/util/lua.lua @@ -0,0 +1,16 @@ +--- Require `name`.lua if exists +--- @param name string +local function prequire(name) + local ok, module = pcall(function() + return require(name) + end) + if ok then return module end +end + +local t = { + prequire = prequire, +} + +for k, v in pairs(t) do + _G[k] = v +end diff --git a/src/util/os.lua b/src/util/os.lua new file mode 100644 index 00000000..1d859299 --- /dev/null +++ b/src/util/os.lua @@ -0,0 +1,16 @@ +--- @param cmd string +--- @return boolean success +--- @return string? result +local function runcmd(cmd) + local handle = io.popen(cmd) + if handle then + local result = handle:read("*a") + handle:close() + return true, result + end + return false +end + +return { + runcmd = runcmd, +} diff --git a/src/util/range.lua b/src/util/range.lua index ceb8b439..a350f611 100644 --- a/src/util/range.lua +++ b/src/util/range.lua @@ -1,3 +1,5 @@ +local class = require('util.class') + --- @class Range --- @field start integer --- @field fin integer @@ -5,38 +7,79 @@ --- @field inc fun(self, integer): boolean --- @field translate fun(self, integer): Range --- @field __tostring fun(self): string -Range = {} -Range.__index = Range - -setmetatable(Range, { - __call = function(cls, ...) - return cls.new(...) - end, -}) - +Range = class.create( --- @param s integer --- @param e integer -function Range.new(s, e) - -- TODO: validate - local self = setmetatable({ - start = s, fin = e - }, Range) - return self + function(s, e) + --- TODO: validate + return { + start = s, fin = e + } + end) + +function Range.singleton(n) + return Range(n, n) +end + +function Range:len() + local s = self.start + local e = self.fin + return e - s + 1 end function Range:__tostring() local s = self.start local e = self.fin - return string.format('{%d-%d}[%d]', s, e, e - s + 1) + return string.format('{%d-%d}[%d]', s, e, self:len()) +end + +function Range:ln_label() + local s = self.start + local e = self.fin + if s == e then + return string.format('L%d', s, 1) + else + return string.format('L%d-%d(%d)', s, e, self:len()) + end end +--- Determine whether `n` is in the range --- @param n integer +--- @return boolean function Range:inc(n) + if type(n) ~= 'number' then return false end if self.start > n then return false end if self.fin < n then return false end return true end +--- Determine the how much `n` is in outside the range +--- (signed result) +--- @param n integer +--- @return integer? +function Range:outside(n) + if type(n) ~= 'number' then return nil end + if self:inc(n) then + return 0 + else + if n < self.start then + return n - self.start + end + if n > self.fin then + return n - self.fin + end + end +end + +--- @return integer[] +function Range:enumerate() + local ret = {} + for i = self.start, self.fin do + table.insert(ret, i) + end + return ret +end + --- Translate functions do not modify the original --- @param by integer diff --git a/src/util/scrollable.lua b/src/util/scrollable.lua new file mode 100644 index 00000000..78f79af2 --- /dev/null +++ b/src/util/scrollable.lua @@ -0,0 +1,22 @@ +require("util.range") +local class = require('util.class') + +--- @class Scrollable +Scrollable = class.create() + +--- @param size_max integer +--- @param len integer +--- @return Range +function Scrollable.calculate_end_range(size_max, len) + local L = size_max + local clen = len or 0 + local off = math.max(clen - L, 0) + local si = 1 + local ei = math.min(L, clen + 1) + return Range(si, ei):translate(off) +end + +function Scrollable.to_end(size_max, len) + local end_r = Scrollable.calculate_end_range(size_max, len) + return end_r +end diff --git a/src/util/string.lua b/src/util/string.lua index 5e277ed6..60850d60 100644 --- a/src/util/string.lua +++ b/src/util/string.lua @@ -1,13 +1,40 @@ +--- @diagnostic disable: duplicate-set-field utf8 = require('util.utf') +--- @param s string +--- @param p string +--- @param regex boolean? +--- @return boolean +string.matches = function(s, p, regex) + local r = not (regex or false) + local f = string.find(s, p, nil, r) + if f then return true end + return false +end + +--- @param s string +--- @param p string +--- @return boolean +string.matches_r = function(s, p) + return string.matches(s, p, true) +end + +--- @param t string? +--- @return string? string.debug_text = function(t) if not t or type(t) ~= 'string' then return end return string.format("'%s'", t) end +--- @param s string +--- @return string string.normalize = function(s) - return string.gsub(s, "%s+", "") + local r, _ = string.gsub(s, "%s+", "") + return r end + +--- @param s string +--- @return string string.trim = function(s) if not s then return '' end local pre = string.gsub(s, "^%s+", "") @@ -15,8 +42,11 @@ string.trim = function(s) return post end +--- @param s string? +--- @param no_trim boolean? +--- @return boolean string.is_non_empty_string = function(s, no_trim) - if s and type(s) == 'string' and s ~= '' then + if type(s) == 'string' and s ~= '' then local str = (function() if no_trim then return s @@ -31,6 +61,8 @@ string.is_non_empty_string = function(s, no_trim) return false end +--- @param sa string[]? +--- @return boolean string.is_non_empty_string_array = function(sa) if type(sa) ~= 'table' then return false @@ -44,6 +76,8 @@ string.is_non_empty_string_array = function(sa) end end +--- @param s string +--- @return integer string.ulen = function(s) if s then return utf8.len(s) @@ -53,6 +87,10 @@ string.ulen = function(s) end -- original from http://lua-users.org/lists/lua-l/2014-04/msg00590.html +--- @param s string +--- @param i integer +--- @param j integer? +--- @return string string.usub = function(s, i, j) i = i or 1 j = j or -1 @@ -83,6 +121,17 @@ string.usub = function(s, i, j) end end +--- @param s string +--- @param i integer +--- @return string +string.char_at = function(s, i) + return string.usub(s, i, i) +end + +--- @param s string +--- @param i integer +--- @return string +--- @return string string.split_at = function(s, i) local str = s or '' local pre, post = '', '' @@ -97,6 +146,9 @@ string.split_at = function(s, i) return pre, post end +--- @param s string +--- @param i integer +--- @return string[] string.wrap_at = function(s, i) if not s or type(s) ~= 'string' or s == '' or @@ -120,37 +172,68 @@ string.wrap_at = function(s, i) return res end +--- @param t string[] +--- @param i integer +--- @return string[] +string.wrap_array = function(t, i) + local res = {} + for _, s in ipairs(t) do + local ws = string.wrap_at(s, i) + for _, l in ipairs(ws) do + table.insert(res, l) + end + end + + return res +end + -- https://stackoverflow.com/a/51893646 +--- @param str string +--- @param delimiter string +--- @return string[] string.split = function(str, delimiter) local del = delimiter or ' ' - if str and type(str) == 'string' and string.is_non_empty_string(str, true) then - local result = {} - local from = 1 - local delim_from, delim_to = string.find(str, del, from) - while delim_from do - table.insert(result, string.sub(str, from, delim_from - 1)) - from = delim_to + 1 - delim_from, delim_to = string.find(str, del, from) + if str and type(str) == 'string' then + if string.is_non_empty_string(str, true) then + local result = {} + local from = 1 + local delim_from, delim_to = string.find(str, del, from) + while delim_from do + table.insert(result, string.sub(str, from, delim_from - 1)) + from = delim_to + 1 + delim_from, delim_to = string.find(str, del, from) + end + table.insert(result, string.sub(str, from)) + return result + else + return { '' } end - table.insert(result, string.sub(str, from)) - return result else return {} end end +--- @param str_arr string[] +--- @param char string +--- @return string[] string.split_array = function(str_arr, char) if not type(str_arr) == 'table' then return {} end local words = {} for _, line in ipairs(str_arr) do - local ws = string.split(line, char) - for _, word in ipairs(ws) do - table.insert(words, word) + if line == '' then + table.insert(words, line) + else + local ws = string.split(line, char) + for _, word in ipairs(ws) do + table.insert(words, word) + end end end return words end +--- @param s str +--- @return string[] string.lines = function(s) if type(s) == 'string' then return string.split(s, '\n') @@ -158,9 +241,10 @@ string.lines = function(s) if type(s) == 'table' then return string.split_array(s, '\n') end + return {} end ---- @param strs string|table +--- @param strs str --- @param char string? --- @return string string.join = function(strs, char) @@ -183,7 +267,7 @@ string.join = function(strs, char) return res end ---- @param strs string|table +--- @param strs str --- @return string string.unlines = function(strs) return string.join(strs, '\n') @@ -225,3 +309,43 @@ string.times = function(s, n) end return res end + +---------------------------- +--- validation utilities --- +---------------------------- +local char = { + is_upper = function(c) + return c == string.upper(c) + end, + is_lower = function(c) + return c == string.lower(c) + end, +} + +--- @param s string +--- @param f fun(string): boolean +--- @return boolean +--- @return integer? +local forall = function(s, f) + for i = 1, string.ulen(s) do + local v = string.char_at(s, i) + if not f(v) then + return false, i + end + end + return true +end + +--- CAUTION: this doesn't work with non-ASCII characters +--- @param s string +--- @return boolean +string.is_upper = function(s) + return forall(s, char.is_upper) +end + +--- CAUTION: this doesn't work with non-ASCII characters +--- @param s string +--- @return boolean +string.is_lower = function(s) + return forall(s, char.is_lower) +end diff --git a/src/util/table.lua b/src/util/table.lua index 54ebdf50..d49afaf0 100644 --- a/src/util/table.lua +++ b/src/util/table.lua @@ -118,7 +118,7 @@ function table.toggle(self, k) end end --- https://stackoverflow.com/a/24823383 +--- https://stackoverflow.com/a/24823383 --- @param self table --- @param first integer? --- @param last integer? @@ -132,3 +132,41 @@ function table.slice(self, first, last, step) return sliced end + +--- @param self table +--- @return boolean is_array +function table.is_array(self) + if not self or not type(self) == "table" then + return false + end + local is_array = true + for k, _ in pairs(self) do + if type(k) ~= 'number' then + return false + end + end + return is_array +end + +--- @param self table +--- @param t string +--- @return boolean +function table.is_instance(self, t) + if not self or not t then return false end + local typ = string.lower(type(self)) + local tag = string.lower(self.tag) + local tt = string.lower(t) + return tt == typ or tt == tag +end + +--- @param self table +--- @param e any +--- @return boolean +function table.is_member(self, e) + if not self or not e then return false end + local ret = false + for _, v in pairs(self) do + if v == e then return true end + end + return ret +end diff --git a/src/util/testTerminal.lua b/src/util/testTerminal.lua index 866ba5fc..0702a975 100644 --- a/src/util/testTerminal.lua +++ b/src/util/testTerminal.lua @@ -4,14 +4,7 @@ local Terminal = require("lib.terminal") TerminalTest = {} -function TerminalTest:new(ctrl) - setmetatable({}, self) - self.__index = self - - return self -end - -function TerminalTest:test(term) +function TerminalTest.test(term) local w = term.width local h = term.height -- save previous state @@ -96,7 +89,7 @@ function TerminalTest:test(term) set_colors() end -function TerminalTest:reset(term) +function TerminalTest.reset(term) term:move_to(1, 1) term:clear() end diff --git a/src/util/view.lua b/src/util/view.lua index 4cb055ae..f9e1a3ba 100644 --- a/src/util/view.lua +++ b/src/util/view.lua @@ -1,3 +1,5 @@ +require("util.string") + --- @param cfg ViewConfig --- @return number local get_drawable_height = function(cfg) @@ -14,6 +16,7 @@ local get_drawable_height = function(cfg) end --- Write a line of text to output +--- pass 0 for breaks if the text is already wrapped! --- @param l number --- @param str string --- @param y number @@ -25,6 +28,28 @@ local write_line = function(l, str, y, breaks, cfg) G.print(str, cfg.border, dy) end +--- Write a token to output +--- @param dy number +--- @param dx number +--- @param token string +--- @param color table +--- @param bgcolor table +--- @param selected boolean +local write_token = function(dy, dx, token, + color, bgcolor, selected) + G.push('all') + if selected then + G.setColor(color) + local back = string.rep('█', string.ulen(token)) + G.print(back, dx, dy) + G.setColor(bgcolor) + else + G.setColor(color) + end + G.print(token, dx, dy) + G.pop() +end + --- Hide elements for debugging --- Return true if DEBUG is not enabled or is --- enabled and the appropriate flag is set @@ -32,7 +57,7 @@ end --- @return boolean local conditional_draw = function(k) if love.DEBUG then - return love.debug[k] + return love.debug[k] == true end return true end @@ -112,6 +137,7 @@ local blendModes = { ViewUtils = { get_drawable_height = get_drawable_height, write_line = write_line, + write_token = write_token, conditional_draw = conditional_draw, blendModes = blendModes, diff --git a/src/util/wrapped_text.lua b/src/util/wrapped_text.lua index 0934f6e9..a1ff5947 100644 --- a/src/util/wrapped_text.lua +++ b/src/util/wrapped_text.lua @@ -1,3 +1,4 @@ +local class = require('util.class') require("util.string") --- Example text: { @@ -9,7 +10,7 @@ require("util.string") --- 'EDDA ', --- 'AC/DC', --- } ---- @alias WrapForward table +--- @alias WrapForward integer[][] --- Mapping from original line numbers to wrapped line numbers. --- e.g. {1: {1}, 2: {2, 3}} --- @alias WrapReverse integer[] @@ -18,26 +19,23 @@ require("util.string") --- unwrapped original, e.g. {1: 1, 2: 2, 3: 2} means two --- lines of text were broken up into three, because the second --- exceeded the width limit +--- @alias WrapRank integer[] +--- The number of wraps that produced this line +--- (i.e. offset from the original line number) --- @class WrappedText --- @field text string[] --- @field wrap_w integer --- @field wrap_forward WrapForward --- @field wrap_reverse WrapReverse +--- @field wrap_rank WrapRank --- @field n_breaks integer --- --- @field wrap function --- @field get_text function --- @field get_line function --- @field get_text_length function -WrappedText = {} -WrappedText.__index = WrappedText - -setmetatable(WrappedText, { - __call = function(cls, ...) - return cls.new(...) - end, -}) +WrappedText = class.create() --- @param w integer --- @param text string[]? @@ -60,6 +58,7 @@ function WrappedText:_init(w, text) self.wrap_w = w self.wrap_forward = {} self.wrap_reverse = {} + self.wrap_rank = {} self.n_breaks = 0 if text then self:wrap(text) @@ -72,6 +71,7 @@ function WrappedText:wrap(text) local display = {} local wrap_forward = {} local wrap_reverse = {} + local wrap_rank = {} local breaks = 0 local revi = 1 if text then @@ -90,8 +90,9 @@ function WrappedText:wrap(text) -- remember how many apparent lines will be overall local ap = brk + 1 local fwd = {} - for _ = 1, ap do + for r = 1, ap do wrap_reverse[revi] = i + wrap_rank[revi] = r - 1 table.insert(fwd, revi) revi = revi + 1 end @@ -106,6 +107,7 @@ function WrappedText:wrap(text) self.text = display self.wrap_forward = wrap_forward self.wrap_reverse = wrap_reverse + self.wrap_rank = wrap_rank self.n_breaks = breaks end @@ -121,5 +123,5 @@ end --- @return integer function WrappedText:get_text_length() - return #(self.text) or 0 + return #(self.text or {}) end diff --git a/src/view/canvas/bgView.lua b/src/view/canvas/bgView.lua index ac0d7796..c6468088 100644 --- a/src/view/canvas/bgView.lua +++ b/src/view/canvas/bgView.lua @@ -1,21 +1,10 @@ +local class = require("util.class") + --- @class BGView --- @field cfg Config -BGView = {} -BGView.__index = BGView - -setmetatable(BGView, { - __call = function(cls, ...) - return cls.new(...) - end, -}) - -function BGView.new(cfg) - local self = setmetatable({ - cfg = cfg - }, BGView) - - return self -end +BGView = class.create(function(cfg) + return { cfg = cfg } +end) function BGView:draw(drawable_height) --- @type ViewConfig diff --git a/src/view/canvas/canvasView.lua b/src/view/canvas/canvasView.lua index 04b32c49..e3be96b7 100644 --- a/src/view/canvas/canvasView.lua +++ b/src/view/canvas/canvasView.lua @@ -1,6 +1,7 @@ require("view.canvas.bgView") require("view.canvas.terminalView") +local class = require("util.class") require("util.view") local G = love.graphics @@ -9,18 +10,12 @@ local G = love.graphics --- @field cfg Config --- @field bg BGView --- @field draw function -CanvasView = {} - -function CanvasView:new(cfg) - local cv = { +CanvasView = class.create(function(cfg) + return { cfg = cfg, - bg = BGView.new(cfg) + bg = BGView(cfg) } - setmetatable(cv, self) - self.__index = self - - return cv -end +end) --- @param terminal table --- @param canvas love.Canvas @@ -28,7 +23,7 @@ end --- @param drawable_height number --- @param snapshot love.Image? function CanvasView:draw( - terminal, canvas, term_canvas, drawable_height, snapshot + terminal, canvas, term_canvas, drawable_height, snapshot ) local cfg = self.cfg local test = cfg.drawtest diff --git a/src/view/consoleView.lua b/src/view/consoleView.lua index 3e791aac..fd21c340 100644 --- a/src/view/consoleView.lua +++ b/src/view/consoleView.lua @@ -2,45 +2,38 @@ require("view.titleView") require("view.editor.editorView") require("view.canvas.canvasView") require("view.input.interpreterView") + +local class = require("util.class") require("util.color") require("util.view") require("util.debug") local G = love.graphics ---- @class ConsoleView ---- @field title table ---- @field canvas CanvasView ---- @field interpreter InterpreterView ---- @field editor EditorView ---- @field controller ConsoleController ---- @field cfg Config ---- @field drawable_height number -ConsoleView = {} -ConsoleView.__index = ConsoleView - -setmetatable(ConsoleView, { - __call = function(cls, ...) - return cls.new(...) - end, -}) - --- @param cfg Config --- @param ctrl ConsoleController -function ConsoleView.new(cfg, ctrl) - local self = setmetatable({ +local function new(cfg, ctrl) + return { title = TitleView, - canvas = CanvasView:new(cfg), - interpreter = InterpreterView:new(cfg.view, ctrl), + canvas = CanvasView(cfg), + interpreter = InterpreterView(cfg.view, ctrl.interpreter), editor = EditorView(cfg.view, ctrl.editor), controller = ctrl, cfg = cfg, drawable_height = ViewUtils.get_drawable_height(cfg.view), - }, ConsoleView) - - return self + } end +--- @class ConsoleView +--- @field title table +--- @field canvas CanvasView +--- @field interpreter InterpreterView +--- @field editor EditorView +--- @field controller ConsoleController +--- @field cfg Config +--- @field drawable_height number +ConsoleView = class.create(new) + --- @param terminal table --- @param canvas love.Canvas --- @param input InputDTO @@ -56,7 +49,8 @@ function ConsoleView:draw(terminal, canvas, input, snapshot) self.canvas:draw(terminal, canvas, tc, self.drawable_height, snapshot) if ViewUtils.conditional_draw('show_input') then - self.interpreter:draw(input) + local time = self.controller:get_timestamp() + self.interpreter:draw(input, time) end end diff --git a/src/view/editor/bufferView.lua b/src/view/editor/bufferView.lua index 6c7a734d..a9f90729 100644 --- a/src/view/editor/bufferView.lua +++ b/src/view/editor/bufferView.lua @@ -1,48 +1,45 @@ require("view.editor.visibleContent") +require("view.editor.visibleStructuredContent") +local class = require("util.class") +require("util.scrollable") require("util.table") ---- @class BufferView ---- @field cfg ViewConfig ---- @field LINES integer ---- @field SCROLL_BY integer ---- @field w integer ---- ---- @field content VisibleContent ---- @field more More ---- @field offset integer ---- @field buffer BufferModel ---- ---- @field open function ---- @field refresh function ---- @field draw function -BufferView = {} -BufferView.__index = BufferView - -setmetatable(BufferView, { - __call = function(cls, ...) - return cls.new(...) - end, -}) - ---- @param cfg ViewConfig -function BufferView.new(cfg) +local function new(cfg) local l = cfg.lines - local self = setmetatable({ + return { cfg = cfg, LINES = l, SCROLL_BY = math.floor(l / 2), w = cfg.drawableChars, content = nil, + content_type = nil, more = { up = false, down = false }, offset = 0, buffer = nil - }, BufferView) - return self + } end +--- @class BufferView +--- @field cfg ViewConfig +--- +--- @field content VisibleContent|VisibleStructuredContent +--- @field content_type ContentType +--- @field buffer BufferModel +--- +--- @field LINES integer +--- @field SCROLL_BY integer +--- @field w integer +--- @field offset integer +--- @field more More +--- +--- @field open function +--- @field refresh function +--- @field draw function +BufferView = class.create(new) + --- @private --- @param r Range function BufferView:_update_visible(r) @@ -51,14 +48,58 @@ end --- @private --- @return Range -function BufferView:_calculate_end_range() - local L = self.LINES +function BufferView:_get_end_range() local clen = self.content:get_text_length() - local off = math.max(clen - L, 0) - if off > 0 then off = off + 1 end - local si = 1 + off - local ei = math.min(L, clen + 1) + off - return Range(si, ei) + return Scrollable.calculate_end_range(self.LINES, clen) +end + +--- @param dir VerticalDir +--- @param by integer? +--- @param warp boolean? +function BufferView:scroll(dir, by, warp) + local by = by or self.SCROLL_BY + local l = self.content:get_content_length() + local n = (function() + if dir == 'up' then + if warp then + return -l + else + return -by + end + else + if warp then + local ir = self:_get_end_range() + local c = self.content:get_range() + return ir.start - c.start + else + return by + end + end + end)() + local o = self.content:move_range(n) + self.offset = self.offset + o +end + +--- @private +--- @return integer[][] +function BufferView:_get_wrapped_selection() + local sel = self.buffer:get_selection() + local cont = self.content + local ret = {} + if self.content_type == 'lua' + then + --- @type Range? + local br = cont:get_block_pos(sel) + if br then + for _, l in ipairs(br:enumerate()) do + table.insert(ret, self.content.wrap_forward[l]) + end + end + elseif self.content_type == 'plain' + then + ret[1] = self.content.wrap_forward[sel] + end + return ret end --- @param buffer BufferModel @@ -68,38 +109,64 @@ function BufferView:open(buffer) if not self.buffer then error('no buffer') end + local cont = buffer.content_type + self.content_type = cont + + local bufcon = buffer:get_content() + if cont == 'plain' then + self.content = VisibleContent( + self.w, bufcon, self.SCROLL_BY, L) + elseif cont == 'lua' then + self.content = + VisibleStructuredContent( + self.w, + bufcon, + buffer.highlighter, + self.SCROLL_BY, + L) + else + error 'unknown filetype' + end - self.content = VisibleContent(self.w, buffer:get_content(), self.SCROLL_BY) + -- TODO clean this up local clen = self.content:get_text_length() self.offset = math.max(clen - L, 0) local off = self.offset if off > 0 then self.more.up = true - self.offset = off + 1 - off = off + 1 end - local ir = self:_calculate_end_range() + local ir = self:_get_end_range() self:_update_visible(ir) + if off > 0 then self:scroll('down', 1) end end function BufferView:refresh() - if not self.content or not self.content.range then + if not self.content then error('no buffer is open') end - self.content:wrap(self.buffer:get_content()) + self.content:wrap(self.buffer:get_text_content()) local clen = self.content:get_content_length() local off = self.offset local si = 1 + off local ei = math.min(self.LINES, clen + 1) + off self:_update_visible(Range(si, ei)) + if self.content_type == 'lua' then + self.content:load_blocks(self.buffer.content) + end end function BufferView:follow_selection() local sel = self.buffer:get_selection() local r = self.content:get_range() - -- TODO multiline - local s_w = self.content.wrap_forward[sel[1]] + local s_w + if self.content_type == 'lua' + then + s_w = self.content:get_block_app_pos(sel):enumerate() + elseif self.content_type == 'plain' + then + s_w = self.content.wrap_forward[sel] + end local sel_s = s_w[1] local sel_e = s_w[#s_w] if r:inc(sel_s) and r:inc(sel_e) then return end @@ -110,46 +177,23 @@ function BufferView:follow_selection() end)() if dir == 'up' then local d = r.start - sel_s - self:_scroll(dir, d) + self:scroll(dir, d) elseif dir == 'down' then local d = sel_e - r.fin - self:_scroll(dir, d) + self:scroll(dir, d) end end ---- @param dir VerticalDir ---- @param by integer? ---- @param warp boolean? -function BufferView:_scroll(dir, by, warp) - local by = by or self.SCROLL_BY - local l = self.content:get_content_length() - local n = (function() - if dir == 'up' then - if warp then - return -l - else - return -by - end - else - if warp then - local ir = self:_calculate_end_range() - local c = self.content:get_range() - return ir.start - c.start - else - return by - end - end - end)() - local o = self.content:move_range(n) - self.offset = self.offset + o -end - function BufferView:draw() local G = love.graphics - local colors = self.cfg.colors.editor + local cf_colors = self.cfg.colors + local colors = cf_colors.editor local font = self.cfg.font local fh = self.cfg.fh * 1.032 -- magic constant - local content_text = self.content:get_visible() + local fw = self.cfg.fw + local vc = self.content + --- @type VisibleContent|VisibleStructuredContent + local content_text = vc:get_visible() local last_line_n = #content_text local width, height = G.getDimensions() @@ -163,46 +207,112 @@ function BufferView:draw() G.rectangle("fill", 0, 0, width, bh) G.pop() end - local draw_highlight = function(line) - if not line then return end - G.setColor(colors.highlight) - local l_y = (line - 1) * fh - G.rectangle('fill', 0, l_y, width, fh) + local draw_highlight = function() + local highlight_line = function(ln) + if not ln then return end + G.setColor(colors.highlight) + local l_y = (ln - 1) * fh + G.rectangle('fill', 0, l_y, width, fh) + end + + local off = self.offset + local ws = self:_get_wrapped_selection() + for _, w in ipairs(ws) do + for _, v in ipairs(w) do + if self.content.range:inc(v) then + if (not self.cfg.show_append_hl) + and (v == self.content:get_content_length() + 1) then + --- skip hl + else + highlight_line(v - off) + end + end + end + end end + local draw_text = function() G.setFont(font) - G.setColor(colors.fg) - local text = string.unlines(content_text) + if self.content_type == 'lua' then + --- @type VisibleBlock[] + local vbl = vc:get_visible_blocks() + for _, block in ipairs(vbl) do + local rs = block.app_pos.start + --- @type WrappedText + local wt = block.wrapped + for l, line in ipairs(wt:get_text()) do + local ln = rs + (l - 1) - self.offset + if ln > self.cfg.lines then return end - G.print(text) - end + for ci = 1, string.ulen(line) do + local char = string.usub(line, ci, ci) + local hl = block.highlight + if hl then + local lex_t = (function() + if hl[l] then + return hl[l][ci] or colors.fg + end + return colors.fg + end)() + local color = + cf_colors.input.syntax[lex_t] or colors.fg - draw_background() - local off = self.offset - local ws = self:get_wrapped_selection() - for _, w in ipairs(ws) do - for _, v in ipairs(w) do - if self.content.range:inc(v) then - if (not self.cfg.show_append_hl) - and (v == self.content:get_content_length() + 1) then - --- skip hl - else - draw_highlight(v - off) + G.setColor(color) + else + G.setColor(colors.fg) + end + G.print(char, (ci - 1) * fw, (ln - 1) * fh) + end end + end -- for + + if love.DEBUG then + --- phantom text + G.setColor(Color.with_alpha(colors.fg, 0.3)) + local text = string.unlines(content_text) + G.print(text) end + elseif self.content_type == 'plain' then + G.setColor(colors.fg) + local text = string.unlines(content_text) + + G.print(text) end end - draw_text() -end ---- @return integer[][] -function BufferView:get_wrapped_selection() - local ret = {} - local sel = self.buffer:get_selection() - for i, v in ipairs(sel) do - ret[i] = self.content.wrap_forward[v] + local draw_debuginfo = function() + if not love.DEBUG then return end + local showap = false + local lnc = colors.fg + local x = self.cfg.w - font:getWidth(' ') - 3 + local lnvc = Color.with_alpha(lnc, 0.2) + G.setColor(lnvc) + G.rectangle("fill", x, 0, 2, self.cfg.h) + local seen = {} + for ln = 1, self.LINES do + local l_y = (ln - 1) * fh + local vln = ln + self.offset + local ln_w = self.content.wrap_reverse[vln] + if ln_w then + local l = string.format('%3d', ln_w) + local l_x = self.cfg.w - font:getWidth(l) + local l_xv = l_x - font:getWidth(l) - 3.5 + if showap then + G.setColor(lnvc) + G.print(string.format('%3d', vln), l_xv, l_y) + end + if not seen[ln_w] then + G.setColor(lnc) + G.print(l, l_x, l_y) + seen[ln_w] = true + end + end + end end - return ret -end + draw_background() + draw_highlight() + draw_text() + draw_debuginfo() +end diff --git a/src/view/editor/editorView.lua b/src/view/editor/editorView.lua index 96293157..6b04a714 100644 --- a/src/view/editor/editorView.lua +++ b/src/view/editor/editorView.lua @@ -1,40 +1,37 @@ -require("view.input.inputView") +require("view.input.interpreterView") +require("view.input.userInputView") require("view.editor.bufferView") require("util.string") - ---- @class EditorView ---- @field controller EditorController ---- @field input InputView ---- @field buffer BufferView -EditorView = {} -EditorView.__index = EditorView - -setmetatable(EditorView, { - __call = function(cls, ...) - return cls.new(...) - end, -}) +local class = require('util.class') --- @param cfg ViewConfig --- @param ctrl EditorController -function EditorView.new(cfg, ctrl) - local self = setmetatable({ +local function new(cfg, ctrl) + local ev = { cfg = cfg, controller = ctrl, - input = InputView.new(cfg, ctrl.input), - buffer = BufferView(cfg) - }, EditorView) - ctrl.view = self - return self + input = UserInputView(cfg, ctrl.input), + buffer = BufferView(cfg), + } + --- hook the view in the controller + ctrl.view = ev + return ev end +--- @class EditorView +--- @field cfg ViewConfig +--- @field controller EditorController +--- @field input UserInputView +--- @field buffer BufferView +EditorView = class.create(new) + function EditorView:draw() local ctrl = self.controller self.buffer:draw(ctrl:get_active_buffer()) - local IC = self.controller.input - self.input:draw(IC:get_input()) + local input = self.controller:get_input() + self.input:draw(input) end function EditorView:refresh() diff --git a/src/view/editor/visibleBlock.lua b/src/view/editor/visibleBlock.lua new file mode 100644 index 00000000..c07adfc0 --- /dev/null +++ b/src/view/editor/visibleBlock.lua @@ -0,0 +1,64 @@ +require("util.range") +require("util.string") +local class = require('util.class') + +--- @param w integer +--- @param lines str +--- @param pos Range +--- @return VisibleBlock +local function new(w, lines, hl, pos, apos) + local ls = (function() + if type(lines) == 'string' then return { lines } end + return lines + end)() + + local wrapped_text = WrappedText(w, ls) + local wrapped_highlight = {} + if wrapped_text.n_breaks > 0 then + for ln, line in ipairs(wrapped_text.text) do + local rln = wrapped_text.wrap_reverse[ln] + wrapped_highlight[ln] = {} + for i = 1, string.ulen(line) do + local rank = wrapped_text.wrap_rank[ln] + local c = i + rank * w + wrapped_highlight[ln][i] = hl[rln][c] + end + end + else + wrapped_highlight = hl + end + + local self = setmetatable({ + wrapped = wrapped_text, + highlight = wrapped_highlight, + pos = pos, + app_pos = apos, + }, VisibleBlock) + + return self +end + +--- @class VisibleBlock +--- @field wrapped WrappedText +--- @field highlight SyntaxColoring +--- @field pos Range +--- @field app_pos Range +VisibleBlock = class.create(new) + +function VisibleBlock:__tostring() + local r = string.format("%s\t%s", + tostring(self.pos), + tostring(self.app_pos) + ) + local l1 = self.wrapped.text[1] + local txt + if string.is_non_empty_string(l1) then + txt = l1 + if self.wrapped.text[2] then + txt = txt .. '...' + end + else + txt = '' + end + return string.format("%s\t%s", r, txt) +end diff --git a/src/view/editor/visibleContent.lua b/src/view/editor/visibleContent.lua index a014e1c6..bc6a85d2 100644 --- a/src/view/editor/visibleContent.lua +++ b/src/view/editor/visibleContent.lua @@ -1,8 +1,10 @@ require("util.wrapped_text") +require("util.scrollable") require("util.range") --- @class VisibleContent: WrappedText --- @field range Range? +--- @field size_max integer --- @field overscroll_max integer --- @field overscroll integer --- @@ -11,7 +13,8 @@ require("util.range") --- @field move_range fun(self, integer): integer --- @field get_visible fun(self): string[] --- @field get_content_length fun(self): integer - +--- @field get_more fun(self): More +--- @field to_end fun(self) VisibleContent = {} VisibleContent.__index = VisibleContent @@ -25,16 +28,52 @@ setmetatable(VisibleContent, { --- @param w integer --- @param fulltext string[] --- @return VisibleContent -function VisibleContent.new(w, fulltext, overscroll) +function VisibleContent.new(w, fulltext, overscroll, size_max) + --- @type VisibleContent + --- @diagnostic disable-next-line: assign-type-mismatch local self = setmetatable({ - overscroll_max = overscroll + overscroll_max = overscroll, + size_max = size_max, + offset = 0, }, VisibleContent) WrappedText._init(self, w, fulltext) self:_init() + self:to_end() + return self end +--- @return Range +function VisibleContent:get_default_range() + local L = math.min(self.size_max, self:get_content_length()) + return Range(1, L) +end + +--- Set the visible range so that last of the content is visible +function VisibleContent:to_end() + self.range = Scrollable.to_end( + self.size_max, self:get_text_length()) + self.offset = self.range.start - 1 +end + +--- Invoked after text changes, validate range +function VisibleContent:check_range() + local l = self:get_text_length() + local r = self.range + if r then + local rl = r:len() + if r.fin > l then + r.fin = l + end + if rl < self.size_max then + self:set_default_range() + end + else + self:set_default_range() + end +end + --- @private function VisibleContent:_update_meta() local rev = self.wrap_reverse @@ -46,7 +85,7 @@ end --- @protected function VisibleContent:_update_overscroll() - local len = WrappedText.get_text_length(self) + local len = self:get_text_length() local over = math.min(self.overscroll_max, len) self.overscroll = over end @@ -69,7 +108,15 @@ end --- @param r Range function VisibleContent:set_range(r) - self.range = r + if r then + self.offset = r.start - 1 + self.range = r + end +end + +function VisibleContent:set_default_range() + self.range = self:get_default_range() + self.offset = 0 end --- @param by integer @@ -78,9 +125,11 @@ function VisibleContent:move_range(by) if type(by) == "number" then local r = self.range local upper = self:get_text_length() + self.overscroll - local nr, n = r:translate_limit(by, 1, upper) - self:set_range(nr) - return n + if r then + local nr, n = r:translate_limit(by, 1, upper) + self:set_range(nr) + return n + end end return 0 end @@ -95,3 +144,14 @@ end function VisibleContent:get_content_length() return self:get_text_length() end + +--- @return More +function VisibleContent:get_more() + local vrange = self:get_range() + local vlen = self:get_content_length() + local more = { + up = vrange.start > 1, + down = vrange.fin < vlen + } + return more +end diff --git a/src/view/editor/visibleStructuredContent.lua b/src/view/editor/visibleStructuredContent.lua new file mode 100644 index 00000000..9569be7f --- /dev/null +++ b/src/view/editor/visibleStructuredContent.lua @@ -0,0 +1,200 @@ +require("view.editor.visibleBlock") + +require("util.wrapped_text") +require("util.range") + +--- @alias ReverseMap Dequeue +--- Inverse mapping from line number to block index + +--- @class VisibleStructuredContent: WrappedText +--- @field overscroll_max integer +--- @field overscroll integer +--- @field range Range? +--- @field blocks VisibleBlock[] +--- @field reverse_map ReverseMap +--- +--- @field set_range fun(self, Range) +--- @field get_range fun(self): Range +--- @field move_range fun(self, integer): integer +--- @field load_blocks fun(self, blocks: Block[]) +--- @field get_visible fun(self): string[] +--- @field get_visible_blocks fun(self): Block[] +--- @field get_content_length fun(self): integer +--- @field get_block_pos fun(self, integer): Range? +--- @field get_block_app_pos fun(self, integer): Range? +--- @field get_more fun(self): More +--- @field to_end fun(self) + +VisibleStructuredContent = {} +VisibleStructuredContent.__index = VisibleStructuredContent + +setmetatable(VisibleStructuredContent, { + __index = WrappedText, + __call = function(cls, ...) + return cls.new(...) + end, +}) + +--- @param w integer +--- @param blocks Block[] +--- @param highlighter fun(c: string[]): SyntaxColoring +--- @return VisibleStructuredContent +function VisibleStructuredContent.new(w, blocks, highlighter, + overscroll, size_max) + local self = setmetatable({ + highlighter = highlighter, + size_max = size_max, + overscroll_max = overscroll, + w = w, + }, VisibleStructuredContent) + self:load_blocks(blocks) + self:to_end() + + return self +end + +--- Set the visible range so that last of the content is visible +function VisibleStructuredContent:to_end() + self.range = Scrollable.to_end( + self.size_max, self:get_text_length()) + self.offset = self.range.start - 1 +end + +--- Process a list of blocks into VisibleBlocks +--- @param blocks Block[] +function VisibleStructuredContent:load_blocks(blocks) + local fulltext = Dequeue.typed('string') + local revmap = Dequeue.typed('integer') + local visible_blocks = Dequeue() + local off = 0 + for bi, v in ipairs(blocks) do + if v.tag == 'chunk' then + fulltext:append_all(v.lines) + local hl = self.highlighter(v.lines) + local vblock = VisibleBlock(self.w, v.lines, hl, + v.pos, v.pos:translate(off)) + off = off + vblock.wrapped.n_breaks + visible_blocks:append(vblock) + elseif v.tag == 'empty' then + fulltext:append('') + local npos = v.pos:translate(off) + visible_blocks:append( + VisibleBlock(self.w, { '' }, {}, v.pos, npos)) + end + if (v.pos) then + for _, l in ipairs(v.pos:enumerate()) do + revmap[l] = bi + end + end + end + WrappedText._init(self, self.w, fulltext) + self:_init() + self.reverse_map = revmap + self.blocks = visible_blocks +end + +--- @private +function VisibleStructuredContent:_update_meta() + local rev = self.wrap_reverse + local rl = #rev + local fwd = self.wrap_forward + table.insert(rev, ((rev[rl] or 0) + 1)) + table.insert(fwd, { #(self.text) + 1 }) + self:_update_overscroll() +end + +--- @protected +function VisibleStructuredContent:_update_overscroll() + local len = WrappedText.get_text_length(self) + local over = math.min(self.overscroll_max, len) + self.overscroll = over +end + +--- @protected +function VisibleStructuredContent:_init() + self:_update_overscroll() +end + +--- @param text string[] +function VisibleStructuredContent:wrap(text) + WrappedText.wrap(self, text) + self:_update_meta() +end + +--- @return Range +function VisibleStructuredContent:get_range() + return self.range +end + +--- @param r Range +function VisibleStructuredContent:set_range(r) + self.range = r +end + +--- @param by integer +--- @return integer n +function VisibleStructuredContent:move_range(by) + if type(by) == "number" then + local r = self.range + local upper = self:get_text_length() + self.overscroll + local nr, n = r:translate_limit(by, 1, upper) + self:set_range(nr) + return n + end + return 0 +end + +--- @return string[] +function VisibleStructuredContent:get_visible() + local si, ei = self.range.start, self.range.fin + return table.slice(self.text, si, ei) +end + +--- @return VisibleBlock[] +function VisibleStructuredContent:get_visible_blocks() + local si = self.wrap_reverse[self.range.start] + local ei = self.wrap_reverse[self.range.fin] + local sbi, sei = self.reverse_map[si], self.reverse_map[ei] + return table.slice(self.blocks, sbi, sei) +end + +--- @return integer +function VisibleStructuredContent:get_content_length() + return self:get_text_length() +end + +--- @param bn integer +--- @return Range? +function VisibleStructuredContent:get_block_pos(bn) + local cl = #(self.blocks) + if bn > 0 and bn <= cl then + return self.blocks[bn].pos + elseif cl == 0 then --- empty/new file + Range.singleton(1) + elseif bn == cl + 1 then + return Range.singleton(self.blocks[cl].pos.fin + 1) + end +end + +--- @param bn integer +--- @return Range? +function VisibleStructuredContent:get_block_app_pos(bn) + local cl = #(self.blocks) + if bn > 0 and bn <= cl then + return self.blocks[bn].app_pos + elseif bn == cl + 1 then + local wr = self.wrap_reverse + return Range.singleton(#wr) + end +end + +--- @return More +function VisibleStructuredContent:get_more() + local vrange = self:get_range() + local vlen = self:get_content_length() + local more = { + up = vrange.start > 1, + down = vrange.fin < vlen + } + return more +end diff --git a/src/view/input/customStatus.lua b/src/view/input/customStatus.lua index 865c14cd..dc696665 100644 --- a/src/view/input/customStatus.lua +++ b/src/view/input/customStatus.lua @@ -1,19 +1,25 @@ +local class = require('util.class') + --- @class CustomStatus table ---- @field line integer +--- @field content_type ContentType --- @field buflen integer ---- @field more More -CustomStatus = {} -CustomStatus.__index = CustomStatus - -setmetatable(CustomStatus, { - __call = function(cls, ...) - return cls.new(...) - end, -}) - -function CustomStatus.new() - local self = setmetatable({ - }, CustomStatus) +--- @field buffer_more More +--- @field selection integer +--- @field range Range? +CustomStatus = class.create(function(ct, len, more, sel, range) + return { + content_type = ct, + buflen = len, + buffer_more = more, + selection = sel, + range = range, + } +end) - return self +function CustomStatus:__tostring() + if self.range then + return 'B' .. self.range + else + return 'L' .. self.selection + end end diff --git a/src/view/input/inputView.lua b/src/view/input/inputView.lua index d93c5ce5..e80e15ac 100644 --- a/src/view/input/inputView.lua +++ b/src/view/input/inputView.lua @@ -1,208 +1,33 @@ require("view.input.statusline") + +local class = require("util.class") require("util.debug") require("util.view") ---- @class InputView ---- @field cfg ViewConfig ---- @field controller InputController ---- @field statusline table ---- @field oneshot boolean ---- @field draw function -InputView = {} -InputView.__index = InputView - -setmetatable(EditorController, { - __call = function(cls, ...) - return cls.new(...) - end, -}) --- @param cfg ViewConfig --- @param ctrl InputController -function InputView.new(cfg, ctrl) - local self = setmetatable({ +local function new(cfg, ctrl) + return { cfg = cfg, controller = ctrl, - statusline = Statusline:new(cfg), + statusline = Statusline(cfg), oneshot = ctrl.model.oneshot, } - , InputView) - - return self end +--- @class InputView +--- @field cfg ViewConfig +--- @field controller InputController +--- @field statusline table +--- @field oneshot boolean +--- @field draw function +InputView = class.create(new) + + --- @param input InputDTO --- @param time number function InputView:draw(input, time) - local G = love.graphics - local status = self.controller:get_status() - local cf_colors = self.cfg.colors - local colors = (function() - if love.state.app_state == 'inspect' then - return cf_colors.input.inspect - elseif love.state.app_state == 'running' then - return cf_colors.input.user - else - return cf_colors.input.console - end - end)() - local b = self.cfg.border - local fh = self.cfg.fh - local fw = self.cfg.fw - local h = self.cfg.h - local drawableWidth = self.cfg.drawableWidth - local drawableChars = self.cfg.drawableChars - -- drawtest hack - if drawableWidth < love.fixWidth / 3 then - drawableChars = drawableChars * 2 - end - - local highlight = input.highlight - local text = input.text - local inLines = #text - local apparentLines = inLines - local inHeight = inLines * fh - local y = h - b - inHeight - - local apparentHeight = inHeight - local wt = input.wrapped_text - local display = wt.text - local wrap_forward = wt.wrap_forward - local wrap_reverse = wt.wrap_reverse - local breaks = wt.n_breaks - apparentHeight = apparentHeight + breaks - apparentLines = apparentLines + breaks - - local start_y = h - b - apparentLines * fh - local function drawCursor() - local cursorInfo = self.controller:get_cursor_info() - local cl, cc = cursorInfo.cursor.l, cursorInfo.cursor.c - local x_offset = (function() - if cc > drawableChars then - return math.fmod(cc, drawableChars) - else - return cc - end - end)() - local y_offset = math.floor((cc - 1) / drawableChars) - local yh = 0 - local n = #(wrap_forward[cl] or {}) - -- how many apparent lines we have so far? - for i = 1, cl do - yh = yh + (#(wrap_forward[i] or {})) - end - local ch = - -- top of the box - start_y + - -- full height of input line - yh * fh - -- adjust in-line: from all lines, move back - -- the number of line wraps - - (n - y_offset) * fh - G.push('all') - G.setColor(cf_colors.input.cursor) - G.print('|', b + (x_offset - 1.5) * fw, ch) - G.pop() - end - - local drawBackground = function() - G.setColor(colors.bg) - G.rectangle("fill", - b, - start_y, - drawableWidth, - apparentHeight * fh) - end - - --- Write a token to output - --- @param l number - --- @param c number - --- @param token string - --- @param color table - local write_token = function(l, c, token, color, selected) - local dy = y - (-l + 1 + breaks) * fh - local dx = b + (c - 1) * fw - G.push('all') - if selected then - G.setColor(color) - G.print('█', dx, dy) - G.setColor(colors.bg) - else - G.setColor(color) - end - G.print(token, dx, dy) - G.pop() - end - - -- draw - G.push('all') - G.scale(self.cfg.FAC, self.cfg.FAC) - G.setFont(self.cfg.font) - G.setBackgroundColor(colors.bg) - G.setColor(colors.fg) - self.statusline:draw(status, apparentLines, time) - drawBackground() - - G.setColor(colors.fg) - if love.timer.getTime() % 1 > 0.5 then - drawCursor() - end - if highlight then - local perr = highlight.parse_err - local el, ec - if perr then - el = perr.l - ec = perr.c - end - for l, s in ipairs(display) do - for i = 1, string.ulen(s) do - local char = string.usub(s, i, i) - local hl_li = wrap_reverse[l] - local hl_ci = (function() - if #(wrap_forward[hl_li]) > 1 then - local offset = l - hl_li - return i + drawableChars * offset - else - return i - end - end)() - local row = highlight.hl[hl_li] or {} - local ttype = row[hl_ci] - local color - if perr and l > el or - (l == el and (i > ec or ec == 1)) then - color = cf_colors.input.error - else - color = cf_colors.input.syntax[ttype] or colors.fg - end - local selected = (function() - local sel = input.selection - local startl = sel.start and sel.start.l - local endl = sel.fin and sel.fin.l - if startl then - local startc = sel.start.c - local endc = sel.fin.c - if startc and endc then - if startl == endl then - local sc = math.min(sel.start.c, sel.fin.c) - local endi = math.max(sel.start.c, sel.fin.c) - return l == startl and i >= sc and i < endi - else - return - (l == startl and i >= sel.start.c) or - (l > startl and l < endl) or - (l == endl and i < sel.fin.c) - end - end - end - end)() - write_token(l, i, char, color, selected) - end - end - else - for l, str in ipairs(display) do - ViewUtils.write_line(l, str, y, breaks, self.cfg) - end - end - G.pop() + ---@diagnostic disable-next-line: param-type-mismatch + UserInputView.draw_input(self, input, time) end diff --git a/src/view/input/interpreterView.lua b/src/view/input/interpreterView.lua index ca0f61bc..824958de 100644 --- a/src/view/input/interpreterView.lua +++ b/src/view/input/interpreterView.lua @@ -1,29 +1,26 @@ require("view.input.inputView") ---- @class InterpreterView ---- @field cfg ViewConfig ---- @field controller ConsoleController ---- @field input InputView -InterpreterView = {} +local class = require("util.class") --- @param cfg ViewConfig ---- @param ctrl ConsoleController -function InterpreterView:new(cfg, ctrl) - local iv = { +--- @param ctrl InterpreterController +local new = function(cfg, ctrl) + return { cfg = cfg, controller = ctrl, - input = InputView.new(cfg, ctrl.input), + input = InputView(cfg, ctrl.input), } - setmetatable(iv, self) - self.__index = self - - return iv end +--- @class InterpreterView +--- @field cfg ViewConfig +--- @field controller InterpreterController +--- @field input InputView +InterpreterView = class.create(new) + --- @param input InputDTO -function InterpreterView:draw(input) +function InterpreterView:draw(input, time) local vd = self.controller:get_viewdata() - local time = self.controller:get_timestamp() local isError = string.is_non_empty_string_array(vd.w_error) local err_text = vd.w_error or {} @@ -52,10 +49,10 @@ function InterpreterView:draw(input) drawBackground() G.setColor(colors.input.error) for l, str in ipairs(err_text) do - local breaks = #err_text - 1 + local breaks = 0 -- starting height is already calculated ViewUtils.write_line(l, str, start_y, breaks, self.cfg) end else self.input:draw(input, time) end -end; +end diff --git a/src/view/input/statusline.lua b/src/view/input/statusline.lua index 41d97174..acc8eb3e 100644 --- a/src/view/input/statusline.lua +++ b/src/view/input/statusline.lua @@ -1,18 +1,11 @@ +local class = require("util.class") + --- @class Statusline --- @field cfg ViewConfig -Statusline = {} - ---- @param cfg ViewConfig ---- @return Statusline -function Statusline:new(cfg) - local s = { - cfg = cfg, - } - setmetatable(s, self) - self.__index = self - - return s -end +Statusline = class.create(function(cfg) + return { cfg = cfg } +end) + --- @param status Status --- @param nLines integer @@ -49,6 +42,23 @@ function Statusline:draw(status, nLines, time) G.rectangle("fill", start_box.x, start_box.y - corr, w, fh + corr) end + --- @param m More? + --- @return string + local function morelabel(m) + local l = '' + if not m then return '' end + + if m.up and not m.down then + return '↑↑' + elseif not m.up and m.down then + return '↓↓ ' + elseif m.up and m.down then + return '↕↕ ' + else + return '' + end + end + local function drawStatus() local custom = status.custom local start_text = { @@ -57,8 +67,9 @@ function Statusline:draw(status, nLines, time) } G.setColor(colors.fg) - if status.input_type then - G.print(status.input_type, start_text.x, start_text.y) + local label = status.label + if label then + G.print(label, start_text.x, start_text.y) end if love.DEBUG then G.setColor(cf.colors.debug) @@ -71,43 +82,88 @@ function Statusline:draw(status, nLines, time) end G.setColor(colors.fg) end + local c = status.cursor if type(c) == 'table' then - local pos_c = ':' .. c.c - local ln, l_lim - local more_i = '' if custom then - ln = custom.line - l_lim = custom.buflen - local m = custom.more - if m.up and not m.down then - more_i = more_i .. '↑↑ ' - elseif not m.up and m.down then - more_i = more_i .. '↓↓ ' - elseif m.up and m.down then - more_i = more_i .. '↕↕ ' + local t_ic = ' ' .. c.l .. ':' .. c.c + local lim = custom.buflen + local sel, t_bbp, t_blp + -- local more_i = '' + if custom.content_type == 'plain' then + sel = custom.selection + t_blp = 'L' .. sel + end + if custom.content_type == 'lua' then + sel = custom.selection + t_bbp = 'B' .. sel .. ' ' + t_blp = custom.range:ln_label() + end + local more_b = morelabel(custom.buffer_more) .. ' ' + local more_i = morelabel(status.input_more) .. ' ' + + G.setColor(colors.fg) + local w_il = G.getFont():getWidth(" 999:9999") + local w_br = G.getFont():getWidth("B999 L999-999(99)") + local w_mb = G.getFont():getWidth(" ↕↕ ") + local w_mi = G.getFont():getWidth(" ↕↕ ") + local s_mb = endTextX - w_br - w_il - w_mi - w_mb + local cw_p = G.getFont():getWidth(t_blp) + local cw_il = G.getFont():getWidth(t_ic) + local sxl = endTextX - (cw_p + w_il + w_mi) + local s_mi = endTextX - w_il + + + G.setFont(self.cfg.font) + G.setColor(colors.fg) + if colors.fg2 then G.setColor(colors.fg2) end + --- cursor pos + G.print(t_ic, endTextX - cw_il, start_text.y) + --- input more + G.setFont(self.cfg.iconfont) + G.print(more_i, s_mi, start_text.y - 3) + + G.setColor(colors.fg) + --- block line range / line + G.setFont(self.cfg.font) + G.print(t_blp, sxl, start_text.y) + --- block number + if custom.content_type == 'lua' then + local bpw = G.getFont():getWidth(t_bbp) + local sxb = sxl - bpw + if sel == lim then + G.setColor(colors.indicator) + end + G.print(t_bbp, sxb, start_text.y) end + + --- buffer more + G.setColor(colors.fg) + G.setFont(self.cfg.iconfont) + G.print(more_b, s_mb, start_text.y - 3) else - ln = c.l - l_lim = status.n_lines - end - if ln == l_lim then - G.setColor(colors.indicator) - end - local pos_l = 'L' .. ln + --- normal statusline + local pos_c = ':' .. c.c + local ln, l_lim + if custom then + ln = custom.line + l_lim = custom.buflen + else + ln = c.l + l_lim = status.n_lines + end + if ln == l_lim then + G.setColor(colors.indicator) + end + local pos_l = 'L' .. ln - local lw = G.getFont():getWidth(pos_l) - local mlw = G.getFont():getWidth("L99999:999") - local cw = G.getFont():getWidth(pos_c) - local sx = endTextX - (lw + cw) - G.print(pos_l, sx, start_text.y) - G.setColor(colors.fg) - G.setFont(self.cfg.iconfont) - local mw = G.getFont():getWidth(more_i) - local mx = endTextX - mlw - mw - G.print(more_i, mx, start_text.y - 3) - G.setFont(self.cfg.font) - G.print(pos_c, sx + lw, start_text.y) + local lw = G.getFont():getWidth(pos_l) + local cw = G.getFont():getWidth(pos_c) + local sx = endTextX - (lw + cw) + G.print(pos_l, sx, start_text.y) + G.setColor(colors.fg) + G.print(pos_c, sx + lw, start_text.y) + end end end diff --git a/src/view/input/userInputView.lua b/src/view/input/userInputView.lua new file mode 100644 index 00000000..db63f7a0 --- /dev/null +++ b/src/view/input/userInputView.lua @@ -0,0 +1,215 @@ +require("view.input.statusline") + +local class = require('util.class') +require("util.debug") +require("util.view") + + +--- @param cfg ViewConfig +--- @param ctrl UserInputController +local new = function(cfg, ctrl) + return { + cfg = cfg, + controller = ctrl, + statusline = Statusline(cfg), + oneshot = ctrl.model.oneshot, + } +end + +--- @class UserInputView +--- @field cfg ViewConfig +--- @field controller UserInputController +--- @field statusline table +--- @field oneshot boolean +--- @field draw function +UserInputView = class.create(new) + +--- @param input InputDTO +--- @param time number +function UserInputView:draw_input(input, time) + local G = love.graphics + + local cfg = self.cfg + local status = self.controller:get_status() + local cf_colors = cfg.colors + local colors = (function() + if love.state.app_state == 'inspect' then + return cf_colors.input.inspect + elseif love.state.app_state == 'running' then + return cf_colors.input.user + else + return cf_colors.input.console + end + end)() + + local fh = cfg.fh + local fw = cfg.fw + local h = cfg.h + local drawableWidth = cfg.drawableWidth + local drawableChars = cfg.drawableChars + -- drawtest hack + if drawableWidth < love.fixWidth / 3 then + drawableChars = drawableChars * 2 + end + + local text = input.text + local vc = input.visible + local inLines = math.min( + vc:get_content_length(), + cfg.input_max) + local apparentLines = inLines + local inHeight = inLines * fh + local apparentHeight = inHeight + local y = h - (#text * fh) + + local wrap_forward = vc.wrap_forward + local wrap_reverse = vc.wrap_reverse + + local start_y = h - apparentLines * fh + + local drawBackground = function() + G.setColor(colors.bg) + G.rectangle("fill", + 0, + start_y, + drawableWidth, + apparentHeight * fh) + end + + start_y = h - apparentLines * fh + + local function drawCursor() + local cursorInfo = self.controller:get_cursor_info() + local cl, cc = cursorInfo.cursor.l, cursorInfo.cursor.c + local y_offset = math.floor((cc - 1) / drawableChars) + local yi = y_offset + 1 + local acl = (wrap_forward[cl] or { 1 })[yi] or 1 + local vcl = acl - vc.offset + + if vcl < 1 then return end + + local ch = start_y + (vcl - 1) * fh + local x_offset = (function() + if cc > drawableChars then + return math.fmod(cc, drawableChars) + else + return cc + end + end)() + + G.push('all') + G.setColor(cf_colors.input.cursor) + G.print('|', (x_offset - 1.5) * fw, ch) + G.pop() + end + + local highlight = input.highlight + local visible = vc:get_visible() + G.push('all') + G.setFont(self.cfg.font) + drawBackground() + self.statusline:draw(status, apparentLines, time) + + if highlight then + local perr = highlight.parse_err + local el, ec + if perr then + el = perr.l + ec = perr.c + end + for l, s in ipairs(visible) do + local ln = l + vc.offset + for c = 1, string.ulen(s) do + local char = string.usub(s, c, c) + local hl_li = wrap_reverse[ln] + local hl_ci = (function() + if #(wrap_forward[hl_li]) > 1 then + local offset = l - hl_li + return c + drawableChars * offset + else + return c + end + end)() + local row = highlight.hl[hl_li] or {} + local ttype = row[hl_ci] + local color + if perr and ln > el or + (ln == el and (c > ec or ec == 1)) then + color = cf_colors.input.error + else + color = cf_colors.input.syntax[ttype] or colors.fg + end + local selected = (function() + local sel = input.selection + local startl = sel.start and sel.start.l + local endl = sel.fin and sel.fin.l + if startl then + local startc = sel.start.c + local endc = sel.fin.c + if startc and endc then + if startl == endl then + local sc = math.min(sel.start.c, sel.fin.c) + local endi = math.max(sel.start.c, sel.fin.c) + return l == startl and c >= sc and c < endi + else + return + (l == startl and c >= sel.start.c) or + (l > startl and l < endl) or + (l == endl and c < sel.fin.c) + end + end + end + end)() + --- number of lines back from EOF + local diffset = #text - vc.range.fin + local dy = y - (-ln - diffset + 1) * fh + local dx = (c - 1) * fw + ViewUtils.write_token(dy, dx, + char, color, colors.bg, selected) + end + end + else + for l, str in ipairs(visible) do + G.setColor(colors.fg) + ViewUtils.write_line(l, str, start_y, 0, self.cfg) + end + end + drawCursor() + G.pop() +end + +--- @param input InputDTO +function UserInputView:draw(input, time) + local err_text = input.wrapped_error or {} + local isError = string.is_non_empty_string_array(err_text) + + local colors = self.cfg.colors + local b = self.cfg.border + local fh = self.cfg.fh + local h = self.cfg.h + + if isError then + local drawableWidth = self.cfg.drawableWidth + local inLines = #err_text + local inHeight = inLines * fh + local apparentHeight = #err_text + local start_y = h - b - inHeight + local drawBackground = function() + G.setColor(colors.input.error_bg) + G.rectangle("fill", + b, + start_y, + drawableWidth, + apparentHeight * fh) + end + + drawBackground() + G.setColor(colors.input.error) + for l, str in ipairs(err_text) do + local breaks = 0 -- starting height is already calculated + ViewUtils.write_line(l, str, start_y, breaks, self.cfg) + end + else + self:draw_input(input, time) + end +end diff --git a/src/view/view.lua b/src/view/view.lua index b4664369..e7f72d07 100644 --- a/src/view/view.lua +++ b/src/view/view.lua @@ -11,7 +11,7 @@ View = { G.push('all') local terminal = C:get_terminal() local canvas = C:get_canvas() - local input = C.input:get_input() + local input = C.interpreter:get_input() CV:draw(terminal, canvas, input, canvas_snapshot) G.pop() end, diff --git a/tests/editor/buffer_spec.lua b/tests/editor/buffer_spec.lua new file mode 100644 index 00000000..c5f23019 --- /dev/null +++ b/tests/editor/buffer_spec.lua @@ -0,0 +1,345 @@ +require('model.editor.bufferModel') +local parser = require('model.lang.parser')() + +require('util.table') + +describe('Buffer #editor', function() + local w = 64 + local chunker = function(t, single) + return parser.chunker(t, w, single) + end + -- local chunker = parser.chunker + local hl = parser.highlighter + + it('render plaintext', function() + local l1 = 'line 1' + local l2 = 'line 2' + local l3 = 'x = 1' + local l4 = 'the end' + local tst = { l1, l2, l3, l4 } + local cbuffer = BufferModel('untitled', tst, nil) + local bc = cbuffer:get_content() + assert.same(cbuffer.content_type, 'plain') + assert.same(4, #bc) + assert.same(l1, bc[1]) + assert.same(l2, bc[2]) + assert.same(l3, bc[3]) + assert.same(l4, bc[4]) + end) + + it('render lua', function() + local l1 = '--- comment 1' + local l2 = '--- comment 2' + local l3 = 'x = 1' + local l4 = '--- comment end' + local tst = { l1, l2, l3, l4 } + local cbuffer = BufferModel('untitled.lua', tst, chunker, hl) + local bc = cbuffer:get_content() + assert.same(cbuffer.content_type, 'lua') + assert.same(4, #bc) + assert.same({ l1 }, bc[1].lines) + assert.same({ l2 }, bc[2].lines) + assert.same({ l3 }, bc[3].lines) + assert.same({ l4 }, bc[4].lines) + end) + + describe('setup', function() + local meat = [[function sierpinski(depth) + lines = { '*' } + for i = 2, depth + 1 do + sp = string.rep(' ', 2 ^ (i - 2)) + tmp = {} -- comment + for idx, line in ipairs(lines) do + tmp[idx] = sp .. line .. sp + tmp[idx + #lines] = line .. ' ' .. line + end + lines = tmp + end + return table.concat(lines, '\n') +end]] + local txt = string.lines([[--- @param depth integer +]] .. meat .. [[ + + +print(sierpinski(4))]]) + + local buffer = BufferModel('test.lua', txt, chunker, hl) + it('sets name', function() + assert.same('test.lua', buffer.name) + end) + local bufcon = buffer:get_content() + it('sets content', function() + assert.same('block', bufcon:type()) + assert.same(4, #bufcon) + assert.same({ '--- @param depth integer' }, bufcon[1].lines) + assert.same(string.lines(meat), bufcon[2].lines) + assert.is_true(table.is_instance(bufcon[3], 'empty')) + assert.same({ 'print(sierpinski(4))' }, bufcon[4].lines) + end) + end) + + describe('modifications', function() + describe('plaintext', function() + it('', function() + end) + end) + + describe('lua', function() + local turtle = { + '--- @diagnostic disable', + 'width, height = G.getDimensions()', + 'midx = width / 2', + 'midy = height / 2', + 'incr = 5', + '', + 'tx, ty = midx, midy', + 'debug = false', + 'debugColor = Color.yellow', + '', + 'bg_color = Color.black', + '', + 'local function drawHelp()', + ' G.setColor(Color[Color.white])', + ' G.print("Press [I] to open console", 20, 20)', + ' G.print("Enter \'forward\', \'back\', \'left\', or \'right\' to move the turtle!", 20, 40)', + 'end', + '', + 'local function drawDebuginfo()', + ' G.setColor(Color[debugColor])', + ' local label = string.format("Turtle position: (%d, %d)", tx, ty)', + ' G.print(label, width - 200, 20)', + 'end', + '', + 'function love.draw()', + ' drawBackground()', + ' drawHelp()', + ' drawTurtle(tx, ty)', + ' if debug then drawDebuginfo() end', + 'end', + '', + 'function love.keypressed(key)', + ' if love.keyboard.isDown("lshift", "rshift") then', + ' if key == \'r\' then', + ' tx, ty = midx, midy', + ' end', + ' end', + ' if key == \'space\' then', + ' debug = not debug', + ' end', + ' if key == \'pause\' then', + ' stop()', + ' end', + 'end', + '', + 'function love.keyreleased(key)', + ' if key == \'i\' then', + ' input_text(r)', + ' end', + ' if key == \'return\' then', + ' eval()', + ' end', + '', + ' if love.keyboard.isDown("lctrl", "rctrl") then', + ' if key == "escape" then', + ' love.event.quit()', + ' end', + ' end', + 'end', + '', + 'local t = 0', + 'function love.update(dt)', + ' t = t + dt', + ' if ty > midy then', + ' debugColor = Color.red', + ' end', + 'end', + } + local text = turtle + + local buffer = BufferModel('main.lua', text, chunker, hl) + local bc = buffer:get_content() + local n_blocks = 24 + it('invariants', function() + assert.same(n_blocks, #bc) + assert.same(n_blocks, buffer:get_content_length()) + + assert.same(text, buffer:get_text_content()) + + assert.same(n_blocks + 1, buffer:get_selection()) + local ln = buffer:get_selection_start_line() + assert.same(68, ln) + end) + + it('dropping blocks', function() + local delbuf = table.clone(buffer) + delbuf:move_selection('up', nil, true) + assert.same(1, delbuf:get_selection()) + delbuf:delete_selected_text() + assert.same(n_blocks - 1, delbuf:get_content_length()) + delbuf:delete_selected_text() + assert.same(n_blocks - 2, delbuf:get_content_length()) + assert.same(1, delbuf:get_selection()) + assert.same({ text[3] }, delbuf:get_selected_text()) + end) + + it('replacing single line with empty', function() + local replbuf = table.clone(buffer) + replbuf:move_selection('down', nil, true) + replbuf:move_selection('up', 2) + + local ln = replbuf:get_selection_start_line() + assert.same(61, ln) + assert.same({ 'local t = 0' }, replbuf:get_selected_text()) + assert.same({ text[ln] }, replbuf:get_selected_text()) + + local empty = Empty(ln) + local ins, n = replbuf:replace_selected_text({ empty }) + assert.truthy(ins) + assert.same(1, n) + end) + it('replacing block with empty', function() + local replbuf = table.clone(buffer) + replbuf:move_selection('down', nil, true) + replbuf:move_selection('up', 1) + + local ln = replbuf:get_selection_start_line() + assert.same( + { 'function love.update(dt)', + ' t = t + dt', + ' if ty > midy then', + ' debugColor = Color.red', + ' end', + 'end', }, + replbuf:get_selected_text()) + + local empty = Empty(ln) + local ins, n = replbuf:replace_selected_text({ empty }) + assert.truthy(ins) + assert.same(1, n) + end) + it('replacing middle block with empty', function() + local replbuf = table.clone(buffer) + replbuf:move_selection('down', nil, true) + replbuf:move_selection('up', 4) + + local ln = replbuf:get_selection_start_line() + assert.same(46, ln) + assert.same({ + 'function love.keyreleased(key)', + " if key == 'i' then", + ' input_text(r)', + ' end', + " if key == 'return' then", + ' eval()', + ' end', + '', + ' if love.keyboard.isDown("lctrl", "rctrl") then', + ' if key == "escape" then', + ' love.event.quit()', + ' end', + ' end', + 'end', + }, + replbuf:get_selected_text()) + + local empty = Empty(ln) + local ins, n = replbuf:replace_selected_text({ empty }) + assert.truthy(ins) + assert.same(1, n) + + replbuf:move_selection('down', nil, true) + + ln = replbuf:get_selection_start_line() + assert.same(55, ln) + end) + + it('replacing line in block', function() + local replbuf = table.clone(buffer) + replbuf:move_selection('down', nil, true) + replbuf:move_selection('up', 1) + + local orig = { 'function love.update(dt)', + ' t = t + dt', + ' if ty > midy then', + ' debugColor = Color.red', + ' end', + 'end' } + assert.same(orig, replbuf:get_selected_text()) + local new = table.clone(orig) + new[2] = ' t = t + dt + 1' + + local ok, chunks = chunker(new, true) + assert.is_true(ok) + local _, n = replbuf:replace_selected_text(chunks) + assert.same(1, n) + end) + it('breaking line in block', function() + local replbuf = table.clone(buffer) + replbuf:move_selection('down', nil, true) + replbuf:move_selection('up', 1) + + local orig = { 'function love.update(dt)', + ' t = t + dt', + ' if ty > midy then', + ' debugColor = Color.red', + ' end', + 'end' } + assert.same(orig, replbuf:get_selected_text()) + local new = table.clone(orig) + new[2] = ' t = t +' + table.insert(new, 3, ' dt') + + local ok, chunks = chunker(new, true) + assert.is_true(ok) + local _, n = replbuf:replace_selected_text(chunks) + assert.same(1, n) + + assert.same(24, replbuf:get_selection()) + end) + + describe('lua', function() + local addbuf = table.clone(buffer) + local orig_b = { 'function love.update(dt)', + ' t = t + dt', + ' if ty > midy then', + ' debugColor = Color.red', + ' end', + 'end' } + + it('introducing a new line', function() + addbuf:move_selection('down', nil, true) + addbuf:move_selection('up', 2) + + local orig = { 'local t = 0' } + assert.same(orig, addbuf:get_selected_text()) + local new = table.clone(orig) + table.insert(new, 'local t2 = 2') + + local ok, chunks = chunker(new, true) + assert.is_true(ok) + local _, n = addbuf:replace_selected_text(chunks) + assert.same(2, n) + + assert.same(23, addbuf:get_selection()) + end) + it('adding line in block', function() + addbuf:move_selection('down', 2) + assert.same(orig_b, addbuf:get_selected_text()) + local new = table.clone(orig_b) + table.insert(new, 3, ' t2 = t2 + 2 * dt') + + local ok, chunks = chunker(new, true) + assert.is_true(ok) + + local _, n = addbuf:replace_selected_text(chunks) + assert.same(1, n) + + assert.same(25, addbuf:get_selection()) + end) + end) + + + --- end --- + end) + end) +end) diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index cf0b5599..86c1fe98 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -1,3 +1,4 @@ +--- @diagnostic disable: invisible require("model.editor.editorModel") require("controller.editorController") require("view.editor.editorView") @@ -5,7 +6,7 @@ require("view.editor.visibleContent") local mock = require("tests.mock") -describe('Editor', function() +describe('Editor #editor', function() local love = { state = { --- @type AppState @@ -18,16 +19,56 @@ describe('Editor', function() 'Turtle graphics game inspired the LOGO family of languages.', '', } + --- @param w integer + --- @param l integer? + local function getMockConf(w, l) + return { + view = { + drawableChars = w, + lines = l or 16, + input_max = 14 + }, + } + end + + --- @param cfg Config + --- @return EditorController + --- @return function press + local function wire(cfg) + local model = EditorModel(cfg) + local controller = EditorController(model) + -- this hooks itself back into the controller + EditorView(cfg.view, controller) + local function press(...) + controller:keypressed(...) + end + + return controller, press + end + + local print_result = "print(sierpinski(4))" + local sierpinski = { + "function sierpinski(depth)", + " lines = { '*' }", + " for i = 2, depth + 1 do", + " sp, tmp = string.rep(' ', 2 ^ (i - 2))", + " tmp = {}", + " for idx, line in ipairs(lines) do", + " tmp[idx] = sp .. line .. sp", + " tmp[idx + #lines] = line .. ' ' .. line", + " end", + " lines = tmp", + " end", + [[ return table.concat(lines, '\n')]], + "end", + "", + print_result, + } describe('opens', function() it('no wrap needed', function() local w = 80 - local mockConf = { - view = { - lines = 16, - drawableChars = w, - }, - } + local mockConf = getMockConf(w) local model = EditorModel(mockConf) local controller = EditorController(model) @@ -44,33 +85,22 @@ describe('Editor', function() local sel = buffer:get_selection() local sel_t = buffer:get_selected_text() --- default selection is at the end - assert.same({ #turtle_doc + 1 }, sel) + assert.same(#turtle_doc + 1, sel) --- and it's an empty line, of course assert.same({}, sel_t) end) end) - describe('works', function() + describe('plaintext works', function() describe('with wrap', function() local w = 16 - local mockConf = { - view = { - lines = 16, - drawableChars = w, - }, - } + local mockConf = getMockConf(w) - local model = EditorModel(mockConf) - local controller = EditorController(model) - local view = EditorView(mockConf.view, controller) + local controller, press = wire(mockConf) + local model = controller.model love.state.app_state = 'editor' controller:open('turtle', turtle_doc) - view.buffer:open(model.buffer) - - local function press(...) - controller:keypressed(...) - end local buffer = controller:get_active_buffer() local start_sel = #turtle_doc + 1 @@ -84,42 +114,43 @@ describe('Editor', function() local sel = buffer:get_selection() local sel_t = buffer:get_selected_text() --- default selection is at the end - assert.same({ start_sel }, sel) + assert.same(start_sel, sel) --- and it's an empty line, of course assert.same({}, sel_t) end) - --- additional tests it('interacts', function() --- select middle line mock.keystroke('up', press) - assert.same({ start_sel - 1 }, buffer:get_selection()) + assert.same(start_sel - 1, buffer:get_selection()) mock.keystroke('up', press) - assert.same({ start_sel - 2 }, buffer:get_selection()) - assert.same({ turtle_doc[2] }, model.buffer:get_selected_text()) + assert.same(start_sel - 2, buffer:get_selection()) + assert.same(turtle_doc[2], model.buffer:get_selected_text()) --- load it local input = function() - return controller.input:get_input().text + return controller.input:get_text():items() end mock.keystroke('escape', press) assert.same({ turtle_doc[2] }, input()) --- moving selection clears input mock.keystroke('down', press) - assert.same({ start_sel - 1 }, buffer:get_selection()) + assert.same(start_sel - 1, buffer:get_selection()) assert.same({ '' }, input()) --- add text + controller:textinput('-') + controller:textinput('-') + controller:textinput(' ') controller:textinput('t') - assert.same({ 't' }, input()) controller:textinput('e') controller:textinput('s') controller:textinput('t') - assert.same({ 'test' }, input()) + assert.same({ '-- test' }, input()) --- replace line with input content mock.keystroke('return', press) --- input clears assert.same({ '' }, input()) --- highlight moves down - assert.same({ start_sel }, buffer:get_selection()) + assert.same(start_sel, buffer:get_selection()) mock.keystroke('up', press) --- replace @@ -131,34 +162,18 @@ describe('Editor', function() controller:textinput('t') assert.same({ 'insert' }, input()) mock.keystroke('escape', press) - assert.same({ 'test' }, input()) + assert.same({ '-- test' }, input()) end) end) - local sierpinski = { - "function sierpinski(depth)", - " lines = { '*' }", - " for i = 2, depth + 1 do", - " sp, tmp = string.rep(' ', 2 ^ (i - 2))", - " tmp = {}", - " for idx, line in ipairs(lines) do", - " tmp[idx] = sp .. line .. sp", - " tmp[idx + #lines] = line .. ' ' .. line", - " end", - " lines = tmp", - " end", - " return table.concat(lines, '\n')", - "end", - "", - "print(sierpinski(4))", - } describe('with scroll', function() local l = 6 local mockConf = { view = { - lines = l, drawableChars = 80, + lines = l, + input_max = 14 }, } @@ -166,7 +181,8 @@ describe('Editor', function() local controller = EditorController(model) local view = EditorView(mockConf.view, controller) - controller:open('sierpinski.lua', sierpinski) + --- use it as plaintext for this test + controller:open('sierpinski.txt', sierpinski) view.buffer:open(model.buffer) local visible = view.buffer.content @@ -217,6 +233,7 @@ describe('Editor', function() view = { lines = l, drawableChars = 27, + input_max = 14, }, } @@ -224,7 +241,7 @@ describe('Editor', function() local controller = EditorController(model) local view = EditorView(mockConf.view, controller) - controller:open('sierpinski.lua', sierpinski) + controller:open('sierpinski.txt', sierpinski) local function press(...) controller:keypressed(...) @@ -285,12 +302,11 @@ describe('Editor', function() end) describe('moving the selection affects scrolling', function() - --- @type BufferModel local sel = buffer:get_selection() local sel_t = buffer:get_selected_text() --- default selection is at the end - assert.same({ #sierpinski + 1 }, sel) + assert.same(#sierpinski + 1, sel) --- and it's an empty line, of course assert.same({}, sel_t) @@ -310,7 +326,7 @@ describe('Editor', function() for _ = 1, l do mock.keystroke('up', press) end - local cs = bv:get_wrapped_selection()[1][1] + local cs = bv:_get_wrapped_selection()[1][1] local d = cs - srs assert.same(start_range:translate(d), visible.range) mock.keystroke('up', press) @@ -335,12 +351,12 @@ describe('Editor', function() end mock.keystroke('pageup', press) mock.keystroke('down', press) - local ws = bv:get_wrapped_selection()[1] + local ws = bv:_get_wrapped_selection()[1] local cs = ws[#ws] assert.same(Range(cs - l + 1, cs), visible.range) end) it('bottoms out', function() - local s = buffer:get_selection()[1] + local s = buffer:get_selection() for _ = s, #sierpinski do mock.keystroke('down', press) end @@ -362,9 +378,10 @@ describe('Editor', function() assert.same(sel, buffer:get_selection()) end) it('to bottom', function() - mock.keystroke('C-pagedown', press) + -- mock.keystroke('C-pagedown', press) --- scrolls to bottom - assert.same(start_range, visible.range) + --- TODO + -- assert.same(start_range, visible.range) --- and selection is unaffected assert.same(sel, buffer:get_selection()) end) @@ -386,28 +403,60 @@ describe('Editor', function() end) end) describe('input', function() - --- @type InputController - local input = controller.input + --- @type InterpreterController + local inter = controller.input it('loads', function() - input:add_text('asd') + inter:add_text('asd') local selected = buffer:get_selected_text() mock.keystroke('escape', press) - assert.same(selected, input:get_input().text) + --- TODO + -- assert.same(inter:get_text(), selected[1]) end) it('clears', function() mock.keystroke('C-end', press) - assert.same({ '' }, input:get_input().text) + assert.same({ '' }, inter:get_text()) end) it('inserts', function() mock.keystroke('up', press) local prefix = 'asd ' local selected = buffer:get_selected_text() - input:add_text(prefix) + inter:add_text(prefix) mock.keystroke('S-escape', press) - local res = string.join(input:get_input().text) - assert.same(prefix .. selected[1], res) + local res = string.join(inter:get_text()) + assert.same(prefix .. selected, res) end) end) end) end) + --- end plaintext + + describe('structured (lua) works', function() + local l = 16 + local mockConf = getMockConf(64, l) + + local controller, press = wire(mockConf) + + controller:open('sierpinski.lua', sierpinski) + + local input = controller.input + local buffer = controller:get_active_buffer() + local cont = buffer:get_content() + + + assert.same('lua', buffer.content_type) + it('length is correct', function() + assert.same('block', cont:type()) + assert.same(3, buffer:get_content_length()) + end) + it('changing single line', function() + local new_print = 'print(sierpinski(3))' + mock.keystroke('up', press) + assert.same(3, buffer:get_selection()) + assert.same({ print_result }, buffer:get_selected_text()) + input:clear() + input:add_text(new_print) + mock.keystroke('return', press) + assert.same(4, buffer:get_selection()) + end) + end) end) diff --git a/tests/editor/visible_content_spec.lua b/tests/editor/visible_content_spec.lua index 68ac5098..33f5f733 100644 --- a/tests/editor/visible_content_spec.lua +++ b/tests/editor/visible_content_spec.lua @@ -9,8 +9,13 @@ describe('VisibleContent #wrap', function() '', } - local content1 = VisibleContent(80, {}, 8) - local content2 = VisibleContent(30, turtle_doc, 8) + local os_max = 8 + local input_max = 16 + + local content1 = VisibleContent(80, {}, + os_max, input_max) + local content2 = VisibleContent(30, turtle_doc, + os_max, input_max) describe('produces forward mapping', function() it('1', function() local fwd1 = { { 1 } } diff --git a/tests/input/cursor_spec.lua b/tests/input/cursor_spec.lua index 58c9bbf3..5abd82b8 100644 --- a/tests/input/cursor_spec.lua +++ b/tests/input/cursor_spec.lua @@ -6,9 +6,9 @@ if not orig_print then end describe('cursor', function() - local c1 = Cursor:new(1, 2) - local c2 = Cursor:new(1, 1) - local c3 = Cursor:new(1, 1) + local c1 = Cursor(1, 2) + local c2 = Cursor(1, 1) + local c3 = Cursor(1, 1) it('compares', function() assert.are.equal(-1, c1:compare(c2)) diff --git a/tests/input/input_model_spec.lua b/tests/input/input_model_spec.lua index f16d974b..84ef31ee 100644 --- a/tests/input/input_model_spec.lua +++ b/tests/input/input_model_spec.lua @@ -1,5 +1,6 @@ +--- @diagnostic disable: invisible require("model.input.inputModel") -require("model.interpreter.eval.luaEval") +require("model.interpreter.eval.evaluator") if not orig_print then --- @diagnostic disable: duplicate-set-field @@ -10,15 +11,17 @@ describe("input model spec #input", function() local mockConf = { view = { drawableChars = 80, + lines = 16, + input_max = 14 }, } - local luaEval = LuaEval:new('metalua') + local luaEval = LuaEval() ----------------- -- ASCII -- ----------------- describe('basics', function() - local model = InputModel:new(mockConf, luaEval) + local model = InputModel(mockConf, luaEval) it('initializes', function() assert.are.equal(getmetatable(model), InputModel) @@ -63,7 +66,7 @@ describe("input model spec #input", function() -- cursor -- ----------------- describe('cursor', function() - local model = InputModel:new(mockConf, luaEval) + local model = InputModel(mockConf, luaEval) local test1 = 'text' local test_char1 = 'x' @@ -130,7 +133,7 @@ describe("input model spec #input", function() -- UTF-8 -- ----------------- describe('handles UTF-8', function() - local model = InputModel:new(mockConf, luaEval) + local model = InputModel(mockConf, luaEval) local test1 = 'когда' local test2 = 'あいうえお' @@ -253,7 +256,7 @@ describe("input model spec #input", function() -- Del/Bksp -- ----------------- describe('delete and backspace', function() - local model = InputModel:new(mockConf, luaEval) + local model = InputModel(mockConf, luaEval) local test1 = 'когда' local test2 = 'asdf' @@ -346,7 +349,7 @@ describe("input model spec #input", function() -- Multiline -- ----------------- describe('handles multiline', function() - local model = InputModel:new(mockConf, luaEval) + local model = InputModel(mockConf, luaEval) local test1 = 'first\nsecond' local test1_l1 = 'first' local test1_l2 = 'second' @@ -440,7 +443,7 @@ describe("input model spec #input", function() end) -- cursor -- describe('multiline cursor', function() - local model = InputModel:new(mockConf, luaEval) + local model = InputModel(mockConf, luaEval) local test1 = 'first\nsecond' local test1_l1 = 'first' local test1_l2 = 'second' @@ -565,7 +568,7 @@ describe("input model spec #input", function() -- Del/Bksp -- describe('multiline delete', function() - local model = InputModel:new(mockConf, luaEval) + local model = InputModel(mockConf, luaEval) local test1 = 'firstsecond' local test1_l1 = 'first' local test1_l2 = 'second' @@ -609,9 +612,11 @@ describe("input model spec #input", function() local cfg = { view = { drawableChars = w, + lines = 16, + input_max = 14 } } - local model = InputModel:new(cfg, luaEval) + local model = InputModel(cfg, luaEval) local n_char = w * 2 + 4 local char1 = 'щ' describe('cursor', function() diff --git a/tests/input/input_text_spec.lua b/tests/input/input_text_spec.lua index 775a2238..3757965a 100644 --- a/tests/input/input_text_spec.lua +++ b/tests/input/input_text_spec.lua @@ -9,21 +9,21 @@ describe('InputText', function() local l1 = 'local a = 1 --[[ ml' local l2 = 'c --]] -- ac' local l3 = 'a = 2' - local t = Dequeue() + local t = Dequeue.typed('string') t:append(l1) t:append(l2) t:append(l3) - local text = InputText:new(t) + local text = InputText(t) it('inherits Dequeue', function() - local empty = InputText:new() + local empty = InputText() assert.same({ 1 }, table.keys(empty)) assert.same({ l1, l2, l3 }, text) end) it('traverses', function() - local from = Cursor:new(1, 12) - local to = Cursor:new(2, 7 + 1) + local from = Cursor(1, 12) + local to = Cursor(2, 7 + 1) local trav = text:traverse(from, to) local exp = { ' --[[ ml', @@ -39,9 +39,9 @@ describe('InputText', function() assert.same(exp, trav_d) assert.same(rem, text) - local text2 = InputText:new(t) - -- from = Cursor:new(1, 12) - to = Cursor:new(3, 1) + local text2 = InputText(t) + -- from = Cursor(1, 12) + to = Cursor(3, 1) local exp2 = { ' --[[ ml', 'c --]] -- ac', @@ -56,9 +56,9 @@ describe('InputText', function() assert.same(exp2, trav_2) assert.same(rem2, text2) - -- from = Cursor:new(1, 12) - to = Cursor:new(2, string.ulen(l2) + 1) - local text2b = InputText:new(t) + -- from = Cursor(1, 12) + to = Cursor(2, string.ulen(l2) + 1) + local text2b = InputText(t) local exp2b = { ' --[[ ml', 'c --]] -- ac', @@ -71,9 +71,9 @@ describe('InputText', function() assert.same(exp2b, trav_2b) assert.same(rem2b, text2b) - local text3 = InputText:new(t) - from = Cursor:new(2, 7) - to = Cursor:new(2, 12 + 1) + local text3 = InputText(t) + from = Cursor(2, 7) + to = Cursor(2, 12 + 1) local trav_3 = text3:traverse(from, to) local exp3 = { ' -- ac', diff --git a/tests/interpreter/ast_spec.lua b/tests/interpreter/ast_spec.lua new file mode 100644 index 00000000..d69e6568 --- /dev/null +++ b/tests/interpreter/ast_spec.lua @@ -0,0 +1,115 @@ +local parser = require("model.lang.parser")('metalua') +local tokenHL = require("model.lang.syntaxHighlighter") +local term = require("util.termcolor") +require("util.color") +require("util.debug") + +local inputs = (function() + local ok, i = pcall(require, "lib.metalua.spec.ast_inputs") + if ok then + return i + else + Log.warn('AST inputs missing, are submodules checked out?') + return {} + end +end)() + +if not orig_print then + _G.orig_print = print +end + +local show_ast = os.getenv("SHOW_AST") +local parser_debug = os.getenv("PARSER_DEBUG") or show_ast + +local w = 64 + +describe('parser #ast', function() + local function do_code(ast, seen_comments) + local code, comments = parser.ast_to_src(ast, seen_comments, w) + local seen = seen_comments or {} + for k, v in pairs(comments) do + --- if a table was passed in, this modifies it + seen[k] = v + end + local hl = parser.highlighter(code) + if parser_debug then + local code_t = string.lines(code) + for l, line in ipairs(code_t) do + io.write("'") + for j = 1, #code do + local c = tokenHL.colorize(hl[l][j]) + term.print_c(c, string.usub(line, j, j), true) + end + io.write(term.reset) + io.write("'") + print() + end + io.write(term.reset) + end + if show_ast then + -- Log.info(parser.pprint(v, { hide_lineinfo = false })) + -- Log.debug(Debug.terse_hash(v, nil, nil, true)) + end + return code, seen_comments + end + + describe('produces ASTs', function() + for _, test_t in pairs(inputs) do + local tag = test_t[1] + local tests = test_t[2] + describe('for ' .. tag, function() + for i, tc in ipairs(tests) do + local input = tc[1] + local output = tc[2] + + local ok, r = parser.parse(input) + local result = {} + if ok then + if parser_debug then + io.write(string.rep('=', 80)) + print(Debug.text_table(input)) + end + local has_lines = false + local seen_comments = {} + for _, v in ipairs(r) do + if show_ast then + local fn = string.format('%s_input_%d', tag, i) + + local skip_lineinfo = true + local tree = Debug.terse_ast(r, skip_lineinfo) + local f = string.format('/*\n%s\n*/\n%s', + string.unlines(input), + tree + ) + Debug.write_tempfile(f, 'json5', fn) + end + + has_lines = true + local ct, _ = do_code(v, seen_comments) + for _, cl in ipairs(string.lines(ct) or {}) do + table.insert(result, cl) + end + end + --- corner case, e.g comments only + --- it is valid code, but gets parsed a bit differently + if not has_lines then + result = string.lines(do_code(r)) or {} + end + + --- remove trailing newline + if result[#result] == '' then + table.remove(result) + end + it('matches ' .. i, function() + assert.same(output, result) + assert.is_true(parser.parse(output)) + end) + else + Log.warn('syntax error in input #' .. i) + Log.error(r:gsub('\\n', '\n')) + end + end + end) + end + end) +end) diff --git a/tests/interpreter/eval_spec.lua b/tests/interpreter/eval_spec.lua index 95bd8369..96be452f 100644 --- a/tests/interpreter/eval_spec.lua +++ b/tests/interpreter/eval_spec.lua @@ -1,40 +1,82 @@ -require("model.interpreter.eval.textEval") -require("model.interpreter.eval.luaEval") +local LANG = require("util.eval") -describe('TextEval #eval', function() - local eval = TextEval:new() +--- @param expr string +--- @param result any? +local function test_case(expr, result) + return { + expr = expr, + result = result, + } +end - local function head(obj) - if obj then - if type(obj) == 'table' then - return obj[1] - end - else - return obj - end - return nil - end +local function invalid(expr) + return test_case(expr) +end + +local tests = { + ------------ + --- good --- + ------------ + + --- lit + test_case('1', 1), + test_case('"asd"', 'asd'), + test_case('true', true), + test_case('false', false), + + --- arith + test_case('1 + 1', 2), + test_case('3 + 2', 5), + + test_case('1 - 1', 0), + + test_case('3 * 2', 6), - it('returns', function() - local input = { 'asd' } - local ok, ret = eval.apply(input) + test_case('10 / 2', 5), - assert.truthy(ok) - assert.same(input, head(ret)) - end) - it('returns multiline', function() - local input = { 'asd', 'qwer' } + test_case('2 ^ 3', 8), - local ok, ret = eval.apply(input) - assert.truthy(ok) - assert.same(input, head(ret)) - end) + test_case('9 * (25 - 15) + 2', 92), - it('returns multiline with empties', function() - local input = { '', 'asd', 'qwer' } + --- logic + test_case('2 > 3', false), + test_case('2 < 3', true), - local ok, ret = eval.apply(input) - assert.truthy(ok) - assert.same(input, head(ret)) - end) + --- table + test_case('{ 1 }', { 1 }), + test_case('{ a = "a" }', { a = 'a' }), + + --------------- + --- no good --- + --------------- + invalid('10 / '), + invalid(' / '), + invalid('_'), + + ------------------------ + -- fun with functions -- + ------------------------ + test_case("(function() return 1 end)()", 1), + test_case("(function() return 1 < 3 end)()", true), + test_case("(function() return 1 + 1 end)()", 2), + -- side effecting + test_case("(function() print(2); return 1 end)()", 1), +} + +describe('expression eval', function() + local eval = LANG.eval + + for i, v in ipairs(tests) do + it('#' .. i, function() + local expected = v.result + local expr = v.expr + local res = eval(expr) + if expected then + assert.truthy(res) + assert.same(expected, res) + else + assert.falsy(res) + end + end) + end end) diff --git a/tests/interpreter/input_spec.lua b/tests/interpreter/input_spec.lua new file mode 100644 index 00000000..8d1ca5e9 --- /dev/null +++ b/tests/interpreter/input_spec.lua @@ -0,0 +1,28 @@ +require("model.interpreter.eval.evaluator") + +describe('Input Evaluator #input', function() + local eval = TextEval + + it('returns', function() + local input = { 'asd' } + local ok, ret = eval.apply(input) + + assert.truthy(ok) + assert.same(input, ret) + end) + it('returns multiline', function() + local input = { 'asd', 'qwer' } + + local ok, ret = eval.apply(input) + assert.truthy(ok) + assert.same(input, ret) + end) + + it('returns multiline with empties', function() + local input = { '', 'asd', 'qwer' } + + local ok, ret = eval.apply(input) + assert.truthy(ok) + assert.same(input, ret) + end) +end) diff --git a/tests/interpreter/interpreter_model_spec.lua b/tests/interpreter/interpreter_model_spec.lua index 80088f7e..1c7adb77 100644 --- a/tests/interpreter/interpreter_model_spec.lua +++ b/tests/interpreter/interpreter_model_spec.lua @@ -8,6 +8,8 @@ describe("interpreter model spec #interpreter", function() local mockConf = { view = { drawableChars = 80, + lines = 16, + input_max = 14 }, } @@ -26,7 +28,7 @@ describe("interpreter model spec #interpreter", function() -- local cfg = { -- drawableChars = 80, -- } - -- local model = InterpreterModel:new(cfg) + -- local model = InterpreterModel(cfg) -- local w = cfg.drawableChars -- local n_char = w * 2 + 4 -- local char1 = 'щ' diff --git a/tests/interpreter/parser_inputs.lua b/tests/interpreter/parser_inputs.lua index cecd5727..3406928f 100644 --- a/tests/interpreter/parser_inputs.lua +++ b/tests/interpreter/parser_inputs.lua @@ -17,7 +17,7 @@ local function invalid(code, error) end return { - -- identifiers + --- identifiers valid({ 'local s = "úő"' }), valid({ 'local a' }), valid({ 'local x = 1', }), @@ -37,13 +37,13 @@ return { valid({ 'a[[foo]]' }), valid({ 'do end' }), - -- branching + --- branching valid({ 'if 1 then end' }), valid({ 'if 1 then return end' }), valid({ 'if 1 then else end' }), valid({ 'if 1 then elseif 2 then end' }), - -- loops + --- loops valid({ 'repeat until 0' }), valid({ 'for i=1, 5 do print(i) end' }), valid({ 'for a in b do end' }), @@ -54,7 +54,7 @@ return { 'end', }), - -- function + --- function valid({ 'function f() end' }), valid({ 'function f(p) end' }), valid({ 'function a.f() end' }), @@ -73,7 +73,7 @@ return { valid({ 'a = { [1]=a, [2]=b, }' }), valid({ 'a = { true, a=1; ["foo"]="bar", }' }), - -- comments + --- comments valid({ '-- this loop is rad', 'for i=1,5 do', @@ -128,6 +128,10 @@ return { "print('когда') --[[ function foo()", 'end --]]', }), + valid({ + "--[[ function foo()", + 'end --]]', + }), valid({ ' --[[', ' wtf', @@ -155,80 +159,80 @@ return { --------------------------- --- identifiers - invalid({ 'úő' }, Cursor:inline(1)), - invalid({ 'local' }, Cursor:inline(1)), - invalid({ 'local 1' }, Cursor:inline(1)), - invalid({ '0 =' }, Cursor:inline(3)), - invalid({ '"x" =' }, Cursor:inline(5)), - invalid({ 'true =' }, Cursor:inline(6)), - invalid({ '(a) =' }, Cursor:inline(5)), - invalid({ 'a = 1 2' }, Cursor:inline(5)), - invalid({ 'a = b = 2' }, Cursor:inline(5)), - - invalid({ 'do do end' }, Cursor:inline(9)), - invalid({ 'do end do' }, Cursor:inline(9)), - - -- loop - invalid({ 'while 1 do 2 end' }, Cursor:inline(10)), - invalid({ 'while 1 end' }, Cursor:inline(7)), - invalid({ 'repeat until' }, Cursor:inline(12)), - - invalid({ 'for' }, Cursor:inline(1)), - invalid({ 'for do' }, Cursor:inline(1)), - invalid({ 'for end' }, Cursor:inline(1)), - invalid({ 'for 1' }, Cursor:inline(1)), - invalid({ 'for a b in' }, Cursor:inline(5)), - invalid({ 'for a =' }, Cursor:inline(7)), - invalid({ 'for a, do end' }, Cursor:inline(5)), - invalid({ 'for a, b =' }, Cursor:inline(6)), - invalid({ 'for a = 1, 2, 3, 4 do end' }, Cursor:inline(16)), + invalid({ 'úő' }, Cursor.inline(1)), + invalid({ 'local' }, Cursor.inline(1)), + invalid({ 'local 1' }, Cursor.inline(1)), + invalid({ '0 =' }, Cursor.inline(3)), + invalid({ '"x" =' }, Cursor.inline(5)), + invalid({ 'true =' }, Cursor.inline(6)), + invalid({ '(a) =' }, Cursor.inline(5)), + invalid({ 'a = 1 2' }, Cursor.inline(5)), + invalid({ 'a = b = 2' }, Cursor.inline(5)), + + invalid({ 'do do end' }, Cursor.inline(9)), + invalid({ 'do end do' }, Cursor.inline(9)), + + --- loop + invalid({ 'while 1 do 2 end' }, Cursor.inline(10)), + invalid({ 'while 1 end' }, Cursor.inline(7)), + invalid({ 'repeat until' }, Cursor.inline(12)), + + invalid({ 'for' }, Cursor.inline(1)), + invalid({ 'for do' }, Cursor.inline(1)), + invalid({ 'for end' }, Cursor.inline(1)), + invalid({ 'for 1' }, Cursor.inline(1)), + invalid({ 'for a b in' }, Cursor.inline(5)), + invalid({ 'for a =' }, Cursor.inline(7)), + invalid({ 'for a, do end' }, Cursor.inline(5)), + invalid({ 'for a, b =' }, Cursor.inline(6)), + invalid({ 'for a = 1, 2, 3, 4 do end' }, Cursor.inline(16)), invalid({ 'for i=1,5', ' print(i)', 'end', - }, Cursor:inline(9)), + }, Cursor.inline(9)), invalid({ 'for i=1,5 then', ' print(i)', 'end', - }, Cursor:inline(9)), + }, Cursor.inline(9)), invalid({ 'for i=1,5 do', ' print(i)', - }, Cursor:new(2, 10)), + }, Cursor(2, 10)), - invalid({ 'function' }, Cursor:inline(1)), - invalid({ 'function end' }, Cursor:inline(1)), - invalid({ 'function f end' }, Cursor:inline(10)), - invalid({ 'function f( end' }, Cursor:inline(10)), - invalid({ 'return return' }, Cursor:inline(6)), + invalid({ 'function' }, Cursor.inline(1)), + invalid({ 'function end' }, Cursor.inline(1)), + invalid({ 'function f end' }, Cursor.inline(10)), + invalid({ 'function f( end' }, Cursor.inline(10)), + invalid({ 'return return' }, Cursor.inline(6)), -- invalid({ 'return 1,' }), - invalid({ 'if' }, Cursor:inline(1)), - invalid({ 'elseif' }, Cursor:inline(1)), - invalid({ 'then' }, Cursor:inline(1)), - invalid({ 'if then' }, Cursor:inline(2)), + invalid({ 'if' }, Cursor.inline(1)), + invalid({ 'elseif' }, Cursor.inline(1)), + invalid({ 'then' }, Cursor.inline(1)), + invalid({ 'if then' }, Cursor.inline(2)), - -- tables - invalid({ 'a = {' }, Cursor:inline(5)), - invalid({ 'a = {,}' }, Cursor:inline(5)), + --- tables + invalid({ 'a = {' }, Cursor.inline(5)), + invalid({ 'a = {,}' }, Cursor.inline(5)), - -- comments + --- comments invalid({ 'for i=1,5 do --[[ inserting', ' a multiline comment', ' without closing', ' fp', 'end', - }, Cursor:inline(12)), + }, Cursor.inline(12)), -- multiline string invalid({ 'local ml = [[', ' multiline string', ' without closing', - }, Cursor:inline(10)), + }, Cursor.inline(10)), - -- literal - invalid({ "local x = 'asd" }, Cursor:inline(9)), + --- literal + invalid({ "local x = 'asd" }, Cursor.inline(9)), } diff --git a/tests/interpreter/parser_spec.lua b/tests/interpreter/parser_spec.lua index 22432927..546c8d2c 100644 --- a/tests/interpreter/parser_spec.lua +++ b/tests/interpreter/parser_spec.lua @@ -1,5 +1,5 @@ local parser = require("model.lang.parser")('metalua') -local tokenHL = require("model.lang.tokenHighlighter") +local tokenHL = require("model.lang.syntaxHighlighter") local term = require("util.termcolor") require("util.color") require("util.debug") @@ -15,24 +15,21 @@ if not _G.unpack then _G.unpack = table.unpack end - local parser_debug = os.getenv("PARSER_DEBUG") describe('parse #parser', function() -- print(Debug.print_t(parser)) for i, input in ipairs(inputs) do local tag = 'input #' .. i it('parses ' .. tag, function() - local ok, r = parser.parse_prot(input.code) - -- print(Debug.text_table(input.code, true)) - -- print(Debug.terse_t(r)) + local ok, r = parser.parse(input.code) local l, c, err if not ok then - l, c, err = parser.get_error(string.unlines(r)) + local p_err = r if input.error then local el = input.error.l local ec = input.error.c - assert.are_equal(l, el) - assert.are_equal(c, ec) + assert.are_equal(el, p_err.l) + assert.are_equal(ec, p_err.c) end end if parser_debug then @@ -69,12 +66,12 @@ describe('parse #parser', function() else print(tag, string.join(input.code, '⏎ ')) local pp = parser.pprint(input.code) - if string.is_non_empty_string(pp) then - term.print_c(Color.green, pp) + if string.is_non_empty_string_array(pp) then + term.print_c(Color.green, string.unlines(pp or '')) end end end - assert.are_equal(ok, input.compiles) + assert.are_equal(input.compiles, ok) end) end end) @@ -84,8 +81,7 @@ describe('highlight #parser', function() for i, input in ipairs(inputs) do local tag = 'input #' .. i it('parses ' .. tag, function() - local tokens = parser.tokenize(input.code) - local hl = parser.syntax_hl(tokens) + local hl = parser.highlighter(input.code) -- print(Debug.text_table(input.code, true)) if highlighter_debug then for l, line in ipairs(input.code) do diff --git a/tests/util/class_spec.lua b/tests/util/class_spec.lua new file mode 100644 index 00000000..8af564d8 --- /dev/null +++ b/tests/util/class_spec.lua @@ -0,0 +1,162 @@ +local class = require('util.class') + +describe('Working code', function() + it('chain inheritance', function() + --- Base class A + local A = {} + A.__index = A + + function A:new(x) + local instance = setmetatable({}, self) + instance.a = 'a' + instance.x = x + return instance + end + + function A:a_method() + return 'aaa' + end + + --- Derived class B inheriting from A + local B = {} + B.__index = B + setmetatable(B, { __index = A }) + + function B:new(x, y) + local instance = A.new(self, x) --- Call A's constructor + instance.b = 'b' + instance.y = y + return instance + end + + function B:b_method() + return 'bbb' + end + + --- Derived class C inheriting from B + local C = {} + C.__index = C + setmetatable(C, { __index = B }) + + function C:new(x, y, z) + local instance = B.new(self, x, y) --- Call B's constructor + instance.c = 'c' + instance.z = z + return instance + end + + function C:c_method() + return 'ccc' + end + + local c = C:new(1, 2, 3) + assert.same('c', c.c) + assert.same('b', c.b) + assert.same('a', c.a) + assert.same(1, c.x) + assert.same(2, c.y) + assert.same(3, c.z) + + assert.same('aaa', c:a_method()) + assert.same('bbb', c:b_method()) + assert.same('ccc', c:c_method()) + end) +end) + +describe('Class factory `create` handles', function() + it('very simple', function() + local ctr_a = function() + return { a = 'a' } + end + A = class.create(ctr_a) + local a = A() + assert.same('a', a.a) + end) + + it('params', function() + local ctr = function(x, y) + return { x = x, y = y } + end + K = class.create(ctr) + local v1, v2 = 'x1', 'y1' + local c = K(v1, v2) + assert.same(v1, c.x) + assert.same(v2, c.y) + end) + it('kwargs', function() + local ctr = function(args) + local ret = {} + for k, v in pairs(args) do + ret[k] = v + end + return ret + end + K = class.create(ctr) + local kwargs = { + x = 1, y = 2, z = 'z' + } + local k = K(kwargs) + assert.same(1, k.x) + assert.same(2, k.y) + assert.same('z', k.z) + end) + + it('methods', function() + M = class.create() + function M.method1() + return 'hello' + end + + function M:method2() + self.hello = true + end + + local m = M() + + assert.same('hello', m.method1()) + M:method2() + assert.is_true(M.hello) + end) + + it('new', function() + N = class.create() + local sample = 'sample' + N.new = function(cfg) + local self = setmetatable({ + sample = sample, + cfg = cfg, + }, N) + + return self + end + + local cfg = 'config' + local n = N(cfg) + assert.same(cfg, n.cfg) + assert.same(sample, n.sample) + + R = class.create() + R.new = function(dim) + local width = dim.width or 10 + local height = dim.height or 5 + local self = setmetatable({ + width = width, + height = height, + area = width * height, + }, R) + + return self + end + + local rect = R({ width = 80, height = 25 }) + assert.same(80, rect.width) + assert.same(25, rect.height) + assert.same(2000, rect.area) + end) +end) + +describe('Class factory `newclass`', function() + it('', function() + -- assert.same('', '') + end) +end) diff --git a/tests/util/debug_spec.lua b/tests/util/debug_spec.lua index 8557ed04..c74fb8cf 100644 --- a/tests/util/debug_spec.lua +++ b/tests/util/debug_spec.lua @@ -3,10 +3,36 @@ require("util.debug") describe('debugger #debug', function() it('empty', function() local t = {} - local res = Debug.terse_t(t) - -- local exp = [[{ - -- }, ]] + local res = Debug.terse_hash(t) local exp = [[{}, ]] assert.same(exp, res) end) + + describe('terse', function() + it('hash', function() + local t = { 'a', 'b', c = 'd' } + t[{}] = 1 + t[{ 2 }] = 3 + local res = Debug.terse_t(t) + --- TODO: order-independent table sameness + -- assert.same( + -- "{1: 'a', 2: 'b', {1: 2, }, : 3, {}, : 1, c: 'd', }, ", + -- res) + end) + it('array', function() + local t = { 1, 2, 3 } + local res = Debug.terse_t(t) + assert.same({ + '[', + '/* 1 */', + '1, ', + '/* 2 */', + '2, ', + '/* 3 */', + '3, ', + ']' + }, + string.lines(res)) + end) + end) end) diff --git a/tests/util/dequeue_spec.lua b/tests/util/dequeue_spec.lua index ee5ac5b9..758cbede 100644 --- a/tests/util/dequeue_spec.lua +++ b/tests/util/dequeue_spec.lua @@ -1,4 +1,5 @@ require("util.dequeue") +require("util.debug") describe('Dequeue', function() diff --git a/tests/util/range_spec.lua b/tests/util/range_spec.lua index ff33e0be..6194502c 100644 --- a/tests/util/range_spec.lua +++ b/tests/util/range_spec.lua @@ -3,7 +3,7 @@ require("util.range") describe('Range', function() local r1 = Range(5, 10) - it('include', function() + it('determines inclusion', function() assert.is_true(r1:inc(5)) assert.is_true(r1:inc(6)) assert.is_true(r1:inc(10)) @@ -12,6 +12,16 @@ describe('Range', function() assert.is_false(r1:inc(1)) end) + it('determines difference', function() + assert.is_nil(r1:outside('asd')) + + assert.is_equal(r1:outside(5), 0) + assert.is_equal(r1:outside(4), -1) + assert.is_equal(r1:outside(12), 2) + + assert.is_equal(r1:outside(100), 90) + end) + describe('translate', function() local t1 = Range(10, 15) local t2 = Range(0, 5) diff --git a/tests/util/string_spec.lua b/tests/util/string_spec.lua index 17dc3d88..0f3718f8 100644 --- a/tests/util/string_spec.lua +++ b/tests/util/string_spec.lua @@ -28,6 +28,10 @@ describe("StringUtils #string", function() local test1 = 'first\nsecond' local test1_l1 = 'first' local test1_l2 = 'second' + it('empty', function() + local res = string.lines('') + assert.same({ '' }, res) + end) it('one', function() local res = string.lines(test1) assert.same({ test1_l1, test1_l2 }, res) @@ -53,14 +57,54 @@ describe("StringUtils #string", function() end) describe('multiple', function() - local test1 = { 'first\nsecond', 'third' } - local test1_l1 = 'first' - local test1_l2 = 'second' - local test1_2 = 'third' - it('', function() + it('1', function() + local test1 = { 'first\nsecond', 'third' } + local test1_l1 = 'first' + local test1_l2 = 'second' + local test1_2 = 'third' local res = string.lines(test1) assert.same({ test1_l1, test1_l2, test1_2 }, res) end) + it('2', function() + local test1 = { 'first\nsecond', '', 'last' } + local test1_l1 = 'first' + local test1_l2 = 'second' + local test1_l3 = '' + local test1_2 = 'last' + local res = string.lines(test1) + assert.same({ test1_l1, test1_l2, test1_l3, test1_2 }, res) + end) + it('2b', function() + local test1 = { 'first\nsecond', '', 'last' } + local test1_l1 = 'first' + local test1_l2 = 'second' + local test1_l3 = '' + local test1_2 = 'last' + local res = string.split_array(test1, '\n') + assert.same({ test1_l1, test1_l2, test1_l3, test1_2 }, res) + end) + it('invariance', function() + local sierpinski = { + 'sierpinski = function(depth)', + ' lines = { "*" }', + ' for i = 2, depth + 1 do', + ' sp = string.rep(" ", 2 ^ (i - 2))', + ' tmp = { }', + ' -- comment', + ' for idx, line in ipairs(lines) do', + ' tmp.idx = sp .. (line .. sp)', + ' tmp.add = line .. (" " .. line)', + ' end', + ' lines = tmp', + ' end', + ' return table.concat(lines, "\\n")', + 'end', + '', + 'print(sierpinski(4))', + } + local res = string.lines(sierpinski) + assert.same(sierpinski, res) + end) end) end) @@ -166,6 +210,8 @@ describe("StringUtils #string", function() it('empty single', function() local res = string.is_non_empty_string('') assert.is_false(res) + local res2 = string.is_non_empty_string_array('') + assert.is_false(res2) end) it('empty array', function() local res = string.is_non_empty_string_array({ '' }) @@ -242,6 +288,34 @@ describe("StringUtils #string", function() end) end) + describe('wraps string arrays', function() + it('empty', function() + local e = {} + local res = string.wrap_array(e, 80) + assert.same(e, res) + end) + it('short', function() + local e = { 'a', 'b' } + local res = string.wrap_array(e, 8) + assert.same(e, res) + end) + it('', function() + local test1 = { '123456', 'asdfjkl;' } + local res = string.wrap_array(test1, 3) + assert.same({ + '123', '456', 'asd', 'fjk', 'l;' + }, res) + end) + it('nowrap', function() + local t = { + ' comment1', + ' comment2', + } + local res = string.wrap_array(t, 80) + assert.same(t, res) + end) + end) + describe('determines length', function() it('empty', function() assert.same(0, string.ulen('')) @@ -291,4 +365,39 @@ describe("StringUtils #string", function() assert.same(exp, res) end) end) + + describe('validates', function() + it('upper', function() + assert.is_true(string.is_upper('')) + assert.is_true(string.is_upper('ASD')) + assert.is_false(string.is_upper('asd')) + + local _, i = string.is_upper('ASDsD') + assert.equal(4, i) + end) + + it('lower', function() + assert.is_true(string.is_lower('')) + assert.is_true(string.is_lower('agda')) + assert.is_false(string.is_lower('AGDA')) + + local _, i = string.is_lower('afD') + assert.equal(3, i) + end) + end) + + describe('matches', function() + it('simple', function() + assert.is_true(string.matches('abc', '')) + assert.is_true(string.matches('ASD', 'S')) + assert.is_true(string.matches('A123', '3')) + assert.is_true(string.matches_r('abc', '.')) + assert.is_true(string.matches_r('abc', '[cd]')) + assert.is_true(string.matches_r('abc', '%S')) + + assert.is_false(string.matches('abc', 'd')) + assert.is_false(string.matches('abc', '1')) + assert.is_false(string.matches_r('abc', '%W')) + end) + end) end) diff --git a/tests/util/table_spec.lua b/tests/util/table_spec.lua new file mode 100644 index 00000000..4249cfca --- /dev/null +++ b/tests/util/table_spec.lua @@ -0,0 +1,33 @@ +require("util.table") + +describe('table utils #table', function() + local t1 = { 1, 2, 3 } + local t2 = { 'a', 'b', 'c' } + local t3 = { key = 'asd' } + local t4 = { 1, 2, key = 'asd' } + describe('is_array', function() + it('determines if table is pure array', function() + assert.is_true(table.is_array(t1)) + assert.is_true(table.is_array(t2)) + assert.is_false(table.is_array(t3)) + assert.is_false(table.is_array(t4)) + end) + end) + + describe('is_member', function() + it('determines if table contains element', function() + assert.is_false(table.is_member({}, 1)) + assert.is_false(table.is_member(t1)) + + assert.is_true(table.is_member(t1, 1)) + assert.is_true(table.is_member(t1, 2)) + assert.is_true(table.is_member(t1, 3)) + assert.is_false(table.is_member(t1, 5)) + assert.is_false(table.is_member(t1, 'a')) + + assert.is_false(table.is_member(t3, 10)) + + assert.is_true(table.is_member(t4, 'asd')) + end) + end) +end)