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)