diff --git a/.config/detekt/.detekt.yml b/.config/detekt/.detekt.yml
index e82d97d21..4a0ea58c2 100644
--- a/.config/detekt/.detekt.yml
+++ b/.config/detekt/.detekt.yml
@@ -1,65 +1,81 @@
comments:
- # License in each file is unnecessary.
- AbsentOrWrongFileLicense:
- active: false
- # Nothing wrong with documenting private methods.
- CommentOverPrivateFunction:
- active: false
- # Nothing wrong with documenting private properties.
- CommentOverPrivateProperty:
- active: false
- # Bugged, see https://github.com/detekt/detekt/issues/4727, fixed in Detekt 1.21.0
- OutdatedDocumentation:
- active: false
+ # [Disagree] License in each file is unnecessary.
+ AbsentOrWrongFileLicense:
+ active: false
+ # [Disagree] Simple private functions should be documented too.
+ CommentOverPrivateFunction:
+ active: false
+ # [Disagree] Simple private properties should be documented too.
+ CommentOverPrivateProperty:
+ active: false
complexity:
- # Static analysis is not good at estimating this.
- TooManyFunctions:
- thresholdInClasses: 20
- # Acceptable if used sparingly.
- LabeledExpression:
- active: false
+ # [Disagree] Acceptable if used sparingly.
+ LabeledExpression:
+ active: false
+ # [False Positive] Solved using resource bundles. All remaining duplicates are resource identifiers.
+ StringLiteralDuplication:
+ active: false
+ # [Exception] Acceptable for helper files.
+ TooManyFunctions:
+ excludes: '**/*Helpers.kt'
formatting:
- # Experimental. Too many false positives.
- ArgumentListWrapping:
- active: false
- # That's part of my code style.
- NoConsecutiveBlankLines:
- active: false
- # Acceptable because of auto-formatting.
- MultiLineIfElse:
- active: false
-
-naming:
- # Static analysis is not good at estimating this.
- FunctionMaxLength:
- active: false
- # Cannot be suppressed in case of false positives.
- MatchingDeclarationName:
- active: false
+ # [Disagree] Acceptable for many short arguments.
+ ArgumentListWrapping:
+ active: false
+ # [Bug] Incorrectly detects violation if semicolon is followed by more than one newline.
+ EnumWrapping:
+ active: false
+ # [Disagree] Multi-line expressions are easier to understand if they start on the next line.
+ FunctionSignature:
+ active: false
+ # [Disagree] Short one-liners are easier to understand.
+ IfElseWrapping:
+ active: false
+ # [Bug] Causes ugly indentation when using named multiline arguments to a function.
+ MultilineExpressionWrapping:
+ active: false
+ # [Disagree] Braces use unnecessary extra space.
+ MultiLineIfElse:
+ active: false
+ # [Disagree] Consecutive blank lines are used consistently to group blocks of code.
+ NoConsecutiveBlankLines:
+ active: false
+ # [Disagree] Required for lists and varargs, but ugly when it is unlikely that the function signature will change.
+ TrailingCommaOnCallSite:
+ active: false
potential-bugs:
- # The alternative is to safe cast and throw an exception, which is equally bad.
- UnsafeCast:
- active: false
+ # [Exception] Initialised by scene builder.
+ LateinitUsage:
+ ignoreOnClassesPattern: ".*Editor"
style:
- # Those functions are added as conscious design decisions.
- DataClassContainsFunctions:
- active: false
- # Not if they're settings objects.
- DataClassShouldBeImmutable:
- active: false
- # Not an issue if names are self-explanatory.
- DestructuringDeclarationWithTooManyEntries:
- active: false
- # Such comments are OK.
- ForbiddenComment:
- active: false
- # No braces are easier to read.
- MandatoryBracesIfStatements:
- active: false
- # False positives when there are two newlines after the imports.
- SpacingBetweenPackageAndImports:
- active: false
+ # [Disagree] `apply` is confusing to use because of namespace conflicts.
+ AlsoCouldBeApply:
+ active: false
+ # [Disagree] Braces use unnecessary extra space, but consistency helps in legibility.
+ BracesOnIfStatements:
+ multiLine: consistent
+ # [Disagree] Legibility is possible despite consistency because it is wrapped in a multi-line `when` block.
+ BracesOnWhenStatements:
+ multiLine: necessary
+ # [Disagree] Functions on data classes are useful.
+ DataClassContainsFunctions:
+ active: false
+ # [Disagree] Mutable data classes are useful.
+ DataClassShouldBeImmutable:
+ active: false
+ # [Exception] Acceptable in (parameterized) tests.
+ DestructuringDeclarationWithTooManyEntries:
+ excludes: '**/test/**'
+ # [Bug] Fails when used as function expression body.
+ MultilineRawStringIndentation:
+ active: false
+ # [Bug] False positives when there are two newlines after the imports.
+ SpacingBetweenPackageAndImports:
+ active: false
+ # [Bug] Suggests using raw string in constant, but then `trimIndent` is not possible, resulting in weird indenting.
+ StringShouldBeRawString:
+ active: false
diff --git a/.editorconfig b/.editorconfig
index fd1294f9b..c1d462b19 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -9,3 +9,6 @@ insert_final_newline = true
indent_style = space
indent_size = 4
+
+[*.yml]
+indent_size = 2
diff --git a/.gitattributes b/.gitattributes
index fcaa80568..3af866718 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,2 @@
-*.dic binary
*.sketch binary
*.svg binary
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 1a3b4e6e5..6d0896530 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -22,7 +22,7 @@ Pull requests that do not meet the following guidelines are **fine**; these are
### 📚 Documentation
Always update related documentation, both code documentation and user instructions such as the [README](../README.md).
-Additionally, make sure you update the [change notes](../src/main/resources/META-INF/change-notes.html) if necessary.
+Additionally, make sure you update the [change notes](../CHANGELOG.md) if necessary.
### 🧪 Tests
If you fixed a bug or added a new feature, make sure you add tests that cover this.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 9cccea6cf..f468ab780 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -24,10 +24,10 @@ assignees: FWDekker
**Version information**
- - Randomness version [e.g. 2.7.6]:
- - IDE version [e.g. IntelliJ Community 2022.3]:
- - Operating system [e.g. Windows 11, Ubuntu 22.10, macOS 13.1]:
- - Java version [e.g. 11.0.17, 17.0.5]:
+ - Randomness version [e.g. 2.7.7]:
+ - IDE version [e.g. IntelliJ Community 2023.1.3]:
+ - Operating system [e.g. Windows 11, Ubuntu 22.04.2, macOS 13.4.1]:
+ - Java version [e.g. 17.0.5, 19.0.2]:
**Additional context**
diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md
index ec0c89032..3087a6626 100644
--- a/.github/RELEASE_CHECKLIST.md
+++ b/.github/RELEASE_CHECKLIST.md
@@ -1,16 +1,32 @@
# Release checklist
## Documentation
* Bump the version number according to [Semantic Versioning](https://semver.org/).
-* Update `README.md`, `change-notes.html`, and `description.html` if necessary.
- * Make sure to preview the change notes in the IDE by loading the plugin.
-* Update screenshots in `.github/img/` and on the plugin repository if necessary.
- * Distance between bottom of "Refresh" button and top of button bar at bottom is 50 pixels, or the original distance,
- whichever is smaller.
-* Ensure documentation generates without errors and push documentation to `gh-pages` branch.
+* Update [`README.md`](../README.md), [`CHANGELOG.md`](../CHANGELOG.md), and
+ [`description.html`](../src/main/resources/META-INF/description.html) if necessary.
+ * Make sure to preview the change notes in the IDE by loading the plugin.
+ * Make sure the list of acknowledgements is up-to-date.
+* Update screenshots and GIFs in `.github/img/` and on the plugin repository if necessary.
+ * Set the global UI scale to 200% before recording/screenshotting to ensure high-resolution images.
+ * Use the project in `src/test/resources/screenshots/` to store code snippets in.
+ Do not store `.idea/`, `.gradle`, and similar build files in this project.
+ * Hide (inlay) hints and set font size to 20.
+ * Distance between bottom of "Refresh" button and top of button bar at bottom is 50 pixels, or the original
+ distance, whichever is smaller.
+ * On Linux, the screen can be recorded using [peek](https://github.com/phw/peek) or
+ [SimpleScreenRecorder](https://www.maartenbaert.be/simplescreenrecorder/).
+ * Reducing GIF size is a difficult process.
+ The following seems to work fine:
+ 1. `for f in ./*.webm; do ffmpeg -y -i "$f" -vf "fps=10,scale=768:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" -loop 0 "${f%.*}.gif"; done`
+ 2. Go to [ezgif](https://ezgif.com/optimize) and upload the GIF to reduce in size.
+ 3. Apply the following optimisations in question; after each result, you can click "Optimize" to apply another filter:
+ 1. "Color Reduction" to 64 colours
+ 2. "Optimize Transparency" with 2% fuzz
+ 3. "Lossy GIF" with compression level 30
+* Ensure documentation generates without errors, and push the documentation to the `gh-pages` branch.
## Verification
* Run tests and static analysis one more time.
* Try out the plugin yourself and check that old and new features work properly.
* Run the plugin verifier.
- * Make sure the latest IDE version is in the plugin verifier's configuration.
+ * Make sure the latest IDE version is in the plugin verifier's configuration.
* Ensure settings from the previous version correctly load into the new version.
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index d3a7c7b7f..febc65443 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -1,7 +1,8 @@
# Security policy
## Supported versions
-Only the [latest version](https://github.com/FWDekker/intellij-randomness/releases/latest) is ever supported and supplied with security patches.
+Only the [latest version](https://github.com/FWDekker/intellij-randomness/releases/latest) is ever supported and
+supplied with security patches.
## Reporting a vulnerability
-To report a security vulnerability, send an email to security@fwdekker.com instead of using the issue tracker.
+To report a security vulnerability, email `security@fwdekker.com` instead of using the issue tracker.
You will be contacted as soon as possible.
diff --git a/.github/img/array-insertion-sample.gif b/.github/img/array-insertion-sample.gif
new file mode 100644
index 000000000..d717c9b5b
Binary files /dev/null and b/.github/img/array-insertion-sample.gif differ
diff --git a/.github/img/configuration-sample.gif b/.github/img/configuration-sample.gif
new file mode 100644
index 000000000..687e6c71a
Binary files /dev/null and b/.github/img/configuration-sample.gif differ
diff --git a/.github/img/fast-insertion.png b/.github/img/fast-insertion.png
new file mode 100644
index 000000000..c18cc2af3
Binary files /dev/null and b/.github/img/fast-insertion.png differ
diff --git a/.github/img/icons.sketch b/.github/img/icons.sketch
index d4d6cc278..6cac96c22 100644
Binary files a/.github/img/icons.sketch and b/.github/img/icons.sketch differ
diff --git a/.github/img/insertion-sample.gif b/.github/img/insertion-sample.gif
new file mode 100644
index 000000000..965c61cc9
Binary files /dev/null and b/.github/img/insertion-sample.gif differ
diff --git a/.github/img/live-sample.gif b/.github/img/live-sample.gif
deleted file mode 100644
index 1eb848d0f..000000000
Binary files a/.github/img/live-sample.gif and /dev/null differ
diff --git a/.github/img/previews.gif b/.github/img/previews.gif
new file mode 100644
index 000000000..669dddffc
Binary files /dev/null and b/.github/img/previews.gif differ
diff --git a/.github/img/shortcuts.png b/.github/img/shortcuts.png
new file mode 100644
index 000000000..2010bbcd6
Binary files /dev/null and b/.github/img/shortcuts.png differ
diff --git a/.github/img/word-settings.png b/.github/img/word-settings.png
deleted file mode 100644
index b877e2f05..000000000
Binary files a/.github/img/word-settings.png and /dev/null differ
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..cd344f06a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,44 @@
+name: CI
+
+on:
+ push:
+ paths-ignore:
+ - 'README.md'
+ - '.github/**.md'
+ - '.github/img/**'
+ pull_request:
+ paths-ignore:
+ - 'README.md'
+ - '.github/**.md'
+ - '.github/img/**'
+
+jobs:
+ build:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [ ubuntu-latest, windows-latest ]
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+ cache: 'gradle'
+ - name: Run checks (Ubuntu)
+ if: matrix.os == 'ubuntu-latest'
+ run: |
+ chmod +x ./gradlew
+ sudo apt install -y xvfb
+ xvfb-run --auto-servernum ./gradlew --no-daemon check
+ ./gradlew --stop
+ - name: Run checks (Windows)
+ if: matrix.os == 'windows-latest'
+ run: |
+ ./gradlew --no-daemon check
+ ./gradlew --stop
+ - uses: codecov/codecov-action@v3
+ if: success() && matrix.os == 'ubuntu-latest'
+ with:
+ fail_ci_if_error: true
+ files: build/reports/kover/report.xml
diff --git a/.gitignore b/.gitignore
index 1c6eefe3e..6f8960ef0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@
# Package Files #
*.jar
*.war
+*.nar
*.ear
*.zip
*.tar.gz
@@ -21,13 +22,14 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
+replay_pid*
## Gradle
.gradle
-/build/
-/out/
+**/build/
+!src/**/build/
# Ignore Gradle GUI config
gradle-app.setting
@@ -35,8 +37,14 @@ gradle-app.setting
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
+# Avoid ignore Gradle wrappper properties
+!gradle-wrapper.properties
+
# Cache of project
.gradletasknamecache
-# Work around https://youtrack.jetbrains.com/issue/IDEA-116898
-gradle/wrapper/gradle-wrapper.properties
+# Eclipse Gradle plugin generated files
+# Eclipse Core
+.project
+# JDT-specific (Eclipse Java Development Tools)
+.classpath
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 5d579ecab..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-language: java
-os: ["linux"]
-dist: trusty
-jdk: oraclejdk11
-
-# Display Gradle version instead of letting Travis execute './gradlew assemble' by default
-install:
- - ./gradlew -version
-
-before_script:
- - "export DISPLAY=:99.0"
- - "sh -e /etc/init.d/xvfb start"
- - sleep 3 # give xvfb some time to start
-
-script:
- - ./gradlew check
- - ./gradlew jacocoTestReport
-
-after_success:
- - bash <(curl -s https://codecov.io/bash)
-
-# Caching
-before_cache:
- - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- - rm -fr $HOME/.gradle/caches/*/plugin-resolution/
-cache:
- directories:
- - $HOME/.gradle/caches/
- - $HOME/.gradle/wrapper/
-
-notifications:
- email: false
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..2d5f2123a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,452 @@
+# Changelog
+## 3.0.0 -- 2023-11-17
+This release brings a major overhaul of how data is generated, allowing you to create your own data types such as IP
+addresses or entire JSON objects.
+At the same time, it remains just as easy to generate plain numbers.
+Check the plugin description for more details and animated usage examples.
+
+### Added
+* Each time you insert an array your demands will be slightly different, so when you insert an array a dialog is shown
+ in which you can quickly vary the array's settings.
+* In addition to a list of standard separators, you can now also choose your own separator for all data types, including
+ for arrays.
+* You can automatically pad (or truncate) integers to a specific length.
+* A notification is shown after upgrading to Randomness 3 to inform the user of incompatibilities with settings from
+ older versions.
+* Future backwards compatibility ensures that your settings can always be imported into future versions.
+
+### Changed
+* Randomness now uses templates to generate data.
+ A template consists of a list of "primitive" data types which are concatenated together.
+ Data types include the old data types (integer, decimal, string, word, UUID), but also the new date-time and the
+ template reference.
+* Strings no longer consist of customisable symbol sets, but are specified using a regex.
+* Words are no longer read from dictionary files, but are stored directly in the settings window. To reuse words in
+ multiple templates, consider using template references.
+* The preview pane now looks more beautiful :-)
+* Icons for templates and data types are dynamically generated based on the involved data types.
+* Invalid settings are now easier to correct with more specific error messages.
+* All strings have been internationalised, to make future translation easier.
+* Changelogs are now kept in [keep a changelog](https://keepachangelog.com/en/1.0.0/) style.
+
+### Deprecated
+* Minimum IDE version has been increased to 2022.3.
+
+### Fixed
+* The settings-only popup is now also shown when editing a read-only file.
+
+
+## 2.7.7 -- 2023-06-30
+### Breaking changes
+Minimum IDE version has been increased to 2022.2.
+
+### Fixes
+Resolve compatibility issues with upcoming IDE versions.
+([#459](https://github.com/FWDekker/intellij-randomness/issues/459))
+([#460](https://github.com/FWDekker/intellij-randomness/issues/460))
+
+
+## 2.7.6 -- 2022-12-14
+### Breaking changes
+Minimum IDE version has been increased to 2022.1.
+
+### Fixes
+Resolve compatibility issues with upcoming IDE versions.
+
+
+## 2.7.5 -- 2022-05-15
+### Breaking changes
+Minimum IDE version has been increased to 2021.2.
+
+### Fixes
+* Add prefix and suffix options for strings.
+* Ensure consistent capitalisation between previews.
+
+
+## 2.7.4 -- 2021-12-10
+### Fixes
+Custom action shortcuts should use current configuration.
+([#423](https://github.com/FWDekker/intellij-randomness/issues/423))
+
+
+## 2.7.3 -- 2021-10-01
+### Breaking changes
+Minimum IDE version has been increased to 2020.3.
+([#358](https://github.com/FWDekker/intellij-randomness/issues/358))
+([#386](https://github.com/FWDekker/intellij-randomness/issues/386))
+
+### Fixes
+* Shorter error messages in preview window.
+* Resolved critical UI error in upcoming 2021.3 IDEs as result of using incorrect factory.
+ ([#418](https://github.com/FWDekker/intellij-randomness/issues/418))
+
+
+## 2.7.2 -- 2021-07-07
+### Fixes
+Prevent symbol set settings from being truncated after restarting IDE.
+([#382](https://github.com/FWDekker/intellij-randomness/issues/382))
+
+
+## 2.7.1 -- 2021-07-05
+### Breaking changes
+Minimum IDE version has been increased to 2020.2. (#375)
+
+### New features
+* Remove limit on difference between minimum and maximum integer.
+ ([#367](https://github.com/FWDekker/intellij-randomness/issues/367))
+* Add "byte" integer type to generate integers from -127 to 128.
+ ([#368](https://github.com/FWDekker/intellij-randomness/issues/368))
+* Input field widths now reflect the expected input sizes.
+ ([#374](https://github.com/FWDekker/intellij-randomness/issues/374))
+* Significantly improved performance when generating long strings.
+ ([#373](https://github.com/FWDekker/intellij-randomness/issues/373))
+* Generator timeout prevents IntelliJ from freezing when using excessively complex inputs.
+ ([#373](https://github.com/FWDekker/intellij-randomness/issues/373))
+
+### Fixes
+Prevent overflows when using a large range with integers.
+([#370](https://github.com/FWDekker/intellij-randomness/issues/370))
+
+
+## 2.7.0 -- 2020-12-30
+This plugin is also available on the [plugin repository](https://plugins.jetbrains.com/plugin/9836-randomness).
+
+### Breaking changes
+* Minimum IDE version has been increased to 2020.1.
+ ([#209](https://github.com/FWDekker/intellij-randomness/issues/209),
+ [#345](https://github.com/FWDekker/intellij-randomness/issues/345),
+ [#361](https://github.com/FWDekker/intellij-randomness/issues/361))
+
+* Extended English dictionary (english_extended.dic) has been removed to improve performance and reduce plugin
+ size.
+ ([#352](https://github.com/FWDekker/intellij-randomness/issues/352))
+
+### New features
+* **🔠 Integer capitalization**.\
+ Option to change capitalization of Integers with base greater than 10.
+ For example, generate 0xFF instead of 0xff.
+ ([#346](https://github.com/FWDekker/intellij-randomness/issues/346))
+* Short inline explanation of how dictionaries work in the Words settings dialog.
+ ([#347](https://github.com/FWDekker/intellij-randomness/issues/347))
+* Improved support for drag-and-drop of dictionary files into dictionary table.
+ ([#350](https://github.com/FWDekker/intellij-randomness/issues/350))
+* Improved error messages for IO failures with dictionaries.
+ ([#354](https://github.com/FWDekker/intellij-randomness/issues/354))
+* Natural column widths in dictionary table.
+ ([#354](https://github.com/FWDekker/intellij-randomness/issues/354))
+* Changes to dictionary contents are detected directly while in the Words settings dialog.
+ ([#354](https://github.com/FWDekker/intellij-randomness/issues/354))
+* Invalid settings are marked as modified even if settings have not been changed.
+ ([#354](https://github.com/FWDekker/intellij-randomness/issues/354))
+
+### Fixes
+Listing of data types in Randomness settings dialog has been fixed.
+([#341](https://github.com/FWDekker/intellij-randomness/issues/341))
+
+
+## 2.6.1 -- 2020-06-24
+### New features
+* The error report dialogue will now inform you of the privacy policy applicable to the reporting.
+
+### Fixes
+* Excessively long error reports are now partially truncated to prevent HTTP 414 errors on GitHub.
+* Reserved URI characters in error reports are now truncated to prevent URI misinterpretations.
+
+
+## 2.6.0 -- 2020-05-14
+### New features
+* **💫 Prefixes and suffixes**.\
+ Prepend or append strings to your integers and decimals
+* Check the list of look-alike symbols by hovering over the option in the string settings.
+
+### Fixes
+* Schemes can now be saved and loaded correctly.
+* Deleting a scheme no longer loads that scheme into the default scheme.
+* Mnemonics for spinners now actually work.
+
+
+## 2.5.1 -- 2020-02-08
+### New features
+* **🚨 Error reporter**.\
+ Easily report fatal Randomness errors to GitHub by clicking the report button in IntelliJ's
+ [event log](https://www.jetbrains.com/help/idea/event-log-tool-window.html).
+
+### Fixes
+* Time-based UUID previews now use fixed seed until preview is refreshed.
+* Radio buttons are no longer in a grid, giving the corresponding labels more natural sizes.
+* The layout of UUID options has been altered slightly.
+* Holding modifier keys in settings-only popup now triggers normal behavior of opening settings window.
+
+
+## 2.5.0 -- 2020-01-24
+**Symbol set settings will be reset when updating to v2.5.0 because of changes in how settings are stored.**
+You can safely re-add your custom symbol sets after updating.
+
+### New features
+* **♻️ Scheme switcher**\
+ Quickly switch between your schemes by holding Alt + Ctrl while selecting a data type.
+* **😀 Emoji**\
+ Generate random strings of emoji by adding them to symbol sets.
+* **🕵 Look-alike characters**\
+ Exclude characters that look like each other (e.g. `1`, `l`, `I`) in generated strings to prevent confusion.
+* Change your settings using the Randomness popup even when you don't have an editor opened.
+* Slightly friendlier and more accurate error messages for tables.
+
+### Fixes
+* Input fields now correctly resize when shrinking the dialog.
+* Symbol set table now makes full use of vertical space.
+* Dictionary table no longer exceeds dialog width.
+* Adding a new entry to a table using the "Add" button will now allow you to immediately edit the new entry.
+* Leading and trailing whitespace characters no longer disappear when expanding a symbol set field.
+* Pressing Enter in a table now activates the editor for the currently-selected cell.
+* Duplicate symbols in single symbol sets are now ignored.
+* Arrow keys now work correctly for all radio buttons.
+
+
+## 2.4.1 -- 2020-01-14
+### Fixes
+Resolves an issue causing plugin incompatibility with IDE versions 2018.1 through 2018.3.
+
+
+## 2.4.0 -- 2020-01-07
+**All Randomness settings will be reset when updating to v2.4.0 because of changes in how settings are stored.**
+You can safely reconfigure Randomness after updating.
+
+### New features
+* **🗃️ Schemes**\
+ Save your Randomness configurations using schemes. Simply create some new schemes to your liking, and you can change
+ back and forth between your schemes without any typing.
+* **🖼️ Icons**\
+ In addition to Randomness' new logo, all Randomness actions can now be identified by their unique icons.
+* **♻️ Repeat**\
+ Hold Alt (⌥) while selecting a data type to insert will insert the same value at all
+ [carets](https://www.jetbrains.com/help/idea/working-with-source-code.html#multiple_cursor).
+ Like all other Randomness actions, the repeat action can be assigned a shortcut if you want.
+* **⌨️ Modifier keys**\
+ Hold multiple modifier keys (Alt, Ctrl, Shift) to combine their effects.
+ For example, hold Ctrl + Shift to change the array settings, or hold Alt + Shift to insert
+ repeated arrays.
+* **📏 Expandable fields**\
+ View large symbol sets in a single glance using expandable text fields.
+ No more horizontal scrolling back and forth.
+* Empty tables now contain clickable links for adding new values.
+* Array previews now contain multiple random numbers instead of repeating the number 17.
+* Groups of settings are separated by horizontal lines instead of being surrounded by borders.
+
+### Fixes
+* The Randomness popup now appears at your caret instead of in the middle of the screen.
+* The Randomness popup no longer appears when the editor isn't selected.
+* The Randomness popup is now wide enough to display the full header text.
+* Invalid number inputs result in preciser error messages.
+* Previews that exceed the dialog width now wrap around.
+* Adding a new symbol set or dictionary will move focus to the first editable column so you can start typing right away.
+
+
+## 2.3.0 -- 2019-12-09
+### New features
+* **Previews**\
+ To help you decide what settings to choose, a preview of the data that is generated with your current settings is
+ shown at the bottom of the settings window.
+* **New UUID settings**\
+ You can now choose between time-based (version 1) and random (version 4) UUIDs.
+ Additionally, you can change the capitalisation and remove the dashes if you want.
+* **Layout**\
+ The improved layout adds vertical space in between groups to make it easier to find settings quickly.
+* **Easier range controls**\
+ When you set the minimum value to be higher than the maximum value, the maximum value will automatically be increased
+ as well---and vice versa for changing the maximum value.
+* Randomness can now also be found in the
+ [Generate menu](https://www.jetbrains.com/help/idea/generating-code.html).
+* The "space after separator" option in arrays is now disabled if the newline separator is set.
+* Random capitalisation mode is now available for words as well.
+
+### Bug fixes
+* The copy button is now disabled when a bundled dictionary is selected in the word settings.
+* Invalid controls are now disabled when you open the settings dialog.
+* Exceptions caused by corrupted settings are now handled properly.
+
+
+## 2.2.0 -- 2019-08-06
+**Notice**\
+Please note that symbol set settings will be reset when updating to v2.2.0 because of changes in how settings are
+stored.
+You can re-add your custom symbol sets after updating.
+
+### New features
+* Custom symbol sets for generating strings.
+* Inline editing of symbol sets and dictionaries.
+* Persistent order of symbol sets and dictionaries.
+* Improved error messages.
+* More consistent settings layouts.
+
+
+## 2.1.0 -- 2019-07-10
+### New features
+* Settings have been moved to the native settings window.
+* Retain trailing zeroes in decimals.
+
+### Bug fixes
+* Bundled dictionaries no longer disappear if they are not active when word settings are saved.
+
+
+## 2.0.0 -- 2019-05-16
+**Notice**
+Please note that dictionary settings will be reset when updating to v2.0.0 because of changes in how settings are
+stored.
+You can re-add your custom dictionaries after updating.
+
+### New features
+* Inserting multiple words is now much faster.
+* An additional bundled dictionary with simpler English words.
+* Some setting defaults have been changed.
+* All-around more descriptive error messages.
+
+### Bug fixes
+* Dictionaries are now re-loaded before validating settings.
+
+
+## 1.6.1 -- 2018-09-12
+### New features
+* Allow changing quotation marks around generated UUIDs.
+
+
+## 1.6.0 -- 2018-09-11
+### New features
+* Generating type 4 UUIDs.
+* Generating hexadecimal strings.
+* Different capitalisation modes for strings.
+
+### Bug fixes
+* Newline separator for arrays is now an actual newline instead of the text "\n".
+
+
+## 1.5.2 -- 2018-05-27
+### New features
+* Dictionary contents are now refreshed when word settings are saved.
+
+
+## 1.5.1 -- 2018-05-15
+### Bug fixes
+* Dictionary validity is now also checked when a custom dictionary is added.
+
+
+## 1.5.0 -- 2018-05-13
+### New features
+* Add newline character as separator when inserting arrays.
+* Add two new capitalistion modes when inserting words:
+ - `Retain`: Does not change capitalisation w.r.t. dictionary file.
+ - `First Letter`: Capitalises the first letter of each word.
+
+### Bug fixes
+* Deletion, renaming, and emptiness of custom dictionaries are detected when configuring and inserting random words.
+
+
+## 1.4.0 -- 2018-04-30
+### New features
+* Mnemonics for some buttons.
+* Integers can be generated in customisable radix.
+
+
+## 1.3.2 -- 2018-01-21
+### New features
+* All actions can now be given keybinds separately.
+* The Randomness action can now also be found in the Tools menu.
+
+
+## 1.3.1 -- 2018-01-15
+### New features
+* Added option to add no brackets around arrays.
+
+### Bug fixes
+* Adjusted default random string length.
+
+
+## 1.3.0 -- 2018-01-09
+### New features
+* Added ability to generate arrays/lists/vectors of data.
+* Overhauled action popup. Use modifier keys to change behaviour.
+
+
+## 1.2.0 -- 2018-01-05
+### New features
+* Added ability to generate random words.\
+ Words are selected from dictionary files.
+ Custom dictionary files can be added by the user.
+
+### Bug fixes
+* Minor typo fixes.
+
+
+## 1.1.0 -- 2017-07-30
+### New features
+* Added option to change decimal separator for decimals.
+* Added option to change grouping separator for integers and decimals.
+
+### Bug fixes
+* Input is no longer validated while typing.
+
+
+## 1.0.1 -- 2017-07-20
+### New features
+* Random integers can now go from `-2^63` to `2^63`.
+* More precise input validation messages.
+
+### Bug fixes
+* Excessively large ranges are now rejected.
+
+
+## 1.0.0 -- 2017-07-16
+### New features
+* Generated values are now cryptographically safe.\
+ (_Note: This is no longer true starting in v1.0.1._)
+* Key binding was changed to `Ctrl + R` (or `⌥R`).
+
+### Bug fixes
+* Multiple insertions can now be undone together.
+* Miscellaneous performance fixes.
+
+
+## 0.5.0 -- 2017-07-13
+### New features
+* All actions have been grouped together under `Insert Random Data`.
+* The keyboard shortcut `Ctrl + Alt + R` opens a list of all actions.
+* Symbols for string generation can now be changed in the settings.
+
+
+## 0.4.0 -- 2017-07-12
+### New features
+* Settings are now saved over multiple IDE sessions.
+* Different quotation marks can be chosen for string insertion.
+* Settings dialogs are now slightly wider to make input easier to read.
+
+
+## 0.3.0 -- 2017-07-11
+### New features
+* Data can be inserted at multiple carets simultaneously.
+* Inserted data is highlighted after it is inserted.
+* Dialogs now have mnemonics.
+
+### Bug fixes
+* Plugin works again on IntelliJ versions above 2016.1.
+
+
+## 0.2.0 -- 2017-07-10
+**Because of a configuration error, this release only works for IntelliJ 2016.1.**
+
+### New features
+* Random decimals can now be inserted.
+* Quotation marks around strings can now be disabled.
+
+### Bug fixes
+* Non-numerical input is no longer accepted where numerical input is expected.
+* The minimum value input can no longer exceed the maximum value input.
+
+
+## 0.1.0 -- 2017-07-10
+This first release of the Randomness plugin provides some basic features.
+
+### Features
+* Insert random numbers at caret.
+* Insert random strings at caret.
+* Change range of generated numbers.
+* Change length of generated strings.
diff --git a/LICENSE b/LICENSE
index 9b0810669..a01edc105 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2017 F.W. Dekker
+Copyright (c) 2017 Florine W. Dekker
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 5b9e7fc8c..2ab6775d3 100644
--- a/README.md
+++ b/README.md
@@ -5,68 +5,82 @@
-
-
+
+
+
-
-
+
+
+
Rather than going to [random.org](https://www.random.org/) or making up your own random data, you can now insert random
-numbers, strings, and UUIDs using an IntelliJ action!
+numbers, UUIDs, names, IP addresses, and much more using an IntelliJ action!
-This plugin is also available on the [plugin repository](https://plugins.jetbrains.com/plugin/9836-randomness).
+This plugin is also available on the [plugin repository](https://plugins.jetbrains.com/plugin/9836-randomness)!
## 📖 How to use
-
-
-To insert random data, press Alt + R (⌥R) and choose the type of data you want to insert.
-A different value will be inserted at each caret.
+To insert random data, press Alt + R (or ⌥R) to open the list of available templates and choose
+one that suits your task.
+By default, a different value is inserted at each caret.
You can modify this behavior by holding a key while selecting the type of data to insert:
-* **Array**: Hold Shift to insert a whole array of values.
+* **Array**: Hold Shift to insert a customisable array of values.
+* **Repeat**: Hold Alt (or ⌥) to insert the same value at each caret.
* **Settings**: Hold Ctrl to open the settings of that data type.
-* **Repeat**: Hold Alt (⌥) to insert the same value at each caret.
-You can also hold multiple modifier keys to combine their effects.
+You can hold multiple modifier keys to combine their effects.
Randomness can also be found in the main menu under Tools or in Code > Generate.
+
+
## ✨ Features
* 🕸 **Data Types**
- There are five types of data that can be inserted:
- 1. **Integers**, such as `7,826,922`, in any base from binary to hexatrigesimal.
- 2. **Decimals**, such as `8,816,573.10`, using customisable separators.
- 3. **Strings**, such as `"PaQDQqSBEH"`, with custom symbol lists.
- 4. **Words**, such as `"Bridge"`, with custom word lists.
- 5. **UUIDs**, such as `0caa7b28-fe58-4ba6-a25a-9e5beaaf8f4b`, with or without dashes.
-
- In addition to these data types, it's also possible to generate entire **arrays** of a data type.
+ There are six basic data types that can be inserted and customised:
+ 1. **Integers**, such as `7,826,922`, from a custom range, in any base from binary to hexatrigesimal.
+ 2. **Decimals**, such as `8,816,573.10`, using customisable separators.
+ 3. **Strings**, such as `"PaQDQqSBEH"`, with support for reverse regex.
+ 4. **Words**, such as `"Bridge"`, with predefined or custom word lists.
+ 5. **UUIDs**, such as `0caa7b28-fe58-4ba6-a25a-9e5beaaf8f4b`, with or without dashes.
+ 6. **Date-times**, such as `2022-02-03 19:03`, or any other format you want.
+* 🧬 **Templates**
+ For complex kinds of data, you can use templates.
+ A template is a list of data types that should be concatenated to create random data.
+ Insert **phone numbers**, **email addresses**, **URLs**, **IP addresses**, or any **custom data type** you can think
+ of.
+ Of course, Randomness comes bundled with a whole array of predefined templates to help you out.
+ If needed, you can reuse a template by including it in another template using a **reference**.
+
+
+* 🗃️ **Arrays**
+ Need a lot of data?
+ Insert an **entire array** of any template you want.
For example, an array of integers might look like `[978, 881, 118, 286, 288]`.
-* ⚙ **Settings**
- The way the data is generated can be **adjusted to your demands**.
- You can customise the smallest integer to generate, the quotation marks to surround strings with, the number of
- elements to put in an array, the decimal separator to use, the capitalisation of strings and UUIDs, and much more.
-* 👀 **Previews**
- To **help you decide** what settings to choose, a preview of the data that is generated with your current settings is
- shown at the bottom of the settings window.
-* 💨 **Shortcuts**
+ You can customise the brackets, delimiter, and number of elements to your liking every time you insert an array,
+ because no two arrays are the same.
+
+
+* ⌨️ **Shortcuts**
Instead of using up all your shortcuts, Randomness only uses the Alt + R (or ⌥R) shortcut by
default.
- However, you have the option to assign a unique shortcut to each individual data type and each settings window to
- **streamline your workflow**.
-* 💬 **Symbol sets**
- While Randomness comes with a varied selection of symbols to use for generating strings, it also gives you the option
- to **add your own symbols**, with support for Chinese symbols and emoji.
-* 📚 **Dictionaries**
- Randomness is bundled with a small English dictionary from which it chooses random words.
- However, you may want to add random words from another language or insert random movie quotes.
- You can **create your own dictionaries** by creating a text file and putting one option on each line, and saving the
- file with the `.dic` extension.
- Empty lines and lines that start with a `#` are ignored.
+ However, to **streamline your workflow**, you can assign shortcuts to any template under your IDE's Keymap
+ settings.
+
+
+* 💨 **Fast insertion**
+ The insertion popup (shown when you press Alt + R (or ⌥R) by default) is **searchable**:
+ Just type something in the popup and relevant templates will be filtered out.
+ Or use the **hotkeys** that are assigned to the first 10 templates in the list:
+ Press any digit to directly insert the corresponding template.
+ **Reorder templates** in the settings menu to change which template uses which hotkey.
+
+
+* 👀 **Previews**
+ To **help you decide** what settings to choose, a preview of the template is shown while you're editing.
-
+
## 💻 Development
@@ -77,29 +91,49 @@ Please also check the [contribution guidelines](.github/CONTRIBUTING.md).
```bash
$ gradlew runIde # Open a sandbox IntelliJ instance running the plugin
$ gradlew buildPlugin # Build an installable zip of the plugin
+$ gradlew signPlugin # Sign built plugin
```
+Signing the plugin requires specific environment variables to be set to refer to appropriate key files.
+See [Plugin Signing](https://plugins.jetbrains.com/docs/intellij/plugin-signing.html) for more information.
### 🧪 Quality assurance
```bash
-$ gradlew test # Run tests
-$ gradlew test --tests X # Run tests in class X
-$ gradlew check # Run tests and static analysis
-$ gradlew jacocoTestReport # Run tests and calculate coverage
-$ gradlew runPluginVerifier # Check for compatibility issues
+$ gradlew test # Run tests (and collect coverage)
+$ gradlew test --tests X # Run tests in class X (package name optional)
+$ gradlew test -Pkotest.tags="X" # Run tests matching tag(s) X (also supports not (!), and (&), or (|))
+$ gradlew koverHtmlReport # Create HTML coverage report for previous test run
+$ gradlew check # Run tests and static analysis
+$ gradlew runPluginVerifier # Check for compatibility issues
```
+#### 🏷️ Tagging and filtering tests
+[Kotest tests can be tagged](https://kotest.io/docs/framework/tags.html) to allow selectively running tests.
+The tags for Randomness are statically defined in
+[`Tags`](https://github.com/FWDekker/intellij-randomness/blob/main/src/test/kotlin/com/fwdekker/randomness/testhelpers/Tags.kt).
+
+Tag an entire test class by adding `tags(...)` to the class definition, or tag an individual test `context` by
+writing `context("foo").config(tags = setOf(...)) {`.
+It is not possible to tag an individual `test` due to limitations in Kotest.
+
+To run only one `context` in some test class `X`, prefix the `context`'s name with `f:` and run with `--tests X`.
+The prefix `f:` filters out other `context`s in that test class, and `--tests X` filters out other test classes.
+Alternatively, tag the desired `context` with the `Focus` tag and run with `-Pkotest.tags="Focus"` to filter by that
+tag.
+
### 📚 Documentation
```bash
$ gradlew dokkaHtml # Generate documentation
```
### 🖼 Icons
-The icons used by the plugin are found in the [.sketch](.github/img/icons.sketch) file.
+The icons used by the plugin are found in [the file `icons.sketch`](.github/img/icons.sketch).
You can open this file with [Sketch](https://www.sketch.com/) (macOS), [Lunacy](https://icons8.com/lunacy) (Windows), or
[Figma](https://github.com/Figma-Linux/figma-linux) (Linux).
## 🙏 Acknowledgements
+I want to thank everyone who contributed something to Randomness, no matter the size of that contribution.
+
In chronological order of contribution:
* Thanks to [Casper Boone](https://github.com/casperboone) for
[reporting a bug](https://github.com/FWDekker/intellij-randomness/issues/25) and for
@@ -108,7 +142,7 @@ In chronological order of contribution:
[suggesting the array data type](https://github.com/FWDekker/intellij-randomness/issues/54)!
* Thanks to [Georgios Andreadis](https://github.com/gandreadis) for the
[original logo](https://github.com/FWDekker/intellij-randomness/pull/86)!
-* Thanks to [Oleksii K.](https://github.com/ok3141) for
+* Thanks to [Oleksii](https://github.com/ok3141) for
[suggesting the UUID data type](https://github.com/FWDekker/intellij-randomness/issues/88) and for
[suggesting the hex symbol set](https://github.com/FWDekker/intellij-randomness/issues/89)!
* Thanks to [Meilina Reksoprodjo](https://github.com/meilinar) for help with macOS user testing!
@@ -136,9 +170,23 @@ In chronological order of contribution:
[reporting that symbol sets didn't get saved](https://github.com/FWDekker/intellij-randomness/issues/382)!
* Thanks to [Aleksey Bobyr](https://github.com/Alexsey) for
[reporting a critical UI bug in WebStorm EAP](https://github.com/FWDekker/intellij-randomness/issues/418)!
-* Thanks to [Xiakitl](https://github.com/Xiakitl) for
+* Thanks to [Lukas](https://github.com/LukasAppleFan) for
+ [helping me find a bug in IntelliJ](https://github.com/FWDekker/intellij-randomness/issues/421)!
+* Thanks to [Pascal](https://github.com/theMunichDev) for
[reporting a bug with custom shortcuts](https://github.com/FWDekker/intellij-randomness/issues/423)!
* Thanks to [Rishi Maharaj](https://github.com/rshmhrj) for
- [suggesting to add prefix and postfix options to strings]!
+ [suggesting to add prefix and postfix options to strings](https://github.com/FWDekker/intellij-randomness/issues/431)!
+* Thanks to [Christian Baune](https://github.com/programaths) for
+ [reporting recurring issues](https://github.com/FWDekker/intellij-randomness/issues/441)
+ [with load](https://github.com/FWDekker/intellij-randomness/issues/442)
+ [corrupted settings](https://github.com/FWDekker/intellij-randomness/issues/444)!
+* Thanks to [Vladislav Rassokhin](https://github.com/VladRassokhin) for
+ [reporting an issue with slow actions during indexing](https://github.com/FWDekker/intellij-randomness/issues/445)!
+* Thanks to [Luc Everse](https://github.com/cmpsb) for
+ [suggesting generating non-matching strings](https://github.com/FWDekker/intellij-randomness/issues/447)!
+* Thanks to [ForNeVeR](https://github.com/ForNeVeR) for
+ [reporting a compatibility issue with the IntelliJ EAP](https://github.com/FWDekker/intellij-randomness/issues/459)!
+* Thanks to [Vitaly Provodin](https://github.com/vprovodin) for
+ [also reporting that compatibility issue](https://github.com/FWDekker/intellij-randomness/issues/460)!
If I should add, remove, or change anything here, just open an issue or email me!
diff --git a/build.gradle.kts b/build.gradle.kts
index a24355327..6a8ef223b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,3 +1,8 @@
+import io.gitlab.arturbosch.detekt.Detekt
+import org.gradle.api.tasks.testing.logging.TestExceptionFormat
+import org.gradle.api.tasks.testing.logging.TestLogEvent
+import org.jetbrains.changelog.Changelog
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.net.URL
import java.time.Year
@@ -7,18 +12,18 @@ fun properties(key: String) = project.findProperty(key).toString()
/// Plugins
plugins {
// Compilation
- id("java")
- id("org.jetbrains.kotlin.jvm") version "1.8.22" // See also `gradle.properties`
- id("org.jetbrains.intellij") version "1.14.2"
+ id("org.jetbrains.kotlin.jvm") version "1.9.20" // Use latest version, ignoring `gradle.properties`
+ id("org.jetbrains.intellij") version "1.16.0"
// Tests/coverage
- id("jacoco")
+ id("org.jetbrains.kotlinx.kover") version "0.7.4"
// Static analysis
- id("io.gitlab.arturbosch.detekt") version "1.20.0" // See also `gradle.properties`
+ id("io.gitlab.arturbosch.detekt") version "1.23.3" // See also `gradle.properties`
// Documentation
- id("org.jetbrains.dokka") version "1.8.20"
+ id("org.jetbrains.changelog") version "2.2.0"
+ id("org.jetbrains.dokka") version "1.9.10"
}
@@ -29,18 +34,17 @@ repositories {
dependencies {
implementation("com.fasterxml.uuid:java-uuid-generator:${properties("uuidGeneratorVersion")}")
+ implementation("com.github.sisyphsu:dateparser:${properties("dateparserVersion")}")
+ implementation("com.github.curious-odd-man:rgxgen:${properties("rgxgenVersion")}")
implementation("com.vdurmont:emoji-java:${properties("emojiVersion")}")
api("org.jetbrains.kotlin:kotlin-reflect")
- testImplementation("org.assertj:assertj-core:${properties("assertjVersion")}")
testImplementation("org.assertj:assertj-swing-junit:${properties("assertjSwingVersion")}")
- testImplementation("org.junit.platform:junit-platform-runner:${properties("junitRunnerVersion")}")
- testImplementation("org.junit.jupiter:junit-jupiter-api:${properties("junitVersion")}")
- testImplementation("org.junit.jupiter:junit-jupiter-engine:${properties("junitVersion")}")
+ testRuntimeOnly("org.junit.platform:junit-platform-runner:${properties("junitRunnerVersion")}")
testImplementation("org.junit.vintage:junit-vintage-engine:${properties("junitVersion")}")
- testImplementation("org.mockito.kotlin:mockito-kotlin:${properties("mockitoKotlinVersion")}")
- testImplementation("org.spekframework.spek2:spek-dsl-jvm:${properties("spekVersion")}")
- testRuntimeOnly("org.spekframework.spek2:spek-runner-junit5:${properties("spekVersion")}")
+ testImplementation("io.kotest:kotest-assertions-core:${properties("kotestVersion")}")
+ testImplementation("io.kotest:kotest-framework-datatest:${properties("kotestVersion")}")
+ testImplementation("io.kotest:kotest-runner-junit5:${properties("kotestVersion")}")
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:${properties("detektVersion")}")
}
@@ -50,72 +54,84 @@ dependencies {
tasks {
// Compilation
withType {
- sourceCompatibility = properties("jvmVersion")
- targetCompatibility = properties("jvmVersion")
+ sourceCompatibility = properties("javaVersion")
+ targetCompatibility = properties("javaVersion")
}
- withType {
+ withType {
kotlinOptions {
- jvmTarget = properties("jvmVersion")
+ jvmTarget = properties("javaVersion")
apiVersion = properties("kotlinApiVersion")
languageVersion = properties("kotlinVersion")
}
}
- withType {
- jvmTarget = properties("jvmVersion")
+ withType {
+ jvmTarget = properties("javaVersion")
}
intellij {
version.set(properties("intellijVersion"))
downloadSources.set(true)
- updateSinceUntilBuild.set(false)
+ updateSinceUntilBuild.set(false) // Set in `patchPluginXml`
}
patchPluginXml {
- changeNotes.set(file("src/main/resources/META-INF/change-notes.html").readText())
+ changeNotes.set(provider {
+ changelog.renderItem(
+ if (changelog.has(properties("version"))) changelog.get(properties("version"))
+ else changelog.getUnreleased(),
+ Changelog.OutputType.HTML
+ )
+ })
pluginDescription.set(file("src/main/resources/META-INF/description.html").readText())
sinceBuild.set(properties("pluginSinceBuild"))
}
+ changelog {
+ repositoryUrl.set("https://github.com/FWDekker/intellij-randomness")
+ itemPrefix.set("*")
+ }
+
+ signPlugin {
+ if (System.getenv("CERTIFICATE_CHAIN") != null) {
+ certificateChainFile.set(file(System.getenv("CERTIFICATE_CHAIN")))
+ privateKeyFile.set(file(System.getenv("PRIVATE_KEY")))
+ password.set(System.getenv("PRIVATE_KEY_PASSWORD"))
+ }
+ }
+
// Tests/coverage
test {
- systemProperty("spek2.execution.test.timeout", 0)
+ systemProperty("java.awt.headless", "false")
+ if (project.hasProperty("kotest.tags")) systemProperty("kotest.tags", project.findProperty("kotest.tags")!!)
useJUnitPlatform {
- includeEngines("junit-vintage", "junit-jupiter", "spek2")
+ if (!project.hasProperty("kotest.tags"))
+ includeEngines("junit-vintage")
+
+ includeEngines("kotest")
}
testLogging {
- events = setOf(org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED)
- exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
+ events = setOf(TestLogEvent.FAILED)
+ exceptionFormat = TestExceptionFormat.FULL
}
- }
- jacoco {
- toolVersion = properties("jacocoVersion")
+ finalizedBy(koverXmlReport)
}
- jacocoTestReport {
- executionData(file("$buildDir/jacoco/test.exec"))
-
- sourceSets { sourceSets.main }
-
- reports {
- csv.required.set(false)
- html.required.set(true)
- xml.required.set(true)
- xml.outputLocation.set(file("$buildDir/reports/jacoco/report.xml"))
+ koverReport {
+ defaults {
+ html { onCheck = false }
+ xml { onCheck = false }
}
-
- dependsOn(test)
}
// Static analysis
detekt {
- toolVersion = properties("detektVersion")
allRules = true
- config = files(".config/detekt/.detekt.yml")
+ config.setFrom(".config/detekt/.detekt.yml")
}
runPluginVerifier {
@@ -125,11 +141,17 @@ tasks {
// Documentation
dokkaHtml.configure {
- pluginsMapConfiguration.set(mapOf(
- "org.jetbrains.dokka.base.DokkaBase" to """{ "footerMessage": "© ${Year.now().value} F.W. Dekker" }"""
- ))
+ notCompatibleWithConfigurationCache("cf. https://github.com/Kotlin/dokka/issues/1217")
+
+ pluginsMapConfiguration.set(
+ mapOf(
+ "org.jetbrains.dokka.base.DokkaBase" to
+ """{ "footerMessage": "© ${Year.now().value} Florine W. Dekker" }"""
+ )
+ )
moduleName.set("Randomness v${properties("version")}")
offlineMode.set(true)
+ suppressInheritedMembers.set(true)
dokkaSourceSets {
named("main") {
@@ -137,7 +159,7 @@ tasks {
jdkVersion.set(properties("javaVersion").toInt())
- includeNonPublic.set(false)
+ includeNonPublic.set(true)
skipDeprecated.set(false)
reportUndocumented.set(true)
skipEmptyPackages.set(true)
diff --git a/gradle.properties b/gradle.properties
index 951324b5f..e4a08a450 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,45 +1,52 @@
-group = com.fwdekker
-version = 2.7.7
+group=com.fwdekker
+# Version number should also be updated in `com.fwdekker.randomness.PersistentSettings.Companion.CURRENT_VERSION`.
+version=3.0.0
# Compatibility
-# * If latest is 20xx.y, then support at least [20xx-1].[y+1].
-# e.g., if latest is 2020.3, support at least 2019.4 (aka 2020.1).
-# See also https://data.services.jetbrains.com/products?fields=name,releases.version,releases.build&code=IC,CL.
-pluginSinceBuild = 222.0
-intellijVersion = 2022.2
-pluginVerifierIdeVersions = IC-2022.2.5, IC-2022.3.3, IC-2023.1.3, CL-2022.2.5, CL-2022.3.3, CL-2023.1.4
+# * `pluginSinceBuild`:
+# If latest is 20xx.y, then support at least [20xx-1].[y+1]. For example, if latest is 2020.3, support at least
+# 2019.4 (aka 2020.1).
+# * `intellijVersion`:
+# Use the oldest supported version, because that's what the plugin will be compiled against.
+# * `pluginVerifierIdeVersions`:
+# For every supported version minor release, include both the IC and the CL release with the highest patch version
+# See also https://data.services.jetbrains.com/products?fields=name,releases.version,releases.build&code=IC,CL.
+pluginSinceBuild=223.0
+intellijVersion=2022.3
+pluginVerifierIdeVersions=IC-2022.3.3, IC-2023.1.5, IC-2023.2.5, CL-2022.3.3, CL-2023.1.5, CL-2023.2.2
# Targets
-# * Java
-# * `javaVersion` is the same as `jvmVersion`.
-# * Java version should be the one used by the oldest Randomness-supported version of IntelliJ, as listed in
-# https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html#intellij-platform-based-products-of-recent-ide-versions
+# * Java:
+# Java version should be the one used by the oldest Randomness-supported version of IntelliJ. See also
+# https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html#intellij-platform-based-products-of-recent-ide-versions.
#
# * Kotlin
# * `kotlinVersion` is the same as `kotlinApiVersion`.
-# * Kotlin should also be updated in `plugins` block.
-# * Kotlin version should be bundled stdlib version of oldest supported IntelliJ version listed in
-# https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#kotlin-standard-library
-javaVersion = 17
-jvmVersion = 17
-kotlinVersion = 1.6
-kotlinApiVersion = 1.6
+# * Kotlin version should be bundled stdlib version of oldest supported IntelliJ version. See also
+# https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#kotlin-standard-library.
+javaVersion=17
+kotlinVersion=1.7
+kotlinApiVersion=1.7
# Dependencies
# * Detekt should also be updated in `plugins` block.
-# * Check https://github.com/assertj/assertj-swing/releases for valid AssertJ version combinations.
-assertjVersion = 3.17.2
-assertjSwingVersion = 3.17.1
-detektVersion = 1.20.0
-emojiVersion = 5.1.1
-jacocoVersion = 0.8.8
-junitVersion = 5.9.1
-junitRunnerVersion = 1.9.1
-mockitoKotlinVersion = 4.1.0
-spekVersion = 2.0.19
-uuidGeneratorVersion = 3.3.0
+# * RgxGen should also be updated in `StringSchemeEditor` link.
+assertjSwingVersion=3.17.1
+dateparserVersion=1.0.11
+detektVersion=1.23.3
+emojiVersion=5.1.1
+junitVersion=5.10.1
+junitRunnerVersion=1.10.1
+kotestVersion=5.8.0
+rgxgenVersion=1.4
+uuidGeneratorVersion=4.3.0
+
+# Gradle
+org.gradle.caching=true
+org.gradle.configuration-cache=true
# Kotlin
-kotlin.code.style = official
-kotlin.incremental.useClasspathSnapshot = false
-kotlin.stdlib.default.dependency = false
+kotlin.code.style=official
+kotlin.stdlib.default.dependency=false
+# TODO: Workaround for https://jb.gg/intellij-platform-kotlin-oom, will not be necessary as of Kotlin 1.9.0
+kotlin.incremental.useClasspathSnapshot=false
diff --git a/gradle/scripts/verifier.gradle b/gradle/scripts/verifier.gradle
deleted file mode 100644
index df9b178bc..000000000
--- a/gradle/scripts/verifier.gradle
+++ /dev/null
@@ -1,4 +0,0 @@
-// WARNING! The plugin verifier script is deprecated. The runPluginVerifier task is built into the
-// gradle-intellij-plugin as of version 0.6.0. For more information, see
-// https://github.com/JetBrains/gradle-intellij-plugin/issues/385#issuecomment-718796665.
-// This file will be removed from the intellij-random repository on 2022-12-20.
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index dcf0f19c5..5e6b54271 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip
diff --git a/packages.md b/packages.md
index 0016e6071..ebec40624 100644
--- a/packages.md
+++ b/packages.md
@@ -1,35 +1,47 @@
# Package com.fwdekker.randomness
-Contains classes shared by all sub-packages.
+Entry point of the plugin. Contains main classes, shared classes, and helper classes.
+
+# Package com.fwdekker.randomness.affix
+
+Decorator for surrounding a value with constant strings such as quotation marks and braces.
# Package com.fwdekker.randomness.array
-Insertion of random arrays of other types of data.
+Decorator for generating multiple values each time.
+
+# Package com.fwdekker.randomness.datetime
+
+Scheme for random dates and times.
# Package com.fwdekker.randomness.decimal
-Insertion of random decimals.
+Scheme for random decimal numbers.
+
+# Package com.fwdekker.randomness.fixedlength
+
+Decorator for forcing values to a specified length.
# Package com.fwdekker.randomness.integer
-Insertion of random integers.
+Scheme for random non-decimal numbers.
# Package com.fwdekker.randomness.string
-Insertion of random strings.
+Scheme for random and non-random strings.
+
+# Package com.fwdekker.randomness.template
+
+Scheme consisting of other schemes.
# Package com.fwdekker.randomness.ui
-Custom or specialized Swing components.
+Custom or specialized UI components.
# Package com.fwdekker.randomness.uuid
-Insertion of random UUIDs.
+Scheme for random UUIDs.
# Package com.fwdekker.randomness.word
-Insertion of random words.
-
-# Package icons
-
-Collection of available icons.
+Scheme for random words selected from word lists.
diff --git a/settings.gradle b/settings.gradle.kts
similarity index 100%
rename from settings.gradle
rename to settings.gradle.kts
diff --git a/src/main/kotlin/com/fwdekker/randomness/Box.kt b/src/main/kotlin/com/fwdekker/randomness/Box.kt
new file mode 100644
index 000000000..dabb8e8a5
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/Box.kt
@@ -0,0 +1,20 @@
+package com.fwdekker.randomness
+
+
+/**
+ * A lazily-instantiated reference to an object of type [T].
+ *
+ * @param T the type of the referenced object
+ * @param generator generates the referenced object when [unaryPlus] is invoked for the first time
+ * @param value do not assign this field in the constructor; this field is placed in the constructor to ensure Kotlin
+ * includes it in the automatically-generated [copy] method
+ */
+data class Box(private val generator: () -> T, private var value: T? = null) {
+ /**
+ * If this method is invoked for the first time, [generator] is invoked and the result is returned. In subsequent
+ * invocations of this method, the previously-generated value is returned each time.
+ *
+ * @return the referenced value
+ */
+ operator fun unaryPlus(): T = value ?: generator().also { value = it }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/Bundle.kt b/src/main/kotlin/com/fwdekker/randomness/Bundle.kt
new file mode 100644
index 000000000..5fac53f29
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/Bundle.kt
@@ -0,0 +1,52 @@
+package com.fwdekker.randomness
+
+import java.util.MissingResourceException
+import java.util.ResourceBundle
+
+
+/**
+ * Simple accessor for working with internationalized strings.
+ */
+object Bundle {
+ /**
+ * The main bundle for Randomness.
+ */
+ private val RESOURCE_BUNDLE = ResourceBundle.getBundle("randomness")
+
+
+ /**
+ * Returns the string at [key].
+ *
+ * @throws MissingResourceException if no string with [key] can be found
+ */
+ @Throws(MissingResourceException::class)
+ operator fun invoke(key: String): String = RESOURCE_BUNDLE.getString(key)
+
+ /**
+ * Returns the string at [key] formatted with [arguments].
+ *
+ * @throws MissingResourceException if no string with [key] can be found
+ */
+ @Throws(MissingResourceException::class)
+ operator fun invoke(key: String, vararg arguments: Any?): String = this(key).format(*arguments)
+}
+
+
+/**
+ * Returns `true` if [format] is a format string for `this` string, optionally after inserting [args] into [format].
+ *
+ * @throws java.util.MissingFormatArgumentException if [args] has fewer arguments than required for [format]
+ */
+fun String.matchesFormat(format: String, vararg args: String) =
+ Regex("%[0-9]+\\\$[Ssd]").findAll(format)
+ .toList()
+ .reversed()
+ .fold(format) { acc, match ->
+ if (match.value.drop(1).dropLast(2).toInt() > args.size)
+ acc.replaceRange(match.range, ".*")
+ else
+ acc
+ }
+ .format(*args)
+ .let { Regex(it) }
+ .matches(this)
diff --git a/src/main/kotlin/com/fwdekker/randomness/Cache.kt b/src/main/kotlin/com/fwdekker/randomness/Cache.kt
deleted file mode 100644
index 1b501e13a..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/Cache.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.fwdekker.randomness
-
-
-/**
- * A simple thread-safe cache of objects.
- *
- * @param K the type of keys
- * @param V the type of values
- * @property creator a function that maps a key to a value, used to instantiate a value when it is requested and not in
- * the cache
- */
-class Cache(private val creator: (K) -> V) {
- private val values = mutableMapOf()
-
-
- /**
- * Returns the value that corresponds to `key`.
- *
- * If it does not exist, it is instantiated and then returned. If `useCache` is set to false, a new value is always
- * instantiated, even if there already is a value for `key`.
- *
- * @param key the key to look up the value with
- * @param useCache whether to return the existing value if it exists. Either way, the result is stored in the cache
- * @return the value that corresponds to `key`
- */
- @Synchronized
- fun get(key: K, useCache: Boolean = true) =
- if (useCache)
- values.getOrPut(key) { creator(key) }
- else
- creator(key)
- .also { values[key] = it }
-
- /**
- * Removes all keys and values from the cache.
- */
- @Synchronized
- fun clear() = values.clear()
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/CapitalizationMode.kt b/src/main/kotlin/com/fwdekker/randomness/CapitalizationMode.kt
index 2771ae710..445e5a106 100644
--- a/src/main/kotlin/com/fwdekker/randomness/CapitalizationMode.kt
+++ b/src/main/kotlin/com/fwdekker/randomness/CapitalizationMode.kt
@@ -7,80 +7,51 @@ import kotlin.random.Random
/**
* A mode in which a word should be capitalized.
*
- * @property descriptor the name of the capitalization mode
- * @property transformer the function which capitalizes the given string to the mode's format
+ * @property transform The function which capitalizes the given string to the mode's format.
*/
-enum class CapitalizationMode(val descriptor: String, private val transformer: (String, Random) -> String) {
+enum class CapitalizationMode(val transform: (String, Random) -> String) {
/**
* Does not change the string.
*/
- RETAIN("retain", { string, _ -> string }),
+ RETAIN({ string, _ -> string }),
/**
* Makes the first character uppercase and all characters after that lowercase.
*/
- SENTENCE("sentence", { string, _ -> string.toSentenceCase() }),
+ SENTENCE({ string, _ -> string.toSentenceCase() }),
/**
* Makes all characters uppercase.
*/
- UPPER("upper", { string, _ -> string.uppercase(Locale.getDefault()) }),
+ UPPER({ string, _ -> string.uppercase(Locale.getDefault()) }),
/**
* Makes all characters lowercase.
*/
- LOWER("lower", { string, _ -> string.lowercase(Locale.getDefault()) }),
+ LOWER({ string, _ -> string.lowercase(Locale.getDefault()) }),
/**
* Makes the first letter of each word uppercase.
*/
- FIRST_LETTER("first letter", { string, _ -> string.split(' ').joinToString(" ") { it.toSentenceCase() } }),
+ FIRST_LETTER({ string, _ -> string.split(' ').joinToString(" ") { it.toSentenceCase() } }),
/**
* Makes each letter randomly uppercase or lowercase.
*/
- RANDOM("random", { string, random -> string.toCharArray().map { it.toRandomCase(random) }.joinToString("") });
+ RANDOM({ string, random -> string.toCharArray().map { it.toRandomCase(random) }.joinToString("") }),
+ ;
/**
- * Invokes [transformer] with [random].
- *
- * @param string the string to transform
- * @param random the random instance to use for transforming
- * @return the returned value of [transformer]
+ * Returns the localized string name of this mode.
*/
- fun transform(string: String, random: Random = Random.Default) = transformer(string, random)
-
- /**
- * Returns the descriptor of the capitalization mode.
- *
- * @return the descriptor of the capitalization mode
- */
- override fun toString() = descriptor
-
-
- /**
- * Holds static elements.
- */
- companion object {
- /**
- * Returns the capitalization mode with the given name.
- *
- * @param descriptor the descriptor of the capitalization mode to return
- * @return the capitalization mode with the given descriptor
- */
- fun getMode(descriptor: String) =
- values().firstOrNull { it.descriptor == descriptor }
- ?: throw IllegalArgumentException("There does not exist a capitalization mode with name `$descriptor`.")
- }
+ fun toLocalizedString() =
+ Bundle("shared.capitalization.${toString().replace(' ', '_').lowercase(Locale.getDefault())}")
}
/**
- * Randomly converts this character to uppercase or lowercase.
- *
- * @param random the source of randomness to use
- * @return the uppercase or lowercase version of this character
+ * Randomly converts this character to uppercase or lowercase using [random] as a source of randomness.
*/
private fun Char.toRandomCase(random: Random) =
if (random.nextBoolean()) this.lowercaseChar()
@@ -88,8 +59,6 @@ private fun Char.toRandomCase(random: Random) =
/**
* Turns the first character uppercase while all other characters become lowercase.
- *
- * @return the sentence-case form of this string
*/
private fun String.toSentenceCase() =
this.lowercase(Locale.getDefault()).replaceFirstChar { it.uppercaseChar() }
diff --git a/src/main/kotlin/com/fwdekker/randomness/DataActions.kt b/src/main/kotlin/com/fwdekker/randomness/DataActions.kt
deleted file mode 100644
index 0087b9338..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/DataActions.kt
+++ /dev/null
@@ -1,440 +0,0 @@
-package com.fwdekker.randomness
-
-import com.fwdekker.randomness.array.ArrayScheme
-import com.fwdekker.randomness.array.ArraySettingsAction
-import com.intellij.codeInsight.hint.HintManager
-import com.intellij.icons.AllIcons
-import com.intellij.ide.actions.QuickSwitchSchemeAction
-import com.intellij.openapi.actionSystem.ActionGroup
-import com.intellij.openapi.actionSystem.AnAction
-import com.intellij.openapi.actionSystem.AnActionEvent
-import com.intellij.openapi.actionSystem.CommonDataKeys
-import com.intellij.openapi.actionSystem.DataContext
-import com.intellij.openapi.actionSystem.DefaultActionGroup
-import com.intellij.openapi.command.WriteCommandAction
-import com.intellij.openapi.options.ShowSettingsUtil
-import com.intellij.openapi.project.DumbAwareAction
-import com.intellij.openapi.project.Project
-import icons.RandomnessIcons
-import java.awt.event.ActionEvent
-import java.util.concurrent.ExecutionException
-import java.util.concurrent.Executors
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.TimeoutException
-import javax.swing.Icon
-import kotlin.random.Random
-
-
-/**
- * Thrown if a random datum could not be generated.
- *
- * @param message the detail message
- * @param cause the cause
- */
-class DataGenerationException(message: String? = null, cause: Throwable? = null) : Exception(message, cause)
-
-
-/**
- * A group of actions for a particular type of random data that can be generated.
- *
- * @property icon the icon to display with the action
- */
-abstract class DataGroupAction(private val icon: Icon = RandomnessIcons.Data.Base) : ActionGroup() {
- /**
- * The action used to insert single data.
- */
- abstract val insertAction: DataInsertAction
-
- /**
- * The action used to insert arrays of data.
- */
- abstract val insertArrayAction: DataInsertArrayAction
-
- /**
- * The action used to insert repeated single data.
- */
- abstract val insertRepeatAction: DataInsertRepeatAction
-
- /**
- * The action used to insert repeated arrays of data.
- */
- abstract val insertRepeatArrayAction: DataInsertRepeatArrayAction
-
- /**
- * The action used to edit the generator settings for this data type.
- */
- abstract val settingsAction: DataSettingsAction
-
- /**
- * The action used to quickly switch between schemes of this data type.
- */
- abstract val quickSwitchSchemeAction: DataQuickSwitchSchemeAction<*>
-
- /**
- * The action used to quickly switch between array schemes.
- */
- abstract val quickSwitchArraySchemeAction: DataQuickSwitchSchemeAction<*>
-
-
- /**
- * Returns the insert action, array insert action, and settings action.
- *
- * @param event carries information on the invocation place
- * @return the insert action, array insert action, and settings action
- */
- override fun getChildren(event: AnActionEvent?) =
- arrayOf(insertArrayAction, insertRepeatAction, insertRepeatArrayAction, settingsAction, quickSwitchSchemeAction)
-
- /**
- * Chooses one of the three actions to execute based on the key modifiers in [event].
- *
- * @param event carries information on the invocation place
- */
- override fun actionPerformed(event: AnActionEvent) {
- val altPressed = event.modifiers and ActionEvent.ALT_MASK != 0
- val ctrlPressed = event.modifiers and ActionEvent.CTRL_MASK != 0
- val shiftPressed = event.modifiers and ActionEvent.SHIFT_MASK != 0
-
- // alt behavior is handled by implementation of `actionPerformed`
- when {
- altPressed && ctrlPressed && shiftPressed -> quickSwitchArraySchemeAction.actionPerformed(event)
- altPressed && ctrlPressed -> quickSwitchSchemeAction.actionPerformed(event)
- altPressed && shiftPressed -> insertRepeatArrayAction.actionPerformed(event)
- ctrlPressed && shiftPressed -> ArraySettingsAction().actionPerformed(event)
- altPressed -> insertRepeatAction.actionPerformed(event)
- ctrlPressed -> settingsAction.actionPerformed(event)
- shiftPressed -> insertArrayAction.actionPerformed(event)
- else -> insertAction.actionPerformed(event)
- }
- }
-
- /**
- * Sets the title of this action.
- *
- * @param event carries information on the invocation place
- */
- override fun update(event: AnActionEvent) {
- super.update(event)
-
- event.presentation.text = insertAction.name
- event.presentation.icon = icon
- event.presentation.isPerformGroup = true
- event.presentation.isPopupGroup = true
- }
-}
-
-
-/**
- * Inserts randomly generated strings at the event's editor's carets.
- *
- * @property icon the icon to display with the action
- */
-abstract class DataInsertAction(private val icon: Icon) : AnAction() {
- /**
- * The name of the action to display.
- */
- abstract val name: String
-
- /**
- * The random generator used to generate random values.
- */
- var random: Random = Random.Default
-
-
- /**
- * Sets the title of this action and disables this action if no editor is currently opened.
- *
- * @param event carries information on the invocation place
- */
- override fun update(event: AnActionEvent) {
- val presentation = event.presentation
- val editor = event.getData(CommonDataKeys.EDITOR)
-
- presentation.text = name
- presentation.icon = icon
- presentation.isEnabled = editor != null
- }
-
- /**
- * Inserts the data generated by [generateStrings] at the caret(s) in the editor; one datum for each caret.
- *
- * @param event carries information on the invocation place
- */
- @Suppress("ReturnCount") // Result of null checks at start
- override fun actionPerformed(event: AnActionEvent) {
- val editor = event.getData(CommonDataKeys.EDITOR)
- ?: return
- val project = event.getData(CommonDataKeys.PROJECT)
- ?: return
-
- val data =
- try {
- generateStringsTimely(editor.caretModel.caretCount)
- } catch (e: DataGenerationException) {
- HintManager.getInstance().showErrorHint(
- editor,
- """
- Randomness was unable to generate random data.
- ${if (!e.message.isNullOrBlank()) "The following error was encountered: ${e.message}\n" else ""}
- Check your Randomness settings and try again.
- """.trimIndent()
- )
- return
- }
-
- WriteCommandAction.runWriteCommandAction(project) {
- editor.caretModel.allCarets.forEachIndexed { i, caret ->
- val start = caret.selectionStart
- val end = caret.selectionEnd
- val newEnd = start + data[i].length
-
- editor.document.replaceString(start, end, data[i])
- caret.setSelection(start, newEnd)
- }
- }
- }
-
- /**
- * Generates a random datum.
- *
- * @return a random datum
- * @throws DataGenerationException if data could not be generated
- */
- @Throws(DataGenerationException::class)
- fun generateString() = generateStrings(1).first()
-
- /**
- * Generates a random datum, or throws an exception if it takes longer than [GENERATOR_TIMEOUT] milliseconds.
- *
- * @return a random datum
- * @throws DataGenerationException if data could not be generated in time
- */
- @Throws(DataGenerationException::class)
- fun generateStringTimely() = generateTimely { generateString() }
-
- /**
- * Generates random data.
- *
- * @param count the number of data to generate
- * @return random data
- * @throws DataGenerationException if data could not be generated
- */
- @Throws(DataGenerationException::class)
- abstract fun generateStrings(count: Int = 1): List
-
- /**
- * Generates random data, or throws an exception if it takes longer than [GENERATOR_TIMEOUT] milliseconds.
- *
- * @param count the number of data to generate
- * @return random data
- * @throws DataGenerationException if data could not be generated in time
- */
- @Throws(DataGenerationException::class)
- fun generateStringsTimely(count: Int = 1) = generateTimely { generateStrings(count) }
-
- /**
- * Runs the given function and returns its return value, or throws an exception if it takes longer than
- * [GENERATOR_TIMEOUT] milliseconds.
- *
- * @param T the return type of [generator]
- * @param generator the function to call
- * @return the return value of [generator]
- * @throws DataGenerationException if data could not be generated in time
- */
- @Throws(DataGenerationException::class)
- private fun generateTimely(generator: () -> T): T {
- val executor = Executors.newSingleThreadExecutor()
- try {
- return executor.submit { generator() }.get(GENERATOR_TIMEOUT, TimeUnit.MILLISECONDS)
- } catch (e: TimeoutException) {
- throw DataGenerationException("Timed out while generating data.", e)
- } catch (e: ExecutionException) {
- throw DataGenerationException(e.cause?.message ?: e.message, e)
- } finally {
- executor.shutdown()
- }
- }
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The timeout in milliseconds before the generator should be interrupted when generating a value.
- */
- const val GENERATOR_TIMEOUT = 5000L
- }
-}
-
-
-/**
- * Inserts randomly generated arrays of strings at the event's editor's carets.
- *
- * @property arrayScheme the scheme to use for generating arrays
- * @property dataInsertAction the action to generate data with
- * @param icon the icon to display with the action
- */
-abstract class DataInsertArrayAction(
- private val arrayScheme: () -> ArrayScheme,
- private val dataInsertAction: DataInsertAction,
- icon: Icon = RandomnessIcons.Data.Array
-) : DataInsertAction(icon) {
- /**
- * Generates array-like strings of random data.
- *
- * @param count the number of array-like strings to generate
- * @return array-like strings of random data
- * @throws DataGenerationException if data could not be generated
- */
- @Throws(DataGenerationException::class)
- override fun generateStrings(count: Int): List {
- val arrayScheme = arrayScheme()
- if (arrayScheme.count <= 0)
- throw DataGenerationException("Array cannot have fewer than 1 element.")
-
- dataInsertAction.random = random
- return dataInsertAction.generateStrings(count * arrayScheme.count)
- .chunked(arrayScheme.count)
- .map { arrayScheme.arrayify(it) }
- }
-}
-
-
-/**
- * Inserts the same randomly generated string at the event's editor's carets.
- *
- * @property dataInsertAction the action to generate data with
- * @param icon the icon to display with the action
- */
-abstract class DataInsertRepeatAction(
- private val dataInsertAction: DataInsertAction,
- icon: Icon = RandomnessIcons.Data.Array
-) : DataInsertAction(icon) {
- /**
- * Generates a random datum and repeats it [count] times.
- *
- * @param count the number of times to repeat the data
- * @return a random datum, repeated [count] times
- * @throws DataGenerationException if data could not be generated
- */
- @Throws(DataGenerationException::class)
- override fun generateStrings(count: Int): List {
- dataInsertAction.random = random
- return dataInsertAction.generateString().let { string -> List(count) { string } }
- }
-}
-
-
-/**
- * Inserts the same randomly generated array of strings at the event's editor's carets.
- *
- * @property dataInsertArrayAction the action to generate data with
- * @param icon the icon to display with the action
- */
-abstract class DataInsertRepeatArrayAction(
- private val dataInsertArrayAction: DataInsertArrayAction,
- icon: Icon = RandomnessIcons.Data.Array
-) : DataInsertAction(icon) {
- /**
- * Generates a random array-like string of random data and repeats it [count] times.
- *
- * @param count the number of times to repeat the data
- * @return a random array-like string of random data and repeats it [count] times
- * @throws DataGenerationException if data could not be generated
- */
- @Throws(DataGenerationException::class)
- override fun generateStrings(count: Int) =
- dataInsertArrayAction.generateString().let { string -> List(count) { string } }
-}
-
-
-/**
- * Opens the settings window for changing settings.
- *
- * @param icon the icon to display with the action
- */
-abstract class DataSettingsAction(private val icon: Icon = RandomnessIcons.Data.Settings) : AnAction() {
- /**
- * The name of the action.
- */
- abstract val name: String
-
- /**
- * The class of the configurable maintaining the settings.
- */
- protected abstract val configurableClass: Class>
-
-
- /**
- * Sets the title of this action.
- *
- * @param event carries information on the invocation place
- */
- override fun update(event: AnActionEvent) {
- super.update(event)
-
- event.presentation.text = name
- event.presentation.icon = icon
- }
-
- /**
- * Opens the IntelliJ settings menu at the right location to adjust the `Configurable` of type [configurableClass].
- *
- * @param event carries information on the invocation place
- */
- override fun actionPerformed(event: AnActionEvent) =
- ShowSettingsUtil.getInstance().showSettingsDialog(event.project, configurableClass)
-}
-
-
-/**
- * Opens a popup to allow the user to quickly switch to the selected scheme.
- *
- * @param T the type of scheme that can be switched between
- * @property settings the settings containing the schemes that can be switched between
- * @property icon the icon to present with this action
- */
-abstract class DataQuickSwitchSchemeAction>(
- private val settings: Settings<*, T>,
- private val icon: Icon = RandomnessIcons.Data.Settings
-) : QuickSwitchSchemeAction(true) {
- /**
- * The name of the action.
- */
- abstract val name: String
-
-
- /**
- * Sets the title and icon of this action.
- *
- * @param event carries information on the invocation place
- */
- override fun update(event: AnActionEvent) {
- super.update(event)
-
- event.presentation.text = name
- event.presentation.icon = icon
- }
-
- /**
- * Adds actions for all schemes in `settings` to the given group.
- *
- * @param project ignored
- * @param group the group to add actions to
- * @param dataContext ignored
- */
- override fun fillActions(project: Project?, group: DefaultActionGroup, dataContext: DataContext) {
- val current = settings.currentScheme
-
- settings.schemes.forEach { scheme ->
- val icon = if (scheme === current) AllIcons.Actions.Forward else ourNotCurrentAction
-
- group.add(object : DumbAwareAction(scheme.myName, "", icon) {
- override fun actionPerformed(event: AnActionEvent) {
- settings.currentSchemeName = scheme.name
- }
- })
- }
- }
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/DummyActions.kt b/src/main/kotlin/com/fwdekker/randomness/DummyActions.kt
deleted file mode 100644
index 1fc5e8450..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/DummyActions.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.fwdekker.randomness
-
-import com.fwdekker.randomness.array.ArrayScheme
-import com.fwdekker.randomness.array.ArraySettings
-import icons.RandomnessIcons
-import kotlin.random.Random
-
-
-/**
- * Inserts a dummy value.
- *
- * Mostly for testing and demonstration purposes.
- *
- * @property dummySupplier generates dummy values to insert
- */
-class DummyInsertAction(private val dummySupplier: (Random) -> String) : DataInsertAction(RandomnessIcons.Data.Base) {
- override val name = "Random Dummy"
-
- override fun generateStrings(count: Int) = List(count) { dummySupplier(random) }
-}
-
-/**
- * Inserts an array of dummy values.
- *
- * Mostly for testing and demonstration purposes.
- *
- * @param arrayScheme the scheme to use for generating arrays
- * @param dummySupplier generates dummy values to insert
- */
-class DummyInsertArrayAction(
- arrayScheme: () -> ArrayScheme = { ArraySettings.default.currentScheme },
- dummySupplier: (Random) -> String
-) : DataInsertArrayAction(arrayScheme, DummyInsertAction(dummySupplier), RandomnessIcons.Data.Array) {
- override val name = "Random Dummy Array"
-}
-
-/**
- * Inserts a repeated array of dummy values.
- *
- * Mostly for testing and demonstration purposes.
- *
- * @param dummySupplier generates dummy values to insert
- */
-class DummyInsertRepeatAction(dummySupplier: (Random) -> String) :
- DataInsertRepeatAction(DummyInsertAction(dummySupplier), RandomnessIcons.Data.Repeat) {
- override val name = "Random Repeat Dummy"
-}
-
-/**
- * Inserts a repeated array of dummy values.
- *
- * Mostly for testing and demonstration purposes.
- *
- * @param arrayScheme the scheme to use for generating arrays
- * @param dummySupplier generates dummy values to insert
- */
-class DummyInsertRepeatArrayAction(
- arrayScheme: () -> ArrayScheme = { ArraySettings.default.currentScheme },
- dummySupplier: (Random) -> String
-) : DataInsertRepeatArrayAction(DummyInsertArrayAction(arrayScheme, dummySupplier), RandomnessIcons.Data.Repeat) {
- override val name = "Random Repeat Dummy"
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/ErrorReporter.kt b/src/main/kotlin/com/fwdekker/randomness/ErrorReporter.kt
index 0201c66ae..21a19fdb1 100644
--- a/src/main/kotlin/com/fwdekker/randomness/ErrorReporter.kt
+++ b/src/main/kotlin/com/fwdekker/randomness/ErrorReporter.kt
@@ -2,10 +2,8 @@ package com.fwdekker.randomness
import com.intellij.ide.BrowserUtil
import com.intellij.ide.DataManager
-import com.intellij.ide.plugins.IdeaPluginDescriptor
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.application.ApplicationInfo
-import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.ErrorReportSubmitter
import com.intellij.openapi.diagnostic.IdeaLoggingEvent
import com.intellij.openapi.diagnostic.SubmittedReportInfo
@@ -13,29 +11,26 @@ import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.Task.Backgroundable
import com.intellij.util.Consumer
import java.awt.Component
-import java.net.IDN
-import java.net.URI
-import java.net.URL
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
/**
* A report submitter that opens a pre-filled issue creation form on Randomness' GitHub repository.
*
* This class pertains to reports of exceptions that are not caught by the plugin and end up being shown to the user
- * as a notification by IntelliJ.
+ * as a notification by the IDE.
*/
class ErrorReporter : ErrorReportSubmitter() {
/**
* Returns the text that is displayed in the button to report the error.
- *
- * @return the text that is displayed in the button to report the error
*/
- override fun getReportActionText() = "Report on GitHub"
+ override fun getReportActionText() = Bundle("reporter.report")
/**
- * Submits the exception as desired by the user.
+ * Submits the exception by opening the browser to create an issue on GitHub.
*
- * @param events the events that caused the exception
+ * @param events ignored
* @param additionalInfo additional information provided by the user
* @param parentComponent ignored
* @param consumer ignored
@@ -45,21 +40,19 @@ class ErrorReporter : ErrorReportSubmitter() {
events: Array,
additionalInfo: String?,
parentComponent: Component,
- consumer: Consumer
+ consumer: Consumer,
): Boolean {
val project = CommonDataKeys.PROJECT.getData(DataManager.getInstance().getDataContext(parentComponent))
- object : Backgroundable(project, "Opening GitHub in browser") {
+ object : Backgroundable(project, Bundle("reporter.opening")) {
override fun run(indicator: ProgressIndicator) {
- BrowserUtil.open(getIssueUrl(events, additionalInfo))
- ApplicationManager.getApplication().invokeLater {
- consumer.consume(
- SubmittedReportInfo(
- "https://github.com/FWDekker/intellij-randomness/issues",
- "Issue on GitHub",
- SubmittedReportInfo.SubmissionStatus.NEW_ISSUE
- )
+ BrowserUtil.open(getIssueUrl(additionalInfo))
+ consumer.consume(
+ SubmittedReportInfo(
+ "https://github.com/FWDekker/intellij-randomness/issues",
+ Bundle("reporter.issue"),
+ SubmittedReportInfo.SubmissionStatus.NEW_ISSUE
)
- }
+ )
}
}.queue()
return true
@@ -67,122 +60,48 @@ class ErrorReporter : ErrorReportSubmitter() {
/**
* Returns the privacy notice text.
- *
- * @return the privacy notice text
*/
- override fun getPrivacyNoticeText() =
- """
- Pressing the Report button will open a form on a web page with the details of this error filled in.
- Submitting the form requires a GitHub account and is subject to
- GitHub's privacy policy.
- """.trimIndent()
+ override fun getPrivacyNoticeText() = Bundle("reporter.privacy_notice")
/**
- * Constructs a URL to create an issue with the given information that is below the maximum URL limit.
- *
- * @param events the events that caused the exception
- * @param additionalInfo additional information provided by the user
- * @return a URL to create an issue with the given information that is below the maximum URL limit
+ * Constructs a URL to create an issue that provides [additionalInfo] and is below the maximum URL limit.
*/
- // Public for testability
- fun getIssueUrl(events: Array, additionalInfo: String?): String {
+ fun getIssueUrl(additionalInfo: String?): String {
val baseUrl = "https://github.com/FWDekker/intellij-randomness/issues/new?body="
+
val additionalInfoSection = createMarkdownSection(
"Additional info",
- if (additionalInfo.isNullOrBlank()) "_No additional information provided._"
- else additionalInfo
+ if (additionalInfo.isNullOrBlank()) MORE_DETAIL_MESSAGE else additionalInfo
)
- val stackTracesSection = createMarkdownSection("Stacktraces", formatEvents(events))
+ val stacktraceSection = createMarkdownSection("Stacktraces", STACKTRACE_MESSAGE)
val versionSection = createMarkdownSection("Version information", getFormattedVersionInformation())
- val candidates = listOf(
- additionalInfoSection + stackTracesSection + versionSection,
- additionalInfoSection + versionSection,
- stackTracesSection + versionSection,
- versionSection,
- ""
- )
- return baseUrl + candidates.first { encodeUrl(baseUrl + it).length <= MAX_URL_LENGTH }
- .replace(' ', '+')
- .filterNot { it in listOf('#', '&', ';') }
+ return URLEncoder.encode(additionalInfoSection + stacktraceSection + versionSection, StandardCharsets.UTF_8)
+ .replace("%2B", "+")
+ .let { baseUrl + it }
}
/**
- * Creates a Markdown "section" containing the title in bold followed by the contents on the next line, finalized by
- * two newlines.
- *
- * @param title the title of the section
- * @param contents the contents of the section
- * @return a Markdown "section" with the given title and contents
- */
- private fun createMarkdownSection(title: String, contents: String) = "**${title.trim()}**\n${contents.trim()}\n\n"
-
- /**
- * Formats IDEA events as Markdown-style code blocks inside spoilers.
- *
- * @param events the events to format
- */
- private fun formatEvents(events: Array) =
- events.mapIndexed { i, event ->
- wrapInMarkdownSpoiler(
- title = "Stacktrace ${i + 1}/${events.size}",
- contents = wrapInJavaCodeBlock(event.throwableText.trim())
- )
- }.joinToString("\n\n")
-
- /**
- * Creates a Markdown-style Java code block.
- *
- * @param contents the contents of the code block
- * @return a Markdown-style Java code block
- */
- private fun wrapInJavaCodeBlock(contents: String) = "```java\n$contents\n```"
-
- /**
- * Creates a Markdown-style spoiler with the given title and contents.
- *
- * @param title the title, which is the only thing that is displayed when the contents are hidden
- * @param contents the contents which are initially hidden
- * @return a Markdown-style spoiler with the given title
- */
- private fun wrapInMarkdownSpoiler(title: String, contents: String) =
- "\n$title
\n\n\n$contents\n\n
\n "
-
- /**
- * Returns the version number of Randomness, or `null` if it could not be determined.
- *
- * @return the version number of Randomness, or `null` if it could not be determined
+ * Creates a Markdown "section" containing the [title] in bold followed by the [contents] on the next line,
+ * finalized by two newlines.
*/
- private fun getPluginVersion() =
- if (pluginDescriptor is IdeaPluginDescriptor) (pluginDescriptor as IdeaPluginDescriptor).version
- else null
+ private fun createMarkdownSection(title: String, contents: String) =
+ """
+ **${title.trim()}**
+ ${contents.trim()}
+ """.trimIndent()
/**
* Returns version information on the user's environment as a Markdown-style list.
- *
- * @return version information on the user's environment as a Markdown-style list
*/
private fun getFormattedVersionInformation() =
- listOf(
- Pair("Randomness version", getPluginVersion() ?: "_Unknown_"),
- Pair("IDE version", ApplicationInfo.getInstance().apiVersion),
- Pair("Operating system", System.getProperty("os.name")),
- Pair("Java version", System.getProperty("java.version"))
- ).joinToString("\n") { "- ${it.first}: ${it.second}" }
-
- /**
- * Correctly encodes a string describing a URL.
- *
- * Taken from https://stackoverflow.com/a/25735202.
- *
- * @param urlString the string to encode
- * @return an encoded URL
- */
- private fun encodeUrl(urlString: String) =
- URL(urlString.replace(' ', '+'))
- .let { URI(it.protocol, it.userInfo, IDN.toASCII(it.host), it.port, it.path, it.query, it.ref) }
- .toASCIIString()
+ """
+ - Randomness version: ${pluginDescriptor?.version ?: "_Unknown_"}
+ - IDE version: ${ApplicationInfo.getInstance().apiVersion}
+ - Operating system: ${System.getProperty("os.name")}
+ - Java version: ${System.getProperty("java.version")}
+ """.trimIndent()
/**
@@ -190,8 +109,15 @@ class ErrorReporter : ErrorReportSubmitter() {
*/
companion object {
/**
- * Maximum URL length supported by GitHub, experimentally verified.
+ * Message asking the user to provide more information about the exception.
+ */
+ const val MORE_DETAIL_MESSAGE =
+ "Please describe your issue in more detail here. What were you doing when the exception occurred?"
+
+ /**
+ * Message asking the user to provide stacktrace information.
*/
- const val MAX_URL_LENGTH = 8000
+ const val STACKTRACE_MESSAGE =
+ "Please paste the full stacktrace from the IDE's error popup below.\n```java\n\n```"
}
}
diff --git a/src/main/kotlin/com/fwdekker/randomness/Icons.kt b/src/main/kotlin/com/fwdekker/randomness/Icons.kt
new file mode 100644
index 000000000..6f2a0912b
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/Icons.kt
@@ -0,0 +1,345 @@
+package com.fwdekker.randomness
+
+import com.intellij.openapi.util.IconLoader
+import com.intellij.ui.ColorUtil
+import com.intellij.util.IconUtil
+import java.awt.Color
+import java.awt.Component
+import java.awt.Graphics
+import java.awt.image.RGBImageFilter
+import javax.swing.Icon
+import kotlin.math.atan2
+
+
+/**
+ * Basic Randomness icons.
+ */
+object Icons {
+ /**
+ * The main icon of Randomness.
+ */
+ val RANDOMNESS = IconLoader.findIcon("/icons/randomness.svg", this.javaClass.classLoader)!!
+
+ /**
+ * The template icon for template icons.
+ */
+ val TEMPLATE = IconLoader.findIcon("/icons/template.svg", this.javaClass.classLoader)!!
+
+ /**
+ * The template icon for scheme icons.
+ */
+ val SCHEME = IconLoader.findIcon("/icons/scheme.svg", this.javaClass.classLoader)!!
+
+ /**
+ * An icon for settings.
+ */
+ val SETTINGS = IconLoader.findIcon("/icons/settings.svg", this.javaClass.classLoader)!!
+
+ /**
+ * A filled-in version of [SETTINGS].
+ */
+ val SETTINGS_FILLED = IconLoader.findIcon("/icons/settings-filled.svg", this.javaClass.classLoader)!!
+
+ /**
+ * An icon for arrays.
+ */
+ val ARRAY = IconLoader.findIcon("/icons/array.svg", this.javaClass.classLoader)!!
+
+ /**
+ * A filled-in version of [ARRAY].
+ */
+ val ARRAY_FILLED = IconLoader.findIcon("/icons/array-filled.svg", this.javaClass.classLoader)!!
+
+ /**
+ * An icon for references.
+ */
+ val REFERENCE = IconLoader.findIcon("/icons/reference.svg", this.javaClass.classLoader)!!
+
+ /**
+ * A filled-in version of [REFERENCE].
+ */
+ val REFERENCE_FILLED = IconLoader.findIcon("/icons/reference-filled.svg", this.javaClass.classLoader)!!
+
+ /**
+ * An icon for repeated insertions.
+ */
+ val REPEAT = IconLoader.findIcon("/icons/repeat.svg", this.javaClass.classLoader)!!
+
+ /**
+ * A filled-in version of [REPEAT].
+ */
+ val REPEAT_FILLED = IconLoader.findIcon("/icons/repeat-filled.svg", this.javaClass.classLoader)!!
+}
+
+
+/**
+ * A colored icon with some text in it.
+ *
+ * @property base The underlying icon which should be given color; must be square.
+ * @property text The text to display inside the [base].
+ * @property colors The colors to give to the [base].
+ */
+data class TypeIcon(val base: Icon, val text: String, val colors: List) : Icon {
+ init {
+ require(colors.isNotEmpty()) { "At least one color must be defined." }
+ }
+
+
+ /**
+ * Paints the colored text icon.
+ *
+ * @param c a [Component] to get properties useful for painting
+ * @param g the graphics context
+ * @param x the X coordinate of the icon's top-left corner
+ * @param y the Y coordinate of the icon's top-left corner
+ */
+ override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) {
+ if (c == null || g == null) return
+
+ val filter = RadialColorReplacementFilter(colors, Pair(iconWidth / 2, iconHeight / 2))
+ IconUtil.filterIcon(base, { filter }, c).paintIcon(c, g, x, y)
+
+ val textIcon = IconUtil.textToIcon(text, c, FONT_SIZE * iconWidth)
+ textIcon.paintIcon(c, g, x + (iconWidth - textIcon.iconWidth) / 2, y + (iconHeight - textIcon.iconHeight) / 2)
+ }
+
+ /**
+ * The width of the base icon.
+ */
+ override fun getIconWidth() = base.iconWidth
+
+ /**
+ * The height of the base icon.
+ */
+ override fun getIconHeight() = base.iconHeight
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The scale of the text inside the icon relative to the icon's size.
+ */
+ const val FONT_SIZE = 12f / 32f
+
+
+ /**
+ * Returns a single icon that describes all [icons], or `null` if [icons] is empty.
+ */
+ fun combine(icons: Collection): TypeIcon? =
+ if (icons.isEmpty()) null
+ else TypeIcon(
+ Icons.TEMPLATE,
+ if (icons.map { it.text }.toSet().size == 1) icons.first().text else "",
+ icons.flatMap { it.colors }
+ )
+ }
+}
+
+/**
+ * An overlay icon, which can be displayed on top of other icons.
+ *
+ * This icon is drawn as the [base] surrounded by a small margin of background color, which creates visual distance
+ * between the overlay and the rest of the icon this overlay is shown in top of. The background color is determined when
+ * the icon is drawn.
+ *
+ * @property base The base of the icon; must be square.
+ * @property background The background shape to ensure that the small margin of background color is also applied inside
+ * the [base], or `null` if [base] is already a solid shape.
+ */
+data class OverlayIcon(val base: Icon, val background: Icon? = null) : Icon {
+ /**
+ * Paints the overlay icon.
+ *
+ * @param c a [Component] to get properties useful for painting
+ * @param g the graphics context
+ * @param x the X coordinate of the icon's top-left corner
+ * @param y the Y coordinate of the icon's top-left corner
+ */
+ override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) {
+ if (c == null || g == null) return
+
+ IconUtil.filterIcon(background ?: base, { RadialColorReplacementFilter(listOf(c.background)) }, c)
+ .paintIcon(c, g, x, y)
+ IconUtil.scale(base, c, 1 - 2 * MARGIN)
+ .paintIcon(c, g, x + (MARGIN * iconWidth).toInt(), y + (MARGIN * iconHeight).toInt())
+ }
+
+ /**
+ * The width of the base icon.
+ */
+ override fun getIconWidth() = base.iconWidth
+
+ /**
+ * The height of the base icon.
+ */
+ override fun getIconHeight() = base.iconHeight
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The margin around the base image that is filled with background color.
+ *
+ * This number is a fraction relative to the base image's size.
+ */
+ const val MARGIN = 4f / 32
+
+
+ /**
+ * Overlay icon for arrays.
+ */
+ val ARRAY by lazy { OverlayIcon(Icons.ARRAY, Icons.ARRAY_FILLED) }
+
+ /**
+ * Overlay icon for template references.
+ */
+ val REFERENCE by lazy { OverlayIcon(Icons.REFERENCE, Icons.REFERENCE_FILLED) }
+
+ /**
+ * Overlay icon for repeated insertion.
+ */
+ val REPEAT by lazy { OverlayIcon(Icons.REPEAT, Icons.REPEAT_FILLED) }
+
+ /**
+ * Overlay icon for settings.
+ */
+ val SETTINGS by lazy { OverlayIcon(Icons.SETTINGS, Icons.SETTINGS_FILLED) }
+ }
+}
+
+/**
+ * An icon with various icons displayed on top of it as overlays.
+ *
+ * @property base The underlying base icon.
+ * @property overlays The various icons that are overlayed on top of [base].
+ */
+data class OverlayedIcon(val base: Icon, val overlays: List = emptyList()) : Icon {
+ init {
+ require(base.iconWidth == base.iconHeight) { "Base must be square." }
+ require(overlays.all { it.iconWidth == it.iconHeight }) { "Overlays must be square." }
+ require(overlays.map { it.iconWidth }.toSet().size <= 1) { "All overlays must have same size." }
+ }
+
+
+ /**
+ * Returns a copy of this icon that has [icon] as an additional overlay icon.
+ */
+ fun plusOverlay(icon: Icon) = copy(overlays = overlays + icon)
+
+
+ /**
+ * Paints the scheme icon.
+ *
+ * @param c a [Component] to get properties useful for painting
+ * @param g the graphics context
+ * @param x the X coordinate of the icon's top-left corner
+ * @param y the Y coordinate of the icon's top-left corner
+ */
+ override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) {
+ if (c == null || g == null) return
+
+ base.paintIcon(c, g, x, y)
+ overlays.forEachIndexed { i, overlay ->
+ val overlaySize = iconWidth.toFloat() / OVERLAYS_PER_ROW
+ val overlayX = (i % OVERLAYS_PER_ROW * overlaySize).toInt()
+ val overlayY = (i / OVERLAYS_PER_ROW * overlaySize).toInt()
+
+ IconUtil.scale(overlay, null, overlaySize / overlay.iconWidth).paintIcon(c, g, overlayX, overlayY)
+ }
+ }
+
+ /**
+ * The width of the base icon.
+ */
+ override fun getIconWidth() = base.iconWidth
+
+ /**
+ * The height of the base icon.
+ */
+ override fun getIconHeight() = base.iconHeight
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * Number of overlays displayed per row.
+ */
+ const val OVERLAYS_PER_ROW = 2
+ }
+}
+
+
+/**
+ * Replaces all colors with one of [colors] depending on the angle relative to [center].
+ *
+ * @param colors the colors that should be used, in clockwise order starting north-west
+ * @param center the center relative to which colors should be calculated; not required if only one color is given
+ */
+class RadialColorReplacementFilter(
+ private val colors: List,
+ private val center: Pair? = null,
+) : RGBImageFilter() {
+ init {
+ require(colors.isNotEmpty()) { "At least one color must be defined." }
+ require(colors.size == 1 || center != null) { "Center must be defined if more than one color is given." }
+ }
+
+
+ /**
+ * Returns the color to be displayed at ([x], [y]), considering the coordinates relative to the [center] and the
+ * relative alpha of the encountered color.
+ *
+ * @param x the X coordinate of the pixel
+ * @param y the Y coordinate of the pixel
+ * @param rgb `0` if and only if the pixel's color should be replaced
+ * @return `0` if [rgb] is `0`, or one of [colors] with its alpha shifted by [rgb]'s alpha otherwise
+ */
+ @Suppress("UseJBColor") // Filtering works the same in both themes
+ override fun filterRGB(x: Int, y: Int, rgb: Int) =
+ if (rgb == 0) 0
+ else if (center == null || colors.size == 1) shiftAlpha(colors[0], Color(rgb, true)).rgb
+ else shiftAlpha(positionToColor(Pair(x - center.first, y - center.second)), Color(rgb, true)).rgb
+
+
+ /**
+ * Returns [toShift] which has its alpha multiplied by that of [shiftBy].
+ */
+ private fun shiftAlpha(toShift: Color, shiftBy: Color) =
+ ColorUtil.withAlpha(toShift, asFraction(toShift.alpha) * asFraction(shiftBy.alpha))
+
+ /**
+ * Represents a [number] in the range `[0, 256)` as a fraction of that range.
+ */
+ private fun asFraction(number: Int) = number / COMPONENT_MAX.toDouble()
+
+ /**
+ * Returns the appropriate color from [colors] for an [offset] relative to the [center].
+ */
+ private fun positionToColor(offset: Pair): Color {
+ val angle = 2 * Math.PI - (atan2(offset.second.toDouble(), offset.first.toDouble()) + STARTING_ANGLE)
+ val index = angle / (2 * Math.PI / colors.size)
+ return colors[Math.floorMod(index.toInt(), colors.size)]
+ }
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * Maximum value for an RGB component.
+ */
+ const val COMPONENT_MAX = 255
+
+ /**
+ * The angle in radians at which the first color should start being displayed.
+ */
+ const val STARTING_ANGLE = -(3 * Math.PI / 4)
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/InsertAction.kt b/src/main/kotlin/com/fwdekker/randomness/InsertAction.kt
new file mode 100644
index 000000000..70e6b6aa5
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/InsertAction.kt
@@ -0,0 +1,109 @@
+package com.fwdekker.randomness
+
+import com.fwdekker.randomness.Timely.generateTimely
+import com.intellij.codeInsight.hint.HintManager
+import com.intellij.openapi.actionSystem.ActionUpdateThread
+import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.actionSystem.CommonDataKeys
+import com.intellij.openapi.command.WriteCommandAction
+import com.intellij.openapi.options.Configurable
+import com.intellij.openapi.options.newEditor.SettingsDialogFactory
+import javax.swing.Icon
+
+
+/**
+ * Inserts strings in the editor.
+ *
+ * If [configurable] is not `null` when [actionPerformed] is invoked, a modal editor is shown to edit that
+ * [configurable] right before inserting the strings to allow for last-minute adjustments to how the strings are
+ * generated.
+ *
+ * @property repeat `true` if and only if the same value should be inserted at each caret.
+ * @property text The text that identifies the action to the user.
+ * @param description the optional description of the action
+ * @param icon the icon that represents the action
+ */
+abstract class InsertAction(
+ val repeat: Boolean = false,
+ val text: String,
+ description: String? = null,
+ icon: Icon? = null,
+) : AnAction(text, description, icon) {
+ /**
+ * The configurable to open as soon as the action is performed but before the strings are inserted.
+ *
+ * Use this to make modifications to settings right before inserting strings.
+ */
+ protected open val configurable: Configurable? = null
+
+
+ /**
+ * Specifies the thread in which [update] is invoked.
+ */
+ override fun getActionUpdateThread() = ActionUpdateThread.EDT
+
+ /**
+ * Sets the title of this action and disables this action if no editor is currently opened.
+ *
+ * @param event carries contextual information
+ */
+ override fun update(event: AnActionEvent) {
+ val presentation = event.presentation
+ val editor = event.getData(CommonDataKeys.EDITOR)
+
+ presentation.isEnabled = editor?.document?.isWritable == true
+ }
+
+ /**
+ * Inserts the data generated by the scheme at the caret(s) in the editor; one datum for each caret.
+ *
+ * @param event carries contextual information
+ */
+ @Suppress("detekt:ReturnCount") // Result of null checks at start
+ override fun actionPerformed(event: AnActionEvent) {
+ val editor = event.getData(CommonDataKeys.EDITOR) ?: return
+ val project = event.getData(CommonDataKeys.PROJECT) ?: return
+
+ configurable?.also {
+ if (!SettingsDialogFactory.getInstance().create(project, text, it, false, false).showAndGet())
+ return
+ }
+
+ val data =
+ try {
+ generateTimely {
+ if (repeat)
+ generateStrings(1).single().let { string -> List(editor.caretModel.caretCount) { string } }
+ else
+ generateStrings(editor.caretModel.caretCount)
+ }
+ } catch (exception: DataGenerationException) {
+ HintManager.getInstance().showErrorHint(
+ editor,
+ if (exception.message.isNullOrBlank())
+ Bundle("shared.error.could_not_generate")
+ else
+ Bundle("shared.error.could_not_generate.no_message", exception.message)
+ )
+ return
+ }
+
+ WriteCommandAction.runWriteCommandAction(project) {
+ editor.caretModel.allCarets.forEachIndexed { i, caret ->
+ val start = caret.selectionStart
+ val end = caret.selectionEnd
+ val newEnd = start + data[i].length
+
+ editor.document.replaceString(start, end, data[i])
+ caret.setSelection(start, newEnd)
+ }
+ }
+ }
+
+
+ /**
+ * Generates [count] strings.
+ */
+ abstract fun generateStrings(count: Int): List
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/ListHelpers.kt b/src/main/kotlin/com/fwdekker/randomness/ListHelpers.kt
new file mode 100644
index 000000000..dfbad7c3d
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/ListHelpers.kt
@@ -0,0 +1,10 @@
+package com.fwdekker.randomness
+
+
+/**
+ * Removes all elements from this collection and adds all elements from [collection].
+ */
+fun MutableCollection.setAll(collection: Collection) {
+ clear()
+ addAll(collection)
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/Notifier.kt b/src/main/kotlin/com/fwdekker/randomness/Notifier.kt
new file mode 100644
index 000000000..977215931
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/Notifier.kt
@@ -0,0 +1,64 @@
+package com.fwdekker.randomness
+
+import com.intellij.ide.util.PropertiesComponent
+import com.intellij.notification.Notification
+import com.intellij.notification.NotificationAction
+import com.intellij.notification.NotificationGroupManager
+import com.intellij.notification.NotificationType
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.application.PathManager
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.startup.StartupActivity
+import com.intellij.openapi.ui.MessageDialogBuilder
+
+
+/**
+ * Displays notifications when a project is opened.
+ */
+class Notifier : StartupActivity {
+ /**
+ * Shows startup notifications.
+ */
+ override fun runActivity(project: Project) = showWelcomeToV3(project)
+
+ /**
+ * Shows a notification introducing the user to version 3 of Randomness.
+ */
+ private fun showWelcomeToV3(project: Project) {
+ val key = "notifications.welcome_to_v3"
+ val oldConfig = PathManager.getOptionsFile("randomness")
+
+ val propComp = PropertiesComponent.getInstance()
+ val propKey = "com.fwdekker.randomness.$key"
+ if (propComp.isTrueValue(propKey) || !oldConfig.exists())
+ return
+
+ NotificationGroupManager.getInstance()
+ .getNotificationGroup("com.fwdekker.randomness.updates")
+ .createNotification(Bundle("$key.title"), Bundle("$key.content"), NotificationType.INFORMATION)
+ .setSubtitle(Bundle("$key.subtitle"))
+ .setIcon(Icons.RANDOMNESS)
+ .setSuggestionType(true)
+ .addAction(object : NotificationAction(Bundle("$key.delete_old_config")) {
+ override fun actionPerformed(event: AnActionEvent, notification: Notification) {
+ MessageDialogBuilder
+ .yesNo(Bundle("$key.delete_old_config_confirm"), "", Icons.RANDOMNESS)
+ .ask(project)
+ .also {
+ if (it) {
+ oldConfig.delete()
+ propComp.setValue(propKey, true)
+ notification.hideBalloon()
+ }
+ }
+ }
+ })
+ .addAction(object : NotificationAction(Bundle("$key.do_not_ask_again")) {
+ override fun actionPerformed(event: AnActionEvent, notification: Notification) {
+ propComp.setValue(propKey, true)
+ notification.hideBalloon()
+ }
+ })
+ .notify(project)
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/PopupAction.kt b/src/main/kotlin/com/fwdekker/randomness/PopupAction.kt
index 812e85163..9a28f312b 100644
--- a/src/main/kotlin/com/fwdekker/randomness/PopupAction.kt
+++ b/src/main/kotlin/com/fwdekker/randomness/PopupAction.kt
@@ -1,72 +1,72 @@
package com.fwdekker.randomness
-import com.fwdekker.randomness.array.ArraySettingsAction
-import com.fwdekker.randomness.decimal.DecimalGroupAction
-import com.fwdekker.randomness.decimal.DecimalSettingsAction
-import com.fwdekker.randomness.integer.IntegerGroupAction
-import com.fwdekker.randomness.integer.IntegerSettingsAction
-import com.fwdekker.randomness.string.StringGroupAction
-import com.fwdekker.randomness.string.StringSettingsAction
-import com.fwdekker.randomness.ui.registerModifierActions
-import com.fwdekker.randomness.uuid.UuidGroupAction
-import com.fwdekker.randomness.uuid.UuidSettingsAction
-import com.fwdekker.randomness.word.WordGroupAction
-import com.fwdekker.randomness.word.WordSettingsAction
+import com.fwdekker.randomness.template.TemplateGroupAction
+import com.fwdekker.randomness.template.TemplateSettingsAction
import com.intellij.openapi.actionSystem.ActionGroup
+import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.Separator
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.ui.popup.list.ListPopupImpl
-import icons.RandomnessIcons
import java.awt.event.ActionEvent
+import java.awt.event.KeyEvent
+import java.util.Locale
+import javax.swing.AbstractAction
+import javax.swing.KeyStroke
/**
* Shows a popup for all available Randomness actions.
*/
-class PopupAction : AnAction() {
+class PopupAction : AnAction(Icons.RANDOMNESS) {
/**
- * Whether the user focused the editor when opening this popup.
+ * `true` if and only if the user focused a non-viewer editor when opening this popup.
*/
- private var hasEditor: Boolean = true
+ private var isEditable: Boolean = true
+ /**
+ * Specifies the thread in which [update] is invoked.
+ */
+ override fun getActionUpdateThread() = ActionUpdateThread.EDT
+
/**
* Sets the icon of this action.
*
- * @param event carries information on the invocation place
+ * @param event carries contextual information
*/
override fun update(event: AnActionEvent) {
- event.presentation.icon = RandomnessIcons.Data.Base
- hasEditor = event.getData(CommonDataKeys.EDITOR) != null
+ event.presentation.icon = Icons.RANDOMNESS
+
+ // Running this in [actionPerformed] always sets it to `true`
+ isEditable = event.getData(CommonDataKeys.EDITOR)?.document?.isWritable == true
}
/**
* Displays a popup with all actions provided by Randomness.
*
- * @param event carries information on the invocation place
+ * @param event carries contextual information
*/
override fun actionPerformed(event: AnActionEvent) {
- val popupGroup = if (hasEditor) PopupGroup() else SettingsOnlyPopupGroup()
+ val popupGroup = if (isEditable) PopupGroup() else SettingsOnlyPopupGroup()
val popup = JBPopupFactory.getInstance()
.createActionGroupPopup(
- TITLE, popupGroup, event.dataContext,
- JBPopupFactory.ActionSelectionAid.NUMBERING,
+ Bundle("popup.title"), popupGroup, event.dataContext,
+ JBPopupFactory.ActionSelectionAid.ALPHA_NUMBERING,
true
)
as ListPopupImpl
- popup.speedSearch.setEnabled(false)
- if (hasEditor) {
- popup.setCaption(TITLE)
- popup.setAdText(AD_TEXT)
+ if (isEditable) {
+ popup.setCaption(Bundle("popup.title"))
+ popup.setAdText(Bundle("popup.ad"))
popup.registerModifierActions { this.captionModifier(it) }
} else {
- popup.setCaption(CTRL_TITLE)
- popup.setAdText("Editor is not selected. Displaying settings only.")
- popup.registerModifierActions { CTRL_TITLE }
+ popup.setCaption(Bundle("popup.title.ctrl"))
+ popup.setAdText(Bundle("popup.ad.settings_only"))
+ popup.registerModifierActions { Bundle("popup.title.ctrl") }
}
popup.showInBestPositionFor(event.dataContext)
@@ -74,12 +74,9 @@ class PopupAction : AnAction() {
/**
- * Returns the desired title for the popup given an event.
- *
- * @param event the event on which the title should be based
- * @return the desired title for the popup given an event
+ * Returns the desired title for the popup given [event].
*/
- @Suppress("ComplexMethod") // Cannot be simplified
+ @Suppress("detekt:ComplexMethod") // Cannot be simplified
private fun captionModifier(event: ActionEvent?): String {
val modifiers = event?.modifiers ?: 0
val altPressed = modifiers and ActionEvent.ALT_MASK != 0
@@ -87,58 +84,46 @@ class PopupAction : AnAction() {
val shiftPressed = modifiers and ActionEvent.SHIFT_MASK != 0
return when {
- altPressed && ctrlPressed && shiftPressed -> ALT_CTRL_SHIFT_TITLE
- altPressed && ctrlPressed -> ALT_CTRL_TITLE
- altPressed && shiftPressed -> ALT_SHIFT_TITLE
- ctrlPressed && shiftPressed -> CTRL_SHIFT_TITLE
- altPressed -> ALT_TITLE
- ctrlPressed -> CTRL_TITLE
- shiftPressed -> SHIFT_TITLE
- else -> TITLE
+ altPressed && ctrlPressed && shiftPressed -> Bundle("popup.title.alt_ctrl_shift")
+ altPressed && ctrlPressed -> Bundle("popup.title.alt_ctrl")
+ altPressed && shiftPressed -> Bundle("popup.title.alt_shift")
+ ctrlPressed && shiftPressed -> Bundle("popup.title.ctrl_shift")
+ altPressed -> Bundle("popup.title.alt")
+ ctrlPressed -> Bundle("popup.title.ctrl")
+ shiftPressed -> Bundle("popup.title.shift")
+ else -> Bundle("popup.title")
}
}
/**
- * The `ActionGroup` containing all Randomness actions.
+ * The [ActionGroup] containing all Randomness actions.
*/
private class PopupGroup : ActionGroup() {
/**
* Returns all group actions.
*
- * @param event carries information on the invocation place
+ * @param event carries contextual information
*/
override fun getChildren(event: AnActionEvent?) =
- arrayOf(
- IntegerGroupAction(),
- DecimalGroupAction(),
- StringGroupAction(),
- WordGroupAction(),
- UuidGroupAction(),
- Separator(),
- ArraySettingsAction()
- )
+ Settings.DEFAULT.templates.map { TemplateGroupAction(it) }.toTypedArray() +
+ Separator() +
+ TemplateSettingsAction()
}
/**
- * The `ActionGroup` containing only settings-related actions.
+ * The [ActionGroup] containing only settings-related actions.
*/
private class SettingsOnlyPopupGroup : ActionGroup() {
/**
* Returns all settings actions.
*
- * @param event carries information on the invocation place
+ * @param event carries contextual information
*/
override fun getChildren(event: AnActionEvent?) =
- arrayOf(
- IntegerSettingsAction(),
- DecimalSettingsAction(),
- StringSettingsAction(),
- WordSettingsAction(),
- UuidSettingsAction(),
- Separator(),
- ArraySettingsAction()
- )
+ Settings.DEFAULT.templates.map { TemplateSettingsAction(it) }.toTypedArray() +
+ Separator() +
+ TemplateSettingsAction()
}
@@ -147,48 +132,111 @@ class PopupAction : AnAction() {
*/
companion object {
/**
- * The default popup title.
- */
- const val TITLE = "Insert Data"
-
- /**
- * The popup title while the alt key is held down.
- */
- const val ALT_TITLE = "Insert Repeated Data"
-
- /**
- * The popup title when the alt and control keys are held down.
- */
- const val ALT_CTRL_TITLE = "Quick Switch Scheme"
-
- /**
- * The popup title when the alt, control, and shift keys are held down.
- */
- const val ALT_CTRL_SHIFT_TITLE = "Quick Switch Array Scheme"
-
- /**
- * The popup title when the alt and shift keys are held down.
+ * Returns the mnemonic for the entry at the [index]th row.
+ *
+ * @see JBPopupFactory.ActionSelectionAid.ALPHA_NUMBERING
*/
- const val ALT_SHIFT_TITLE = "Insert Repeated Array"
+ fun indexToMnemonic(index: Int) =
+ (('1'..'9') + '0' + ('A'..'Z')).getOrNull(index)
+ }
+}
- /**
- * The popup title when the control key is held down.
- */
- const val CTRL_TITLE = "Change Settings"
- /**
- * The popup title when the control and shift keys are held down.
- */
- const val CTRL_SHIFT_TITLE = "Change Array Settings"
+/**
+ * An [AbstractAction] that uses [myActionPerformed] as the implementation of its [actionPerformed] method.
+ *
+ * @param myActionPerformed the code to execute in [actionPerformed]
+ */
+private class SimpleAbstractAction(private val myActionPerformed: (ActionEvent?) -> Unit) : AbstractAction() {
+ /**
+ * @see myActionPerformed
+ */
+ override fun actionPerformed(event: ActionEvent?) = myActionPerformed(event)
+}
- /**
- * The popup title when the shift key is held down.
- */
- const val SHIFT_TITLE = "Insert Array"
+/**
+ * Returns the cartesian product of [this] and [other].
+ *
+ * By requiring both lists to actually be lists of lists, this method can be chained.
+ *
+ * Consider the following examples, using a simplified notation for lists for readability:
+ * ```
+ * $ [[1, 2]] * [[3, 4]]
+ * [[1, 3], [1, 4], [2, 3], [2, 4]]
+ *
+ * $ [[1, 2]] * [[3, 4]] * [[5, 6]]
+ * [[1, 3, 5], [1, 3, 6], [1, 4, 5], [1, 4, 6], [2, 3, 5], [2, 3, 6], [2, 4, 5], [2, 4, 6]]
+ * ```
+ */
+private operator fun List>.times(other: List>) =
+ this.flatMap { t1 -> other.map { t2 -> t1 + t2 } }
- /**
- * The text shown at the bottom of the popup.
- */
- const val AD_TEXT = "Shift = Array. Ctrl = Settings. Alt = Repeat."
+/**
+ * Registers actions such that actions can be selected while holding (combinations of) modifier keys.
+ *
+ * All combinations of modifier keys are registered for events. Additionally, the [captionModifier] function is invoked
+ * every time the user presses or releases any modifier key, even while holding other modifier keys.
+ *
+ * Events are also registered for pressing the Enter key (with or without modifier keys) to invoke the action that is
+ * currently highlighted, and for pressing one of the numbers 1-9 (with or without modifier keys) to invoke the action
+ * at that index in the popup.
+ *
+ * @param captionModifier returns the caption to set based on the event
+ */
+fun ListPopupImpl.registerModifierActions(captionModifier: (ActionEvent?) -> String) {
+ val modifiers = listOf(listOf("alt"), listOf("control"), listOf("shift"))
+ val optionalModifiers = listOf(listOf("")) + modifiers
+
+ (modifiers * optionalModifiers * optionalModifiers).forEach { (a, b, c) ->
+ registerAction(
+ "${a}${b}${c}Released",
+ KeyStroke.getKeyStroke("$b $c released ${a.uppercase(Locale.getDefault())}"),
+ SimpleAbstractAction { setCaption(captionModifier(it)) }
+ )
+ registerAction(
+ "${a}${b}${c}Pressed",
+ KeyStroke.getKeyStroke("$a $b $c pressed ${a.uppercase(Locale.getDefault())}"),
+ SimpleAbstractAction { setCaption(captionModifier(it)) }
+ )
+ registerAction(
+ "${a}${b}${c}invokeAction",
+ KeyStroke.getKeyStroke("$a $b $c pressed ENTER"),
+ SimpleAbstractAction { event ->
+ event ?: return@SimpleAbstractAction
+
+ handleSelect(
+ true,
+ KeyEvent(
+ component,
+ event.id, event.getWhen(), event.modifiers,
+ KeyEvent.VK_ENTER, KeyEvent.CHAR_UNDEFINED, KeyEvent.KEY_LOCATION_UNKNOWN
+ )
+ )
+ }
+ )
+
+ @Suppress("detekt:ForEachOnRange") // Not relevant for such small numbers
+ (0 until list.model.size)
+ .associateWith { PopupAction.indexToMnemonic(it) }
+ .filterValues { it != null }
+ .forEach { (index, mnemonic) ->
+ registerAction(
+ "${a}${b}${c}invokeAction$mnemonic",
+ KeyStroke.getKeyStroke("$a $b $c pressed $mnemonic"),
+ SimpleAbstractAction { event ->
+ event ?: return@SimpleAbstractAction
+
+ list.addSelectionInterval(index, index)
+ handleSelect(
+ true,
+ KeyEvent(
+ component,
+ event.id, event.getWhen(), event.modifiers,
+ KeyEvent.VK_ENTER, KeyEvent.CHAR_UNDEFINED, KeyEvent.KEY_LOCATION_UNKNOWN
+ )
+ )
+ }
+ )
+ }
}
}
diff --git a/src/main/kotlin/com/fwdekker/randomness/Scheme.kt b/src/main/kotlin/com/fwdekker/randomness/Scheme.kt
new file mode 100644
index 000000000..c0ccef3b0
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/Scheme.kt
@@ -0,0 +1,132 @@
+package com.fwdekker.randomness
+
+import com.fwdekker.randomness.affix.AffixDecorator
+import com.fwdekker.randomness.array.ArrayDecorator
+import com.fwdekker.randomness.fixedlength.FixedLengthDecorator
+import com.intellij.util.xmlb.annotations.Transient
+import com.intellij.util.xmlb.annotations.XCollection
+import kotlin.random.Random
+
+
+/**
+ * A scheme is a [State] that is also a configurable random number generator.
+ *
+ * Schemes may use [DecoratorScheme]s to extend their functionality.
+ */
+abstract class Scheme : State() {
+ /**
+ * The name of the scheme as shown to the user.
+ */
+ abstract val name: String
+
+ /**
+ * The icon signifying the type of data represented by this scheme, ignoring its [decorators], or `null` if this
+ * scheme does not represent any kind of data, as is the case for [DecoratorScheme]s.
+ */
+ @get:Transient
+ open val typeIcon: TypeIcon? get() = null
+
+ /**
+ * The icon signifying this scheme in its entirety, or `null` if it does not have an icon.
+ */
+ @get:Transient
+ open val icon: OverlayedIcon? get() = typeIcon?.let { OverlayedIcon(it, decorators.mapNotNull(Scheme::icon)) }
+
+ /**
+ * Additional logic that determines how strings are generated.
+ *
+ * Decorators are automatically applied when [generateStrings] is invoked. To generate strings without using
+ * decorators, use [generateUndecoratedStrings]. Decorators are applied in ascending order. That is, the output of
+ * the scheme is fed into the decorator at index `0`, and that output is fed into the decorator at index `1`, and so
+ * on.
+ */
+ @get:Transient
+ @get:XCollection(elementTypes = [AffixDecorator::class, ArrayDecorator::class, FixedLengthDecorator::class])
+ abstract val decorators: List
+
+ /**
+ * The random number generator used to generate random values.
+ */
+ @get:Transient
+ var random: Random = Random.Default
+
+
+ override fun applyContext(context: Box) {
+ super.applyContext(context)
+ decorators.forEach { it.applyContext(context) }
+ }
+
+
+ /**
+ * Generates [count] random decorated data according to the settings in this scheme and its decorators.
+ *
+ * By default, this method applies the decorators on the output of [generateUndecoratedStrings]. Override this
+ * method if the scheme should interact with its decorators in a different way.
+ *
+ * @throws DataGenerationException if data could not be generated
+ */
+ @Throws(DataGenerationException::class)
+ open fun generateStrings(count: Int = 1): List {
+ doValidate()?.also { throw DataGenerationException(it) }
+
+ return decorators
+ .fold(this::generateUndecoratedStrings) { previousGenerator, currentScheme ->
+ currentScheme.random = random
+ currentScheme.generator = previousGenerator
+ currentScheme::generateStrings
+ }
+ .invoke(count)
+ }
+
+ /**
+ * Generates [count] random data according to the settings in this scheme, ignoring settings from decorators.
+ *
+ * @throws DataGenerationException if data could not be generated
+ * @see generateStrings
+ */
+ @Throws(DataGenerationException::class)
+ protected abstract fun generateUndecoratedStrings(count: Int = 1): List
+
+
+ abstract override fun deepCopy(retainUuid: Boolean): Scheme
+}
+
+/**
+ * Transparently extends or alters the functionality of a [Scheme] with a decorating function.
+ *
+ * Requires that [generator] is set before invoking [generateStrings].
+ */
+@Suppress("detekt:LateinitUsage") // Alternatives not feasible
+abstract class DecoratorScheme : Scheme() {
+ /**
+ * Whether this decorator is enabled, or whether any invocation of [generateStrings] should be passed directly to
+ * the [generator].
+ */
+ protected open val isEnabled: Boolean = true
+
+ /**
+ * The generating function whose output should be decorated.
+ */
+ @get:Transient
+ lateinit var generator: (Int) -> List
+
+
+ override fun generateStrings(count: Int): List {
+ doValidate()?.also { throw DataGenerationException(it) }
+
+ return if (isEnabled) super.generateStrings(count)
+ else generator(count)
+ }
+
+
+ abstract override fun deepCopy(retainUuid: Boolean): DecoratorScheme
+}
+
+
+/**
+ * Thrown if a random datum could not be generated.
+ *
+ * @param message the detail message
+ * @param cause the cause
+ */
+class DataGenerationException(message: String? = null, cause: Throwable? = null) : Exception(message, cause)
diff --git a/src/main/kotlin/com/fwdekker/randomness/SchemeEditor.kt b/src/main/kotlin/com/fwdekker/randomness/SchemeEditor.kt
new file mode 100644
index 000000000..7f0f421e1
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/SchemeEditor.kt
@@ -0,0 +1,80 @@
+package com.fwdekker.randomness
+
+import com.fwdekker.randomness.ui.addChangeListenerTo
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.ui.DialogPanel
+import com.intellij.openapi.util.Disposer
+import javax.swing.JComponent
+
+
+/**
+ * An editor for a [Scheme].
+ *
+ * @param S the type of scheme edited in this editor
+ * @property scheme The scheme edited in this editor.
+ */
+abstract class SchemeEditor(val scheme: S) : Disposable {
+ /**
+ * The root component of the editor.
+ */
+ abstract val rootComponent: DialogPanel
+
+ /**
+ * The components contained within this editor that determine the editor's current state.
+ */
+ val components: Collection
+ get() = rootComponent.components.filterNot { it.name == null } + extraComponents
+
+ /**
+ * The additional [components] that determine the editor's current state but do not have a name.
+ *
+ * Do not register [SchemeEditor]s here; use the [decoratorEditors] field for that.
+ */
+ protected val extraComponents = mutableListOf()
+
+ /**
+ * The [SchemeEditor]s of [scheme]'s [DecoratorScheme]s.
+ *
+ * The editors registered in this list are automatically reset and applied in [reset] and [apply], respectively.
+ */
+ protected val decoratorEditors = mutableListOf>()
+
+ /**
+ * The component that this editor prefers to be focused when the editor is focused.
+ */
+ open val preferredFocusedComponent: JComponent?
+ get() = components.filterIsInstance().firstOrNull { it.isVisible }
+
+
+ /**
+ * Resets the editor's state to that of [scheme].
+ *
+ * If [apply] has been called, then [reset] resets to the state at the last invocation of [apply].
+ */
+ fun reset() {
+ rootComponent.reset()
+ decoratorEditors.forEach { it.reset() }
+ }
+
+ /**
+ * Saves the editor's state into [scheme].
+ */
+ fun apply() {
+ rootComponent.apply()
+ decoratorEditors.forEach { it.apply() }
+ }
+
+
+ /**
+ * Ensures [listener] is invoked on every change in this editor.
+ */
+ @Suppress("detekt:SpreadOperator") // Acceptable because this method is called rarely
+ fun addChangeListener(listener: () -> Unit) =
+ addChangeListenerTo(*(components + decoratorEditors).toTypedArray(), listener = listener)
+
+
+ /**
+ * Disposes this editor's resources.
+ */
+ override fun dispose() = decoratorEditors.forEach { Disposer.dispose(it) }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/Settings.kt b/src/main/kotlin/com/fwdekker/randomness/Settings.kt
index 7d905c675..90c244b0d 100644
--- a/src/main/kotlin/com/fwdekker/randomness/Settings.kt
+++ b/src/main/kotlin/com/fwdekker/randomness/Settings.kt
@@ -1,102 +1,122 @@
package com.fwdekker.randomness
+import com.fwdekker.randomness.PersistentSettings.Companion.CURRENT_VERSION
+import com.fwdekker.randomness.template.Template
+import com.fwdekker.randomness.template.TemplateList
import com.intellij.openapi.components.PersistentStateComponent
+import com.intellij.openapi.components.SettingsCategory
+import com.intellij.openapi.components.Storage
+import com.intellij.openapi.components.service
+import com.intellij.util.xmlb.XmlSerializer
+import com.intellij.util.xmlb.annotations.OptionTag
import com.intellij.util.xmlb.annotations.Transient
+import org.jdom.Element
+import java.lang.module.ModuleDescriptor.Version
+import com.intellij.openapi.components.State as JBState
/**
- * Settings are composed of [Scheme]s and persist these over IDE restarts.
+ * Contains references to various [State] objects.
*
- * @param SELF the type of settings that should be persisted; should be a self reference
- * @param SCHEME the type of scheme that the settings consist of
+ * @property version The version of Randomness with which these settings were created.
+ * @property templateList The template list.
*/
-interface Settings> : PersistentStateComponent {
+data class Settings(
+ var version: String = CURRENT_VERSION,
+ @OptionTag
+ val templateList: TemplateList = TemplateList(),
+) : State() {
/**
- * The various schemes that are contained within the settings.
+ * @see TemplateList.templates
*/
- var schemes: MutableList
+ @get:Transient
+ val templates: MutableList get() = templateList.templates
- /**
- * The name of the scheme that is currently active.
- */
- var currentSchemeName: String
+ init {
+ applyContext(this)
+ }
- /**
- * The instance of the scheme that is currently active.
- *
- * This field is backed by [currentSchemeName]. If [currentSchemeName] refers to a scheme that is not contained in
- * [schemes], `get`ting this field will throw an exception.
- */
- @Suppress("UseCheckOrError") // This is shorter and faster
- var currentScheme: SCHEME
- @Transient
- get() = schemes.firstOrNull { it.name == currentSchemeName }
- ?: throw IllegalStateException("Current scheme does not exist.")
- set(value) {
- currentSchemeName = value.name
- }
+ override fun applyContext(context: Box) {
+ super.applyContext(context)
+ templateList.applyContext(context)
+ }
- /**
- * Returns a deep copy of the settings and the contained schemes.
- *
- * @return a deep copy of the settings and the contained schemes
- */
- fun deepCopy(): SELF
- /**
- * Returns `this`.
- *
- * @return `this`
- */
- override fun getState(): SELF
+ override fun doValidate() = templateList.doValidate()
+
+ override fun deepCopy(retainUuid: Boolean) =
+ copy(templateList = templateList.deepCopy(retainUuid = retainUuid))
+ .deepCopyTransient(retainUuid)
+ .also { it.applyContext(it) }
+
/**
- * Copies the fields of `state` to `this`.
- *
- * @param state the state to load into `this`
+ * Holds constants.
*/
- override fun loadState(state: SELF)
+ companion object {
+ /**
+ * The persistent [Settings] instance.
+ */
+ val DEFAULT: Settings
+ get() = service().settings
+ }
}
-
/**
- * A scheme is a collection of configurable values.
+ * The persistent [Settings] instance, stored as an [Element] to allow custom conversion for backwards compatibility.
*
- * In a typical use case a user can quickly switch between instances of schemes of the same type to change the "preset"
- * or "configuration" that is currently being used.
- *
- * @param SELF the type of scheme that is stored; should be a self reference
- * @see Settings
+ * @see Settings.DEFAULT Preferred method of accessing the persistent settings instance.
*/
-interface Scheme : com.intellij.openapi.options.Scheme {
+@JBState(
+ name = "Randomness",
+ storages = [
+ Storage("randomness-beta.xml", deprecated = true, exportable = true),
+ Storage("randomness3.xml", exportable = true),
+ ],
+ category = SettingsCategory.PLUGINS,
+)
+class PersistentSettings : PersistentStateComponent {
/**
- * The name of the scheme, used to identify it.
+ * The [Settings] that should be persisted.
+ *
+ * @see Settings.DEFAULT Preferred method of accessing the persistent settings instance.
*/
- var myName: String
+ var settings = Settings()
/**
- * Same as [myName].
+ * Returns the [settings] as an [Element].
*/
- override fun getName() = myName
-
+ override fun getState(): Element = XmlSerializer.serialize(settings)
/**
- * Shallowly copies the state of [other] into `this`.
- *
- * @param other the state to copy into `this`
+ * Deserializes [element] into a [Settings] instance, which is then stored in [settings].
*/
- fun copyFrom(other: SELF)
+ override fun loadState(element: Element) {
+ settings = XmlSerializer.deserialize(upgrade(element), Settings::class.java)
+ }
+
/**
- * Returns a copy of this scheme that has the given name.
- *
- * @param name the name to give to the copy
- * @return a copy of this scheme that has the given name
+ * Silently upgrades the format of the settings contained in [element] to the format of the latest version.
*/
- fun copyAs(name: String): SELF
+ private fun upgrade(element: Element): Element {
+ val elementVersion = element.getAttributeValueByName("version")?.let { Version.parse(it) }
+
+ when {
+ elementVersion == null -> Unit
+
+ // Placeholder to show how an upgrade might work. Remove this once an actual upgrade has been added.
+ elementVersion < Version.parse("0.0.0-placeholder") ->
+ element.getContentByPath("templateList", null, "templates", null)?.getElements()
+ ?.forEachIndexed { idx, template -> template.setAttributeValueByName("name", "Template$idx") }
+ }
+
+ element.setAttributeValueByName("version", CURRENT_VERSION)
+ return element
+ }
/**
@@ -104,8 +124,8 @@ interface Scheme : com.intellij.openapi.options.Scheme {
*/
companion object {
/**
- * The name of the default scheme.
+ * The currently-running version of Randomness.
*/
- const val DEFAULT_NAME = "Default"
+ const val CURRENT_VERSION: String = "3.0.0" // Synchronize this with the version in `gradle.properties`
}
}
diff --git a/src/main/kotlin/com/fwdekker/randomness/SettingsComponent.kt b/src/main/kotlin/com/fwdekker/randomness/SettingsComponent.kt
deleted file mode 100644
index ed41c0ed9..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/SettingsComponent.kt
+++ /dev/null
@@ -1,411 +0,0 @@
-package com.fwdekker.randomness
-
-import com.fwdekker.randomness.SchemesPanel.Listener
-import com.intellij.application.options.schemes.AbstractSchemeActions
-import com.intellij.application.options.schemes.SchemesModel
-import com.intellij.application.options.schemes.SimpleSchemesPanel
-import javax.swing.JPanel
-
-
-/**
- * A component that allows the user to edit settings and its corresponding schemes.
- *
- * Subclasses **MUST** call `loadSettings` in their constructor.
- *
- * There are multiple settings [S] instances at any time. The `settings` given in the constructor is read when the
- * component is created and is written to when the user saves its changes. The currently-selected scheme is loaded into
- * the component's inputs. When the user selects a different scheme of which to change its values, the values in the
- * input fields are stored in a copy of `settings`. This way, the local changes are not lost when switching between
- * schemes, and the user can still revert all unsaved changes if desired.
- *
- * @param S the type of settings to manage
- * @param T the type of scheme to manage
- * @property settings the settings to manage
- */
-abstract class SettingsComponent, T : Scheme>(private val settings: S) : SettingsManager {
- /**
- * The panel containing the settings.
- */
- abstract val rootPane: JPanel?
-
- /**
- * The local copy that represents the currently-unsaved settings that are being edited by the user.
- */
- abstract val unsavedSettings: S
-
- /**
- * The panel containing the dropdown box of schemes and action buttons to rename, delete, etc. schemes.
- */
- abstract val schemesPanel: SchemesPanel
-
-
- final override fun loadSettings() = loadSettings(settings)
-
- final override fun loadSettings(settings: S) {
- unsavedSettings.loadState(settings.deepCopy())
- loadScheme(unsavedSettings.currentScheme)
- schemesPanel.updateComboBoxList()
- }
-
- /**
- * Loads the given scheme into the component's state.
- *
- * @param scheme the scheme to load
- */
- abstract fun loadScheme(scheme: T)
-
- final override fun saveSettings() = saveSettings(settings)
-
- final override fun saveSettings(settings: S) {
- saveScheme(unsavedSettings.currentScheme)
- settings.loadState(unsavedSettings.deepCopy())
- }
-
- /**
- * Saves the component's state into the given scheme.
- *
- * @param scheme the scheme to save
- */
- abstract fun saveScheme(scheme: T)
-
-
- /**
- * Returns true if this component contains unsaved changes.
- *
- * @return true if this component contains unsaved changes
- */
- fun isModified() = settings.deepCopy().also { saveSettings(it) } != settings || isModified(settings)
-
- /**
- * Returns true if this component contains unsaved changes.
- *
- * Implement this method only if meaningful changes can be made to the settings object that are not detected using
- * the settings' equals method.
- *
- * @param settings the settings as they were loaded into the component
- * @return true if this component contains unsaved changes
- */
- open fun isModified(settings: S): Boolean = false
-
- /**
- * Discards unsaved changes.
- */
- fun reset() = loadSettings()
-
-
- /**
- * Validates all input fields.
- *
- * @return `null` if the input is valid, or a `ValidationInfo` object explaining why the input is invalid
- */
- abstract fun doValidate(): ValidationInfo?
-}
-
-
-/**
- * A panel to manage schemes with, providing a dropdown box to select schemes from and buttons to remove, rename, etc.
- * schemes.
- *
- * @param T the type of scheme to manage
- * @property settings the settings model that backs this panel; changes made through this panel are reflected in the
- * given settings instance
- */
-abstract class SchemesPanel>(val settings: Settings<*, T>) : SimpleSchemesPanel(), SchemesModel {
- /**
- * Listeners for changes in the scheme selection.
- */
- private val listeners = mutableListOf>()
-
- /**
- * Actions that can be performed with this panel.
- */
- val actions = createSchemeActions()
-
-
- /**
- * The type of scheme being managed.
- */
- abstract val type: Class
-
- /**
- * Returns a list of the default instances.
- *
- * This method **must** return new instances every time it is called.
- *
- * @return a list of the default instances
- */
- abstract fun createDefaultInstances(): List
-
-
- /**
- * Registers a listener that will be informed whenever the scheme selection changed.
- *
- * @param listener the listener that listens to scheme-change events
- */
- fun addListener(listener: Listener) = listeners.add(listener)
-
- /**
- * Forcefully updates the combo box so that its entries and the current selection reflect the `settings` instance.
- *
- * This panel instance will update the combo box by itself. Call this method if the `settings` instance has been
- * changed externally and these changes need to be reflected.
- */
- fun updateComboBoxList() {
- settings.currentScheme.also { currentScheme ->
- resetSchemes(settings.schemes)
- selectScheme(currentScheme)
- }
- }
-
-
- /**
- * Returns the scheme with the given name.
- *
- * @param name the name of the scheme to return
- */
- fun getScheme(name: String) = settings.schemes.single { it.name == name }
-
- /**
- * Removes the given scheme from the settings.
- *
- * If the currently-selected scheme is removed, the current selection will change to the default scheme.
- * If there are somehow no more schemes, the default scheme is inserted and selected.
- *
- * @param scheme the scheme to remove
- * @throws IllegalArgumentException if the default scheme is being removed
- */
- override fun removeScheme(scheme: T) {
- require(scheme.name !in createDefaultInstances().map { it.name }) { "Cannot remove default scheme." }
-
- listeners.forEach { it.onCurrentSchemeWillChange(scheme) }
-
- settings.currentSchemeName = createDefaultInstances().map { it.name }[0]
- settings.schemes.remove(scheme)
-
- if (settings.schemes.isEmpty()) {
- settings.schemes.addAll(createDefaultInstances())
- settings.currentSchemeName = createDefaultInstances().map { it.name }[0]
- }
-
- listeners.forEach { it.onCurrentSchemeHasChanged(settings.currentScheme) }
-
- updateComboBoxList()
- }
-
- /**
- * Returns true if a scheme with the given name is present in this panel.
- *
- * @param name the name to check for
- * @param projectScheme ignored
- * @return true if a scheme with the given name is present in this panel
- */
- override fun containsScheme(name: String, projectScheme: Boolean) = settings.schemes.any { it.name == name }
-
- /**
- * Returns an object with a number of actions that can be performed on this panel.
- *
- * @return an object with a number of actions that can be performed on this panel
- */
- final override fun createSchemeActions() = SchemeActions()
-
-
- /**
- * Returns this panel, because this panel also functions as the model.
- *
- * @return this panel, because this panel also functions as the model
- */
- override fun getModel() = this
-
- /**
- * Returns true if the given scheme is the default scheme.
- *
- * Since the default scheme is defined by its name, this method returns true if its name is not the default name.
- *
- * @param scheme the scheme to compare against the default scheme
- */
- override fun differsFromDefault(scheme: T) = scheme !in createDefaultInstances()
-
- /**
- * Returns false because project-specific schemes are not supported.
- *
- * @return false because project-specific schemes are not supported
- */
- override fun supportsProjectSchemes() = false
-
- /**
- * Returns true so that non-default schemes are highlighted.
- *
- * @return true so that non-default schemes are highlighted
- */
- override fun highlightNonDefaultSchemes() = true
-
- /**
- * Returns true so that non-removable schemes are highlighted.
- *
- * Since only the default scheme is non-removable, only the default scheme will be bold.
- *
- * @return true so that non-removable schemes are highlighted
- */
- override fun useBoldForNonRemovableSchemes() = true
-
- /**
- * Returns false because project-specific schemes are not supported.
- *
- * @param scheme ignored
- * @return false because project-specific schemes are not supported
- */
- override fun isProjectScheme(scheme: T) = false
-
- /**
- * Returns true if the given scheme can be deleted.
- *
- * Because only the default scheme cannot be deleted, this method is equivalent to [differsFromDefault].
- *
- * @param scheme the scheme to check for deletability
- * @return true if the given scheme can be deleted
- */
- override fun canDeleteScheme(scheme: T) = scheme.name !in createDefaultInstances().map { it.name }
-
- /**
- * Returns true because all schemes can be duplicated.
- *
- * @param scheme ignored
- * @return true because all schemes can be duplicated
- */
- override fun canDuplicateScheme(scheme: T) = true
-
- /**
- * Returns true if the given scheme can be renamed.
- *
- * Because only the default scheme cannot be renamed, this method is equivalent to [differsFromDefault].
- *
- * @param scheme the scheme to check for renamability
- * @return true if the given scheme can be renamed
- */
- override fun canRenameScheme(scheme: T) = scheme.name !in createDefaultInstances().map { it.name }
-
- /**
- * Returns true if the given scheme can be reset.
- *
- * Because only the default scheme cannot be reset, this method is equivalent to [differsFromDefault].
- *
- * @param scheme the scheme to check for resetability
- * @return true if the given scheme can be reset
- */
- override fun canResetScheme(scheme: T) = scheme.name in createDefaultInstances().map { it.name }
-
-
- /**
- * The actions that can be performed with this panel.
- */
- inner class SchemeActions : AbstractSchemeActions(this) {
- /**
- * Called when the user changes the scheme using the combo box.
- *
- * @param scheme the scheme that has become the selected scheme
- */
- public override fun onSchemeChanged(scheme: T?) {
- if (scheme == null)
- return
-
- listeners.forEach { it.onCurrentSchemeWillChange(settings.currentScheme) }
- settings.currentScheme = scheme
- listeners.forEach { it.onCurrentSchemeHasChanged(scheme) }
- }
-
- /**
- * Called when the user renames a scheme.
- *
- * @param scheme the scheme that is being renamed
- * @param newName the new name of the scheme
- */
- public override fun renameScheme(scheme: T, newName: String) {
- require(canRenameScheme(scheme)) { "Cannot rename given scheme." }
-
- listeners.forEach { it.onCurrentSchemeWillChange(scheme) }
-
- scheme.myName = newName
- settings.currentScheme = scheme
- updateComboBoxList()
-
- listeners.forEach { it.onCurrentSchemeHasChanged(scheme) }
- }
-
- /**
- * Called when a user opts to reset a scheme to its defaults.
- *
- * @param scheme the scheme to reset
- */
- public override fun resetScheme(scheme: T) {
- require(canResetScheme(scheme)) { "Cannot reset given scheme." }
-
- scheme.copyFrom(createDefaultInstances().single { it.myName == scheme.myName })
- listeners.forEach { it.onCurrentSchemeHasChanged(scheme) }
- }
-
- /**
- * Called when a user wants to duplicate a scheme.
- *
- * @param scheme the scheme to duplicate; it is assumed that this is the currently-selected scheme
- * @param newName the name to be applied to the duplicate
- */
- public override fun duplicateScheme(scheme: T, newName: String) {
- require(canDuplicateScheme(scheme)) { "Cannot duplicate given scheme." }
-
- listeners.forEach { it.onCurrentSchemeWillChange(scheme) }
-
- val copy = scheme.copyAs(newName)
- settings.schemes.add(copy)
- settings.currentScheme = copy
- updateComboBoxList()
-
- listeners.forEach { it.onCurrentSchemeHasChanged(copy) }
- }
-
- /**
- * Returns the type of scheme actions apply to.
- *
- * @return the type of scheme actions apply to
- */
- override fun getSchemeType() = type
- }
-
-
- /**
- * A listener that listens to events that occur to this panel.
- *
- * @param T the type of scheme about which events are generated
- */
- interface Listener> {
- /**
- * Invoked when the currently-selected scheme is about to change.
- *
- * @param scheme the scheme that is about to be replaced in favor of another scheme
- */
- fun onCurrentSchemeWillChange(scheme: T)
-
- /**
- * Invoked when the currently-selected scheme has just been changed.
- *
- * @param scheme the scheme that has become the currently-selected scheme
- */
- fun onCurrentSchemeHasChanged(scheme: T)
- }
-}
-
-
-/**
- * A [Listener] that takes events occurring in a [SchemesPanel] and handles them in a [SettingsComponent].
- *
- * Consider this listener the glue between the schemes panel (the model) and the settings component (the view).
- *
- * @param S the type of settings to manage
- * @param T the type of scheme to manage
- * @property component the settings component in which the changes should be reflected
- */
-class SettingsComponentListener, T : Scheme>(private val component: SettingsComponent) :
- Listener {
- override fun onCurrentSchemeWillChange(scheme: T) = component.saveScheme(scheme)
-
- override fun onCurrentSchemeHasChanged(scheme: T) = component.loadScheme(scheme)
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/SettingsConfigurable.kt b/src/main/kotlin/com/fwdekker/randomness/SettingsConfigurable.kt
deleted file mode 100644
index 0591ed3b4..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/SettingsConfigurable.kt
+++ /dev/null
@@ -1,158 +0,0 @@
-package com.fwdekker.randomness
-
-import com.intellij.ide.DataManager
-import com.intellij.openapi.options.Configurable
-import com.intellij.openapi.options.ConfigurationException
-import com.intellij.openapi.options.ex.Settings.KEY
-import com.intellij.ui.components.ActionLink
-import com.intellij.ui.components.JBLabel
-import com.intellij.util.ui.JBEmptyBorder
-import java.util.ResourceBundle
-import javax.swing.BorderFactory
-import javax.swing.Box
-import javax.swing.JComponent
-
-
-/**
- * A configurable to change settings of type [S].
- *
- * Allows the settings to be displayed in IntelliJ's settings window.
- *
- * @param S the type of settings the configurable changes.
- * @param T the type of scheme the configurable contains
- */
-abstract class SettingsConfigurable, T : Scheme> : Configurable {
- /**
- * The user interface for changing the settings.
- */
- protected abstract val component: SettingsComponent
-
-
- /**
- * Returns the name of the configurable as displayed in the settings window.
- *
- * @return the name of the configurable as displayed in the settings window
- */
- abstract override fun getDisplayName(): String
-
- /**
- * Returns true if the settings were modified since they were loaded or they are invalid.
- *
- * @return true if the settings were modified since they were loaded or they are invalid
- */
- override fun isModified() = component.isModified() || component.doValidate() != null
-
- /**
- * Saves the changes in the settings component to the default settings object.
- *
- * @throws ConfigurationException if the changes cannot be saved
- */
- @Throws(ConfigurationException::class)
- override fun apply() {
- val validationInfo = component.doValidate()
- if (validationInfo != null)
- throw ConfigurationException(validationInfo.message, "Failed to save settings")
- .also { it.setQuickFix(validationInfo.quickFix) }
-
- component.saveSettings()
- }
-
- /**
- * Discards unsaved changes in the settings component.
- */
- override fun reset() = component.reset()
-
- /**
- * Returns the root pane of the settings component.
- *
- * @return the root pane of the settings component
- */
- override fun createComponent(): JComponent? = component.rootPane
-}
-
-
-/**
- * Randomness' root configurable; all other configurables are its children.
- */
-class RandomnessConfigurable : Configurable {
- /**
- * Returns the name of the configurable as displayed in the settings window.
- *
- * @return the name of the configurable as displayed in the settings window
- */
- override fun getDisplayName() = "Randomness"
-
- /**
- * Returns false because there is nothing to be modified.
- *
- * @return false because there is nothing to be modified
- */
- override fun isModified() = false
-
- /**
- * Does nothing because nothing can be done.
- */
- override fun apply() = Unit
-
- /**
- * Returns a panel containing links to the other configurables.
- *
- * @return a panel containing links to the other configurables
- */
- override fun createComponent(): JComponent {
- val box = Box.createVerticalBox()
-
- box.add(JBLabel(ResourceBundle.getBundle("randomness").getString("settings.main_text")))
-
- val actionBox = Box.createVerticalBox()
- .also { it.border = BorderFactory.createEmptyBorder(LINK_GROUP_MARGIN_TOP, LINK_GROUP_MARGIN_LEFT, 0, 0) }
-
- val settings = DataManager.getInstance()
- .dataContextFromFocusAsync.blockingGet(FIND_SETTINGS_TIMEOUT)
- ?.let { KEY.getData(it) }
- ?: return box
- Configurable.APPLICATION_CONFIGURABLE.extensionList
- .single { it.id == "randomness.MainConfigurable" }
- .children
- .mapNotNull { child -> settings.find(child.id)?.let { Pair(child.id, it) } }
- .map { (id, child) ->
- ActionLink(child.displayName ?: id) { settings.select(child) }
- .also { it.border = JBEmptyBorder(LINK_MARGIN_TOP, 0, LINK_MARGIN_BOTTOM, 0) }
- }
- .forEach { actionBox.add(it) }
- box.add(actionBox)
-
- return box
- }
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * Maximum number of milliseconds to try to find current data manager instance.
- */
- const val FIND_SETTINGS_TIMEOUT = 1000
-
- /**
- * The margin to insert above the group of links.
- */
- const val LINK_GROUP_MARGIN_TOP = 10
-
- /**
- * The margin to insert left of the group of links.
- */
- const val LINK_GROUP_MARGIN_LEFT = 20
-
- /**
- * The margin to insert above each link.
- */
- const val LINK_MARGIN_TOP = 1
-
- /**
- * The margin to insert below each link.
- */
- const val LINK_MARGIN_BOTTOM = 3
- }
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/SettingsManager.kt b/src/main/kotlin/com/fwdekker/randomness/SettingsManager.kt
deleted file mode 100644
index 35038dc5d..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/SettingsManager.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.fwdekker.randomness
-
-
-/**
- * A `SettingsManager` is an object that can save and load settings.
- *
- * @param S the type of settings that is saved and loaded
- */
-interface SettingsManager> {
- /**
- * Loads the default settings object.
- */
- fun loadSettings()
-
- /**
- * Loads `settings`.
- *
- * @param settings the settings to load
- */
- fun loadSettings(settings: S)
-
- /**
- * Saves settings to the default settings object.
- */
- fun saveSettings()
-
- /**
- * Saves settings to `settings`.
- *
- * @param settings the settings to save to
- */
- fun saveSettings(settings: S)
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/State.kt b/src/main/kotlin/com/fwdekker/randomness/State.kt
new file mode 100644
index 000000000..1fdec454a
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/State.kt
@@ -0,0 +1,82 @@
+package com.fwdekker.randomness
+
+import com.fasterxml.uuid.Generators
+import com.intellij.util.xmlb.annotations.Transient
+import kotlin.random.Random
+import kotlin.random.asJavaRandom
+
+
+/**
+ * A state holds variables that can be configured, validated, copied, and loaded.
+ *
+ * In a [State], fields are typically mutable, because the user can change the field. However, fields that themselves
+ * also contain data (e.g. a [Collection] or a [DecoratorScheme]) should be stored as immutable references (but may
+ * themselves be mutable). For example, instead of having the field `var list: List`, a [State] should have the
+ * field `val list: MutableList`. This reflects the typical case in which the user does not change the reference
+ * to the object, but changes the properties inside the object. And, more importantly, means that nested a
+ * [SchemeEditor] can share a single unchanging reference to a [State] with its nested [SchemeEditor]s.
+ */
+abstract class State {
+ /**
+ * A UUID to uniquely track this scheme even when it is copied.
+ */
+ var uuid: String = Generators.randomBasedGenerator(Random.Default.asJavaRandom()).generate().toString()
+
+ /**
+ * The context of this state in the form of a reference to the [Settings].
+ *
+ * Useful in case the scheme's behavior depends not only on its own internal state, but also on that of other
+ * schemes.
+ *
+ * @see applyContext
+ */
+ @get:Transient
+ var context: Box = Box({ Settings.DEFAULT })
+ protected set
+
+
+ /**
+ * Sets the [State.context] of this [State] to be a reference to [context].
+ */
+ fun applyContext(context: Settings) = applyContext(Box({ context }))
+
+ /**
+ * Sets the [State.context] of this [State] to [context].
+ */
+ open fun applyContext(context: Box) {
+ this.context = context
+ }
+
+
+ /**
+ * Validates the state, and indicates whether and why it is invalid.
+ *
+ * @return `null` if the state is valid, or a string explaining why the state is invalid
+ */
+ open fun doValidate(): String? = null
+
+ /**
+ * Returns a deep copy, retaining the [uuid] if and only if [retainUuid] is `true`.
+ *
+ * Fields annotated with [Transient] are shallow-copied.
+ *
+ * @see deepCopyTransient utility function for subclasses that want to implement [deepCopy]
+ */
+ abstract fun deepCopy(retainUuid: Boolean = false): State
+
+ /**
+ * When invoked by the instance `this` on (another) instance `self` as `self.deepCopyTransient()`, this method
+ * copies [Transient] fields from `this` to `self`, and returns `self`.
+ *
+ * @see deepCopy
+ */
+ protected fun SELF.deepCopyTransient(retainUuid: Boolean): SELF {
+ val self: SELF = this
+ val thiz: State = this@State
+
+ if (retainUuid) self.uuid = thiz.uuid
+ self.applyContext(thiz.context.copy()) // Copies the [Box], not the context itself
+
+ return self
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/Timely.kt b/src/main/kotlin/com/fwdekker/randomness/Timely.kt
new file mode 100644
index 000000000..42d1265f9
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/Timely.kt
@@ -0,0 +1,38 @@
+package com.fwdekker.randomness
+
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+
+/**
+ * Functions relating to time-limited behavior.
+ */
+object Timely {
+ /**
+ * The timeout in milliseconds before the generator should be interrupted when generating a value.
+ */
+ const val GENERATOR_TIMEOUT = 5000L
+
+
+ /**
+ * Runs the [generator] and returns its return value, or throws an exception if it takes longer than
+ * [GENERATOR_TIMEOUT] milliseconds.
+ *
+ * @throws DataGenerationException if the generator timed out or if data could not be generated
+ */
+ @Throws(DataGenerationException::class)
+ fun generateTimely(generator: () -> T): T {
+ val executor = Executors.newSingleThreadExecutor()
+ try {
+ return executor.submit { generator() }.get(GENERATOR_TIMEOUT, TimeUnit.MILLISECONDS)
+ } catch (exception: TimeoutException) {
+ throw DataGenerationException(Bundle("helpers.error.timed_out"), exception)
+ } catch (exception: ExecutionException) {
+ throw DataGenerationException(exception.cause?.message ?: exception.message, exception)
+ } finally {
+ executor.shutdown()
+ }
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/ValidationInfo.kt b/src/main/kotlin/com/fwdekker/randomness/ValidationInfo.kt
deleted file mode 100644
index f758c5830..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/ValidationInfo.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.fwdekker.randomness
-
-import javax.swing.JComponent
-
-
-/**
- * Carries information on why a component has invalid input.
- *
- * Based on `com.intellij.openapi.ui.ValidationInfo` and `com.intellij.openapi.options.ConfigurationException`.
- *
- * @property message the message explaining why the component is not valid
- * @property component the component that is not valid
- * @property quickFix a runnable that can be executed to make the component valid
- */
-data class ValidationInfo(val message: String, val component: JComponent? = null, val quickFix: Runnable? = null)
diff --git a/src/main/kotlin/com/fwdekker/randomness/XmlHelpers.kt b/src/main/kotlin/com/fwdekker/randomness/XmlHelpers.kt
new file mode 100644
index 000000000..9457d846a
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/XmlHelpers.kt
@@ -0,0 +1,49 @@
+package com.fwdekker.randomness
+
+import org.jdom.Element
+
+
+/**
+ * Returns a list of all [Element]s contained in this [Element].
+ */
+fun Element.getElements(): List =
+ content().toList().filterIsInstance()
+
+/**
+ * Returns the [Element] contained in this [Element] that has attribute `name="[name]"`, or `null` if no single such
+ * [Element] exists.
+ */
+fun Element.getContentByName(name: String): Element? =
+ getContent { (it as Element).getAttribute("name")?.value == name }.singleOrNull()
+
+/**
+ * Returns the value of the `value` attribute of the single [Element] contained in this [Element] that has attribute
+ * `name="[name]"`, or `null` if no single such [Element] exists.
+ */
+fun Element.getAttributeValueByName(name: String): String? =
+ getContentByName(name)?.getAttribute("value")?.value
+
+/**
+ * Sets the value of the `value` attribute of the single [Element] contained in this [Element] that has attribute
+ * `name="[name]"` to [value], or does nothing if no single such [Element] exists.
+ */
+fun Element.setAttributeValueByName(name: String, value: String) {
+ getContentByName(name)?.setAttribute("value", value)
+}
+
+/**
+ * Returns the single [Element] that is contained in this [Element], or `null` if this [Element] does not contain
+ * exactly one [Element].
+ */
+fun Element.getSingleContent(): Element? =
+ content.singleOrNull() as? Element
+
+/**
+ * Traverses a path of [Element]s based on their [names] by monadically calling either [getContentByName] (if the name
+ * is not `null`) or [getSingleContent] (if the name is `null`).
+ */
+fun Element.getContentByPath(vararg names: String?): Element? =
+ names.fold(this as? Element) { acc, name ->
+ if (name == null) acc?.getSingleContent()
+ else acc?.getContentByName(name)
+ }
diff --git a/src/main/kotlin/com/fwdekker/randomness/affix/AffixDecorator.kt b/src/main/kotlin/com/fwdekker/randomness/affix/AffixDecorator.kt
new file mode 100644
index 000000000..5c25dd449
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/affix/AffixDecorator.kt
@@ -0,0 +1,66 @@
+package com.fwdekker.randomness.affix
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.DecoratorScheme
+
+
+/**
+ * Decorates a string by adding a prefix and suffix.
+ *
+ * @property enabled Whether to apply this decorator.
+ * @property descriptor The description of the affix. Here, `'\'` is the escape character (which also escapes itself),
+ * and each unescaped `'@'` is replaced with the original string. If the descriptor does not contain an unescaped `'@'`,
+ * then the entire descriptor is placed both in front of and after the original string. For example, affixing `"word"`
+ * with descriptor `"(@)"` gives `"(word)"`, and affixing `"word"` with descriptor `"()"` gives `"()word()"`.
+ */
+data class AffixDecorator(
+ var enabled: Boolean = DEFAULT_ENABLED,
+ var descriptor: String = DEFAULT_DESCRIPTOR,
+) : DecoratorScheme() {
+ override val name = Bundle("affix.title")
+ override val decorators = emptyList()
+ override val isEnabled get() = enabled
+
+
+ override fun generateUndecoratedStrings(count: Int): List {
+ val affixes = descriptor
+ .fold(Pair(listOf(""), false)) { (parts, isEscaped), char ->
+ when (char) {
+ '\\' -> Pair(parts.dropLast(1) + listOf(parts.last() + if (isEscaped) '\\' else ""), !isEscaped)
+ '@' ->
+ if (isEscaped) Pair(parts.dropLast(1) + listOf("${parts.last()}@"), false)
+ else Pair(parts + listOf(""), false)
+ else -> Pair(parts.dropLast(1) + listOf("${parts.last()}$char"), false)
+ }
+ }
+ .first
+ .let {
+ if (it.size == 1) listOf(it.single(), it.single())
+ else it
+ }
+
+ return generator(count).map { affixes.joinToString(it) }
+ }
+
+ override fun doValidate(): String? =
+ if (!descriptor.fold(false) { escaped, char -> if (char == '\\') !escaped else false }) null
+ else Bundle("affix.error.trailing_escape")
+
+ override fun deepCopy(retainUuid: Boolean) = copy().deepCopyTransient(retainUuid)
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The default value of the [enabled] field.
+ */
+ const val DEFAULT_ENABLED = false
+
+ /**
+ * The minimum valid value of the [descriptor] field.
+ */
+ const val DEFAULT_DESCRIPTOR = ""
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/affix/AffixDecoratorEditor.kt b/src/main/kotlin/com/fwdekker/randomness/affix/AffixDecoratorEditor.kt
new file mode 100644
index 000000000..0af922a29
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/affix/AffixDecoratorEditor.kt
@@ -0,0 +1,63 @@
+package com.fwdekker.randomness.affix
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.SchemeEditor
+import com.fwdekker.randomness.ui.bindCurrentText
+import com.fwdekker.randomness.ui.disableMnemonic
+import com.fwdekker.randomness.ui.isEditable
+import com.fwdekker.randomness.ui.loadMnemonic
+import com.fwdekker.randomness.ui.withName
+import com.intellij.openapi.ui.ComboBox
+import com.intellij.ui.dsl.builder.Cell
+import com.intellij.ui.dsl.builder.bindSelected
+import com.intellij.ui.dsl.builder.panel
+import com.intellij.ui.dsl.builder.selected
+import com.intellij.ui.layout.ComponentPredicate
+import com.intellij.ui.layout.and
+import java.util.Locale
+import javax.swing.JCheckBox
+
+
+/**
+ * Component for editing an [AffixDecorator].
+ *
+ * @param scheme the scheme to edit
+ * @param presets the default affixation options available to the user in the editor
+ * @param enabledIf the predicate that determines whether the components in this editor are enabled
+ * @param enableMnemonic whether to enable mnemonics
+ * @param namePrefix the string to prepend to all component names
+ */
+class AffixDecoratorEditor(
+ scheme: AffixDecorator,
+ presets: Collection,
+ enabledIf: ComponentPredicate? = null,
+ enableMnemonic: Boolean = true,
+ namePrefix: String = "",
+) : SchemeEditor(scheme) {
+ override val rootComponent = panel {
+ row {
+ lateinit var enabledCheckBox: Cell
+
+ checkBox(Bundle("affix.ui.option"))
+ .let { if (enableMnemonic) it.loadMnemonic() else it.disableMnemonic() }
+ .withName(camelConcat(namePrefix, "affixEnabled"))
+ .bindSelected(scheme::enabled)
+ .also { enabledCheckBox = it }
+
+ cell(ComboBox(presets.toTypedArray()))
+ .enabledIf(enabledIf?.and(enabledCheckBox.selected) ?: enabledCheckBox.selected)
+ .isEditable(true)
+ .withName(camelConcat(namePrefix, "affixDescriptor"))
+ .bindCurrentText(scheme::descriptor)
+ contextHelp(Bundle("affix.ui.comment"))
+ }.also { if (enabledIf != null) it.enabledIf(enabledIf) }
+ }
+}
+
+
+/**
+ * Prefixes [name] with [prefix], ensuring the resulting string is still in camelCase.
+ */
+private fun camelConcat(prefix: String, name: String) =
+ if (prefix == "") name
+ else "${prefix}${name[0].uppercase(Locale.getDefault())}${name.drop(1)}"
diff --git a/src/main/kotlin/com/fwdekker/randomness/array/ArrayDecorator.kt b/src/main/kotlin/com/fwdekker/randomness/array/ArrayDecorator.kt
new file mode 100644
index 000000000..cbf93b4b3
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/array/ArrayDecorator.kt
@@ -0,0 +1,98 @@
+package com.fwdekker.randomness.array
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.DecoratorScheme
+import com.fwdekker.randomness.OverlayIcon
+import com.fwdekker.randomness.OverlayedIcon
+import com.fwdekker.randomness.affix.AffixDecorator
+
+
+/**
+ * The user-configurable collection of schemes applicable to generating arrays.
+ *
+ * @property enabled `true` if and only if arrays should be generated instead of singular values.
+ * @property minCount The minimum number of elements to generate, inclusive.
+ * @property maxCount The maximum number of elements to generate, inclusive.
+ * @property separatorEnabled Whether to separate elements using [separator].
+ * @property separator The string to place between generated elements.
+ * @property affixDecorator The affixation to apply to the generated values.
+ */
+data class ArrayDecorator(
+ var enabled: Boolean = DEFAULT_ENABLED,
+ var minCount: Int = DEFAULT_MIN_COUNT,
+ var maxCount: Int = DEFAULT_MAX_COUNT,
+ var separatorEnabled: Boolean = DEFAULT_SEPARATOR_ENABLED,
+ var separator: String = DEFAULT_SEPARATOR,
+ val affixDecorator: AffixDecorator = DEFAULT_AFFIX_DECORATOR,
+) : DecoratorScheme() {
+ override val name = Bundle("array.title")
+ override val icon get() = if (enabled) OverlayedIcon(OverlayIcon.ARRAY) else null
+ override val decorators = listOf(affixDecorator)
+ override val isEnabled get() = enabled
+
+
+ override fun generateUndecoratedStrings(count: Int): List {
+ val partsPerString = random.nextInt(minCount, maxCount + 1)
+ return generator(count * partsPerString)
+ .chunked(partsPerString) { it.joinToString(if (separatorEnabled) separator.replace("\\n", "\n") else "") }
+ }
+
+
+ override fun doValidate() =
+ if (minCount < MIN_MIN_COUNT) Bundle("array.error.min_count_too_low", MIN_MIN_COUNT)
+ else if (maxCount < minCount) Bundle("array.error.min_count_above_max")
+ else affixDecorator.doValidate()
+
+ override fun deepCopy(retainUuid: Boolean) = copy().deepCopyTransient(retainUuid)
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The default value of the [enabled] field.
+ */
+ const val DEFAULT_ENABLED = false
+
+ /**
+ * The minimum valid value of the [minCount] field.
+ */
+ const val MIN_MIN_COUNT = 1
+
+ /**
+ * The default value of the [minCount] field.
+ */
+ const val DEFAULT_MIN_COUNT = 3
+
+ /**
+ * The default value of the [maxCount] field.
+ */
+ const val DEFAULT_MAX_COUNT = 3
+
+ /**
+ * The default value of the [separatorEnabled] field.
+ */
+ const val DEFAULT_SEPARATOR_ENABLED = true
+
+ /**
+ * The preset values for the [separator] field.
+ */
+ val PRESET_SEPARATORS = arrayOf(", ", "; ", "\\n")
+
+ /**
+ * The default value of the [separator] field.
+ */
+ const val DEFAULT_SEPARATOR = ", "
+
+ /**
+ * The preset values for the [affixDecorator] descriptor.
+ */
+ val PRESET_AFFIX_DECORATOR_DESCRIPTORS = listOf("[@]", "{@}", "(@)")
+
+ /**
+ * The default value of the [affixDecorator] field.
+ */
+ val DEFAULT_AFFIX_DECORATOR get() = AffixDecorator(enabled = true, descriptor = "[@]")
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/array/ArrayDecoratorEditor.kt b/src/main/kotlin/com/fwdekker/randomness/array/ArrayDecoratorEditor.kt
new file mode 100644
index 000000000..1827ecac9
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/array/ArrayDecoratorEditor.kt
@@ -0,0 +1,115 @@
+package com.fwdekker.randomness.array
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.SchemeEditor
+import com.fwdekker.randomness.affix.AffixDecoratorEditor
+import com.fwdekker.randomness.array.ArrayDecorator.Companion.MIN_MIN_COUNT
+import com.fwdekker.randomness.array.ArrayDecorator.Companion.PRESET_AFFIX_DECORATOR_DESCRIPTORS
+import com.fwdekker.randomness.array.ArrayDecorator.Companion.PRESET_SEPARATORS
+import com.fwdekker.randomness.ui.JIntSpinner
+import com.fwdekker.randomness.ui.LiteralPredicate
+import com.fwdekker.randomness.ui.UIConstants
+import com.fwdekker.randomness.ui.bindCurrentText
+import com.fwdekker.randomness.ui.bindIntValue
+import com.fwdekker.randomness.ui.bindSpinners
+import com.fwdekker.randomness.ui.decoratedRowRange
+import com.fwdekker.randomness.ui.isEditable
+import com.fwdekker.randomness.ui.loadMnemonic
+import com.fwdekker.randomness.ui.withFixedWidth
+import com.fwdekker.randomness.ui.withName
+import com.intellij.openapi.ui.ComboBox
+import com.intellij.ui.dsl.builder.BottomGap
+import com.intellij.ui.dsl.builder.Cell
+import com.intellij.ui.dsl.builder.bindSelected
+import com.intellij.ui.dsl.builder.panel
+import com.intellij.ui.dsl.builder.selected
+import com.intellij.ui.layout.ComponentPredicate
+import com.intellij.ui.layout.and
+import com.intellij.ui.layout.or
+import com.intellij.ui.layout.selected
+import javax.swing.JCheckBox
+
+
+/**
+ * Component for editing an [ArrayDecorator].
+ *
+ * @param scheme the scheme to edit
+ * @param embedded `true` if the editor is embedded, which means that no titled separator is shown at the top,
+ * components are additionally indented, and the user cannot disable the array decorator; does not affect the value of
+ * [ArrayDecorator.enabled]
+ */
+class ArrayDecoratorEditor(
+ scheme: ArrayDecorator,
+ private val embedded: Boolean = false,
+) : SchemeEditor(scheme) {
+ override val rootComponent = panel {
+ decoratedRowRange(title = if (!embedded) Bundle("array.title") else null, indent = !embedded) {
+ lateinit var enabledCheckBox: Cell
+ lateinit var isEnabled: ComponentPredicate
+
+ row {
+ checkBox(Bundle("array.ui.enabled"))
+ .loadMnemonic()
+ .withName("arrayEnabled")
+ .bindSelected(scheme::enabled)
+ .also { enabledCheckBox = it }
+ .also { isEnabled = enabledCheckBox.selected.or(LiteralPredicate(embedded)) }
+ }.visible(!embedded)
+
+ decoratedRowRange(indent = !embedded) {
+ lateinit var minCountSpinner: JIntSpinner
+ lateinit var maxCountSpinner: JIntSpinner
+
+ row(Bundle("array.ui.min_count_option")) {
+ cell(JIntSpinner(value = MIN_MIN_COUNT, minValue = MIN_MIN_COUNT))
+ .withFixedWidth(UIConstants.SIZE_SMALL)
+ .withName("arrayMinCount")
+ .bindIntValue(scheme::minCount)
+ .also { minCountSpinner = it.component }
+ }
+
+ row(Bundle("array.ui.max_count_option")) {
+ cell(JIntSpinner(value = MIN_MIN_COUNT, minValue = MIN_MIN_COUNT))
+ .withFixedWidth(UIConstants.SIZE_SMALL)
+ .withName("arrayMaxCount")
+ .bindIntValue(scheme::maxCount)
+ .also { maxCountSpinner = it.component }
+ }.bottomGap(BottomGap.SMALL)
+
+ bindSpinners(minCountSpinner, maxCountSpinner)
+
+ row {
+ lateinit var separatorEnabledCheckBox: JCheckBox
+
+ checkBox(Bundle("array.ui.separator.option"))
+ .withName("arraySeparatorEnabled")
+ .bindSelected(scheme::separatorEnabled)
+ .also { separatorEnabledCheckBox = it.component }
+
+ cell(ComboBox(PRESET_SEPARATORS))
+ .enabledIf(isEnabled.and(separatorEnabledCheckBox.selected))
+ .isEditable(true)
+ .withName("arraySeparator")
+ .bindCurrentText(scheme::separator)
+ }
+
+ row {
+ AffixDecoratorEditor(
+ scheme.affixDecorator,
+ PRESET_AFFIX_DECORATOR_DESCRIPTORS,
+ enabledIf = isEnabled,
+ enableMnemonic = false,
+ namePrefix = "array",
+ )
+ .also { decoratorEditors += it }
+ .let { cell(it.rootComponent) }
+ }
+ }.enabledIf(isEnabled)
+ }
+ }
+
+
+ init {
+ reset()
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/array/ArraySettings.kt b/src/main/kotlin/com/fwdekker/randomness/array/ArraySettings.kt
deleted file mode 100644
index e4f3e6d7d..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/array/ArraySettings.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-package com.fwdekker.randomness.array
-
-import com.fwdekker.randomness.Scheme
-import com.fwdekker.randomness.Scheme.Companion.DEFAULT_NAME
-import com.fwdekker.randomness.Settings
-import com.fwdekker.randomness.SettingsConfigurable
-import com.intellij.openapi.components.State
-import com.intellij.openapi.components.Storage
-import com.intellij.openapi.components.service
-import com.intellij.util.xmlb.XmlSerializerUtil
-import com.intellij.util.xmlb.annotations.MapAnnotation
-
-
-/**
- * The user-configurable collection of schemes applicable to generating arrays.
- *
- * @property schemes the schemes that the user can choose from
- * @property currentSchemeName the scheme that is currently active
- *
- * @see ArraySettingsAction
- * @see ArraySettingsConfigurable
- */
-@State(name = "ArraySettings", storages = [Storage("\$APP_CONFIG\$/randomness.xml")])
-data class ArraySettings(
- @MapAnnotation(sortBeforeSave = false)
- override var schemes: MutableList = DEFAULT_SCHEMES,
- override var currentSchemeName: String = DEFAULT_CURRENT_SCHEME_NAME
-) : Settings {
- override fun deepCopy() = copy(schemes = schemes.map { it.copy() }.toMutableList())
-
- override fun getState() = this
-
- override fun loadState(state: ArraySettings) = XmlSerializerUtil.copyBean(state, this)
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The default value of the [schemes][schemes] field.
- */
- val DEFAULT_SCHEMES: MutableList
- get() = mutableListOf(ArrayScheme())
-
- /**
- * The default value of the [currentSchemeName][currentSchemeName] field.
- */
- const val DEFAULT_CURRENT_SCHEME_NAME = DEFAULT_NAME
-
- /**
- * The persistent `ArraySettings` instance.
- */
- val default: ArraySettings
- get() = service()
- }
-}
-
-
-/**
- * Contains settings for generating arrays of other types of random values.
- *
- * @property myName The name of the scheme.
- * @property count The number of elements to generate.
- * @property brackets The brackets to surround arrays with.
- * @property separator The string to place between generated elements.
- * @property isSpaceAfterSeparator True iff a space should be placed after each separator.
- *
- * @see ArraySettings
- * @see com.fwdekker.randomness.DataInsertArrayAction
- */
-data class ArrayScheme(
- override var myName: String = DEFAULT_NAME,
- var count: Int = DEFAULT_COUNT,
- var brackets: String = DEFAULT_BRACKETS,
- var separator: String = DEFAULT_SEPARATOR,
- var isSpaceAfterSeparator: Boolean = DEFAULT_SPACE_AFTER_SEPARATOR
-) : Scheme {
- override fun copyFrom(other: ArrayScheme) = XmlSerializerUtil.copyBean(other, this)
-
- override fun copyAs(name: String) = this.copy(myName = name)
-
-
- /**
- * Turns a collection of strings into a single string based on the fields of this `ArraySettings` object.
- *
- * @param strings the strings to arrayify
- * @return an array-like string representation of `strings`
- */
- fun arrayify(strings: Collection) =
- strings.joinToString(
- separator = this.separator + if (isSpaceAfterSeparator && this.separator !== "\n") " " else "",
- prefix = brackets.getOrNull(0)?.toString() ?: "",
- postfix = brackets.getOrNull(1)?.toString() ?: ""
- )
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The default value of the [count][count] field.
- */
- const val DEFAULT_COUNT = 5
-
- /**
- * The default value of the [brackets][brackets] field.
- */
- const val DEFAULT_BRACKETS = "[]"
-
- /**
- * The default value of the [separator][separator] field.
- */
- const val DEFAULT_SEPARATOR = ","
-
- /**
- * The default value of the [isSpaceAfterSeparator][isSpaceAfterSeparator] field.
- */
- const val DEFAULT_SPACE_AFTER_SEPARATOR = true
- }
-}
-
-
-/**
- * The configurable for array settings.
- *
- * @see ArraySettingsAction
- */
-class ArraySettingsConfigurable(override val component: ArraySettingsComponent = ArraySettingsComponent()) :
- SettingsConfigurable() {
- override fun getDisplayName() = "Arrays"
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/array/ArraySettingsAction.kt b/src/main/kotlin/com/fwdekker/randomness/array/ArraySettingsAction.kt
deleted file mode 100644
index bf0cbd095..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/array/ArraySettingsAction.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.fwdekker.randomness.array
-
-import com.fwdekker.randomness.DataQuickSwitchSchemeAction
-import com.fwdekker.randomness.DataSettingsAction
-import icons.RandomnessIcons
-
-
-/**
- * Controller for random array generation settings.
- *
- * @see ArraySettings
- * @see ArraySettingsComponent
- */
-class ArraySettingsAction : DataSettingsAction() {
- override val name = "Array Settings"
-
- override val configurableClass = ArraySettingsConfigurable::class.java
-
-
- /**
- * Opens a popup to allow the user to quickly switch to the selected scheme.
- *
- * @param settings the settings containing the schemes that can be switched between
- */
- class ArrayQuickSwitchSchemeAction(settings: ArraySettings = ArraySettings.default) :
- DataQuickSwitchSchemeAction(settings, RandomnessIcons.Data.QuickSwitchScheme) {
- override val name = "Quick Switch Array Scheme"
- }
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/array/ArraySettingsComponent.form b/src/main/kotlin/com/fwdekker/randomness/array/ArraySettingsComponent.form
deleted file mode 100644
index e2393e636..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/array/ArraySettingsComponent.form
+++ /dev/null
@@ -1,203 +0,0 @@
-
-
diff --git a/src/main/kotlin/com/fwdekker/randomness/array/ArraySettingsComponent.kt b/src/main/kotlin/com/fwdekker/randomness/array/ArraySettingsComponent.kt
deleted file mode 100644
index 933047f34..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/array/ArraySettingsComponent.kt
+++ /dev/null
@@ -1,124 +0,0 @@
-package com.fwdekker.randomness.array
-
-import com.fwdekker.randomness.DummyInsertArrayAction
-import com.fwdekker.randomness.SchemesPanel
-import com.fwdekker.randomness.SettingsComponent
-import com.fwdekker.randomness.SettingsComponentListener
-import com.fwdekker.randomness.array.ArrayScheme.Companion.DEFAULT_BRACKETS
-import com.fwdekker.randomness.array.ArrayScheme.Companion.DEFAULT_SEPARATOR
-import com.fwdekker.randomness.array.ArraySettings.Companion.DEFAULT_SCHEMES
-import com.fwdekker.randomness.array.ArraySettings.Companion.default
-import com.fwdekker.randomness.ui.JIntSpinner
-import com.fwdekker.randomness.ui.PreviewPanel
-import com.fwdekker.randomness.ui.getValue
-import com.fwdekker.randomness.ui.setValue
-import javax.swing.ButtonGroup
-import javax.swing.JCheckBox
-import javax.swing.JPanel
-import javax.swing.JRadioButton
-import javax.swing.event.ChangeEvent
-
-
-/**
- * Component for settings of random array generation.
- *
- * @param settings the settings to edit in the component
- * @see ArraySettingsAction
- */
-@Suppress("LateinitUsage") // Initialized by scene builder
-class ArraySettingsComponent(settings: ArraySettings = default) :
- SettingsComponent(settings) {
- override lateinit var unsavedSettings: ArraySettings
- override lateinit var schemesPanel: SchemesPanel
-
- private lateinit var contentPane: JPanel
- private lateinit var previewPanelHolder: PreviewPanel
- private lateinit var previewPanel: JPanel
- private lateinit var countSpinner: JIntSpinner
- private lateinit var bracketsGroup: ButtonGroup
- private lateinit var separatorGroup: ButtonGroup
- private lateinit var newlineSeparatorButton: JRadioButton
- private lateinit var spaceAfterSeparatorCheckBox: JCheckBox
-
- override val rootPane get() = contentPane
-
-
- init {
- loadSettings()
-
- newlineSeparatorButton.addChangeListener {
- spaceAfterSeparatorCheckBox.isEnabled = !newlineSeparatorButton.isSelected
- }
- newlineSeparatorButton.changeListeners.forEach { it.stateChanged(ChangeEvent(newlineSeparatorButton)) }
-
- previewPanelHolder.updatePreviewOnUpdateOf(countSpinner, bracketsGroup, separatorGroup)
- previewPanelHolder.updatePreviewOnUpdateOf(spaceAfterSeparatorCheckBox)
- previewPanelHolder.updatePreview()
- }
-
-
- /**
- * Initialises custom UI components.
- *
- * This method is called by the scene builder at the start of the constructor.
- */
- @Suppress("UnusedPrivateMember") // Used by scene builder
- private fun createUIComponents() {
- unsavedSettings = ArraySettings()
- schemesPanel = ArraySchemesPanel(unsavedSettings)
- .also { it.addListener(SettingsComponentListener(this)) }
-
- previewPanelHolder = PreviewPanel {
- val scheme = ArrayScheme().also { saveScheme(it) }
- DummyInsertArrayAction({ scheme }) { it.nextInt(previewMin, previewMax + 1).toString() }
- }
- previewPanel = previewPanelHolder.rootPane
-
- countSpinner = JIntSpinner(value = 1, minValue = 1, description = "count")
- }
-
- override fun loadScheme(scheme: ArrayScheme) {
- countSpinner.value = scheme.count
- bracketsGroup.setValue(scheme.brackets)
- separatorGroup.setValue(scheme.separator)
- spaceAfterSeparatorCheckBox.isSelected = scheme.isSpaceAfterSeparator
- }
-
- override fun saveScheme(scheme: ArrayScheme) {
- scheme.count = countSpinner.value
- scheme.brackets = bracketsGroup.getValue() ?: DEFAULT_BRACKETS
- scheme.separator = separatorGroup.getValue() ?: DEFAULT_SEPARATOR
- scheme.isSpaceAfterSeparator = spaceAfterSeparatorCheckBox.isSelected
- }
-
- override fun doValidate() = countSpinner.validateValue()
-
-
- /**
- * A panel to select schemes from.
- *
- * @param settings the settings model backing up the panel
- */
- private class ArraySchemesPanel(settings: ArraySettings) : SchemesPanel(settings) {
- override val type: Class
- get() = ArrayScheme::class.java
-
- override fun createDefaultInstances() = DEFAULT_SCHEMES
- }
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The minimal value in the preview (inclusive).
- */
- const val previewMin = 1
-
- /**
- * The maximal value in the preview (inclusive).
- */
- const val previewMax = 20
- }
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/datetime/DateTimeScheme.kt b/src/main/kotlin/com/fwdekker/randomness/datetime/DateTimeScheme.kt
new file mode 100644
index 000000000..145b20988
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/datetime/DateTimeScheme.kt
@@ -0,0 +1,85 @@
+package com.fwdekker.randomness.datetime
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.Icons
+import com.fwdekker.randomness.Scheme
+import com.fwdekker.randomness.TypeIcon
+import com.fwdekker.randomness.array.ArrayDecorator
+import com.fwdekker.randomness.ui.toLocalDateTime
+import com.intellij.ui.JBColor
+import java.awt.Color
+import java.time.Instant
+import java.time.format.DateTimeFormatter
+
+
+/**
+ * Contains settings for generating random date-times.
+ *
+ * @property minDateTime The minimum date-time as a millisecond epoch to be generated, inclusive.
+ * @property maxDateTime The maximum date-time as a millisecond epoch to be generated, inclusive.
+ * @property pattern The pattern in which the generated date-time is formatted.
+ * @property arrayDecorator Settings that determine whether the output should be an array of values.
+ */
+data class DateTimeScheme(
+ var minDateTime: Long = DEFAULT_MIN_DATE_TIME,
+ var maxDateTime: Long = DEFAULT_MAX_DATE_TIME,
+ var pattern: String = DEFAULT_PATTERN,
+ val arrayDecorator: ArrayDecorator = ArrayDecorator(),
+) : Scheme() {
+ override val name = Bundle("datetime.title")
+ override val typeIcon = BASE_ICON
+ override val decorators get() = listOf(arrayDecorator)
+
+
+ override fun generateUndecoratedStrings(count: Int): List {
+ val formatter = DateTimeFormatter.ofPattern(pattern)
+ return List(count) { formatter.format(random.nextLong(minDateTime, maxDateTime + 1).toLocalDateTime()) }
+ }
+
+
+ override fun doValidate(): String? {
+ val formatIsValid =
+ try {
+ DateTimeFormatter.ofPattern(pattern)
+ null
+ } catch (exception: IllegalArgumentException) {
+ exception.message
+ }
+
+ return if (minDateTime > maxDateTime) Bundle("datetime.error.min_datetime_above_max")
+ else formatIsValid
+ }
+
+ override fun deepCopy(retainUuid: Boolean) =
+ copy(arrayDecorator = arrayDecorator.deepCopy(retainUuid)).deepCopyTransient(retainUuid)
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The base icon for date-times.
+ */
+ val BASE_ICON = TypeIcon(
+ Icons.SCHEME,
+ "2:3",
+ listOf(JBColor(Color(249, 139, 158, 154), Color(249, 139, 158, 154)))
+ )
+
+ /**
+ * The default value of the [minDateTime] field.
+ */
+ val DEFAULT_MIN_DATE_TIME: Long = Instant.EPOCH.toEpochMilli()
+
+ /**
+ * The default value of the [maxDateTime] field.
+ */
+ val DEFAULT_MAX_DATE_TIME: Long = Instant.parse("2030-12-31T23:59:59.00Z").toEpochMilli()
+
+ /**
+ * The default value of the [pattern] field.
+ */
+ const val DEFAULT_PATTERN: String = "yyyy-MM-dd HH:mm:ss"
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/datetime/DateTimeSchemeEditor.kt b/src/main/kotlin/com/fwdekker/randomness/datetime/DateTimeSchemeEditor.kt
new file mode 100644
index 000000000..a14fb6e3d
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/datetime/DateTimeSchemeEditor.kt
@@ -0,0 +1,91 @@
+package com.fwdekker.randomness.datetime
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.SchemeEditor
+import com.fwdekker.randomness.array.ArrayDecoratorEditor
+import com.fwdekker.randomness.datetime.DateTimeScheme.Companion.DEFAULT_MAX_DATE_TIME
+import com.fwdekker.randomness.datetime.DateTimeScheme.Companion.DEFAULT_MIN_DATE_TIME
+import com.fwdekker.randomness.ui.JDateTimeField
+import com.fwdekker.randomness.ui.UIConstants
+import com.fwdekker.randomness.ui.addChangeListenerTo
+import com.fwdekker.randomness.ui.bindDateTimeLongValue
+import com.fwdekker.randomness.ui.toLocalDateTime
+import com.fwdekker.randomness.ui.withFixedWidth
+import com.fwdekker.randomness.ui.withName
+import com.intellij.ui.dsl.builder.BottomGap
+import com.intellij.ui.dsl.builder.bindText
+import com.intellij.ui.dsl.builder.panel
+import com.intellij.ui.dsl.gridLayout.HorizontalAlign
+
+
+/**
+ * Component for editing a [DateTimeScheme].
+ *
+ * @param scheme the scheme to edit
+ */
+class DateTimeSchemeEditor(scheme: DateTimeScheme = DateTimeScheme()) : SchemeEditor(scheme) {
+ override val rootComponent = panel {
+ group(Bundle("datetime.ui.value.header")) {
+ lateinit var minDateTimeField: JDateTimeField
+ lateinit var maxDateTimeField: JDateTimeField
+
+ row(Bundle("datetime.ui.value.min_datetime_option")) {
+ cell(JDateTimeField(DEFAULT_MIN_DATE_TIME.toLocalDateTime()))
+ .withFixedWidth(UIConstants.SIZE_LARGE)
+ .withName("minDateTime")
+ .bindDateTimeLongValue(scheme::minDateTime)
+ .also { minDateTimeField = it.component }
+ }
+
+ row(Bundle("datetime.ui.value.max_datetime_option")) {
+ cell(JDateTimeField(DEFAULT_MAX_DATE_TIME.toLocalDateTime()))
+ .withFixedWidth(UIConstants.SIZE_LARGE)
+ .withName("maxDateTime")
+ .bindDateTimeLongValue(scheme::maxDateTime)
+ .also { maxDateTimeField = it.component }
+ }.bottomGap(BottomGap.SMALL)
+
+ bindDateTimes(minDateTimeField, maxDateTimeField)
+
+ row(Bundle("datetime.ui.value.pattern_option")) {
+ textField()
+ .withFixedWidth(UIConstants.SIZE_VERY_LARGE)
+ .comment(Bundle("datetime.ui.pattern_comment"))
+ .withName("pattern")
+ .bindText(scheme::pattern)
+
+ browserLink(Bundle("datetime.ui.value.pattern_help"), Bundle("datetime.ui.value.pattern_help_url"))
+ }
+ }
+
+ row {
+ ArrayDecoratorEditor(scheme.arrayDecorator)
+ .also { decoratorEditors += it }
+ .let { cell(it.rootComponent).horizontalAlign(HorizontalAlign.FILL) }
+ }
+ }
+
+
+ init {
+ reset()
+ }
+}
+
+
+/**
+ * Binds two [JDateTimeField]s together, analogous to how [com.fwdekker.randomness.ui.bindSpinners] works.
+ */
+private fun bindDateTimes(minField: JDateTimeField, maxField: JDateTimeField) {
+ addChangeListenerTo(minField) {
+ val minEpoch = minField.longValue
+ val maxEpoch = maxField.longValue
+
+ if (minEpoch > maxEpoch) maxField.value = minField.value
+ }
+ addChangeListenerTo(maxField) {
+ val minEpoch = minField.longValue
+ val maxEpoch = maxField.longValue
+
+ if (maxEpoch < minEpoch) minField.value = maxField.value
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalActions.kt b/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalActions.kt
deleted file mode 100644
index e8c13b16b..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalActions.kt
+++ /dev/null
@@ -1,140 +0,0 @@
-package com.fwdekker.randomness.decimal
-
-import com.fwdekker.randomness.DataGenerationException
-import com.fwdekker.randomness.DataGroupAction
-import com.fwdekker.randomness.DataInsertAction
-import com.fwdekker.randomness.DataInsertArrayAction
-import com.fwdekker.randomness.DataInsertRepeatAction
-import com.fwdekker.randomness.DataInsertRepeatArrayAction
-import com.fwdekker.randomness.DataQuickSwitchSchemeAction
-import com.fwdekker.randomness.DataSettingsAction
-import com.fwdekker.randomness.array.ArrayScheme
-import com.fwdekker.randomness.array.ArraySettings
-import com.fwdekker.randomness.array.ArraySettingsAction
-import icons.RandomnessIcons
-import java.text.DecimalFormat
-import kotlin.math.nextUp
-
-
-/**
- * All actions related to inserting decimals.
- */
-class DecimalGroupAction : DataGroupAction(RandomnessIcons.Decimal.Base) {
- override val insertAction = DecimalInsertAction()
- override val insertArrayAction = DecimalInsertAction.ArrayAction()
- override val insertRepeatAction = DecimalInsertAction.RepeatAction()
- override val insertRepeatArrayAction = DecimalInsertAction.RepeatArrayAction()
- override val settingsAction = DecimalSettingsAction()
- override val quickSwitchSchemeAction = DecimalSettingsAction.DecimalQuickSwitchSchemeAction()
- override val quickSwitchArraySchemeAction = ArraySettingsAction.ArrayQuickSwitchSchemeAction()
-}
-
-
-/**
- * Inserts random decimals.
- *
- * @property scheme the settings to use for generating decimals
- */
-class DecimalInsertAction(private val scheme: () -> DecimalScheme = { DecimalSettings.default.currentScheme }) :
- DataInsertAction(RandomnessIcons.Decimal.Base) {
- override val name = "Random Decimal"
-
-
- /**
- * Returns random decimals between the minimum and maximum value, inclusive.
- *
- * @param count the number of decimals to generate
- * @return random decimals between the minimum and maximum value, inclusive
- */
- override fun generateStrings(count: Int): List {
- val scheme = scheme()
- if (scheme.minValue > scheme.maxValue)
- throw DataGenerationException("Minimum value is larger than maximum value.")
-
- return List(count) { convertToString(random.nextDouble(scheme.minValue, scheme.maxValue.nextUp())) }
- }
-
-
- /**
- * Returns a nicely formatted representation of a decimal.
- *
- * @param decimal the decimal to format
- * @return a nicely formatted representation of a decimal
- */
- private fun convertToString(decimal: Double): String {
- val scheme = scheme()
-
- val format = DecimalFormat()
- format.isGroupingUsed = scheme.groupingSeparator.isNotEmpty()
-
- val symbols = format.decimalFormatSymbols
- symbols.groupingSeparator = scheme.groupingSeparator.getOrElse(0) { Char.MIN_VALUE }
- symbols.decimalSeparator = scheme.decimalSeparator.getOrElse(0) { Char.MIN_VALUE }
- if (scheme.showTrailingZeroes) format.minimumFractionDigits = scheme.decimalCount
- format.maximumFractionDigits = scheme.decimalCount
- format.decimalFormatSymbols = symbols
-
- return scheme.prefix + format.format(decimal) + scheme.suffix
- }
-
-
- /**
- * Inserts an array-like string of decimals.
- *
- * @param arrayScheme the scheme to use for generating arrays
- * @param scheme the scheme to use for generating decimals
- */
- class ArrayAction(
- arrayScheme: () -> ArrayScheme = { ArraySettings.default.currentScheme },
- scheme: () -> DecimalScheme = { DecimalSettings.default.currentScheme }
- ) : DataInsertArrayAction(arrayScheme, DecimalInsertAction(scheme), RandomnessIcons.Decimal.Array) {
- override val name = "Random Decimal Array"
- }
-
- /**
- * Inserts repeated random decimals.
- *
- * @param scheme the settings to use for generating decimals
- */
- class RepeatAction(scheme: () -> DecimalScheme = { DecimalSettings.default.currentScheme }) :
- DataInsertRepeatAction(DecimalInsertAction(scheme), RandomnessIcons.Decimal.Repeat) {
- override val name = "Random Repeated Decimal"
- }
-
- /**
- * Inserts repeated array-like strings of decimals.
- *
- * @param arrayScheme the scheme to use for generating arrays
- * @param scheme the scheme to use for generating decimals
- */
- class RepeatArrayAction(
- arrayScheme: () -> ArrayScheme = { ArraySettings.default.currentScheme },
- scheme: () -> DecimalScheme = { DecimalSettings.default.currentScheme }
- ) : DataInsertRepeatArrayAction(ArrayAction(arrayScheme, scheme), RandomnessIcons.Decimal.RepeatArray) {
- override val name = "Random Repeated Decimal Array"
- }
-}
-
-
-/**
- * Controller for random decimal generation settings.
- *
- * @see DecimalSettings
- * @see DecimalSettingsComponent
- */
-class DecimalSettingsAction : DataSettingsAction(RandomnessIcons.Decimal.Settings) {
- override val name = "Decimal Settings"
-
- override val configurableClass = DecimalSettingsConfigurable::class.java
-
-
- /**
- * Opens a popup to allow the user to quickly switch to the selected scheme.
- *
- * @param settings the settings containing the schemes that can be switched between
- */
- class DecimalQuickSwitchSchemeAction(settings: DecimalSettings = DecimalSettings.default) :
- DataQuickSwitchSchemeAction(settings, RandomnessIcons.Decimal.QuickSwitchScheme) {
- override val name = "Quick Switch Decimal Scheme"
- }
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalScheme.kt b/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalScheme.kt
new file mode 100644
index 000000000..18077046d
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalScheme.kt
@@ -0,0 +1,169 @@
+package com.fwdekker.randomness.decimal
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.Icons
+import com.fwdekker.randomness.Scheme
+import com.fwdekker.randomness.TypeIcon
+import com.fwdekker.randomness.affix.AffixDecorator
+import com.fwdekker.randomness.array.ArrayDecorator
+import com.intellij.ui.JBColor
+import java.awt.Color
+import java.text.DecimalFormat
+import kotlin.math.nextUp
+
+
+/**
+ * Contains settings for generating random decimals.
+ *
+ * @property minValue The minimum value to be generated, inclusive.
+ * @property maxValue The maximum value to be generated, inclusive.
+ * @property decimalCount The number of decimals to display.
+ * @property showTrailingZeroes Whether to include trailing zeroes in the decimals.
+ * @property decimalSeparator The character that should separate decimals.
+ * @property groupingSeparatorEnabled `true` if and only if the [groupingSeparator] should be used to separate groups.
+ * @property groupingSeparator The character that should separate groups if [groupingSeparatorEnabled] is `true`.
+ * @property affixDecorator The affixation to apply to the generated values.
+ * @property arrayDecorator Settings that determine whether the output should be an array of values.
+ */
+data class DecimalScheme(
+ var minValue: Double = DEFAULT_MIN_VALUE,
+ var maxValue: Double = DEFAULT_MAX_VALUE,
+ var decimalCount: Int = DEFAULT_DECIMAL_COUNT,
+ var showTrailingZeroes: Boolean = DEFAULT_SHOW_TRAILING_ZEROES,
+ var decimalSeparator: String = DEFAULT_DECIMAL_SEPARATOR,
+ var groupingSeparatorEnabled: Boolean = DEFAULT_GROUPING_SEPARATOR_ENABLED,
+ var groupingSeparator: String = DEFAULT_GROUPING_SEPARATOR,
+ val affixDecorator: AffixDecorator = DEFAULT_AFFIX_DECORATOR,
+ val arrayDecorator: ArrayDecorator = DEFAULT_ARRAY_DECORATOR,
+) : Scheme() {
+ override val name = Bundle("decimal.title")
+ override val typeIcon = BASE_ICON
+ override val decorators get() = listOf(affixDecorator, arrayDecorator)
+
+
+ /**
+ * Returns [count] random formatted decimals in the range from [minValue] until [maxValue], inclusive.
+ */
+ override fun generateUndecoratedStrings(count: Int) =
+ List(count) { doubleToString(random.nextDouble(minValue, maxValue.nextUp())) }
+
+ /**
+ * Returns a nicely formatted representation of [decimal].
+ */
+ private fun doubleToString(decimal: Double): String {
+ val format = DecimalFormat()
+
+ if (showTrailingZeroes) format.minimumFractionDigits = decimalCount
+ format.isGroupingUsed = groupingSeparatorEnabled
+ format.maximumFractionDigits = decimalCount
+ format.decimalFormatSymbols =
+ format.decimalFormatSymbols.also {
+ it.groupingSeparator = groupingSeparator.getOrElse(0) { DEFAULT_GROUPING_SEPARATOR[0] }
+ it.decimalSeparator = decimalSeparator[0]
+ }
+
+ return format.format(decimal)
+ }
+
+
+ override fun doValidate() =
+ when {
+ minValue > maxValue -> Bundle("decimal.error.min_value_above_max")
+ maxValue - minValue > MAX_VALUE_DIFFERENCE -> Bundle("decimal.error.value_range", MAX_VALUE_DIFFERENCE)
+ decimalCount < MIN_DECIMAL_COUNT -> Bundle("decimal.error.decimal_count_too_low", MIN_DECIMAL_COUNT)
+ decimalSeparator.length != 1 -> Bundle("decimal.error.decimal_separator_length")
+ groupingSeparator.length != 1 -> Bundle("decimal.error.grouping_separator_length")
+ else -> affixDecorator.doValidate() ?: arrayDecorator.doValidate()
+ }
+
+ override fun deepCopy(retainUuid: Boolean) =
+ copy(
+ affixDecorator = affixDecorator.deepCopy(retainUuid),
+ arrayDecorator = arrayDecorator.deepCopy(retainUuid),
+ ).deepCopyTransient(retainUuid)
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The base icon for decimals.
+ */
+ val BASE_ICON = TypeIcon(
+ Icons.SCHEME,
+ "4.2",
+ listOf(JBColor(Color(98, 181, 67, 154), Color(98, 181, 67, 154)))
+ )
+
+ /**
+ * The maximum valid difference between the [minValue] and [maxValue] fields.
+ */
+ const val MAX_VALUE_DIFFERENCE = 1E53
+
+ /**
+ * The default value of the [minValue] field.
+ */
+ const val DEFAULT_MIN_VALUE = 0.0
+
+ /**
+ * The default value of the [maxValue] field.
+ */
+ const val DEFAULT_MAX_VALUE = 1_000.0
+
+ /**
+ * The minimum valid value for the [decimalCount] field.
+ */
+ const val MIN_DECIMAL_COUNT = 0
+
+ /**
+ * The default value of the [decimalCount] field.
+ */
+ const val DEFAULT_DECIMAL_COUNT = 2
+
+ /**
+ * The default value of the [showTrailingZeroes] field.
+ */
+ const val DEFAULT_SHOW_TRAILING_ZEROES = false
+
+ /**
+ * The preset values for the [decimalSeparator] field.
+ */
+ val PRESET_DECIMAL_SEPARATORS = arrayOf(",", ".")
+
+ /**
+ * The default value of the [decimalSeparator] field.
+ */
+ const val DEFAULT_DECIMAL_SEPARATOR = "."
+
+ /**
+ * The default value of the [groupingSeparatorEnabled] field.
+ */
+ const val DEFAULT_GROUPING_SEPARATOR_ENABLED = false
+
+ /**
+ * The preset values for the [groupingSeparator] field.
+ */
+ val PRESET_GROUPING_SEPARATORS = arrayOf(".", ",", "_")
+
+ /**
+ * The default value of the [groupingSeparator] field.
+ */
+ const val DEFAULT_GROUPING_SEPARATOR = ","
+
+ /**
+ * The preset values for the [affixDecorator] descriptor.
+ */
+ val PRESET_AFFIX_DECORATOR_DESCRIPTORS = listOf("@f", "@d")
+
+ /**
+ * The default value of the [affixDecorator] field.
+ */
+ val DEFAULT_AFFIX_DECORATOR get() = AffixDecorator(enabled = false, descriptor = "f@")
+
+ /**
+ * The default value of the [arrayDecorator] field.
+ */
+ val DEFAULT_ARRAY_DECORATOR get() = ArrayDecorator()
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSchemeEditor.kt b/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSchemeEditor.kt
new file mode 100644
index 000000000..bf1fa3a29
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSchemeEditor.kt
@@ -0,0 +1,124 @@
+package com.fwdekker.randomness.decimal
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.SchemeEditor
+import com.fwdekker.randomness.affix.AffixDecoratorEditor
+import com.fwdekker.randomness.array.ArrayDecoratorEditor
+import com.fwdekker.randomness.decimal.DecimalScheme.Companion.MIN_DECIMAL_COUNT
+import com.fwdekker.randomness.decimal.DecimalScheme.Companion.PRESET_AFFIX_DECORATOR_DESCRIPTORS
+import com.fwdekker.randomness.decimal.DecimalScheme.Companion.PRESET_DECIMAL_SEPARATORS
+import com.fwdekker.randomness.decimal.DecimalScheme.Companion.PRESET_GROUPING_SEPARATORS
+import com.fwdekker.randomness.ui.JDoubleSpinner
+import com.fwdekker.randomness.ui.JIntSpinner
+import com.fwdekker.randomness.ui.MinMaxLengthDocumentFilter
+import com.fwdekker.randomness.ui.UIConstants
+import com.fwdekker.randomness.ui.bindCurrentText
+import com.fwdekker.randomness.ui.bindIntValue
+import com.fwdekker.randomness.ui.bindSpinners
+import com.fwdekker.randomness.ui.hasValue
+import com.fwdekker.randomness.ui.isEditable
+import com.fwdekker.randomness.ui.loadMnemonic
+import com.fwdekker.randomness.ui.withFilter
+import com.fwdekker.randomness.ui.withFixedWidth
+import com.fwdekker.randomness.ui.withName
+import com.intellij.openapi.ui.ComboBox
+import com.intellij.ui.dsl.builder.Cell
+import com.intellij.ui.dsl.builder.bindSelected
+import com.intellij.ui.dsl.builder.bindValue
+import com.intellij.ui.dsl.builder.panel
+import com.intellij.ui.dsl.builder.selected
+import com.intellij.ui.dsl.gridLayout.HorizontalAlign
+import javax.swing.JCheckBox
+
+
+/**
+ * Component for editing a [DecimalScheme].
+ *
+ * @param scheme the scheme to edit
+ */
+class DecimalSchemeEditor(scheme: DecimalScheme = DecimalScheme()) : SchemeEditor(scheme) {
+ override val rootComponent = panel {
+ group(Bundle("decimal.ui.value.header")) {
+ lateinit var minValue: JDoubleSpinner
+ lateinit var maxValue: JDoubleSpinner
+
+ row(Bundle("decimal.ui.value.min_option")) {
+ cell(JDoubleSpinner())
+ .withFixedWidth(UIConstants.SIZE_VERY_LARGE)
+ .withName("minValue")
+ .bindValue(scheme::minValue)
+ .also { minValue = it.component }
+ }
+
+ row(Bundle("decimal.ui.value.max_option")) {
+ cell(JDoubleSpinner())
+ .withFixedWidth(UIConstants.SIZE_VERY_LARGE)
+ .withName("maxValue")
+ .bindValue(scheme::maxValue)
+ .also { maxValue = it.component }
+ }
+
+ bindSpinners(minValue, maxValue, DecimalScheme.MAX_VALUE_DIFFERENCE)
+ }
+
+ group(Bundle("decimal.ui.format.header")) {
+ row(Bundle("decimal.ui.format.number_of_decimals_option")) {
+ lateinit var decimalCount: JIntSpinner
+
+ cell(JIntSpinner(value = MIN_DECIMAL_COUNT, minValue = MIN_DECIMAL_COUNT))
+ .withFixedWidth(UIConstants.SIZE_SMALL)
+ .withName("decimalCount")
+ .bindIntValue(scheme::decimalCount)
+ .also { decimalCount = it.component }
+
+ checkBox(Bundle("decimal.ui.format.show_trailing_zeroes"))
+ .loadMnemonic()
+ .enabledIf(decimalCount.hasValue { it > 0 })
+ .withName("showTrailingZeroes")
+ .bindSelected(scheme::showTrailingZeroes)
+ }
+
+ row(Bundle("decimal.ui.format.decimal_separator_option")) {
+ cell(ComboBox(PRESET_DECIMAL_SEPARATORS))
+ .isEditable(true)
+ .withFilter(MinMaxLengthDocumentFilter(1, 1))
+ .withName("decimalSeparator")
+ .bindCurrentText(scheme::decimalSeparator)
+ }
+
+ row {
+ lateinit var groupingSeparatorEnabled: Cell
+
+ checkBox(Bundle("decimal.ui.format.grouping_separator_option"))
+ .loadMnemonic()
+ .withName("groupingSeparatorEnabled")
+ .bindSelected(scheme::groupingSeparatorEnabled)
+ .also { groupingSeparatorEnabled = it }
+
+ cell(ComboBox(PRESET_GROUPING_SEPARATORS))
+ .enabledIf(groupingSeparatorEnabled.selected)
+ .isEditable(true)
+ .withFilter(MinMaxLengthDocumentFilter(1, 1))
+ .withName("groupingSeparator")
+ .bindCurrentText(scheme::groupingSeparator)
+ }
+
+ row {
+ AffixDecoratorEditor(scheme.affixDecorator, PRESET_AFFIX_DECORATOR_DESCRIPTORS)
+ .also { decoratorEditors += it }
+ .let { cell(it.rootComponent) }
+ }
+ }
+
+ row {
+ ArrayDecoratorEditor(scheme.arrayDecorator)
+ .also { decoratorEditors += it }
+ .let { cell(it.rootComponent).horizontalAlign(HorizontalAlign.FILL) }
+ }
+ }
+
+
+ init {
+ reset()
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSettings.kt b/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSettings.kt
deleted file mode 100644
index 3f1e7936d..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSettings.kt
+++ /dev/null
@@ -1,172 +0,0 @@
-package com.fwdekker.randomness.decimal
-
-import com.fwdekker.randomness.Scheme
-import com.fwdekker.randomness.Scheme.Companion.DEFAULT_NAME
-import com.fwdekker.randomness.Settings
-import com.fwdekker.randomness.SettingsConfigurable
-import com.intellij.openapi.components.State
-import com.intellij.openapi.components.Storage
-import com.intellij.openapi.components.service
-import com.intellij.util.xmlb.XmlSerializerUtil
-import com.intellij.util.xmlb.annotations.MapAnnotation
-
-
-/**
- * The user-configurable collection of schemes applicable to generating decimals.
- *
- * @property schemes the schemes that the user can choose from
- * @property currentSchemeName the scheme that is currently active
- *
- * @see DecimalSettingsAction
- * @see DecimalSettingsConfigurable
- */
-@State(name = "DecimalSettings", storages = [Storage("\$APP_CONFIG\$/randomness.xml")])
-data class DecimalSettings(
- @MapAnnotation(sortBeforeSave = false)
- override var schemes: MutableList = DEFAULT_SCHEMES,
- override var currentSchemeName: String = DEFAULT_CURRENT_SCHEME_NAME
-) : Settings {
- override fun deepCopy() = copy(schemes = schemes.map { it.copy() }.toMutableList())
-
- override fun getState() = this
-
- override fun loadState(state: DecimalSettings) = XmlSerializerUtil.copyBean(state, this)
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The default value of the [schemes][schemes] field.
- */
- val DEFAULT_SCHEMES: MutableList
- get() = mutableListOf(DecimalScheme())
-
- /**
- * The default value of the [currentSchemeName][currentSchemeName] field.
- */
- const val DEFAULT_CURRENT_SCHEME_NAME = DEFAULT_NAME
-
- /**
- * The persistent `DecimalSettings` instance.
- */
- val default: DecimalSettings
- get() = service()
- }
-}
-
-
-/**
- * Contains settings for generating random decimals.
- *
- * @property myName The name of the scheme.
- * @property minValue The minimum value to be generated, inclusive.
- * @property maxValue The maximum value to be generated, inclusive.
- * @property decimalCount The number of decimals to display.
- * @property showTrailingZeroes Whether to include trailing zeroes in the decimals.
- * @property groupingSeparator The character that should separate groups.
- * @property decimalSeparator The character that should separate decimals.
- * @property prefix The string to prepend to the generated value.
- * @property suffix The string to append to the generated value.
- *
- * @see DecimalInsertAction
- * @see DecimalSettings
- */
-// TODO Turn separator properties into char properties once supported by the settings serializer
-data class DecimalScheme(
- override var myName: String = DEFAULT_NAME,
- var minValue: Double = DEFAULT_MIN_VALUE,
- var maxValue: Double = DEFAULT_MAX_VALUE,
- var decimalCount: Int = DEFAULT_DECIMAL_COUNT,
- var showTrailingZeroes: Boolean = DEFAULT_SHOW_TRAILING_ZEROES,
- var groupingSeparator: String = DEFAULT_GROUPING_SEPARATOR,
- var decimalSeparator: String = DEFAULT_DECIMAL_SEPARATOR,
- var prefix: String = DEFAULT_PREFIX,
- var suffix: String = DEFAULT_SUFFIX
-) : Scheme {
- override fun copyFrom(other: DecimalScheme) = XmlSerializerUtil.copyBean(other, this)
-
- override fun copyAs(name: String) = this.copy(myName = name)
-
-
- /**
- * Sets the grouping separator safely to ensure that exactly one character is set.
- *
- * @param groupingSeparator the possibly-unsafe grouping separator string
- */
- fun safeSetGroupingSeparator(groupingSeparator: String?) =
- if (groupingSeparator.isNullOrEmpty())
- this.groupingSeparator = DEFAULT_GROUPING_SEPARATOR
- else
- this.groupingSeparator = groupingSeparator.substring(0, 1)
-
- /**
- * Sets the decimal separator safely to ensure that exactly one character is set.
- *
- * @param decimalSeparator the possibly-unsafe decimal separator string
- */
- fun safeSetDecimalSeparator(decimalSeparator: String?) =
- if (decimalSeparator.isNullOrEmpty())
- this.decimalSeparator = DEFAULT_DECIMAL_SEPARATOR
- else
- this.decimalSeparator = decimalSeparator.substring(0, 1)
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The default value of the [minValue][minValue] field.
- */
- const val DEFAULT_MIN_VALUE = 0.0
-
- /**
- * The default value of the [maxValue][maxValue] field.
- */
- const val DEFAULT_MAX_VALUE = 1_000.0
-
- /**
- * The default value of the [decimalCount][decimalCount] field.
- */
- const val DEFAULT_DECIMAL_COUNT = 2
-
- /**
- * The default value of the [showTrailingZeroes][showTrailingZeroes] field.
- */
- const val DEFAULT_SHOW_TRAILING_ZEROES = true
-
- /**
- * The default value of the [groupingSeparator][groupingSeparator] field.
- */
- const val DEFAULT_GROUPING_SEPARATOR = ""
-
- /**
- * The default value of the [decimalSeparator][decimalSeparator] field.
- */
- const val DEFAULT_DECIMAL_SEPARATOR = "."
-
- /**
- * The default value of the [prefix][prefix] field.
- */
- const val DEFAULT_PREFIX = ""
-
- /**
- * The default value of the [suffix][suffix] field.
- */
- const val DEFAULT_SUFFIX = ""
- }
-}
-
-
-/**
- * The configurable for decimal settings.
- *
- * @see DecimalSettingsAction
- */
-class DecimalSettingsConfigurable(
- override val component: DecimalSettingsComponent = DecimalSettingsComponent()
-) : SettingsConfigurable() {
- override fun getDisplayName() = "Decimals"
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSettingsComponent.form b/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSettingsComponent.form
deleted file mode 100644
index e91511e29..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSettingsComponent.form
+++ /dev/null
@@ -1,301 +0,0 @@
-
-
diff --git a/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSettingsComponent.kt b/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSettingsComponent.kt
deleted file mode 100644
index 3448dd734..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/decimal/DecimalSettingsComponent.kt
+++ /dev/null
@@ -1,135 +0,0 @@
-package com.fwdekker.randomness.decimal
-
-import com.fwdekker.randomness.SchemesPanel
-import com.fwdekker.randomness.SettingsComponent
-import com.fwdekker.randomness.SettingsComponentListener
-import com.fwdekker.randomness.decimal.DecimalSettings.Companion.DEFAULT_SCHEMES
-import com.fwdekker.randomness.decimal.DecimalSettings.Companion.default
-import com.fwdekker.randomness.ui.JDoubleSpinner
-import com.fwdekker.randomness.ui.JIntSpinner
-import com.fwdekker.randomness.ui.JSpinnerRange
-import com.fwdekker.randomness.ui.PreviewPanel
-import com.fwdekker.randomness.ui.getValue
-import com.fwdekker.randomness.ui.setValue
-import javax.swing.ButtonGroup
-import javax.swing.JCheckBox
-import javax.swing.JPanel
-import javax.swing.JTextField
-import javax.swing.event.ChangeEvent
-
-
-/**
- * Component for settings of random decimal generation.
- *
- * @param settings the settings to edit in the component
- *
- * @see DecimalSettingsAction
- */
-@Suppress("LateinitUsage") // Initialized by scene builder
-class DecimalSettingsComponent(settings: DecimalSettings = default) :
- SettingsComponent(settings) {
- override lateinit var unsavedSettings: DecimalSettings
- override lateinit var schemesPanel: SchemesPanel
-
- private lateinit var contentPane: JPanel
- private lateinit var previewPanelHolder: PreviewPanel
- private lateinit var previewPanel: JPanel
- private lateinit var valueRange: JSpinnerRange
- private lateinit var minValue: JDoubleSpinner
- private lateinit var maxValue: JDoubleSpinner
- private lateinit var decimalCount: JIntSpinner
- private lateinit var showTrailingZeroesCheckBox: JCheckBox
- private lateinit var groupingSeparatorGroup: ButtonGroup
- private lateinit var decimalSeparatorGroup: ButtonGroup
- private lateinit var prefixInput: JTextField
- private lateinit var suffixInput: JTextField
-
- override val rootPane get() = contentPane
-
-
- init {
- loadSettings()
-
- decimalCount.addChangeListener { showTrailingZeroesCheckBox.isEnabled = decimalCount.value > 0 }
- decimalCount.changeListeners.forEach { it.stateChanged(ChangeEvent(decimalCount)) }
-
- previewPanelHolder.updatePreviewOnUpdateOf(minValue, maxValue, decimalCount, showTrailingZeroesCheckBox)
- previewPanelHolder.updatePreviewOnUpdateOf(groupingSeparatorGroup, decimalSeparatorGroup, prefixInput)
- previewPanelHolder.updatePreviewOnUpdateOf(suffixInput)
- previewPanelHolder.updatePreview()
- }
-
-
- /**
- * Initialises custom UI components.
- *
- * This method is called by the scene builder at the start of the constructor.
- */
- @Suppress("UnusedPrivateMember") // Used by scene builder
- private fun createUIComponents() {
- unsavedSettings = DecimalSettings()
- schemesPanel = DecimalSchemesPanel(unsavedSettings)
- .also { it.addListener(SettingsComponentListener(this)) }
-
- previewPanelHolder = PreviewPanel { DecimalInsertAction { DecimalScheme().also { saveScheme(it) } } }
- previewPanel = previewPanelHolder.rootPane
-
- minValue = JDoubleSpinner(description = "minimum value")
- maxValue = JDoubleSpinner(description = "maximum value")
- valueRange = JSpinnerRange(minValue, maxValue, MAX_VALUE_RANGE, name = "value")
-
- decimalCount = JIntSpinner(0, 0, description = "decimal count")
- }
-
- override fun loadScheme(scheme: DecimalScheme) {
- minValue.value = scheme.minValue
- maxValue.value = scheme.maxValue
- decimalCount.value = scheme.decimalCount
- showTrailingZeroesCheckBox.isSelected = scheme.showTrailingZeroes
- groupingSeparatorGroup.setValue(scheme.groupingSeparator)
- decimalSeparatorGroup.setValue(scheme.decimalSeparator)
- prefixInput.text = scheme.prefix
- suffixInput.text = scheme.suffix
- }
-
- override fun saveScheme(scheme: DecimalScheme) {
- scheme.minValue = minValue.value
- scheme.maxValue = maxValue.value
- scheme.decimalCount = decimalCount.value
- scheme.showTrailingZeroes = showTrailingZeroesCheckBox.isSelected
- scheme.safeSetGroupingSeparator(groupingSeparatorGroup.getValue())
- scheme.safeSetDecimalSeparator(decimalSeparatorGroup.getValue())
- scheme.prefix = prefixInput.text
- scheme.suffix = suffixInput.text
- }
-
- override fun doValidate() =
- minValue.validateValue()
- ?: maxValue.validateValue()
- ?: valueRange.validateValue()
- ?: decimalCount.validateValue()
-
-
- /**
- * A panel to select schemes from.
- *
- * @param settings the settings model backing up the panel
- */
- private class DecimalSchemesPanel(settings: DecimalSettings) : SchemesPanel(settings) {
- override val type: Class
- get() = DecimalScheme::class.java
-
- override fun createDefaultInstances() = DEFAULT_SCHEMES
- }
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The maximum difference between the minimum and maximum values that can be generated.
- */
- const val MAX_VALUE_RANGE = 1E53
- }
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/fixedlength/FixedLengthDecorator.kt b/src/main/kotlin/com/fwdekker/randomness/fixedlength/FixedLengthDecorator.kt
new file mode 100644
index 000000000..780ed11af
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/fixedlength/FixedLengthDecorator.kt
@@ -0,0 +1,59 @@
+package com.fwdekker.randomness.fixedlength
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.DecoratorScheme
+
+
+/**
+ * Forces generated strings to be exactly [length] characters.
+ *
+ * @property enabled Whether to apply this decorator.
+ * @property length The enforced length.
+ * @property filler The character to pad strings that are too short with.
+ */
+data class FixedLengthDecorator(
+ var enabled: Boolean = DEFAULT_ENABLED,
+ var length: Int = DEFAULT_LENGTH,
+ var filler: String = DEFAULT_FILLER,
+) : DecoratorScheme() {
+ override val name = Bundle("fixed_length.title")
+ override val decorators = emptyList()
+ override val isEnabled get() = enabled
+
+
+ override fun generateUndecoratedStrings(count: Int): List =
+ generator(count).map { it.take(length).padStart(length, filler[0]) }
+
+ override fun doValidate() =
+ if (length < MIN_LENGTH) Bundle("fixed_length.error.length_too_low", MIN_LENGTH)
+ else if (filler.length != 1) Bundle("fixed_length.error.filler_length")
+ else null
+
+ override fun deepCopy(retainUuid: Boolean) = copy().deepCopyTransient(retainUuid)
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The default value of the [enabled] field.
+ */
+ const val DEFAULT_ENABLED = false
+
+ /**
+ * The minimum valid value of the [length] field.
+ */
+ const val MIN_LENGTH = 1
+
+ /**
+ * The default value of the [length] field.
+ */
+ const val DEFAULT_LENGTH = 3
+
+ /**
+ * The default value of the [filler] field.
+ */
+ const val DEFAULT_FILLER = "0"
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/fixedlength/FixedLengthDecoratorEditor.kt b/src/main/kotlin/com/fwdekker/randomness/fixedlength/FixedLengthDecoratorEditor.kt
new file mode 100644
index 000000000..ac09fa004
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/fixedlength/FixedLengthDecoratorEditor.kt
@@ -0,0 +1,64 @@
+package com.fwdekker.randomness.fixedlength
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.SchemeEditor
+import com.fwdekker.randomness.fixedlength.FixedLengthDecorator.Companion.MIN_LENGTH
+import com.fwdekker.randomness.ui.JIntSpinner
+import com.fwdekker.randomness.ui.MaxLengthDocumentFilter
+import com.fwdekker.randomness.ui.UIConstants
+import com.fwdekker.randomness.ui.bindIntValue
+import com.fwdekker.randomness.ui.loadMnemonic
+import com.fwdekker.randomness.ui.withDocument
+import com.fwdekker.randomness.ui.withFixedWidth
+import com.fwdekker.randomness.ui.withName
+import com.intellij.ui.dsl.builder.Cell
+import com.intellij.ui.dsl.builder.bindSelected
+import com.intellij.ui.dsl.builder.bindText
+import com.intellij.ui.dsl.builder.panel
+import com.intellij.ui.dsl.builder.selected
+import javax.swing.JCheckBox
+import javax.swing.text.PlainDocument
+
+
+/**
+ * Component for editing a [FixedLengthDecorator].
+ *
+ * @param scheme the scheme to edit
+ */
+class FixedLengthDecoratorEditor(scheme: FixedLengthDecorator) : SchemeEditor(scheme) {
+ override val rootComponent = panel {
+ group(Bundle("fixed_length.title")) {
+ lateinit var enabledCheckBox: Cell
+
+ row {
+ checkBox(Bundle("fixed_length.ui.enabled"))
+ .loadMnemonic()
+ .withName("fixedLengthEnabled")
+ .bindSelected(scheme::enabled)
+ .also { enabledCheckBox = it }
+ }
+
+ indent {
+ row(Bundle("fixed_length.ui.length_option")) {
+ cell(JIntSpinner(value = MIN_LENGTH, minValue = MIN_LENGTH))
+ .withFixedWidth(UIConstants.SIZE_SMALL)
+ .withName("fixedLengthLength")
+ .bindIntValue(scheme::length)
+ }
+
+ row(Bundle("fixed_length.ui.filler_option")) {
+ textField()
+ .withFixedWidth(UIConstants.SIZE_SMALL)
+ .withDocument(PlainDocument().also { it.documentFilter = MaxLengthDocumentFilter(1) })
+ .withName("fixedLengthFiller")
+ .bindText(scheme::filler)
+ }
+ }.enabledIf(enabledCheckBox.selected)
+ }
+ }
+
+
+ init {
+ reset()
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/integer/IntegerActions.kt b/src/main/kotlin/com/fwdekker/randomness/integer/IntegerActions.kt
deleted file mode 100644
index 4a42949d5..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/integer/IntegerActions.kt
+++ /dev/null
@@ -1,153 +0,0 @@
-package com.fwdekker.randomness.integer
-
-import com.fwdekker.randomness.DataGenerationException
-import com.fwdekker.randomness.DataGroupAction
-import com.fwdekker.randomness.DataInsertAction
-import com.fwdekker.randomness.DataInsertArrayAction
-import com.fwdekker.randomness.DataInsertRepeatAction
-import com.fwdekker.randomness.DataInsertRepeatArrayAction
-import com.fwdekker.randomness.DataQuickSwitchSchemeAction
-import com.fwdekker.randomness.DataSettingsAction
-import com.fwdekker.randomness.array.ArrayScheme
-import com.fwdekker.randomness.array.ArraySettings
-import com.fwdekker.randomness.array.ArraySettingsAction
-import com.fwdekker.randomness.integer.IntegerScheme.Companion.DECIMAL_BASE
-import icons.RandomnessIcons
-import java.text.DecimalFormat
-
-
-/**
- * All actions related to inserting integers.
- */
-class IntegerGroupAction : DataGroupAction(RandomnessIcons.Integer.Base) {
- override val insertAction = IntegerInsertAction()
- override val insertArrayAction = IntegerInsertAction.ArrayAction()
- override val insertRepeatAction = IntegerInsertAction.RepeatAction()
- override val insertRepeatArrayAction = IntegerInsertAction.RepeatArrayAction()
- override val settingsAction = IntegerSettingsAction()
- override val quickSwitchSchemeAction = IntegerSettingsAction.IntegerQuickSwitchSchemeAction()
- override val quickSwitchArraySchemeAction = ArraySettingsAction.ArrayQuickSwitchSchemeAction()
-}
-
-
-/**
- * Inserts random integers.
- *
- * @property scheme the scheme to use for generating integers
- */
-class IntegerInsertAction(private val scheme: () -> IntegerScheme = { IntegerSettings.default.currentScheme }) :
- DataInsertAction(RandomnessIcons.Integer.Base) {
- override val name = "Random Integer"
-
-
- /**
- * Returns random integers between the minimum and maximum value, inclusive.
- *
- * @param count the number of integers to generate
- * @return random integers between the minimum and maximum value, inclusive
- */
- override fun generateStrings(count: Int): List {
- val scheme = scheme()
- if (scheme.minValue > scheme.maxValue)
- throw DataGenerationException("Minimum value is larger than maximum value.")
-
- return List(count) {
- scheme.prefix + convertToString(randomLong(scheme.minValue, scheme.maxValue)) + scheme.suffix
- }
- }
-
-
- /**
- * Returns a random long in the given inclusive range without causing overflow.
- *
- * @param from inclusive lower bound
- * @param until inclusive upper bound
- * @return a random long in the given inclusive
- */
- private fun randomLong(from: Long, until: Long) =
- if (from == Long.MIN_VALUE && until == Long.MAX_VALUE) random.nextLong()
- else if (until == Long.MAX_VALUE) random.nextLong(from - 1, until) + 1
- else random.nextLong(from, until + 1)
-
- /**
- * Returns a nicely formatted representation of an integer.
- *
- * @param value the value to format
- * @return a nicely formatted representation of an integer
- */
- private fun convertToString(value: Long): String {
- val scheme = scheme()
- if (scheme.base != DECIMAL_BASE)
- return scheme.capitalization.transform(value.toString(scheme.base), random)
-
- val format = DecimalFormat()
- format.isGroupingUsed = scheme.groupingSeparator.isNotEmpty()
- format.minimumFractionDigits = 0
- format.maximumFractionDigits = 0
- format.decimalFormatSymbols = format.decimalFormatSymbols
- .also { it.groupingSeparator = scheme.groupingSeparator.getOrElse(0) { Char.MIN_VALUE } }
-
- return format.format(value)
- }
-
-
- /**
- * Inserts an array-like string of integers.
- *
- * @param arrayScheme the scheme to use for generating arrays
- * @param scheme the scheme to use for generating integers
- */
- class ArrayAction(
- arrayScheme: () -> ArrayScheme = { ArraySettings.default.currentScheme },
- scheme: () -> IntegerScheme = { IntegerSettings.default.currentScheme }
- ) : DataInsertArrayAction(arrayScheme, IntegerInsertAction(scheme), RandomnessIcons.Integer.Array) {
- override val name = "Random Integer Array"
- }
-
- /**
- * Inserts repeated random integers.
- *
- * @param scheme the settings to use for generating integers
- */
- class RepeatAction(scheme: () -> IntegerScheme = { IntegerSettings.default.currentScheme }) :
- DataInsertRepeatAction(IntegerInsertAction(scheme), RandomnessIcons.Integer.Repeat) {
- override val name = "Random Repeated Integer"
- }
-
- /**
- * Inserts repeated array-like strings of integers.
- *
- * @param arrayScheme the scheme to use for generating arrays
- * @param scheme the scheme to use for generating integers
- */
- class RepeatArrayAction(
- arrayScheme: () -> ArrayScheme = { ArraySettings.default.currentScheme },
- scheme: () -> IntegerScheme = { IntegerSettings.default.currentScheme }
- ) : DataInsertRepeatArrayAction(ArrayAction(arrayScheme, scheme), RandomnessIcons.Integer.RepeatArray) {
- override val name = "Random Repeated Integer Array"
- }
-}
-
-
-/**
- * Controller for random integer generation settings.
- *
- * @see IntegerSettings
- * @see IntegerSettingsComponent
- */
-class IntegerSettingsAction : DataSettingsAction(RandomnessIcons.Integer.Settings) {
- override val name = "Integer Settings"
-
- override val configurableClass = IntegerSettingsConfigurable::class.java
-
-
- /**
- * Opens a popup to allow the user to quickly switch to the selected scheme.
- *
- * @param settings the settings containing the schemes that can be switched between
- */
- class IntegerQuickSwitchSchemeAction(settings: IntegerSettings = IntegerSettings.default) :
- DataQuickSwitchSchemeAction(settings, RandomnessIcons.Integer.QuickSwitchScheme) {
- override val name = "Quick Switch Integer Scheme"
- }
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/integer/IntegerScheme.kt b/src/main/kotlin/com/fwdekker/randomness/integer/IntegerScheme.kt
new file mode 100644
index 000000000..80fbc6384
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/integer/IntegerScheme.kt
@@ -0,0 +1,182 @@
+package com.fwdekker.randomness.integer
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.CapitalizationMode
+import com.fwdekker.randomness.Icons
+import com.fwdekker.randomness.Scheme
+import com.fwdekker.randomness.TypeIcon
+import com.fwdekker.randomness.affix.AffixDecorator
+import com.fwdekker.randomness.array.ArrayDecorator
+import com.fwdekker.randomness.fixedlength.FixedLengthDecorator
+import com.intellij.ui.JBColor
+import com.intellij.util.xmlb.annotations.Transient
+import java.awt.Color
+import java.text.DecimalFormat
+
+
+/**
+ * Contains settings for generating random integers.
+ *
+ * @property minValue The minimum value to be generated, inclusive.
+ * @property maxValue The maximum value to be generated, inclusive.
+ * @property base The base the generated value should be displayed in.
+ * @property isUppercase `true` if and only if all letters are uppercase.
+ * @property groupingSeparatorEnabled `true` if and only if the [groupingSeparator] should be applied.
+ * @property groupingSeparator The character that should separate groups if [groupingSeparatorEnabled] is `true`.
+ * @property fixedLengthDecorator Settings that determine whether the output should be fixed to a specific length.
+ * @property affixDecorator The affixation to apply to the generated values.
+ * @property arrayDecorator Settings that determine whether the output should be an array of values.
+ */
+data class IntegerScheme(
+ var minValue: Long = DEFAULT_MIN_VALUE,
+ var maxValue: Long = DEFAULT_MAX_VALUE,
+ var base: Int = DEFAULT_BASE,
+ var isUppercase: Boolean = DEFAULT_IS_UPPERCASE,
+ var groupingSeparatorEnabled: Boolean = DEFAULT_GROUPING_SEPARATOR_ENABLED,
+ var groupingSeparator: String = DEFAULT_GROUPING_SEPARATOR,
+ val fixedLengthDecorator: FixedLengthDecorator = DEFAULT_FIXED_LENGTH_DECORATOR,
+ val affixDecorator: AffixDecorator = DEFAULT_AFFIX_DECORATOR,
+ val arrayDecorator: ArrayDecorator = DEFAULT_ARRAY_DECORATOR,
+) : Scheme() {
+ @get:Transient
+ override val name = Bundle("integer.title")
+ override val typeIcon = BASE_ICON
+ override val decorators get() = listOf(fixedLengthDecorator, affixDecorator, arrayDecorator)
+
+
+ /**
+ * Returns [count] random formatted integers from [minValue] (inclusive) to [maxValue] (inclusive).
+ */
+ override fun generateUndecoratedStrings(count: Int) =
+ List(count) { longToString(randomLong(minValue, maxValue)) }
+
+ /**
+ * Returns a random long in the range from [from] (inclusive) to [until] (inclusive) without causing overflow.
+ */
+ private fun randomLong(from: Long, until: Long) =
+ if (from == Long.MIN_VALUE && until == Long.MAX_VALUE) random.nextLong()
+ else if (until == Long.MAX_VALUE) random.nextLong(from - 1, until) + 1
+ else random.nextLong(from, until + 1)
+
+ /**
+ * Returns a nicely formatted representation of [value].
+ */
+ private fun longToString(value: Long): String {
+ if (base != DECIMAL_BASE) {
+ val capitalization = if (isUppercase) CapitalizationMode.UPPER else CapitalizationMode.LOWER
+ return capitalization.transform(value.toString(base), random)
+ }
+
+ val format = DecimalFormat()
+ format.isGroupingUsed = groupingSeparatorEnabled
+ format.minimumFractionDigits = 0
+ format.maximumFractionDigits = 0
+ format.decimalFormatSymbols =
+ format.decimalFormatSymbols.also {
+ it.groupingSeparator = groupingSeparator.getOrElse(0) { DEFAULT_GROUPING_SEPARATOR[0] }
+ }
+
+ return format.format(value)
+ }
+
+
+ override fun doValidate() =
+ when {
+ minValue > maxValue -> Bundle("integer.error.min_value_above_max")
+ base !in MIN_BASE..MAX_BASE -> Bundle("integer.error.base_range", "$MIN_BASE..$MAX_BASE")
+ groupingSeparator.length != 1 -> Bundle("integer.error.grouping_separator_length")
+ else -> fixedLengthDecorator.doValidate() ?: affixDecorator.doValidate() ?: arrayDecorator.doValidate()
+ }
+
+ override fun deepCopy(retainUuid: Boolean) =
+ copy(
+ fixedLengthDecorator = fixedLengthDecorator.deepCopy(retainUuid),
+ affixDecorator = affixDecorator.deepCopy(retainUuid),
+ arrayDecorator = arrayDecorator.deepCopy(retainUuid),
+ ).deepCopyTransient(retainUuid)
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The base icon for integers.
+ */
+ val BASE_ICON = TypeIcon(
+ Icons.SCHEME,
+ "123",
+ listOf(JBColor(Color(64, 182, 224, 154), Color(64, 182, 224, 154)))
+ )
+
+ /**
+ * The default value of the [minValue] field.
+ */
+ const val DEFAULT_MIN_VALUE = 0L
+
+ /**
+ * The default value of the [maxValue] field.
+ */
+ const val DEFAULT_MAX_VALUE = 1000L
+
+ /**
+ * The minimum value of the [base] field.
+ */
+ const val MIN_BASE = Character.MIN_RADIX
+
+ /**
+ * The maximum value of the [base] field.
+ */
+ const val MAX_BASE = Character.MAX_RADIX
+
+ /**
+ * The definition of decimal base.
+ */
+ const val DECIMAL_BASE = 10
+
+ /**
+ * The default value of the [base] field.
+ */
+ const val DEFAULT_BASE = DECIMAL_BASE
+
+ /**
+ * The default value of the [isUppercase] field.
+ */
+ const val DEFAULT_IS_UPPERCASE = false
+
+ /**
+ * The default value of the [groupingSeparatorEnabled] field.
+ */
+ const val DEFAULT_GROUPING_SEPARATOR_ENABLED = false
+
+ /**
+ * The preset values for the [groupingSeparator] descriptor.
+ */
+ val PRESET_GROUPING_SEPARATORS = arrayOf(".", ",", "_")
+
+ /**
+ * The default value of the [groupingSeparator] field.
+ */
+ const val DEFAULT_GROUPING_SEPARATOR = ","
+
+ /**
+ * The preset values for the [affixDecorator] descriptor.
+ */
+ val PRESET_AFFIX_DECORATOR_DESCRIPTORS = listOf("@b", "$@", "0x@")
+
+ /**
+ * The default value of the [fixedLengthDecorator] field.
+ */
+ val DEFAULT_FIXED_LENGTH_DECORATOR get() = FixedLengthDecorator()
+
+ /**
+ * The default value of the [affixDecorator] field.
+ */
+ val DEFAULT_AFFIX_DECORATOR get() = AffixDecorator(enabled = false, descriptor = "0x@")
+
+ /**
+ * The default value of the [arrayDecorator] field.
+ */
+ val DEFAULT_ARRAY_DECORATOR get() = ArrayDecorator()
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSchemeEditor.kt b/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSchemeEditor.kt
new file mode 100644
index 000000000..1dacc7b66
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSchemeEditor.kt
@@ -0,0 +1,124 @@
+package com.fwdekker.randomness.integer
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.SchemeEditor
+import com.fwdekker.randomness.affix.AffixDecoratorEditor
+import com.fwdekker.randomness.array.ArrayDecoratorEditor
+import com.fwdekker.randomness.fixedlength.FixedLengthDecoratorEditor
+import com.fwdekker.randomness.integer.IntegerScheme.Companion.DECIMAL_BASE
+import com.fwdekker.randomness.integer.IntegerScheme.Companion.MAX_BASE
+import com.fwdekker.randomness.integer.IntegerScheme.Companion.MIN_BASE
+import com.fwdekker.randomness.integer.IntegerScheme.Companion.PRESET_AFFIX_DECORATOR_DESCRIPTORS
+import com.fwdekker.randomness.integer.IntegerScheme.Companion.PRESET_GROUPING_SEPARATORS
+import com.fwdekker.randomness.ui.JIntSpinner
+import com.fwdekker.randomness.ui.JLongSpinner
+import com.fwdekker.randomness.ui.MinMaxLengthDocumentFilter
+import com.fwdekker.randomness.ui.UIConstants
+import com.fwdekker.randomness.ui.bindCurrentText
+import com.fwdekker.randomness.ui.bindIntValue
+import com.fwdekker.randomness.ui.bindLongValue
+import com.fwdekker.randomness.ui.bindSpinners
+import com.fwdekker.randomness.ui.hasValue
+import com.fwdekker.randomness.ui.isEditable
+import com.fwdekker.randomness.ui.loadMnemonic
+import com.fwdekker.randomness.ui.withFilter
+import com.fwdekker.randomness.ui.withFixedWidth
+import com.fwdekker.randomness.ui.withName
+import com.intellij.openapi.ui.ComboBox
+import com.intellij.ui.dsl.builder.bindSelected
+import com.intellij.ui.dsl.builder.panel
+import com.intellij.ui.dsl.gridLayout.HorizontalAlign
+import com.intellij.ui.layout.and
+import com.intellij.ui.layout.selected
+import javax.swing.JCheckBox
+
+
+/**
+ * Component for editing an [IntegerScheme].
+ *
+ * @param scheme the scheme to edit
+ */
+class IntegerSchemeEditor(scheme: IntegerScheme = IntegerScheme()) : SchemeEditor(scheme) {
+ override val rootComponent = panel {
+ group(Bundle("integer.ui.value.header")) {
+ lateinit var minValue: JLongSpinner
+ lateinit var maxValue: JLongSpinner
+
+ row(Bundle("integer.ui.value.min_option")) {
+ cell(JLongSpinner())
+ .withFixedWidth(UIConstants.SIZE_LARGE)
+ .withName("minValue")
+ .bindLongValue(scheme::minValue)
+ .also { minValue = it.component }
+ }
+
+ row(Bundle("integer.ui.value.max_option")) {
+ cell(JLongSpinner())
+ .withFixedWidth(UIConstants.SIZE_LARGE)
+ .withName("maxValue")
+ .bindLongValue(scheme::maxValue)
+ .also { maxValue = it.component }
+ }
+
+ bindSpinners(minValue, maxValue, maxRange = null)
+ }
+
+ group(Bundle("integer.ui.format.header")) {
+ lateinit var base: JIntSpinner
+ lateinit var groupingSeparatorEnabled: JCheckBox
+
+ row(Bundle("integer.ui.format.base_option")) {
+ cell(JIntSpinner(DECIMAL_BASE, MIN_BASE, MAX_BASE))
+ .withFixedWidth(UIConstants.SIZE_SMALL)
+ .withName("base")
+ .bindIntValue(scheme::base)
+ .also { base = it.component }
+ }
+
+ row {
+ checkBox(Bundle("integer.ui.format.uppercase_option"))
+ .loadMnemonic()
+ .withName("isUppercase")
+ .bindSelected(scheme::isUppercase)
+ }
+
+ row {
+ checkBox(Bundle("integer.ui.format.grouping_separator_option"))
+ .loadMnemonic()
+ .withName("groupingSeparatorEnabled")
+ .bindSelected(scheme::groupingSeparatorEnabled)
+ .also { groupingSeparatorEnabled = it.component }
+
+ cell(ComboBox(PRESET_GROUPING_SEPARATORS))
+ .enabledIf(base.hasValue { it == DECIMAL_BASE }.and(groupingSeparatorEnabled.selected))
+ .withName("groupingSeparator")
+ .isEditable(true)
+ .withFilter(MinMaxLengthDocumentFilter(1, 1))
+ .bindCurrentText(scheme::groupingSeparator)
+ }.enabledIf(base.hasValue { it == DECIMAL_BASE })
+
+ row {
+ AffixDecoratorEditor(scheme.affixDecorator, PRESET_AFFIX_DECORATOR_DESCRIPTORS)
+ .also { decoratorEditors += it }
+ .let { cell(it.rootComponent) }
+ }
+ }
+
+ row {
+ FixedLengthDecoratorEditor(scheme.fixedLengthDecorator)
+ .also { decoratorEditors += it }
+ .let { cell(it.rootComponent).horizontalAlign(HorizontalAlign.FILL) }
+ }
+
+ row {
+ ArrayDecoratorEditor(scheme.arrayDecorator)
+ .also { decoratorEditors += it }
+ .let { cell(it.rootComponent).horizontalAlign(HorizontalAlign.FILL) }
+ }
+ }
+
+
+ init {
+ reset()
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSettings.kt b/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSettings.kt
deleted file mode 100644
index 23dc7e9fc..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSettings.kt
+++ /dev/null
@@ -1,175 +0,0 @@
-package com.fwdekker.randomness.integer
-
-import com.fwdekker.randomness.CapitalizationMode
-import com.fwdekker.randomness.Scheme
-import com.fwdekker.randomness.Scheme.Companion.DEFAULT_NAME
-import com.fwdekker.randomness.Settings
-import com.fwdekker.randomness.SettingsConfigurable
-import com.intellij.openapi.components.State
-import com.intellij.openapi.components.Storage
-import com.intellij.openapi.components.service
-import com.intellij.util.xmlb.XmlSerializerUtil
-import com.intellij.util.xmlb.annotations.MapAnnotation
-
-
-/**
- * The user-configurable collection of schemes applicable to generating integers.
- *
- * @property schemes the schemes that the user can choose from
- * @property currentSchemeName the scheme that is currently active
- *
- * @see IntegerSettingsAction
- * @see IntegerSettingsConfigurable
- */
-@State(name = "IntegerSettings", storages = [Storage("\$APP_CONFIG\$/randomness.xml")])
-data class IntegerSettings(
- @MapAnnotation(sortBeforeSave = false)
- override var schemes: MutableList = DEFAULT_SCHEMES,
- override var currentSchemeName: String = DEFAULT_CURRENT_SCHEME_NAME
-) : Settings {
- override fun deepCopy() = copy(schemes = schemes.map { it.copy() }.toMutableList())
-
- override fun getState() = this
-
- override fun loadState(state: IntegerSettings) = XmlSerializerUtil.copyBean(state, this)
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The default value of the [schemes][schemes] field.
- */
- val DEFAULT_SCHEMES: MutableList
- get() = mutableListOf(
- IntegerScheme(),
- IntegerScheme("Byte", minValue = -128, maxValue = 127),
- IntegerScheme("Hex", minValue = 0, maxValue = 256, base = 16, groupingSeparator = "", prefix = "0x")
- )
-
- /**
- * The default value of the [currentSchemeName][currentSchemeName] field.
- */
- const val DEFAULT_CURRENT_SCHEME_NAME = DEFAULT_NAME
-
- /**
- * The persistent `IntegerSettings` instance.
- */
- val default: IntegerSettings
- get() = service()
- }
-}
-
-
-/**
- * Contains settings for generating random integers.
- *
- * @property myName The name of the scheme.
- * @property minValue The minimum value to be generated, inclusive.
- * @property maxValue The maximum value to be generated, inclusive.
- * @property base The base the generated value should be displayed in.
- * @property groupingSeparator The character that should separate groups.
- * @property capitalization The capitalization mode of the generated integer, applicable for bases higher than 10.
- * @property prefix The string to prepend to the generated value.
- * @property suffix The string to append to the generated value.
- *
- * @see IntegerInsertAction
- * @see IntegerSettings
- */
-// TODO Turn the separator property into a char property once supported by the settings serializer
-data class IntegerScheme(
- override var myName: String = DEFAULT_NAME,
- var minValue: Long = DEFAULT_MIN_VALUE,
- var maxValue: Long = DEFAULT_MAX_VALUE,
- var base: Int = DEFAULT_BASE,
- var groupingSeparator: String = DEFAULT_GROUPING_SEPARATOR,
- var capitalization: CapitalizationMode = DEFAULT_CAPITALIZATION,
- var prefix: String = DEFAULT_PREFIX,
- var suffix: String = DEFAULT_SUFFIX
-) : Scheme {
- override fun copyFrom(other: IntegerScheme) = XmlSerializerUtil.copyBean(other, this)
-
- override fun copyAs(name: String) = this.copy(myName = name)
-
-
- /**
- * Sets the grouping separator safely to ensure that exactly one character is set.
- *
- * @param groupingSeparator the possibly-unsafe grouping separator string
- */
- fun safeSetGroupingSeparator(groupingSeparator: String?) {
- if (groupingSeparator.isNullOrEmpty())
- this.groupingSeparator = DEFAULT_GROUPING_SEPARATOR
- else
- this.groupingSeparator = groupingSeparator.substring(0, 1)
- }
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The minimum value of the [base][base] field.
- */
- const val MIN_BASE = Character.MIN_RADIX
-
- /**
- * The maximum value of the [base][base] field.
- */
- const val MAX_BASE = Character.MAX_RADIX
-
- /**
- * The definition of decimal base.
- */
- const val DECIMAL_BASE = 10
-
- /**
- * The default value of the [minValue][minValue] field.
- */
- const val DEFAULT_MIN_VALUE = 0L
-
- /**
- * The default value of the [maxValue][maxValue] field.
- */
- const val DEFAULT_MAX_VALUE = 1000L
-
- /**
- * The default value of the [base][base] field.
- */
- const val DEFAULT_BASE = DECIMAL_BASE
-
- /**
- * The default value of the [groupingSeparator][groupingSeparator] field.
- */
- const val DEFAULT_GROUPING_SEPARATOR = ""
-
- /**
- * The default value of the [capitalization][capitalization] field.
- */
- val DEFAULT_CAPITALIZATION = CapitalizationMode.LOWER
-
- /**
- * The default value of the [prefix][prefix] field.
- */
- const val DEFAULT_PREFIX = ""
-
- /**
- * The default value of the [suffix][suffix] field.
- */
- const val DEFAULT_SUFFIX = ""
- }
-}
-
-
-/**
- * The configurable for integer settings.
- *
- * @see IntegerSettingsAction
- */
-class IntegerSettingsConfigurable(
- override val component: IntegerSettingsComponent = IntegerSettingsComponent()
-) : SettingsConfigurable() {
- override fun getDisplayName() = "Integers"
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSettingsComponent.form b/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSettingsComponent.form
deleted file mode 100644
index 8f7366d37..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSettingsComponent.form
+++ /dev/null
@@ -1,294 +0,0 @@
-
-
diff --git a/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSettingsComponent.kt b/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSettingsComponent.kt
deleted file mode 100644
index 44ec5b182..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/integer/IntegerSettingsComponent.kt
+++ /dev/null
@@ -1,128 +0,0 @@
-package com.fwdekker.randomness.integer
-
-import com.fwdekker.randomness.CapitalizationMode.Companion.getMode
-import com.fwdekker.randomness.SchemesPanel
-import com.fwdekker.randomness.SettingsComponent
-import com.fwdekker.randomness.SettingsComponentListener
-import com.fwdekker.randomness.integer.IntegerScheme.Companion.DEFAULT_CAPITALIZATION
-import com.fwdekker.randomness.integer.IntegerSettings.Companion.DEFAULT_SCHEMES
-import com.fwdekker.randomness.integer.IntegerSettings.Companion.default
-import com.fwdekker.randomness.ui.JIntSpinner
-import com.fwdekker.randomness.ui.JLongSpinner
-import com.fwdekker.randomness.ui.JSpinnerRange
-import com.fwdekker.randomness.ui.PreviewPanel
-import com.fwdekker.randomness.ui.forEach
-import com.fwdekker.randomness.ui.getValue
-import com.fwdekker.randomness.ui.setValue
-import javax.swing.ButtonGroup
-import javax.swing.JPanel
-import javax.swing.JTextField
-import javax.swing.event.ChangeEvent
-
-
-/**
- * Component for settings of random integer generation.
- *
- * @param settings the settings to edit in the component
- *
- * @see IntegerSettingsAction
- */
-@Suppress("LateinitUsage") // Initialized by scene builder
-class IntegerSettingsComponent(settings: IntegerSettings = default) :
- SettingsComponent(settings) {
- override lateinit var unsavedSettings: IntegerSettings
- override lateinit var schemesPanel: SchemesPanel
-
- private lateinit var contentPane: JPanel
- private lateinit var previewPanelHolder: PreviewPanel
- private lateinit var previewPanel: JPanel
- private lateinit var valueRange: JSpinnerRange
- private lateinit var minValue: JLongSpinner
- private lateinit var maxValue: JLongSpinner
- private lateinit var base: JIntSpinner
- private lateinit var groupingSeparatorGroup: ButtonGroup
- private lateinit var capitalizationGroup: ButtonGroup
- private lateinit var prefixInput: JTextField
- private lateinit var suffixInput: JTextField
-
- override val rootPane get() = contentPane
-
-
- init {
- loadSettings()
-
- base.addChangeListener {
- groupingSeparatorGroup.forEach { it.isEnabled = base.value == IntegerScheme.DECIMAL_BASE }
- capitalizationGroup.forEach { it.isEnabled = base.value > IntegerScheme.DECIMAL_BASE }
- }
- base.changeListeners.forEach { it.stateChanged(ChangeEvent(base)) }
-
- previewPanelHolder.updatePreviewOnUpdateOf(minValue, maxValue, base, groupingSeparatorGroup)
- previewPanelHolder.updatePreviewOnUpdateOf(capitalizationGroup, prefixInput, suffixInput)
- previewPanelHolder.updatePreview()
- }
-
-
- /**
- * Initialises custom UI components.
- *
- * This method is called by the scene builder at the start of the constructor.
- */
- @Suppress("UnusedPrivateMember") // Used by scene builder
- private fun createUIComponents() {
- unsavedSettings = IntegerSettings()
- schemesPanel = IntegerSchemesPanel(unsavedSettings)
- .also { it.addListener(SettingsComponentListener(this)) }
-
- previewPanelHolder = PreviewPanel { IntegerInsertAction { IntegerScheme().also { saveScheme(it) } } }
- previewPanel = previewPanelHolder.rootPane
-
- minValue = JLongSpinner(description = "minimum value")
- maxValue = JLongSpinner(description = "maximum value")
- base = JIntSpinner(
- IntegerScheme.DECIMAL_BASE,
- IntegerScheme.MIN_BASE, IntegerScheme.MAX_BASE,
- description = "base"
- )
- valueRange = JSpinnerRange(minValue, maxValue, maxRange = null, "value")
- }
-
- override fun loadScheme(scheme: IntegerScheme) {
- minValue.value = scheme.minValue
- maxValue.value = scheme.maxValue
- base.value = scheme.base
- groupingSeparatorGroup.setValue(scheme.groupingSeparator)
- capitalizationGroup.setValue(scheme.capitalization)
- prefixInput.text = scheme.prefix
- suffixInput.text = scheme.suffix
- }
-
- override fun saveScheme(scheme: IntegerScheme) {
- scheme.minValue = minValue.value
- scheme.maxValue = maxValue.value
- scheme.base = base.value
- scheme.safeSetGroupingSeparator(groupingSeparatorGroup.getValue())
- scheme.capitalization = capitalizationGroup.getValue()?.let { getMode(it) } ?: DEFAULT_CAPITALIZATION
- scheme.prefix = prefixInput.text
- scheme.suffix = suffixInput.text
- }
-
- override fun doValidate() =
- minValue.validateValue()
- ?: maxValue.validateValue()
- ?: base.validateValue()
- ?: valueRange.validateValue()
-
-
- /**
- * A panel to select schemes from.
- *
- * @param settings the settings model backing up the panel
- */
- private class IntegerSchemesPanel(settings: IntegerSettings) : SchemesPanel(settings) {
- override val type: Class
- get() = IntegerScheme::class.java
-
- override fun createDefaultInstances() = DEFAULT_SCHEMES
- }
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/string/StringActions.kt b/src/main/kotlin/com/fwdekker/randomness/string/StringActions.kt
deleted file mode 100644
index 014d07540..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/string/StringActions.kt
+++ /dev/null
@@ -1,125 +0,0 @@
-package com.fwdekker.randomness.string
-
-import com.fwdekker.randomness.DataGenerationException
-import com.fwdekker.randomness.DataGroupAction
-import com.fwdekker.randomness.DataInsertAction
-import com.fwdekker.randomness.DataInsertArrayAction
-import com.fwdekker.randomness.DataInsertRepeatAction
-import com.fwdekker.randomness.DataInsertRepeatArrayAction
-import com.fwdekker.randomness.DataQuickSwitchSchemeAction
-import com.fwdekker.randomness.DataSettingsAction
-import com.fwdekker.randomness.array.ArrayScheme
-import com.fwdekker.randomness.array.ArraySettings
-import com.fwdekker.randomness.array.ArraySettingsAction
-import icons.RandomnessIcons
-
-
-/**
- * All actions related to inserting strings.
- */
-class StringGroupAction : DataGroupAction(RandomnessIcons.String.Base) {
- override val insertAction = StringInsertAction()
- override val insertArrayAction = StringInsertAction.ArrayAction()
- override val insertRepeatAction = StringInsertAction.RepeatAction()
- override val insertRepeatArrayAction = StringInsertAction.RepeatArrayAction()
- override val settingsAction = StringSettingsAction()
- override val quickSwitchSchemeAction = StringSettingsAction.StringQuickSwitchSchemeAction()
- override val quickSwitchArraySchemeAction = ArraySettingsAction.ArrayQuickSwitchSchemeAction()
-}
-
-
-/**
- * Inserts random alphanumerical strings.
- *
- * @property scheme the scheme to use for generating strings
- */
-class StringInsertAction(private val scheme: () -> StringScheme = { StringSettings.default.currentScheme }) :
- DataInsertAction(RandomnessIcons.String.Base) {
- override val name = "Random String"
-
-
- /**
- * Returns strings of random alphanumerical characters.
- *
- * @param count the number of strings to generate
- * @return strings of random alphanumerical characters
- */
- override fun generateStrings(count: Int): List {
- val scheme = scheme()
- if (scheme.minLength > scheme.maxLength)
- throw DataGenerationException("Minimum length is larger than maximum length.")
-
- val symbols = scheme.activeSymbolSetList.sum(scheme.excludeLookAlikeSymbols)
- if (symbols.isEmpty())
- throw DataGenerationException("No valid symbols found in active symbol sets.")
-
- return List(count) {
- val length = random.nextInt(scheme.minLength, scheme.maxLength + 1)
- val text = List(length) { symbols.random(random) }.joinToString("")
- val capitalizedText = scheme.capitalization.transform(text, random)
-
- scheme.prefix + scheme.enclosure + capitalizedText + scheme.enclosure + scheme.suffix
- }
- }
-
-
- /**
- * Inserts an array-like string of strings.
- *
- * @param arrayScheme the scheme to use for generating arrays
- * @param scheme the scheme to use for generating strings
- */
- class ArrayAction(
- arrayScheme: () -> ArrayScheme = { ArraySettings.default.currentScheme },
- scheme: () -> StringScheme = { StringSettings.default.currentScheme }
- ) : DataInsertArrayAction(arrayScheme, StringInsertAction(scheme), RandomnessIcons.String.Array) {
- override val name = "Random String Array"
- }
-
- /**
- * Inserts repeated random strings.
- *
- * @param scheme the settings to use for generating strings
- */
- class RepeatAction(scheme: () -> StringScheme = { StringSettings.default.currentScheme }) :
- DataInsertRepeatAction(StringInsertAction(scheme), RandomnessIcons.String.Repeat) {
- override val name = "Random Repeated String"
- }
-
- /**
- * Inserts repeated array-like strings of strings.
- *
- * @param arrayScheme the scheme to use for generating arrays
- * @param scheme the scheme to use for generating strings
- */
- class RepeatArrayAction(
- arrayScheme: () -> ArrayScheme = { ArraySettings.default.currentScheme },
- scheme: () -> StringScheme = { StringSettings.default.currentScheme }
- ) : DataInsertRepeatArrayAction(ArrayAction(arrayScheme, scheme), RandomnessIcons.String.RepeatArray) {
- override val name = "Random Repeated String Array"
- }
-}
-
-
-/**
- * Controller for random string generation settings.
- *
- * @see StringSettings
- * @see StringSettingsComponent
- */
-class StringSettingsAction : DataSettingsAction(RandomnessIcons.String.Settings) {
- override val name = "String Settings"
-
- override val configurableClass = StringSettingsConfigurable::class.java
-
-
- /**
- * Opens a popup to allow the user to quickly switch to the selected scheme.
- *
- * @param settings the settings containing the schemes that can be switched between
- */
- class StringQuickSwitchSchemeAction(settings: StringSettings = StringSettings.default) :
- DataQuickSwitchSchemeAction(settings, RandomnessIcons.String.QuickSwitchScheme) {
- override val name = "Quick Switch String Scheme"
- }
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/string/StringScheme.kt b/src/main/kotlin/com/fwdekker/randomness/string/StringScheme.kt
new file mode 100644
index 000000000..ece5d1c0f
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/string/StringScheme.kt
@@ -0,0 +1,144 @@
+package com.fwdekker.randomness.string
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.CapitalizationMode
+import com.fwdekker.randomness.Icons
+import com.fwdekker.randomness.Scheme
+import com.fwdekker.randomness.TypeIcon
+import com.fwdekker.randomness.array.ArrayDecorator
+import com.fwdekker.randomness.string.StringScheme.Companion.LOOK_ALIKE_CHARACTERS
+import com.github.curiousoddman.rgxgen.RgxGen
+import com.github.curiousoddman.rgxgen.parsing.dflt.RgxGenParseException
+import com.intellij.ui.JBColor
+import java.awt.Color
+import kotlin.random.asJavaRandom
+
+
+/**
+ * Contains settings for generating random strings.
+ *
+ * @property pattern The regex-like pattern according to which the string is generated.
+ * @property isRegex `true` if and only if [pattern] should be interpreted as a regex.
+ * @property removeLookAlikeSymbols Whether the symbols in [LOOK_ALIKE_CHARACTERS] should be removed.
+ * @property capitalization The capitalization mode of the generated string.
+ * @property arrayDecorator Settings that determine whether the output should be an array of values.
+ */
+data class StringScheme(
+ var pattern: String = DEFAULT_PATTERN,
+ var isRegex: Boolean = DEFAULT_IS_REGEX,
+ var removeLookAlikeSymbols: Boolean = DEFAULT_REMOVE_LOOK_ALIKE_SYMBOLS,
+ var capitalization: CapitalizationMode = DEFAULT_CAPITALIZATION,
+ val arrayDecorator: ArrayDecorator = DEFAULT_ARRAY_DECORATOR,
+) : Scheme() {
+ override val name = Bundle("string.title")
+ override val typeIcon = BASE_ICON
+ override val decorators get() = listOf(arrayDecorator)
+
+
+ /**
+ * Returns `true` if and only if this scheme does not use any regex functionality beyond escape characters.
+ */
+ fun isSimple() =
+ doValidate() == null &&
+ generateStrings()[0] == if (isRegex) pattern.replace(Regex("\\\\(.)"), "$1") else pattern
+
+
+ /**
+ * Returns [count] strings of random alphanumerical characters.
+ */
+ override fun generateUndecoratedStrings(count: Int): List {
+ val rawStrings =
+ if (isRegex) {
+ val rgxGen = RgxGen(pattern)
+ List(count) { rgxGen.generate(random.asJavaRandom()) }
+ } else {
+ List(count) { pattern }
+ }
+
+ return rawStrings.map { rawString ->
+ val capitalizedString = capitalization.transform(rawString, random)
+
+ if (removeLookAlikeSymbols) capitalizedString.filterNot { it in LOOK_ALIKE_CHARACTERS }
+ else capitalizedString
+ }
+ }
+
+
+ override fun doValidate() =
+ when {
+ !isRegex -> arrayDecorator.doValidate()
+ pattern.takeLastWhile { it == '\\' }.length.mod(2) != 0 -> Bundle("string.error.trailing_backslash")
+ pattern == "{}" || pattern.contains(Regex("""[^\\]\{}""")) -> Bundle("string.error.empty_curly")
+ pattern == "[]" || pattern.contains(Regex("""[^\\]\[]""")) -> Bundle("string.error.empty_square")
+ else ->
+ @Suppress("detekt:TooGenericExceptionCaught") // Consequence of incomplete validation in RgxGen
+ try {
+ RgxGen(pattern).generate()
+ arrayDecorator.doValidate()
+ } catch (exception: RgxGenParseException) {
+ exception.message
+ } catch (exception: Exception) {
+ "Uncaught RgxGen exception: ${exception.message}"
+ }
+ }
+
+ override fun deepCopy(retainUuid: Boolean) =
+ copy(arrayDecorator = arrayDecorator.deepCopy(retainUuid)).deepCopyTransient(retainUuid)
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * Symbols that look like other symbols.
+ *
+ * To be precise, this string contains the symbols `0`, `1`, `l`, `I`, `O`, `|`, and `﹒`.
+ */
+ const val LOOK_ALIKE_CHARACTERS = "01lLiIoO|﹒"
+
+ /**
+ * The base icon for strings.
+ */
+ val BASE_ICON = TypeIcon(
+ Icons.SCHEME,
+ "abc",
+ listOf(JBColor(Color(244, 175, 61, 154), Color(244, 175, 61, 154)))
+ )
+
+ /**
+ * The default value of the [pattern] field.
+ */
+ const val DEFAULT_PATTERN = "[a-zA-Z0-9]{7,11}"
+
+ /**
+ * The default value of the [isRegex] field.
+ */
+ const val DEFAULT_IS_REGEX = true
+
+ /**
+ * The default value of the [removeLookAlikeSymbols] field.
+ */
+ const val DEFAULT_REMOVE_LOOK_ALIKE_SYMBOLS = false
+
+ /**
+ * The preset values for the [capitalization] field.
+ */
+ val PRESET_CAPITALIZATION = arrayOf(
+ CapitalizationMode.RETAIN,
+ CapitalizationMode.LOWER,
+ CapitalizationMode.UPPER,
+ CapitalizationMode.RANDOM,
+ )
+
+ /**
+ * The default value of the [capitalization] field.
+ */
+ val DEFAULT_CAPITALIZATION get() = CapitalizationMode.RETAIN
+
+ /**
+ * The default value of the [arrayDecorator] field.
+ */
+ val DEFAULT_ARRAY_DECORATOR get() = ArrayDecorator()
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/string/StringSchemeEditor.kt b/src/main/kotlin/com/fwdekker/randomness/string/StringSchemeEditor.kt
new file mode 100644
index 000000000..f5ac2146d
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/string/StringSchemeEditor.kt
@@ -0,0 +1,75 @@
+package com.fwdekker.randomness.string
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.CapitalizationMode
+import com.fwdekker.randomness.SchemeEditor
+import com.fwdekker.randomness.array.ArrayDecoratorEditor
+import com.fwdekker.randomness.string.StringScheme.Companion.PRESET_CAPITALIZATION
+import com.fwdekker.randomness.ui.UIConstants
+import com.fwdekker.randomness.ui.loadMnemonic
+import com.fwdekker.randomness.ui.withFixedWidth
+import com.fwdekker.randomness.ui.withName
+import com.fwdekker.randomness.ui.withSimpleRenderer
+import com.intellij.openapi.ui.ComboBox
+import com.intellij.ui.dsl.builder.BottomGap
+import com.intellij.ui.dsl.builder.bindItem
+import com.intellij.ui.dsl.builder.bindSelected
+import com.intellij.ui.dsl.builder.bindText
+import com.intellij.ui.dsl.builder.panel
+import com.intellij.ui.dsl.builder.toNullableProperty
+import com.intellij.ui.dsl.gridLayout.HorizontalAlign
+
+
+/**
+ * Component for editing a [StringScheme].
+ *
+ * @param scheme the scheme to edit
+ */
+class StringSchemeEditor(scheme: StringScheme = StringScheme()) : SchemeEditor(scheme) {
+ override val rootComponent = panel {
+ group(Bundle("string.ui.value.header")) {
+ row(Bundle("string.ui.value.pattern_option")) {
+ textField()
+ .withFixedWidth(UIConstants.SIZE_VERY_LARGE)
+ .withName("pattern")
+ .bindText(scheme::pattern)
+
+ browserLink(Bundle("string.ui.value.pattern_help"), Bundle("string.ui.value.pattern_help_url"))
+ }
+
+ row("") {
+ checkBox(Bundle("string.ui.value.is_regex_option"))
+ .loadMnemonic()
+ .withName("isRegex")
+ .bindSelected(scheme::isRegex)
+ }
+
+ row("") {
+ checkBox(Bundle("string.ui.value.remove_look_alike"))
+ .loadMnemonic()
+ .withName("removeLookAlikeCharacters")
+ .bindSelected(scheme::removeLookAlikeSymbols)
+
+ contextHelp(Bundle("string.ui.value.remove_look_alike_help", StringScheme.LOOK_ALIKE_CHARACTERS))
+ }.bottomGap(BottomGap.SMALL)
+
+ row(Bundle("string.ui.value.capitalization_option")) {
+ cell(ComboBox(PRESET_CAPITALIZATION))
+ .withSimpleRenderer(CapitalizationMode::toLocalizedString)
+ .withName("capitalization")
+ .bindItem(scheme::capitalization.toNullableProperty())
+ }
+ }
+
+ row {
+ ArrayDecoratorEditor(scheme.arrayDecorator)
+ .also { decoratorEditors += it }
+ .let { cell(it.rootComponent).horizontalAlign(HorizontalAlign.FILL) }
+ }
+ }
+
+
+ init {
+ reset()
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/string/StringSettings.kt b/src/main/kotlin/com/fwdekker/randomness/string/StringSettings.kt
deleted file mode 100644
index 8fe795e8b..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/string/StringSettings.kt
+++ /dev/null
@@ -1,203 +0,0 @@
-package com.fwdekker.randomness.string
-
-import com.fwdekker.randomness.CapitalizationMode
-import com.fwdekker.randomness.Scheme
-import com.fwdekker.randomness.Scheme.Companion.DEFAULT_NAME
-import com.fwdekker.randomness.Settings
-import com.fwdekker.randomness.SettingsConfigurable
-import com.intellij.openapi.components.State
-import com.intellij.openapi.components.Storage
-import com.intellij.openapi.components.service
-import com.intellij.util.xmlb.XmlSerializerUtil
-import com.intellij.util.xmlb.annotations.MapAnnotation
-import com.intellij.util.xmlb.annotations.Transient
-import com.vdurmont.emoji.EmojiParser
-
-
-/**
- * The user-configurable collection of schemes applicable to generating strings.
- *
- * @property schemes the schemes that the user can choose from
- * @property currentSchemeName the scheme that is currently active
- *
- * @see StringSettingsAction
- * @see StringSettingsConfigurable
- */
-@State(name = "StringSettings", storages = [Storage("\$APP_CONFIG\$/randomness.xml")])
-data class StringSettings(
- @MapAnnotation(sortBeforeSave = false)
- override var schemes: MutableList = DEFAULT_SCHEMES,
- override var currentSchemeName: String = DEFAULT_CURRENT_SCHEME_NAME
-) : Settings {
- override fun deepCopy() = copy(schemes = schemes.map { it.copy() }.toMutableList())
-
- override fun getState() = this
-
- override fun loadState(state: StringSettings) = XmlSerializerUtil.copyBean(state, this)
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The default value of the [schemes][schemes] field.
- */
- val DEFAULT_SCHEMES: MutableList
- get() = mutableListOf(StringScheme())
-
- /**
- * The default value of the [currentSchemeName][currentSchemeName] field.
- */
- const val DEFAULT_CURRENT_SCHEME_NAME = DEFAULT_NAME
-
- /**
- * The persistent `StringSettings` instance.
- */
- val default: StringSettings
- get() = service()
- }
-}
-
-
-/**
- * Contains settings for generating random strings.
- *
- * @property myName The name of the scheme.
- * @property minLength The minimum length of the generated string, inclusive.
- * @property maxLength The maximum length of the generated string, inclusive.
- * @property enclosure The string that encloses the generated string on both sides.
- * @property capitalization The capitalization mode of the generated string.
- * @property prefix The string to prepend to the generated value.
- * @property suffix The string to append to the generated value.
- * @property serializedSymbolSets The symbol sets that are available for generating strings. Emoji have been serialized
- * for compatibility with JetBrains' serializer.
- * @property serializedActiveSymbolSets The symbol sets that are actually used for generating strings; a subset of
- * [symbolSets]. Emoji have been serialized for compatibility with JetBrains' serializer.
- * @property excludeLookAlikeSymbols Whether the symbols in [SymbolSet.lookAlikeCharacters] should be excluded.
- *
- * @see StringInsertAction
- * @see StringSettings
- */
-data class StringScheme(
- override var myName: String = DEFAULT_NAME,
- var minLength: Int = DEFAULT_MIN_LENGTH,
- var maxLength: Int = DEFAULT_MAX_LENGTH,
- var enclosure: String = DEFAULT_ENCLOSURE,
- var capitalization: CapitalizationMode = DEFAULT_CAPITALIZATION,
- var prefix: String = DEFAULT_PREFIX,
- var suffix: String = DEFAULT_SUFFIX,
- @MapAnnotation(sortBeforeSave = false)
- var serializedSymbolSets: Map = DEFAULT_SYMBOL_SETS.toMap(),
- @MapAnnotation(sortBeforeSave = false)
- var serializedActiveSymbolSets: Map = DEFAULT_ACTIVE_SYMBOL_SETS.toMap(),
- var excludeLookAlikeSymbols: Boolean = DEFAULT_EXCLUDE_LOOK_ALIKE_SYMBOLS
-) : Scheme {
- /**
- * Same as [symbolSets], except that serialized emoji have been deserialized.
- */
- var symbolSets: Map
- @Transient
- get() = serializedSymbolSets.map { SymbolSet(it.key, EmojiParser.parseToUnicode(it.value)) }.toMap()
- set(value) {
- serializedSymbolSets = value.map { SymbolSet(it.key, EmojiParser.parseToAliases(it.value)) }.toMap()
- }
-
- /**
- * Same as [activeSymbolSets], except that serialized emoji have been deserialized.
- */
- var activeSymbolSets: Map
- @Transient
- get() = serializedActiveSymbolSets.map { SymbolSet(it.key, EmojiParser.parseToUnicode(it.value)) }.toMap()
- set(value) {
- serializedActiveSymbolSets = value.map { SymbolSet(it.key, EmojiParser.parseToAliases(it.value)) }.toMap()
- }
-
- /**
- * A list view of the `SymbolSet` objects described by [symbolSets].
- */
- var symbolSetList: Collection
- @Transient
- get() = symbolSets.toSymbolSets()
- set(value) {
- symbolSets = value.toMap()
- }
-
- /**
- * A list view of the `SymbolSet` objects described by [activeSymbolSets].
- */
- var activeSymbolSetList: Collection
- @Transient
- get() = activeSymbolSets.toSymbolSets()
- set(value) {
- activeSymbolSets = value.toMap()
- }
-
-
- override fun copyFrom(other: StringScheme) = XmlSerializerUtil.copyBean(other, this)
-
- override fun copyAs(name: String) = this.copy(myName = name)
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The default value of the [minLength][minLength] field.
- */
- const val DEFAULT_MIN_LENGTH = 3
-
- /**
- * The default value of the [maxLength][maxLength] field.
- */
- const val DEFAULT_MAX_LENGTH = 8
-
- /**
- * The default value of the [enclosure][enclosure] field.
- */
- const val DEFAULT_ENCLOSURE = "\""
-
- /**
- * The default value of the [capitalization][capitalization] field.
- */
- val DEFAULT_CAPITALIZATION = CapitalizationMode.RANDOM
-
- /**
- * The default value of the [prefix][prefix] field.
- */
- const val DEFAULT_PREFIX = ""
-
- /**
- * The default value of the [suffix][suffix] field.
- */
- const val DEFAULT_SUFFIX = ""
-
- /**
- * The default value of the [symbolSets][symbolSets] field.
- */
- val DEFAULT_SYMBOL_SETS = SymbolSet.defaultSymbolSets.toMap()
-
- /**
- * The default value of the [activeSymbolSets][activeSymbolSets] field.
- */
- val DEFAULT_ACTIVE_SYMBOL_SETS = listOf(SymbolSet.ALPHABET, SymbolSet.DIGITS).toMap()
-
- /**
- * The default value of the [excludeLookAlikeSymbols][excludeLookAlikeSymbols] field.
- */
- const val DEFAULT_EXCLUDE_LOOK_ALIKE_SYMBOLS = false
- }
-}
-
-
-/**
- * The configurable for string settings.
- *
- * @see StringSettingsAction
- */
-class StringSettingsConfigurable(
- override val component: StringSettingsComponent = StringSettingsComponent()
-) : SettingsConfigurable() {
- override fun getDisplayName() = "Strings"
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/string/StringSettingsComponent.form b/src/main/kotlin/com/fwdekker/randomness/string/StringSettingsComponent.form
deleted file mode 100644
index 2fb28ca44..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/string/StringSettingsComponent.form
+++ /dev/null
@@ -1,328 +0,0 @@
-
-
diff --git a/src/main/kotlin/com/fwdekker/randomness/string/StringSettingsComponent.kt b/src/main/kotlin/com/fwdekker/randomness/string/StringSettingsComponent.kt
deleted file mode 100644
index e17e4f9e8..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/string/StringSettingsComponent.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-package com.fwdekker.randomness.string
-
-import com.fwdekker.randomness.CapitalizationMode.Companion.getMode
-import com.fwdekker.randomness.SchemesPanel
-import com.fwdekker.randomness.SettingsComponent
-import com.fwdekker.randomness.SettingsComponentListener
-import com.fwdekker.randomness.string.StringScheme.Companion.DEFAULT_CAPITALIZATION
-import com.fwdekker.randomness.string.StringScheme.Companion.DEFAULT_ENCLOSURE
-import com.fwdekker.randomness.string.StringSettings.Companion.DEFAULT_SCHEMES
-import com.fwdekker.randomness.string.StringSettings.Companion.default
-import com.fwdekker.randomness.ui.JIntSpinner
-import com.fwdekker.randomness.ui.JSpinnerRange
-import com.fwdekker.randomness.ui.PreviewPanel
-import com.fwdekker.randomness.ui.getValue
-import com.fwdekker.randomness.ui.setValue
-import com.intellij.ui.SeparatorFactory
-import java.awt.font.TextAttribute
-import java.util.ResourceBundle
-import javax.swing.ButtonGroup
-import javax.swing.JCheckBox
-import javax.swing.JComponent
-import javax.swing.JPanel
-import javax.swing.JTextField
-
-
-/**
- * Component for settings of random string generation.
- *
- * @param settings the settings to edit in the component
- *
- * @see StringSettingsAction
- * @see SymbolSetTable
- */
-@Suppress("LateinitUsage") // Initialized by scene builder
-class StringSettingsComponent(settings: StringSettings = default) :
- SettingsComponent(settings) {
- override lateinit var unsavedSettings: StringSettings
- override lateinit var schemesPanel: SchemesPanel
-
- private lateinit var contentPane: JPanel
- private lateinit var previewPanelHolder: PreviewPanel
- private lateinit var previewPanel: JPanel
- private lateinit var lengthRange: JSpinnerRange
- private lateinit var minLength: JIntSpinner
- private lateinit var maxLength: JIntSpinner
- private lateinit var enclosureGroup: ButtonGroup
- private lateinit var capitalizationGroup: ButtonGroup
- private lateinit var prefixInput: JTextField
- private lateinit var suffixInput: JTextField
- private lateinit var symbolSetPanel: JPanel
- private lateinit var symbolSetSeparator: JComponent
- private lateinit var symbolSetTable: SymbolSetTable
- private lateinit var excludeLookAlikeSymbolsCheckBox: JCheckBox
-
- override val rootPane get() = contentPane
-
-
- init {
- loadSettings()
-
- excludeLookAlikeSymbolsCheckBox.font = excludeLookAlikeSymbolsCheckBox.font.attributes.toMutableMap()
- .also { it[TextAttribute.UNDERLINE] = TextAttribute.UNDERLINE_LOW_DOTTED }
- .let { excludeLookAlikeSymbolsCheckBox.font.deriveFont(it) }
- excludeLookAlikeSymbolsCheckBox.toolTipText =
- "Excludes the following characters from all generated strings: ${SymbolSet.lookAlikeCharacters}"
-
- previewPanelHolder.updatePreviewOnUpdateOf(minLength, maxLength, enclosureGroup, capitalizationGroup)
- previewPanelHolder.updatePreviewOnUpdateOf(prefixInput, suffixInput, symbolSetTable)
- previewPanelHolder.updatePreview()
- }
-
-
- /**
- * Initialises custom UI components.
- *
- * This method is called by the scene builder at the start of the constructor.
- */
- @Suppress("UnusedPrivateMember") // Used by scene builder
- private fun createUIComponents() {
- val bundle = ResourceBundle.getBundle("randomness")
-
- unsavedSettings = StringSettings()
- schemesPanel = StringSchemesPanel(unsavedSettings)
- .also { it.addListener(SettingsComponentListener(this)) }
-
- previewPanelHolder = PreviewPanel { StringInsertAction { StringScheme().also { saveScheme(it) } } }
- previewPanel = previewPanelHolder.rootPane
-
- minLength = JIntSpinner(1, 1, description = "minimum length")
- maxLength = JIntSpinner(1, 1, description = "maximum length")
- lengthRange = JSpinnerRange(minLength, maxLength, Int.MAX_VALUE.toDouble(), "length")
- symbolSetTable = SymbolSetTable()
- symbolSetPanel = symbolSetTable.panel
-
- symbolSetSeparator = SeparatorFactory.createSeparator(bundle.getString("settings.symbol_sets"), null)
- }
-
- override fun loadScheme(scheme: StringScheme) {
- minLength.value = scheme.minLength
- maxLength.value = scheme.maxLength
- enclosureGroup.setValue(scheme.enclosure)
- capitalizationGroup.setValue(scheme.capitalization)
- prefixInput.text = scheme.prefix
- suffixInput.text = scheme.suffix
- symbolSetTable.data = scheme.symbolSetList
- symbolSetTable.activeData = scheme.activeSymbolSetList
- excludeLookAlikeSymbolsCheckBox.isSelected = scheme.excludeLookAlikeSymbols
- }
-
- override fun saveScheme(scheme: StringScheme) {
- scheme.minLength = minLength.value
- scheme.maxLength = maxLength.value
- scheme.enclosure = enclosureGroup.getValue() ?: DEFAULT_ENCLOSURE
- scheme.capitalization = capitalizationGroup.getValue()?.let { getMode(it) } ?: DEFAULT_CAPITALIZATION
- scheme.prefix = prefixInput.text
- scheme.suffix = suffixInput.text
- scheme.symbolSetList = symbolSetTable.data
- scheme.activeSymbolSetList = symbolSetTable.activeData
- scheme.excludeLookAlikeSymbols = excludeLookAlikeSymbolsCheckBox.isSelected
- }
-
- /**
- * Returns true if any symbol sets have been reordered.
- *
- * @param settings the settings to check for modifications
- * @return true if any symbol sets have been reordered
- */
- override fun isModified(settings: StringSettings): Boolean {
- val tableSymbolSets: List = ArrayList(symbolSetTable.data)
- val settingsSymbolSets: List = ArrayList(settings.currentScheme.symbolSetList)
-
- return tableSymbolSets.size != settingsSymbolSets.size ||
- tableSymbolSets.zip(settingsSymbolSets).any { it.first != it.second }
- }
-
- override fun doValidate() =
- minLength.validateValue()
- ?: maxLength.validateValue()
- ?: lengthRange.validateValue()
- ?: symbolSetTable.doValidate(excludeLookAlikeSymbolsCheckBox.isSelected)
-
-
- /**
- * A panel to select schemes from.
- *
- * @param settings the settings model backing up the panel
- */
- private class StringSchemesPanel(settings: StringSettings) : SchemesPanel(settings) {
- override val type: Class
- get() = StringScheme::class.java
-
- override fun createDefaultInstances() = DEFAULT_SCHEMES
- }
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/string/SymbolSet.kt b/src/main/kotlin/com/fwdekker/randomness/string/SymbolSet.kt
deleted file mode 100644
index 0034a7735..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/string/SymbolSet.kt
+++ /dev/null
@@ -1,111 +0,0 @@
-package com.fwdekker.randomness.string
-
-import com.vdurmont.emoji.EmojiParser
-
-
-/**
- * A `SymbolSet` represents a named collection of symbols.
- *
- * @property name the name of the symbol set
- * @property symbols the symbols in the symbol set
- */
-data class SymbolSet(var name: String, var symbols: String) {
- /**
- * Returns the `name` field.
- *
- * @return the `name` field
- */
- override fun toString() = name
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * Symbols that look like other symbols.
- *
- * To be precise, this string contains the symbols `0`, `1`, `l`, `I`, `O`, `|`, and `﹒`.
- */
- const val lookAlikeCharacters = "01lLiIoO|﹒"
-
- /**
- * The lowercase English alphabet.
- */
- val ALPHABET = SymbolSet("Alphabet (a, b, c, ...)", "abcdefghijklmnopqrstuvwxyz")
-
- /**
- * The digits 0 through 9.
- */
- val DIGITS = SymbolSet("Digits (0, 1, 2, ...)", "0123456789")
-
- /**
- * The hexadecimal digits 0 through f.
- */
- val HEXADECIMAL = SymbolSet("Hexadecimal (0, 1, 2, ..., d, e, f)", "0123456789abcdef")
-
- /**
- * A minus (`-`).
- */
- val MINUS = SymbolSet("Minus (-)", "-")
-
- /**
- * An underscore (`_`).
- */
- val UNDERSCORE = SymbolSet("Underscore (_)", "_")
-
- /**
- * A whitespace (` `).
- */
- val SPACE = SymbolSet("Space ( )", " ")
-
- /**
- * A collection of special characters.
- */
- val SPECIAL = SymbolSet("Special (!, @, #, $, %, ^, &, *)", "!@#$%^&*")
-
- /**
- * A collection of brackets and parentheses.
- */
- val BRACKETS = SymbolSet("Brackets ((, ), [, ], {, }, <, >)", "()[]{}<>")
-
- /**
- * List of default symbol sets.
- */
- val defaultSymbolSets = listOf(ALPHABET, DIGITS, HEXADECIMAL, MINUS, UNDERSCORE, SPACE, SPECIAL, BRACKETS)
- }
-}
-
-
-/**
- * Converts a collection of symbol sets to a map from the symbol sets' names to the respective symbols.
- *
- * @return a map from the symbol sets' names to the respective symbols
- */
-fun Collection.toMap() = this.associate { (name, symbols) -> name to symbols }
-
-/**
- * Converts a map to a list of symbol sets, using the key as the name and the value as the symbols.
- *
- * @return a list of symbol sets
- */
-fun Map.toSymbolSets() = this.map { (name, symbols) -> SymbolSet(name, symbols) }.toList()
-
-/**
- * Combines the symbols of all the symbol sets, optionally removing duplicate characters.
- *
- * This method respects emoji sequences and will not remove duplicate characters if these characters are essential to
- * displaying the embedded emoji correctly.
- *
- * @param excludeLookAlikeSymbols whether to remove symbols that occur in [SymbolSet.lookAlikeCharacters]
- * @return a list of all symbols in all active symbol sets, optionally excluding duplicate characters
- */
-fun Iterable.sum(excludeLookAlikeSymbols: Boolean = false): List =
- this.fold("") { acc, symbolSet -> acc + symbolSet.symbols }
- .let { Pair(EmojiParser.extractEmojis(it).distinct(), EmojiParser.removeAllEmojis(it).toList().distinct()) }
- .let { (emoji, noEmoji) -> Pair(emoji, noEmoji.map { it.toString() }) }
- .let { (emoji, noEmoji) ->
- emoji +
- if (excludeLookAlikeSymbols) noEmoji.filterNot { it in SymbolSet.lookAlikeCharacters }
- else noEmoji
- }
diff --git a/src/main/kotlin/com/fwdekker/randomness/string/SymbolSetTable.kt b/src/main/kotlin/com/fwdekker/randomness/string/SymbolSetTable.kt
deleted file mode 100644
index acdd168a5..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/string/SymbolSetTable.kt
+++ /dev/null
@@ -1,150 +0,0 @@
-package com.fwdekker.randomness.string
-
-import com.fwdekker.randomness.ValidationInfo
-import com.fwdekker.randomness.ui.ActivityTableModelEditor
-import com.fwdekker.randomness.ui.EditableDatum
-import com.intellij.ui.components.fields.ExpandableTextField
-import com.intellij.util.ui.AbstractTableCellEditor
-import com.intellij.util.ui.CollectionItemEditor
-import javax.swing.JTable
-
-
-private typealias EditableSymbolSet = EditableDatum
-
-
-/**
- * An editable table for selecting and editing [SymbolSet]s.
- *
- * @see StringSettingsComponent
- */
-class SymbolSetTable : ActivityTableModelEditor(
- arrayOf(NAME_COLUMN, SYMBOLS_COLUMN),
- ITEM_EDITOR,
- EMPTY_TEXT, EMPTY_SUB_TEXT
-) {
- /**
- * Creates a new placeholder [SymbolSet] instance.
- *
- * @return a new placeholder [SymbolSet] instance
- */
- override fun createElement() = Companion.createElement()
-
- /**
- * Validates the symbol sets entered into this table.
- *
- * @param excludeLookAlikeSymbols `true` if and only if look-alike symbols are excluded
- * @return `null` if the input is valid, or a `ValidationInfo` object explaining why the input is invalid
- */
- fun doValidate(excludeLookAlikeSymbols: Boolean): ValidationInfo? {
- val duplicateName = data.map { it.name }.firstNonDistinctOrNull()
- val emptySymbolSet = data.firstOrNull { it.symbols.isEmpty() }
-
- return when {
- data.isEmpty() ->
- ValidationInfo("Add at least one symbol set.", panel)
- data.any { it.name.isEmpty() } ->
- ValidationInfo("All symbol sets should have a name.", panel)
- duplicateName != null ->
- ValidationInfo("There are multiple symbol sets with the name `$duplicateName`.", panel)
- emptySymbolSet != null ->
- ValidationInfo("Symbol set `$emptySymbolSet` should contain at least one symbol.", panel)
- activeData.isEmpty() ->
- ValidationInfo("Activate at least one symbol set.", panel)
- activeData.sum(excludeLookAlikeSymbols).isEmpty() ->
- ValidationInfo(
- "Active symbol sets should contain at least one non-look-alike character if look-alike " +
- "characters are excluded.",
- panel
- )
- else -> null
- }
- }
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * The column showing the names of the symbol sets.
- */
- private val NAME_COLUMN = object : EditableColumnInfo("Name") {
- override fun getColumnClass() = String::class.java
-
- override fun valueOf(item: EditableSymbolSet) = item.datum.name
-
- override fun setValue(item: EditableSymbolSet, value: String) {
- item.datum.name = value
- }
- }
-
- /**
- * The column showing the symbols of the symbol sets.
- */
- private val SYMBOLS_COLUMN = object : EditableColumnInfo("Symbols") {
- override fun getColumnClass() = String::class.java
-
- override fun valueOf(item: EditableSymbolSet) = item.datum.symbols
-
- override fun setValue(item: EditableSymbolSet, value: String) {
- item.datum.symbols = value
- }
-
- override fun getEditor(item: EditableSymbolSet?) =
- object : AbstractTableCellEditor() {
- private var component: ExpandableTextField? = null
-
- override fun getTableCellEditorComponent(
- table: JTable?,
- value: Any?,
- isSelected: Boolean,
- row: Int,
- column: Int
- ): ExpandableTextField =
- ExpandableTextField({ it.split("\n") }, { it.joinToString("\n") })
- .also {
- it.text = value as String
- component = it
- }
-
- override fun getCellEditorValue() = component?.text
- }
- }
-
- /**
- * Describes how table rows are edited.
- */
- private val ITEM_EDITOR = object : CollectionItemEditor {
- override fun getItemClass() = createElement()::class.java
-
- override fun clone(item: EditableSymbolSet, forInPlaceEditing: Boolean) =
- EditableSymbolSet(item.active, SymbolSet(item.datum.name, item.datum.symbols))
- }
-
- /**
- * The text that is displayed when the table is empty.
- */
- const val EMPTY_TEXT = "No symbol sets configured."
-
- /**
- * The instruction that is displayed when the table is empty.
- */
- const val EMPTY_SUB_TEXT = "Add symbol set"
-
-
- /**
- * Creates a new placeholder [SymbolSet] instance.
- *
- * @return a new placeholder [SymbolSet] instance
- */
- private fun createElement() = EditableSymbolSet(DEFAULT_STATE, SymbolSet("", ""))
- }
-}
-
-
-/**
- * Returns the first string that occurs multiple times, or `null` if there is no such string.
- *
- * @return the first string that occurs multiple times, or `null` if there is no such string
- */
-private fun List.firstNonDistinctOrNull() = firstOrNull { indexOf(it) != lastIndexOf(it) }
diff --git a/src/main/kotlin/com/fwdekker/randomness/template/Template.kt b/src/main/kotlin/com/fwdekker/randomness/template/Template.kt
new file mode 100644
index 000000000..414f7b898
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/template/Template.kt
@@ -0,0 +1,125 @@
+package com.fwdekker.randomness.template
+
+import com.fwdekker.randomness.Box
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.Icons
+import com.fwdekker.randomness.Scheme
+import com.fwdekker.randomness.Settings
+import com.fwdekker.randomness.TypeIcon
+import com.fwdekker.randomness.array.ArrayDecorator
+import com.fwdekker.randomness.datetime.DateTimeScheme
+import com.fwdekker.randomness.decimal.DecimalScheme
+import com.fwdekker.randomness.integer.IntegerScheme
+import com.fwdekker.randomness.string.StringScheme
+import com.fwdekker.randomness.uuid.UuidScheme
+import com.fwdekker.randomness.word.WordScheme
+import com.intellij.ui.Gray
+import com.intellij.util.xmlb.annotations.XCollection
+import kotlin.random.Random
+
+
+/**
+ * Generates random data by concatenating the random outputs of a list of [Scheme]s.
+ *
+ * @property name The unique name of the template.
+ * @property schemes The ordered list of underlying schemes.
+ * @property arrayDecorator Settings that determine whether the output should be an array of values.
+ */
+data class Template(
+ override var name: String = DEFAULT_NAME,
+ @get:XCollection(
+ elementTypes = [
+ DateTimeScheme::class,
+ DecimalScheme::class,
+ IntegerScheme::class,
+ StringScheme::class,
+ TemplateReference::class,
+ UuidScheme::class,
+ WordScheme::class,
+ ]
+ )
+ val schemes: MutableList = DEFAULT_SCHEMES,
+ val arrayDecorator: ArrayDecorator = DEFAULT_ARRAY_DECORATOR,
+) : Scheme() {
+ override val typeIcon
+ get() = TypeIcon.combine(schemes.mapNotNull { it.typeIcon }) ?: DEFAULT_ICON
+ override val decorators get() = listOf(arrayDecorator)
+
+ /**
+ * The identifier of the action that inserts this [Template].
+ */
+ val actionId get() = "com.fwdekker.randomness.insert.${uuid.replace("-", "")}"
+
+
+ override fun applyContext(context: Box) {
+ super.applyContext(context)
+ schemes.forEach { it.applyContext(context) }
+ }
+
+
+ /**
+ * Returns `true` if `this` template can add a [TemplateReference] that refers to [target] without causing recursion
+ * within the current [context].
+ */
+ fun canReference(target: Template): Boolean {
+ val tempRef = TemplateReference().also { it.applyContext(context) }
+
+ return 0
+ .also { schemes += tempRef }
+ .let { tempRef.canReference(target) }
+ .also { schemes -= tempRef }
+ }
+
+
+ /**
+ * Generates [count] random strings by concatenating the outputs of the [schemes].
+ *
+ * The schemes are first all given a reference to the same [random] before each generating [count] random strings.
+ * These results are then concatenated into the output.
+ */
+ override fun generateUndecoratedStrings(count: Int): List {
+ val seed = random.nextInt()
+
+ return schemes.onEach { it.random = Random(seed + it.uuid.hashCode()) }
+ .map { it.generateStrings(count) }
+ .let { data -> (0 until count).map { i -> data.joinToString("") { it[i] } } }
+ }
+
+
+ override fun doValidate() =
+ if (name.isBlank()) Bundle("template.error.no_name", Bundle("template.name.empty"))
+ else schemes.firstNotNullOfOrNull { scheme -> scheme.doValidate()?.let { "${scheme.name} > $it" } }
+ ?: arrayDecorator.doValidate()
+
+ override fun deepCopy(retainUuid: Boolean) =
+ copy(
+ schemes = schemes.map { it.deepCopy(retainUuid) }.toMutableList(),
+ arrayDecorator = arrayDecorator.deepCopy(retainUuid),
+ ).deepCopyTransient(retainUuid)
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The icon displayed when a template has no schemes.
+ */
+ val DEFAULT_ICON = TypeIcon(Icons.TEMPLATE, "", listOf(Gray._110))
+
+ /**
+ * The default value of the [name] field.
+ */
+ val DEFAULT_NAME = Bundle("template.name.default")
+
+ /**
+ * The default value of the [schemes] field.
+ */
+ val DEFAULT_SCHEMES get() = mutableListOf()
+
+ /**
+ * The default value of the [arrayDecorator] field.
+ */
+ val DEFAULT_ARRAY_DECORATOR get() = ArrayDecorator()
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateActionLoader.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateActionLoader.kt
new file mode 100644
index 000000000..307549466
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateActionLoader.kt
@@ -0,0 +1,80 @@
+package com.fwdekker.randomness.template
+
+import com.fwdekker.randomness.Settings
+import com.intellij.openapi.actionSystem.ActionManager
+import com.intellij.openapi.actionSystem.impl.DynamicActionConfigurationCustomizer
+import com.intellij.openapi.extensions.PluginId
+
+
+/**
+ * Registers, replaces, and unregisters actions for the user's [Template]s so that they can be inserted using shortcuts.
+ *
+ * @param getTemplates shorthand to return all the user's stored [Template]s
+ */
+open class TemplateActionLoader(
+ private val getTemplates: () -> List = { Settings.DEFAULT.templates },
+) : DynamicActionConfigurationCustomizer {
+ /**
+ * Registers the actions for all [Template]s in the user's [Settings] using [actionManager].
+ */
+ override fun registerActions(actionManager: ActionManager) {
+ getTemplates().forEach { registerAction(actionManager, it) }
+ }
+
+ /**
+ * Unregisters the actions of all [Template]s in the user's [Settings] using [actionManager].
+ */
+ override fun unregisterActions(actionManager: ActionManager) {
+ getTemplates().forEach { unregisterAction(actionManager, it) }
+ }
+
+
+ /**
+ * Registers, unregisters, and updates actions as appropriate for a transition from [oldList] to [newList] using
+ * [actionManager].
+ */
+ fun updateActions(
+ oldList: List,
+ newList: List,
+ actionManager: ActionManager = ActionManager.getInstance(),
+ ) {
+ val newUuidList = newList.map { it.uuid }
+ oldList.filterNot { it.uuid in newUuidList }.forEach { unregisterAction(actionManager, it) }
+ newList.forEach { registerAction(actionManager, it) }
+ }
+
+
+ /**
+ * Returns all variant actions belonging to [template].
+ */
+ private fun getActions(template: Template) =
+ mapOf(
+ template.actionId to TemplateInsertAction(template, array = false, repeat = false),
+ "${template.actionId}.array" to TemplateInsertAction(template, array = true, repeat = false),
+ "${template.actionId}.repeat" to TemplateInsertAction(template, array = false, repeat = true),
+ "${template.actionId}.repeat.array" to TemplateInsertAction(template, array = true, repeat = true),
+ "${template.actionId}.settings" to TemplateSettingsAction(template)
+ )
+
+ /**
+ * Registers the actions associated with [template] using [actionManager].
+ */
+ private fun registerAction(actionManager: ActionManager, template: Template) =
+ getActions(template).forEach { (actionId, action) ->
+ if (actionManager.getAction(actionId) == null)
+ actionManager.registerAction(actionId, action, PluginId.getId("com.fwdekker.randomness"))
+ else
+ actionManager.replaceAction(actionId, action)
+ }
+
+ /**
+ * Unregisters the actions associated with [template] using [actionManager].
+ */
+ private fun unregisterAction(actionManager: ActionManager, template: Template) =
+ getActions(template).forEach { (actionId, _) -> actionManager.unregisterAction(actionId) }
+}
+
+/**
+ * Constructor-less version of [TemplateActionLoader], as is required in `plugin.xml`.
+ */
+class DefaultTemplateActionLoader : TemplateActionLoader()
diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateActions.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateActions.kt
new file mode 100644
index 000000000..7aa4013a2
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateActions.kt
@@ -0,0 +1,217 @@
+package com.fwdekker.randomness.template
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.Icons
+import com.fwdekker.randomness.InsertAction
+import com.fwdekker.randomness.OverlayIcon
+import com.fwdekker.randomness.Timely.generateTimely
+import com.fwdekker.randomness.array.ArrayDecorator
+import com.fwdekker.randomness.array.ArrayDecoratorEditor
+import com.fwdekker.randomness.ui.PreviewPanel
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.actionSystem.ActionGroup
+import com.intellij.openapi.actionSystem.ActionUpdateThread
+import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.options.Configurable
+import com.intellij.openapi.options.ShowSettingsUtil
+import com.intellij.openapi.util.Disposer
+import java.awt.BorderLayout
+import javax.swing.JPanel
+
+
+/**
+ * All actions related to inserting template-based strings.
+ *
+ * @param template the template to create actions for
+ * @see TemplateInsertAction
+ * @see TemplateSettingsAction
+ */
+class TemplateGroupAction(private val template: Template) :
+ ActionGroup(template.name, Bundle("template.description.default", template.name), template.icon) {
+ /**
+ * Returns the action that is appropriate for the given keyboard modifiers.
+ *
+ * @param array `true` if and only if the output should be in array form
+ * @param repeat `true` if and only if the output should be repeated
+ * @param settings `true` if and only if settings should be shown
+ */
+ private fun getActionByModifier(array: Boolean = false, repeat: Boolean = false, settings: Boolean = false) =
+ if (settings) TemplateSettingsAction(template)
+ else TemplateInsertAction(template, array = array, repeat = repeat)
+
+
+ /**
+ * Specifies the thread in which [update] is invoked.
+ */
+ override fun getActionUpdateThread() = ActionUpdateThread.EDT
+
+
+ /**
+ * Updates the [event]'s presentation of this action.
+ */
+ override fun update(event: AnActionEvent) {
+ super.update(event)
+
+ event.presentation.isPerformGroup = true
+ event.presentation.isPopupGroup = true
+ }
+
+ /**
+ * Chooses one of the three actions to execute based on the key modifiers in [event].
+ *
+ * @param event carries contextual information
+ */
+ override fun actionPerformed(event: AnActionEvent) =
+ getActionByModifier(
+ array = event.inputEvent?.isShiftDown ?: false,
+ repeat = event.inputEvent?.isAltDown ?: false,
+ settings = event.inputEvent?.isControlDown ?: false
+ ).actionPerformed(event)
+
+ /**
+ * Returns variant actions for the main insertion action.
+ *
+ * @param event carries contextual information
+ * @return variant actions for the main insertion action
+ */
+ override fun getChildren(event: AnActionEvent?) =
+ arrayOf(
+ getActionByModifier(array = true),
+ getActionByModifier(repeat = true),
+ getActionByModifier(array = true, repeat = true),
+ getActionByModifier(settings = true)
+ )
+}
+
+
+/**
+ * Inserts random strings in the editor using the given template.
+ *
+ * @param template the template to use for inserting data
+ * @param array `true` if and only if an array of values should be inserted
+ * @param repeat `true` if and only if the same value should be inserted at each caret
+ * @see TemplateGroupAction
+ */
+class TemplateInsertAction(
+ private val template: Template,
+ private val array: Boolean = false,
+ repeat: Boolean = false,
+) : InsertAction(
+ repeat = repeat,
+ text = template.name
+ .let { if (repeat) Bundle("template.name.repeat_prefix", it) else it }
+ .let { if (array) Bundle("template.name.array_suffix", it) else it },
+ description = when {
+ array && repeat -> Bundle("template.description.repeat.array", template.name)
+ repeat -> Bundle("template.description.repeat", template.name)
+ array -> Bundle("template.description.array", template.name)
+ else -> Bundle("template.description.default", template.name)
+ },
+ icon = template.icon?.let { if (repeat) it.plusOverlay(OverlayIcon.REPEAT) else it }
+) {
+ override val configurable
+ get() =
+ if (array) ArrayDecoratorConfigurable(template.arrayDecorator)
+ else null
+
+
+ override fun generateStrings(count: Int) =
+ generateTimely {
+ val template = template.deepCopy().also { it.arrayDecorator.enabled = array }
+
+ if (repeat) template.generateStrings().single().let { string -> List(count) { string } }
+ else template.generateStrings(count)
+ }
+
+
+ /**
+ * Edits the [ArrayDecorator] of a template and shows a preview.
+ *
+ * @param arrayDecorator the decorator being edited in this configurable
+ */
+ inner class ArrayDecoratorConfigurable(arrayDecorator: ArrayDecorator) : Configurable, Disposable {
+ private val editor =
+ ArrayDecoratorEditor(arrayDecorator, embedded = true)
+ .also { Disposer.register(this, it) }
+ private val previewPanel =
+ PreviewPanel { template.deepCopy(retainUuid = true).also { it.arrayDecorator.enabled = true } }
+ .also { Disposer.register(this, it) }
+
+
+ init {
+ editor.addChangeListener {
+ editor.apply()
+ previewPanel.updatePreview()
+ }
+ previewPanel.updatePreview()
+ }
+
+
+ /**
+ * Returns the component that the [editor] prefers to be focused when the editor is focused.
+ */
+ override fun getPreferredFocusedComponent() = editor.preferredFocusedComponent
+
+ /**
+ * Creates a component containing the [editor] and the [previewPanel].
+ */
+ override fun createComponent() =
+ JPanel(BorderLayout())
+ .also {
+ it.add(editor.rootComponent, BorderLayout.NORTH)
+ it.add(previewPanel.rootComponent, BorderLayout.SOUTH)
+ }
+
+ /**
+ * Returns `true`.
+ */
+ override fun isModified() = true
+
+ /**
+ * Saves the [editor]'s state.
+ */
+ override fun apply() = editor.apply()
+
+ /**
+ * Returns [text].
+ */
+ override fun getDisplayName() = text
+
+ /**
+ * Recursively disposes this configurable's resources.
+ */
+ override fun disposeUIResources() = Disposer.dispose(this)
+
+ /**
+ * Non-recursively disposes this configurable's resources.
+ */
+ override fun dispose() = Unit
+ }
+}
+
+/**
+ * Open the settings dialog to edit [template].
+ *
+ * @param template the template to select after opening the settings dialog
+ * @see TemplateGroupAction
+ * @see TemplateListConfigurable
+ */
+@Suppress("DialogTitleCapitalization") // False positive
+class TemplateSettingsAction(private val template: Template? = null) : AnAction(
+ if (template == null) Bundle("template.name.settings")
+ else Bundle("template.name.settings_suffix", template.name),
+ template?.let { Bundle("template.description.settings", it.name) },
+ template?.icon?.plusOverlay(OverlayIcon.SETTINGS) ?: Icons.SETTINGS
+) {
+ /**
+ * Opens the IntelliJ settings menu at the right location to adjust the template configurable.
+ *
+ * @param event carries contextual information
+ */
+ override fun actionPerformed(event: AnActionEvent) =
+ ShowSettingsUtil.getInstance()
+ .showSettingsDialog(event.project, TemplateListConfigurable::class.java) { configurable ->
+ configurable?.also { it.schemeToSelect = template?.uuid }
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateEditor.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateEditor.kt
new file mode 100644
index 000000000..4933a22ce
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateEditor.kt
@@ -0,0 +1,33 @@
+package com.fwdekker.randomness.template
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.SchemeEditor
+import com.fwdekker.randomness.ui.UIConstants
+import com.fwdekker.randomness.ui.withFixedWidth
+import com.fwdekker.randomness.ui.withName
+import com.intellij.ui.dsl.builder.bindText
+import com.intellij.ui.dsl.builder.panel
+
+
+/**
+ * Component for editing non-children-related aspects of a [Template].
+ *
+ * @param scheme the scheme to edit
+ */
+class TemplateEditor(scheme: Template) : SchemeEditor(scheme) {
+ override val rootComponent = panel {
+ onApply { scheme.arrayDecorator.enabled = false }
+
+ row(Bundle("template.ui.name_option")) {
+ textField()
+ .withFixedWidth(UIConstants.SIZE_LARGE)
+ .withName("templateName")
+ .bindText(scheme::name)
+ }
+ }
+
+
+ init {
+ reset()
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateJTree.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateJTree.kt
new file mode 100644
index 000000000..4584a4619
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateJTree.kt
@@ -0,0 +1,717 @@
+package com.fwdekker.randomness.template
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.PopupAction
+import com.fwdekker.randomness.Scheme
+import com.fwdekker.randomness.datetime.DateTimeScheme
+import com.fwdekker.randomness.decimal.DecimalScheme
+import com.fwdekker.randomness.integer.IntegerScheme
+import com.fwdekker.randomness.string.StringScheme
+import com.fwdekker.randomness.uuid.UuidScheme
+import com.fwdekker.randomness.word.WordScheme
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.actionSystem.ActionToolbarPosition
+import com.intellij.openapi.actionSystem.ActionUpdateThread
+import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.ui.popup.JBPopupFactory
+import com.intellij.openapi.ui.popup.ListSeparator
+import com.intellij.openapi.ui.popup.PopupStep
+import com.intellij.openapi.ui.popup.util.BaseListPopupStep
+import com.intellij.ui.AnActionButton
+import com.intellij.ui.ColoredTreeCellRenderer
+import com.intellij.ui.CommonActionsPanel
+import com.intellij.ui.LayeredIcon
+import com.intellij.ui.RowsDnDSupport.RefinedDropSupport.Position
+import com.intellij.ui.SimpleTextAttributes
+import com.intellij.ui.ToolbarDecorator
+import com.intellij.ui.TreeSpeedSearch
+import com.intellij.ui.treeStructure.Tree
+import com.intellij.util.ui.JBUI
+import javax.swing.JPanel
+import javax.swing.JTree
+import javax.swing.tree.TreeSelectionModel
+import kotlin.math.min
+
+
+/**
+ * A tree containing [Template]s and [Scheme]s.
+ *
+ * Changes made through this tree's interface (e.g. adding, removing, copying) are immediately reflected in the
+ * [currentTemplateList]. The [originalTemplateList] is used only as a reference point to determine what changes have
+ * occurred, for example when [isModified] is called or when the end user requests that changes are (partially)
+ * reverted.
+ *
+ * If changes are made outside of this tree's interface (i.e. by directly operating on the [currentTemplateList]
+ * instance that was passed in the constructor), this tree's internal model becomes desynchronized, and must be
+ * resynchronized by invoking [reload]. Except for [reload], the behaviour of this tree is undefined while
+ * desynchronized.
+ *
+ * Internally, the [currentTemplateList] is loaded into a [TemplateJTreeModel]. The [TemplateJTree] class is the
+ * corresponding user interface class, which additionally provides (1) toolbars and buttons for manipulating the model,
+ * (2) node expansion and selection, and (3) handling for tracking and reversing modifications.
+ *
+ * @param originalTemplateList the (read-only) original templates without modifications
+ * @param currentTemplateList the current templates, including modifications
+ */
+@Suppress("detekt:TooManyFunctions") // Cannot be avoided
+class TemplateJTree(
+ private val originalTemplateList: TemplateList,
+ private var currentTemplateList: TemplateList,
+) : Tree(TemplateJTreeModel(currentTemplateList)) {
+ /**
+ * The tree's model.
+ *
+ * This field should not be accessed directly by outside classes. (But it's not `private` because the field is very
+ * useful during tests.)
+ *
+ * This field cannot be named `model` because this causes an NPE during initialization. This field cannot be named
+ * `treeModel` because this name is already taken and cannot be overridden.
+ */
+ internal val myModel: TemplateJTreeModel
+ get() = super.getModel() as TemplateJTreeModel
+
+ /**
+ * The currently selected node, or `null` if no node is selected, or `null` if the root is selected.
+ */
+ val selectedNodeNotRoot: StateNode?
+ get() = (lastSelectedPathComponent as? StateNode)?.let { if (it == model.root) null else it }
+
+ /**
+ * The currently selected scheme (or template), or `null` if no scheme is currently selected.
+ *
+ * Setting `null` or setting a [Scheme] with a UUID that does not occur in this tree will clear the current
+ * selection.
+ */
+ var selectedScheme: Scheme?
+ get() = selectedNodeNotRoot?.state as? Scheme
+ set(value) {
+ val descendants = myModel.root.descendants()
+
+ if (value == null || StateNode(value) !in descendants)
+ clearSelection()
+ else
+ selectionPath = myModel.getPathToRoot(descendants.single { it == StateNode(value) })
+ }
+
+ /**
+ * The currently selected template, or the parent of the currently selected non-template scheme, or `null` if no
+ * scheme is currently selected.
+ *
+ * Setting a template that is either `null` or not in the tree will select the first template in the tree, or clears
+ * the selection if the tree is empty.
+ *
+ * @see selectedScheme
+ */
+ var selectedTemplate: Template?
+ get() {
+ val node = selectedNodeNotRoot ?: return null
+
+ return if (node.state is Template) node.state
+ else myModel.getParentOf(node)!!.state as Template
+ }
+ set(value) {
+ selectedScheme = value
+ }
+
+ /**
+ * The list of currently-collapsed [Template]s by UUID.
+ *
+ * This field is rather finicky. It is used in [runPreservingState], which is used in [reload], which may be invoked
+ * while the UI and the [myModel] are desynchronized. Therefore, in the getter, this field uses [getUI] to access
+ * the (possibly outdated) UI to determine which [Template]s have currently been collapsed. In the setter,
+ * meanwhile, all [Template]s are expanded except the given [Template]s, to ensure that new [Template]s (which were
+ * added in such a way as to cause desynchronization) are expanded by default.
+ */
+ private var collapsedTemplates: Collection
+ get() {
+ val ui = getUI() ?: return emptyList()
+
+ return (0 until ui.getRowCount(this))
+ .mapNotNull { ui.getPathForRow(this, it) }
+ .filter { isCollapsed(it) }
+ .map { (it.lastPathComponent as StateNode).state.uuid }
+ }
+ set(value) =
+ myModel.root.children
+ .filter { it.state.uuid !in value }
+ .forEach { expandPath(myModel.getPathToRoot(it)) }
+
+
+ init {
+ TreeSpeedSearch(this, true) { path -> path.path.filterIsInstance().joinToString { it.name } }
+
+ emptyText.text = Bundle("template_list.ui.empty")
+ isRootVisible = false
+ showsRootHandles = true
+ selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION
+ myModel.viewIndexToModelIndex = { myModel.root.descendants().indexOf(getPathForRow(it).lastPathComponent) }
+ myModel.wrapDrop = ::runPreservingState
+ setCellRenderer(CellRenderer())
+
+ setSelectionRow(0)
+ }
+
+ /**
+ * Returns a panel containing this tree decorated with accessible action buttons.
+ */
+ fun asDecoratedPanel(): JPanel =
+ ToolbarDecorator.createDecorator(this)
+ .setToolbarPosition(ActionToolbarPosition.TOP)
+ .setPanelBorder(JBUI.Borders.empty())
+ .setScrollPaneBorder(JBUI.Borders.empty())
+ .disableAddAction()
+ .disableRemoveAction()
+ .addExtraAction(AddButton() as AnAction)
+ .addExtraAction(RemoveButton() as AnAction)
+ .addExtraAction(CopyButton() as AnAction)
+ .addExtraAction(UpButton() as AnAction)
+ .addExtraAction(DownButton() as AnAction)
+ .addExtraAction(ResetButton() as AnAction)
+ .setButtonComparator(
+ Bundle("shared.action.add"),
+ Bundle("shared.action.edit"),
+ Bundle("shared.action.remove"),
+ Bundle("shared.action.copy"),
+ Bundle("shared.action.up"),
+ Bundle("shared.action.down"),
+ Bundle("shared.action.reset"),
+ )
+ .setForcedDnD()
+ .createPanel()
+
+
+ /**
+ * Notifies the tree that external changes have been made to [currentTemplateList], and resynchronizes the tree's
+ * model with the template list.
+ *
+ * If [changedScheme] is not `null`, then only the part of the model that includes the [changedScheme] is
+ * resynchronized. Otherwise, if [changedScheme] is `null`, the entire model is resynchronized.
+ *
+ * This is a wrapper around [TemplateJTreeModel.fireNodeStructureChanged] that additionally tries to retain the
+ * current selection and expansion state. Any new templates that have been added will initially be expanded.
+ *
+ * @see runPreservingState
+ */
+ fun reload(changedScheme: Scheme? = null) {
+ runPreservingState { myModel.fireNodeStructureChanged(changedScheme?.let { StateNode(it) } ?: myModel.root) }
+
+ if (selectedScheme == null)
+ selectedScheme = currentTemplateList.templates.firstOrNull()
+ }
+
+ /**
+ * Expands all [Template]s.
+ */
+ fun expandAll() = myModel.root.children.forEach { expandPath(myModel.getPathToRoot(it)) }
+
+
+ /**
+ * Adds [newScheme] at an appropriate location in the tree based on the currently selected node.
+ *
+ * @param newScheme the scheme to add; must be a [Template] if [selectedNodeNotRoot] is `null`
+ */
+ fun addScheme(newScheme: Scheme) {
+ val selectedNode = selectedNodeNotRoot
+ val newNode = StateNode(newScheme)
+ if (newScheme is Template) newScheme.name = findUniqueNameFor(newScheme)
+
+ runPreservingState {
+ if (selectedNode == null) {
+ myModel.insertNode(myModel.root, newNode)
+ } else if (selectedNode.state is Template) {
+ if (newScheme is Template)
+ myModel.insertNodeAfter(myModel.root, selectedNode, newNode)
+ else
+ myModel.insertNode(selectedNode, newNode)
+ } else {
+ if (newScheme is Template)
+ myModel.insertNodeAfter(myModel.root, myModel.getParentOf(selectedNode)!!, newNode)
+ else
+ myModel.insertNodeAfter(myModel.getParentOf(selectedNode)!!, selectedNode, newNode)
+ }
+ }
+
+ val path = myModel.getPathToRoot(newNode)
+ makeVisible(path)
+ expandPath(path)
+ selectedScheme = newScheme
+ }
+
+ /**
+ * Removes [scheme] from the tree, and selects an appropriate other scheme.
+ *
+ * Throws an exception if [scheme] is not in this tree.
+ */
+ fun removeScheme(scheme: Scheme) {
+ val node = StateNode(scheme)
+ val parent = myModel.getParentOf(node)!!
+ val oldIndex = parent.children.indexOf(node)
+
+ runPreservingState { myModel.removeNode(node) }
+
+ selectionPath =
+ myModel.getPathToRoot(
+ if (myModel.isLeaf(parent)) parent
+ else parent.children[min(oldIndex, parent.children.lastIndex)]
+ )
+ }
+
+ /**
+ * Replaces [oldScheme] with [newScheme] in-place.
+ *
+ * If [newScheme] is `null`, then [oldScheme] is removed without being replaced.
+ */
+ fun replaceScheme(oldScheme: Scheme, newScheme: Scheme?) {
+ if (newScheme == null) {
+ removeScheme(oldScheme)
+ return
+ }
+
+ val oldNode = StateNode(oldScheme)
+ val newNode = StateNode(newScheme)
+ val parent = myModel.getParentOf(oldNode)!!
+
+ runPreservingState {
+ val index = parent.children.indexOf(oldNode)
+ myModel.removeNode(oldNode)
+ myModel.insertNode(parent, newNode, index)
+ }
+ }
+
+ /**
+ * Returns `true` if and only if [moveSchemeByOnePosition] can be invoked with these parameters.
+ *
+ * @see TemplateJTreeModel.canMoveRow
+ */
+ fun canMoveSchemeByOnePosition(scheme: Scheme, moveDown: Boolean): Boolean {
+ val (fromIndex, toIndex, position) = getMoveDescriptor(scheme, moveDown)
+ return myModel.canMoveRow(fromIndex, toIndex, position)
+ }
+
+ /**
+ * Moves [scheme] by one position; down if [moveDown] is `true, and up otherwise.
+ *
+ * @see TemplateJTreeModel.moveRow
+ */
+ fun moveSchemeByOnePosition(scheme: Scheme, moveDown: Boolean) {
+ val node = StateNode(scheme)
+
+ runPreservingState {
+ val (fromIndex, toIndex, position) = getMoveDescriptor(scheme, moveDown)
+ myModel.moveRow(fromIndex, toIndex, position)
+ }
+
+ makeVisible(myModel.getPathToRoot(node))
+ }
+
+ /**
+ * Returns the arguments to pass to [TemplateJTreeModel.moveRow] or [TemplateJTreeModel.canMoveRow] to describe
+ * moving [scheme] up (if [moveDown] is `false`) or down (if [moveDown] is `true`).
+ *
+ * If an invalid move is returned, for example with negative indices, the move was not possible to begin with.
+ *
+ * @see canMoveSchemeByOnePosition
+ * @see moveSchemeByOnePosition
+ */
+ @Suppress("detekt:CognitiveComplexMethod", "detekt:MagicNumber") // Complexity unavoidable, -2 is not magic
+ private fun getMoveDescriptor(scheme: Scheme, moveDown: Boolean): Triple {
+ val descendants = myModel.root.descendants()
+ val templates = myModel.root.children
+
+ val fromNode = StateNode(scheme)
+ val (toNode, position) =
+ if (scheme is Template) {
+ val index = templates.indexOf(fromNode)
+
+ if (moveDown && index == templates.lastIndex - 1) Pair(templates.last(), Position.BELOW)
+ else Pair(templates.getOrNull(index + if (!moveDown) -1 else 2), Position.ABOVE)
+ } else {
+ val index = descendants.indexOf(fromNode)
+ val candidate = descendants.getOrNull(index + if (!moveDown) -2 else 1)
+
+ when {
+ candidate == null || candidate.state !is Template -> Pair(candidate, Position.BELOW)
+ candidate.children.isEmpty() -> Pair(candidate, Position.INTO)
+ else -> Pair(candidate.children.first(), Position.ABOVE)
+ }
+ }
+
+ return Triple(descendants.indexOf(fromNode), descendants.indexOf(toNode), position)
+ }
+
+
+ /**
+ * Runs [lambda] while ensuring that the [selectedScheme] and collapsed templates remain unchanged.
+ */
+ private fun runPreservingState(lambda: () -> Unit) {
+ val oldSelected = selectedScheme
+ val oldCollapsed = collapsedTemplates
+
+ lambda()
+
+ collapsedTemplates = oldCollapsed
+ selectedScheme = oldSelected
+ }
+
+ /**
+ * Returns `true` if and only if [scheme] has been modified with respect to [originalTemplateList].
+ */
+ private fun isModified(scheme: Scheme) = scheme != originalTemplateList.getSchemeByUuid(scheme.uuid)
+
+ /**
+ * Finds a good, unique name for [template] so that it can be inserted into this list without conflict.
+ *
+ * If the name is already unique, that name is returned. Otherwise, the name is appended with the first number `i`
+ * such that `$name ($i)` is unique. If the template's current name already ends with a number in parentheses, that
+ * number is taken as the starting number.
+ */
+ private fun findUniqueNameFor(template: Template): String {
+ val templateNames = currentTemplateList.templates.map { it.name }
+ if (template.name !in templateNames) return template.name
+
+ var i = 1
+ var name = template.name
+
+ if (name.matches(Regex(".* \\([1-9][0-9]*\\)"))) {
+ i = name.substring(name.lastIndexOf('(') + 1, name.lastIndexOf(')')).toInt()
+ name = name.substring(0, name.lastIndexOf('(') - 1)
+ }
+
+ while ("$name ($i)" in templateNames) i++
+ return "$name ($i)"
+ }
+
+
+ /**
+ * Renders a cell in the tree.
+ */
+ private inner class CellRenderer : ColoredTreeCellRenderer() {
+ /**
+ * Renders the [Scheme] in [value], ignoring other parameters.
+ */
+ override fun customizeCellRenderer(
+ tree: JTree,
+ value: Any,
+ selected: Boolean,
+ expanded: Boolean,
+ leaf: Boolean,
+ row: Int,
+ hasFocus: Boolean,
+ ) {
+ val scheme = (value as StateNode).state as? Scheme
+ if (scheme == null) {
+ append(Bundle("template.name.unknown"))
+ return
+ }
+
+ icon = scheme.icon
+
+ if (scheme is Template) {
+ PopupAction.indexToMnemonic(currentTemplateList.templates.indexOf(scheme))
+ ?.run { append("$this ", SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES, false) }
+ }
+
+ append(
+ scheme.name.ifBlank { Bundle("template.name.empty") },
+ when {
+ scheme.doValidate() != null -> SimpleTextAttributes.ERROR_ATTRIBUTES
+ isModified(scheme) -> SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES
+ else -> SimpleTextAttributes.REGULAR_ATTRIBUTES
+ }
+ )
+
+ if (scheme is StringScheme && scheme.isSimple())
+ append(" ${scheme.generateStrings()[0]}", SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES)
+ }
+ }
+
+
+ /**
+ * Displays a popup to add a scheme, or immediately adds a template if nothing is currently selected.
+ */
+ private inner class AddButton : AnActionButton(Bundle("shared.action.add"), AllIcons.General.Add) {
+ /**
+ * Specifies the thread in which [update] is invoked.
+ */
+ override fun getActionUpdateThread() = ActionUpdateThread.EDT
+
+ /**
+ * Updates the presentation of the button.
+ *
+ * @param event carries contextual information
+ */
+ override fun updateButton(event: AnActionEvent) {
+ super.updateButton(event)
+
+ event.presentation.icon =
+ if (selectedNodeNotRoot == null) AllIcons.General.Add
+ else LayeredIcon.ADD_WITH_DROPDOWN
+ }
+
+ /**
+ * Returns the shortcut for this action.
+ */
+ override fun getShortcut() = CommonActionsPanel.getCommonShortcut(CommonActionsPanel.Buttons.ADD)
+
+ /**
+ * Displays a popup to add a scheme, or immediately adds a template if nothing is currently selected.
+ *
+ * @param event ignored
+ */
+ override fun actionPerformed(event: AnActionEvent) =
+ JBPopupFactory.getInstance()
+ .createListPopup(MainPopupStep(templatesOnly = selectedNodeNotRoot == null))
+ .show(preferredPopupPoint)
+
+
+ /**
+ * A [PopupStep] for a list of [Scheme]s that can be inserted.
+ *
+ * Elements can be nested by overriding [hasSubstep] and [onChosen].
+ *
+ * @param schemes the schemes that can be inserted
+ */
+ private abstract inner class AbstractPopupStep(
+ schemes: List,
+ ) : BaseListPopupStep(null, schemes) {
+ /**
+ * Returns [value]'s icon.
+ */
+ override fun getIconFor(value: Scheme?) = value?.icon
+
+ /**
+ * Returns [value]'s name.
+ */
+ override fun getTextFor(value: Scheme?) = value?.name ?: Bundle("misc.default_scheme_name")
+
+ /**
+ * Inserts [value] into the tree.
+ *
+ * Subclasses may modify the behavior of this method to instead return the [PopupStep] nested under this
+ * entry.
+ *
+ * @param value the value to insert
+ * @param finalChoice ignored
+ * @return `null`, or the [PopupStep] that is nested under this entry
+ */
+ override fun onChosen(value: Scheme?, finalChoice: Boolean): PopupStep<*>? {
+ if (value != null)
+ addScheme(value.deepCopy().also { it.applyContext(currentTemplateList.context) })
+
+ return null
+ }
+
+
+ /**
+ * Returns `true`.
+ */
+ override fun isSpeedSearchEnabled() = true
+
+ /**
+ * Returns the index of the entry to select by default.
+ */
+ override fun getDefaultOptionIndex() = 0
+ }
+
+ /**
+ * The top-level [PopupStep], which includes the default templates and various reference types.
+ *
+ * @param templatesOnly `true` if and only if non-[Template] schemes cannot be inserted
+ */
+ private inner class MainPopupStep(private val templatesOnly: Boolean) : AbstractPopupStep(POPUP_STEP_SCHEMES) {
+ override fun onChosen(value: Scheme?, finalChoice: Boolean) =
+ when (value) {
+ POPUP_STEP_SCHEMES[0] -> TemplatesPopupStep()
+ POPUP_STEP_SCHEMES[POPUP_STEP_SCHEMES.lastIndex] -> ReferencesPopupStep()
+ else -> super.onChosen(value, finalChoice)
+ }
+
+ /**
+ * Returns `true` if and only if [value] is a [Template] or [templatesOnly] is `false`.
+ */
+ override fun isSelectable(value: Scheme?) = value is Template || !templatesOnly
+
+ /**
+ * Returns `true` if and only if [value] equals the [Template] or [TemplateReference] entry.
+ */
+ override fun hasSubstep(value: Scheme?) =
+ value == POPUP_STEP_SCHEMES[0] || value == POPUP_STEP_SCHEMES[POPUP_STEP_SCHEMES.lastIndex]
+
+ /**
+ * Returns a separator if [value] should be preceded by a separator, or `null` otherwise.
+ */
+ override fun getSeparatorAbove(value: Scheme?) =
+ if (value == POPUP_STEP_SCHEMES[1]) ListSeparator()
+ else null
+ }
+
+ /**
+ * A [PopupStep] that shows only the default templates.
+ */
+ private inner class TemplatesPopupStep :
+ AbstractPopupStep(listOf(Template("Empty")) + TemplateList.DEFAULT_TEMPLATES)
+
+ /**
+ * A [PopupStep] that contains a [TemplateReference] for each [Template] that can currently be referenced from
+ * [selectedTemplate].
+ *
+ * Ineligible [Template]s are automatically filtered out.
+ */
+ private inner class ReferencesPopupStep : AbstractPopupStep(
+ currentTemplateList.templates
+ .filter { selectedTemplate!!.canReference(it) }
+ .map { TemplateReference(it.uuid) }
+ )
+ }
+
+ /**
+ * Removes the selected scheme from the tree.
+ */
+ private inner class RemoveButton : AnActionButton(Bundle("shared.action.remove"), AllIcons.General.Remove) {
+ /**
+ * Specifies the thread in which [update] is invoked.
+ */
+ override fun getActionUpdateThread() = ActionUpdateThread.EDT
+
+ /**
+ * Returns `true` if and only if this action is enabled.
+ */
+ override fun isEnabled() = selectedScheme != null
+
+ /**
+ * Returns the shortcut for this action.
+ */
+ override fun getShortcut() = CommonActionsPanel.getCommonShortcut(CommonActionsPanel.Buttons.REMOVE)
+
+ /**
+ * Removes the selected scheme from the tree.
+ *
+ * @param event ignored
+ */
+ override fun actionPerformed(event: AnActionEvent) = removeScheme(selectedScheme!!)
+ }
+
+ /**
+ * Copies the selected scheme in the tree.
+ */
+ private inner class CopyButton : AnActionButton(Bundle("shared.action.copy"), AllIcons.Actions.Copy) {
+ /**
+ * Specifies the thread in which [update] is invoked.
+ */
+ override fun getActionUpdateThread() = ActionUpdateThread.EDT
+
+ /**
+ * Returns `true` if and only if this action is enabled.
+ *
+ * @return `true` if and only if this action is enabled
+ */
+ override fun isEnabled() = selectedScheme != null
+
+ /**
+ * Copies the selected scheme in the tree.
+ *
+ * @param event ignored
+ */
+ override fun actionPerformed(event: AnActionEvent) = addScheme(selectedScheme!!.deepCopy())
+ }
+
+ /**
+ * Moves the selected scheme up by one position in the tree.
+ */
+ private inner class UpButton : AnActionButton(Bundle("shared.action.up"), AllIcons.Actions.MoveUp) {
+ /**
+ * Specifies the thread in which [update] is invoked.
+ */
+ override fun getActionUpdateThread() = ActionUpdateThread.EDT
+
+ /**
+ * Returns `true` if and only if this action is enabled.
+ */
+ override fun isEnabled() = selectedScheme?.let { canMoveSchemeByOnePosition(it, moveDown = false) } ?: false
+
+ /**
+ * Returns the shortcut for this action.
+ */
+ override fun getShortcut() = CommonActionsPanel.getCommonShortcut(CommonActionsPanel.Buttons.UP)
+
+ /**
+ * Moves the selected scheme up by one position in the tree.
+ *
+ * @param event ignored
+ */
+ override fun actionPerformed(event: AnActionEvent) = moveSchemeByOnePosition(selectedScheme!!, moveDown = false)
+ }
+
+ /**
+ * Moves the selected scheme down by one position in the tree.
+ */
+ private inner class DownButton : AnActionButton(Bundle("shared.action.down"), AllIcons.Actions.MoveDown) {
+ /**
+ * Specifies the thread in which [update] is invoked.
+ */
+ override fun getActionUpdateThread() = ActionUpdateThread.EDT
+
+ /**
+ * Returns `true` if and only if this action is enabled.
+ */
+ override fun isEnabled() = selectedScheme?.let { canMoveSchemeByOnePosition(it, moveDown = true) } ?: false
+
+ /**
+ * Returns the shortcut for this action.
+ */
+ override fun getShortcut() = CommonActionsPanel.getCommonShortcut(CommonActionsPanel.Buttons.DOWN)
+
+ /**
+ * Moves the selected scheme down by one position in the tree.
+ *
+ * @param event ignored
+ */
+ override fun actionPerformed(event: AnActionEvent) = moveSchemeByOnePosition(selectedScheme!!, moveDown = true)
+ }
+
+ /**
+ * Resets the selected scheme to its original state, or removes it if it has no original state.
+ */
+ private inner class ResetButton : AnActionButton(Bundle("shared.action.reset"), AllIcons.General.Reset) {
+ /**
+ * Specifies the thread in which [update] is invoked.
+ */
+ override fun getActionUpdateThread() = ActionUpdateThread.EDT
+
+ /**
+ * Returns `true` if and only if this action is enabled.
+ */
+ override fun isEnabled() = selectedScheme?.let { isModified(it) } ?: false
+
+ /**
+ * Resets the selected scheme to its original state, or removes it if it has no original state.
+ *
+ * @param event ignored
+ */
+ override fun actionPerformed(event: AnActionEvent) =
+ selectedScheme!!
+ .let { replaceScheme(it, originalTemplateList.getSchemeByUuid(it.uuid)?.deepCopy(retainUuid = true)) }
+ }
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The list of schemes that the user can add from the add action.
+ */
+ val POPUP_STEP_SCHEMES: List
+ get() = listOf(
+ Template(Bundle("template.title"), mutableListOf()),
+ IntegerScheme(),
+ DecimalScheme(),
+ StringScheme(),
+ WordScheme(),
+ UuidScheme(),
+ DateTimeScheme(),
+ TemplateReference(),
+ )
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateJTreeModel.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateJTreeModel.kt
new file mode 100644
index 000000000..bd52bff37
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateJTreeModel.kt
@@ -0,0 +1,515 @@
+package com.fwdekker.randomness.template
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.Scheme
+import com.fwdekker.randomness.State
+import com.fwdekker.randomness.setAll
+import com.intellij.ui.RowsDnDSupport.RefinedDropSupport
+import com.intellij.ui.RowsDnDSupport.RefinedDropSupport.Position
+import com.intellij.util.ui.EditableModel
+import javax.swing.JComponent
+import javax.swing.JTree
+import javax.swing.event.TreeModelEvent
+import javax.swing.event.TreeModelListener
+import javax.swing.tree.TreeModel
+import javax.swing.tree.TreePath
+
+
+/**
+ * The underlying model of the [TemplateJTree], which synchronizes its state with a [TemplateList] so that modifications
+ * to the model appear in the list and vice versa.
+ *
+ * If the list is modified externally, this model should be notified using a `fire` method (e.g. [fireNodeChanged]).
+ * Except for the `fire` methods, the behavior of this model is undefined while uninformed of external changes.
+ *
+ * @param list the list to be modeled
+ */
+@Suppress("detekt:TooManyFunctions") // Normal for Swing implementations
+class TemplateJTreeModel(
+ list: TemplateList = TemplateList(mutableListOf()),
+) : TreeModel, EditableModel, RefinedDropSupport {
+ /**
+ * The listeners that are informed when the state of the tree changes.
+ */
+ private val treeModelListeners = mutableListOf()
+
+ /**
+ * The root of the tree, containing the original [TemplateList].
+ */
+ private val root = StateNode(list)
+
+ /**
+ * The list of all [StateNode]s in this tree.
+ */
+ private val nodes get() = listOf(root) + root.descendants()
+
+ /**
+ * Converts the row index in the view to the corresponding row index in the model.
+ *
+ * For example, if the model has rows `[0, 3]` with children `[1, 2]` and `[4]`, respectively, and row `0` is
+ * collapsed in the view, then the view has rows `[0, 3, 4]` while the model has rows `[0, 1, 2, 3, 4]`, and this
+ * method functions as the map `{0: 0, 1: 3, 2: 4}`. Inputs outside the view's valid row indices are not supported.
+ *
+ * This field is used only in the methods [canDrop] and [drop] in order to implement [RefinedDropSupport], which
+ * assumes that the model knows view-based indices.
+ *
+ * @see RefinedDropSupport
+ */
+ var viewIndexToModelIndex: (Int) -> Int = { it }
+
+ /**
+ * A wrapper provided by the view and used by [TemplateJTreeModel.drop] to ensure the view's selection and expansion
+ * state are retained.
+ *
+ * This wrapper is invoked in the method [drop] with a lambda that performs the dropping when invoked. By default,
+ * the wrapper does nothing special, but can be overridden to perform some actions before and after dropping the
+ * [StateNode].
+ *
+ * This field is used only in the method [drop] in order to implement [RefinedDropSupport], which assumes that the
+ * model can somehow retain the view's state.
+ *
+ * @see RefinedDropSupport
+ */
+ var wrapDrop: (() -> Unit) -> Unit = { it() }
+
+
+ /**
+ * Returns [root].
+ */
+ override fun getRoot() = root
+
+
+ /**
+ * Not implemented because this method is used only if this is a model for a table.
+ *
+ * @throws UnsupportedOperationException always
+ */
+ @Throws(UnsupportedOperationException::class)
+ override fun addRow() = throw UnsupportedOperationException()
+
+ /**
+ * Not implemented because this method is used only if this is a model for a table.
+ *
+ * @throws UnsupportedOperationException always
+ */
+ @Throws(UnsupportedOperationException::class)
+ override fun removeRow(index: Int) = throw UnsupportedOperationException()
+
+
+ /**
+ * Inserts [child] as the [index]th child of [parent].
+ *
+ * Throws an exception if [index] is out of bounds in [parent] (of course allowing [index] to equal the
+ * [getChildCount] of [parent]).
+ */
+ fun insertNode(parent: StateNode, child: StateNode, index: Int = getChildCount(parent)) {
+ require(parent in nodes) { Bundle("template_list.error.node_not_in_tree") }
+ require(parent.canHaveChild(child)) { Bundle("template_list.error.wrong_child_type") }
+ require(child !in parent.children) { Bundle("template_list.error.duplicate_uuid") }
+
+ if (parent == root) {
+ val childChildren = child.children
+
+ root.children
+ .associateWith { rootChild -> rootChild.children.filter { it in childChildren } }
+ .filterValues { it.isNotEmpty() }
+ .forEach { (rootChild, duplicates) ->
+ rootChild.children = rootChild.children.toMutableList()
+ .map {
+ if (it in duplicates) StateNode(it.state.deepCopy(retainUuid = false))
+ else it
+ }
+
+ fireNodeStructureChanged(rootChild)
+ }
+ }
+ parent.children = parent.children.toMutableList().also { it.add(index, child) }
+
+ if (parent == root && parent.children.count() == 1)
+ fireNodeStructureChanged(root)
+ else
+ fireNodeInserted(child, parent, index)
+ }
+
+ /**
+ * Inserts [child] as a child into [parent] right after [after].
+ *
+ * Throws an exception if [after] is not a child of [parent].
+ */
+ fun insertNodeAfter(parent: StateNode, after: StateNode, child: StateNode) {
+ require(after in parent.children) { Bundle("template_list.error.wrong_parent") }
+
+ insertNode(parent, child, parent.children.indexOf(after) + 1)
+ }
+
+ /**
+ * Removes [node] from this model.
+ *
+ * Throws an exception if [node] is not contained in this model, or if [node] is [getRoot].
+ */
+ fun removeNode(node: StateNode) {
+ require(node in nodes) { Bundle("template_list.error.node_not_in_tree") }
+ require(node != root) { Bundle("template_list.error.cannot_remove_root") }
+
+ val parent = getParentOf(node)!!
+ val oldIndex = parent.children.indexOf(node)
+
+ parent.children = parent.children.toMutableList().also { it.remove(node) }
+ fireNodeRemoved(node, parent, oldIndex)
+ }
+
+ /**
+ * Use [moveRow] instead.
+ *
+ * @throws UnsupportedOperationException always
+ * @see moveRow
+ */
+ @Throws(UnsupportedOperationException::class)
+ override fun exchangeRows(oldIndex: Int, newIndex: Int) = throw UnsupportedOperationException()
+
+ /**
+ * Use [canMoveRow] instead.
+ *
+ * @throws UnsupportedOperationException always
+ * @see canMoveRow
+ */
+ @Throws(UnsupportedOperationException::class)
+ override fun canExchangeRows(oldIndex: Int, newIndex: Int): Boolean = throw UnsupportedOperationException()
+
+ /**
+ * Returns `true` if and only if [moveRow] can be invoked with the given parameters.
+ *
+ * @see moveRow
+ */
+ fun canMoveRow(fromIndex: Int, toIndex: Int, position: Position): Boolean {
+ val descendants = root.descendants()
+ val templates = root.children
+
+ val fromNode = descendants.getOrNull(fromIndex)
+ val toNode = descendants.getOrNull(toIndex)
+
+ return when {
+ fromIndex == toIndex || fromNode == null || toNode == null -> false
+
+ fromNode.state is Template ->
+ when (position) {
+ Position.INTO -> false
+ Position.BELOW -> toNode == templates.last() && toNode != fromNode
+ Position.ABOVE ->
+ toNode.state is Template && toNode != templates.run { getOrNull(indexOf(fromNode) + 1) }
+ }
+
+ else -> (position == Position.INTO) xor (toNode.state !is Template)
+ }
+ }
+
+ /**
+ * Moves the node at row [fromIndex] near the node at row [toIndex] as specified by [position].
+ *
+ * If [fromIndex] refers to a [Template], then the [Template] can either be moved [Position.ABOVE] another
+ * [Template], or [Position.BELOW] the very last [Scheme] of the [TemplateList].
+ *
+ * If [fromIndex] refers to a non-[Template] [Scheme], then the [Scheme] can either be moved [Position.INTO] a
+ * [Template] to append it to that [Template], or [Position.ABOVE] or [Position.BELOW] another non-[Template]
+ * [Scheme].
+ *
+ * @throws IllegalArgumentException if [canMoveRow] returns `false` for the given arguments
+ */
+ fun moveRow(fromIndex: Int, toIndex: Int, position: Position) {
+ require(canMoveRow(fromIndex, toIndex, position)) {
+ Bundle("template_list.error.cannot_move_row", fromIndex, position.name, toIndex)
+ }
+
+ val descendants = root.descendants()
+ val fromNode = descendants[fromIndex]
+ val toNode = descendants[toIndex]
+
+ removeNode(fromNode)
+
+ if (fromNode.state is Template) {
+ when (position) {
+ Position.ABOVE -> insertNode(root, fromNode, root.children.indexOf(toNode))
+ Position.BELOW -> insertNode(root, fromNode)
+ Position.INTO -> error("Bug: 'canMoveRow' should have caught this case.")
+ }
+ } else {
+ val toNodeParent = getParentOf(toNode)!!
+ when (position) {
+ Position.ABOVE -> insertNode(toNodeParent, fromNode, toNodeParent.children.indexOf(toNode))
+ Position.BELOW -> insertNodeAfter(toNodeParent, toNode, fromNode)
+ Position.INTO -> insertNode(toNode, fromNode)
+ }
+ }
+ }
+
+ /**
+ * Returns `true` if and only if the node at [fromIndex] can be moved [Position.INTO] the node at [toIndex].
+ *
+ * @see canMoveRow
+ * @see RefinedDropSupport
+ */
+ override fun isDropInto(component: JComponent?, fromIndex: Int, toIndex: Int) =
+ canDrop(fromIndex, toIndex, Position.INTO)
+
+ /**
+ * Invokes [canMoveRow] after converting [fromIndex] and [toIndex] using [viewIndexToModelIndex].
+ *
+ * @see canMoveRow
+ * @see RefinedDropSupport
+ */
+ override fun canDrop(fromIndex: Int, toIndex: Int, position: Position) =
+ canMoveRow(viewIndexToModelIndex(fromIndex), viewIndexToModelIndex(toIndex), position)
+
+ /**
+ * Invokes [moveRow] after converting [fromIndex] and [toIndex] using [viewIndexToModelIndex].
+ *
+ * @see moveRow
+ * @see RefinedDropSupport
+ */
+ override fun drop(fromIndex: Int, toIndex: Int, position: Position) =
+ wrapDrop { moveRow(viewIndexToModelIndex(fromIndex), viewIndexToModelIndex(toIndex), position) }
+
+
+ /**
+ * Returns `true` if and only if [node] is contained in this model and [node] does not have children.
+ *
+ * Throws an exception if [node] is not a [StateNode].
+ *
+ * Unlike methods such as [getChild], this method does not throw an exception if [node] is not contained in this
+ * model, because [com.intellij.ui.tree.ui.DefaultTreeUI] calls this method on non-nodes during drag-and-drop.
+ */
+ override fun isLeaf(node: Any): Boolean {
+ require(node is StateNode) {
+ Bundle("template_list.error.unknown_node_type", "node", node.javaClass.canonicalName)
+ }
+
+ return node in nodes && (!node.canHaveChildren || node.children.isEmpty())
+ }
+
+ /**
+ * Returns the [index]th child of [parent].
+ *
+ * Throws an exception if [parent] is not a [StateNode], if [parent] is not contained in this model, if [parent]
+ * cannot have children, or if [parent] has no [index]th child.
+ */
+ override fun getChild(parent: Any, index: Int): StateNode {
+ require(parent is StateNode) {
+ Bundle("template_list.error.unknown_node_type", "parent", parent.javaClass.canonicalName)
+ }
+ require(parent in nodes) { Bundle("template_list.error.node_not_in_tree") }
+
+ return parent.children[index]
+ }
+
+ /**
+ * Returns the number of children of [parent].
+ *
+ * Throws an exception if [parent] is not a [StateNode] or if [parent] is not contained in this model.
+ */
+ override fun getChildCount(parent: Any): Int {
+ require(parent is StateNode) {
+ Bundle("template_list.error.unknown_node_type", "parent", parent.javaClass.canonicalName)
+ }
+ require(parent in nodes) { Bundle("template_list.error.node_not_in_tree") }
+
+ return if (!parent.canHaveChildren) 0
+ else parent.children.size
+ }
+
+ /**
+ * Returns the relative index of [child] in [parent], or `-1` if (1) either [parent] or [child] is `null`,
+ * (2) either [parent] or [child] is not a [StateNode], (3) either [parent] or [child] is not contained in this
+ * model, or (4) if [child] is not a child of [parent].
+ */
+ override fun getIndexOfChild(parent: Any?, child: Any?): Int =
+ if (parent !is StateNode || child !is StateNode) -1
+ else parent.children.indexOf(child)
+
+ /**
+ * Returns the parent of [node], or `null` if [node] has no parent.
+ *
+ * Throws an exception if [node] is not contained in this model.
+ */
+ fun getParentOf(node: StateNode): StateNode? {
+ require(node in nodes) { Bundle("template_list.error.node_not_in_tree") }
+
+ return when (node.state) {
+ is TemplateList -> null
+ is Template -> root
+ else -> root.children.first { node in it.children }
+ }
+ }
+
+ /**
+ * Returns the path from [root] to [node].
+ *
+ * Throws an exception if [node] is not contained in this model.
+ */
+ fun getPathToRoot(node: StateNode): TreePath {
+ require(node in nodes) { Bundle("template_list.error.node_not_in_tree") }
+
+ return when (node.state) {
+ is TemplateList -> TreePath(arrayOf(node))
+ is Template -> TreePath(arrayOf(root, node))
+ else -> TreePath(arrayOf(root, getParentOf(node)!!, node))
+ }
+ }
+
+
+ /**
+ * Informs listeners that [node] has been changed.
+ *
+ * This method is applicable if [node]'s internal state has changed and the way it is displayed should be updated.
+ * However, this method is not applicable if the entire node has been replaced with a different instance or if the
+ * children of [node] have been changed. In those two latter scenarios, use [fireNodeStructureChanged].
+ *
+ * Does nothing if [node] is `null`.
+ */
+ fun fireNodeChanged(node: StateNode?) {
+ if (node == null) return
+
+ val event =
+ if (node == root) {
+ TreeModelEvent(this, getPathToRoot(node))
+ } else {
+ val parent = getParentOf(node)!!
+ TreeModelEvent(this, getPathToRoot(parent), intArrayOf(parent.children.indexOf(node)), arrayOf(node))
+ }
+
+ treeModelListeners.forEach { it.treeNodesChanged(event) }
+ }
+
+ /**
+ * Informs listeners that [node] has been inserted into this model as the [index]th child of [parent].
+ *
+ * Does nothing if [node] is `null`.
+ */
+ fun fireNodeInserted(node: StateNode?, parent: StateNode, index: Int) {
+ if (node == null) return
+ require(node.state !is TemplateList) { Bundle("template_list.error.cannot_insert_root") }
+
+ treeModelListeners.forEach {
+ it.treeNodesInserted(TreeModelEvent(this, getPathToRoot(parent), intArrayOf(index), arrayOf(node)))
+ }
+ }
+
+ /**
+ * Informs listeners that [child] has been removed from this model, but was formerly the [index]th child of
+ * [parent].
+ *
+ * If [child] has children nodes itself, then you must **not** invoke this method on those children.
+ *
+ * Does nothing if [child] is `null`.
+ */
+ fun fireNodeRemoved(child: StateNode?, parent: StateNode, index: Int) {
+ if (child == null) return
+ require(child.state !is TemplateList) { Bundle("template_list.error.cannot_remove_root") }
+
+ treeModelListeners.forEach {
+ it.treeNodesRemoved(TreeModelEvent(this, getPathToRoot(parent), intArrayOf(index), arrayOf(child)))
+ }
+ }
+
+ /**
+ * Informs listeners that [node]'s structure has been changed.
+ */
+ fun fireNodeStructureChanged(node: StateNode = root) {
+ treeModelListeners.forEach { it.treeStructureChanged(TreeModelEvent(this, getPathToRoot(node))) }
+ }
+
+
+ /**
+ * Not implemented because this model does not contain an editor component.
+ *
+ * @throws UnsupportedOperationException always
+ */
+ @Throws(UnsupportedOperationException::class)
+ override fun valueForPathChanged(path: TreePath, newValue: Any) = throw UnsupportedOperationException()
+
+ /**
+ * Adds [listener] as a listener.
+ */
+ override fun addTreeModelListener(listener: TreeModelListener) {
+ treeModelListeners.add(listener)
+ }
+
+ /**
+ * Removes [listener] as a listener.
+ */
+ override fun removeTreeModelListener(listener: TreeModelListener) {
+ treeModelListeners.remove(listener)
+ }
+}
+
+/**
+ * Represents a [State] in a [TemplateJTreeModel].
+ *
+ * By default, two [State]s are equal if their fields are equal, regardless of their UUID. However, this is problematic
+ * for [JTree]s because a [JTree] cannot contain multiple objects that equal each other. The [StateNode] class thus
+ * "replaces" the equals of the contained [State] within the context of the [JTree].
+ *
+ * @property state The state contained in this node.
+ * @see TemplateJTreeModel
+ */
+class StateNode(val state: State) {
+ /**
+ * `true` if and only if this node can have children.
+ */
+ val canHaveChildren: Boolean
+ get() = state is TemplateList || state is Template
+
+ /**
+ * The child nodes contained in this node.
+ *
+ * An exception is thrown if this node cannot have children.
+ */
+ var children: List
+ get() {
+ return when (state) {
+ is TemplateList -> state.templates.map { StateNode(it) }
+ is Template -> state.schemes.map { StateNode(it) }
+ else -> error(Bundle("template_list.error.infertile_parent"))
+ }
+ }
+ set(value) {
+ when (state) {
+ is TemplateList -> state.templates.setAll(value.map { it.state as Template })
+ is Template -> state.schemes.setAll(value.map { it.state as Scheme })
+ else -> error(Bundle("template_list.error.infertile_parent"))
+ }
+ }
+
+
+ /**
+ * Returns `true` if and only if this node can have [child] as a child.
+ */
+ fun canHaveChild(child: StateNode) =
+ when (state) {
+ is TemplateList -> child.state is Template
+ is Template -> child.state is Scheme && child.state !is Template
+ else -> false
+ }
+
+ /**
+ * The (recursive) descendants of this node in depth-first order (excluding `this` node itself), or an empty list
+ * if this node cannot have children.
+ */
+ fun descendants(): List {
+ return when (state) {
+ is TemplateList -> children.flatMap { listOf(it) + it.children }
+ is Template -> children
+ else -> emptyList()
+ }
+ }
+
+
+ /**
+ * Returns `true` if and only if the UUIDs of `this`' [state] and [other]'s [state] are equal.
+ */
+ override fun equals(other: Any?) = other is StateNode && this.state.uuid == other.state.uuid
+
+ /**
+ * Returns the hash code of the [state]'s UUID.
+ */
+ override fun hashCode() = state.uuid.hashCode()
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateList.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateList.kt
new file mode 100644
index 000000000..a22846e15
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateList.kt
@@ -0,0 +1,163 @@
+package com.fwdekker.randomness.template
+
+import com.fwdekker.randomness.Box
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.CapitalizationMode
+import com.fwdekker.randomness.Settings
+import com.fwdekker.randomness.State
+import com.fwdekker.randomness.affix.AffixDecorator
+import com.fwdekker.randomness.array.ArrayDecorator
+import com.fwdekker.randomness.datetime.DateTimeScheme
+import com.fwdekker.randomness.decimal.DecimalScheme
+import com.fwdekker.randomness.integer.IntegerScheme
+import com.fwdekker.randomness.string.StringScheme
+import com.fwdekker.randomness.uuid.UuidScheme
+import com.fwdekker.randomness.word.DefaultWordList
+import com.fwdekker.randomness.word.WordScheme
+import com.intellij.util.xmlb.annotations.OptionTag
+
+
+/**
+ * A collection of different templates.
+ *
+ * @property templates The collection of templates, each with a unique name.
+ */
+data class TemplateList(
+ @OptionTag
+ val templates: MutableList = DEFAULT_TEMPLATES,
+) : State() {
+ override fun applyContext(context: Box) {
+ super.applyContext(context)
+ templates.forEach { it.applyContext(context) }
+ }
+
+
+ /**
+ * Returns the template in this list that has [uuid] as its UUID, or `null` if there is no such template.
+ */
+ fun getTemplateByUuid(uuid: String) = templates.singleOrNull { it.uuid == uuid }
+
+ /**
+ * Returns the template or scheme in this list that has [uuid] as its UUID, or `null` if there is no such template
+ * or scheme.
+ */
+ fun getSchemeByUuid(uuid: String) = templates.flatMap { listOf(it) + it.schemes }.singleOrNull { it.uuid == uuid }
+
+
+ override fun doValidate(): String? {
+ val templateNames = templates.map { it.name }
+ val duplicate = templateNames.firstOrNull { templateNames.indexOf(it) != templateNames.lastIndexOf(it) }
+ val invalid =
+ templates.firstNotNullOfOrNull { template -> template.doValidate()?.let { "${template.name} > $it" } }
+
+ return when {
+ duplicate != null -> Bundle("template_list.error.duplicate_name", duplicate)
+ invalid != null -> invalid
+ else -> null
+ }
+ }
+
+ /**
+ * Note that the [context] must be updated manually.
+ *
+ * @see State.deepCopy
+ */
+ override fun deepCopy(retainUuid: Boolean) =
+ copy(templates = templates.map { it.deepCopy(retainUuid) }.toMutableList()).deepCopyTransient(retainUuid)
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The default value of the [templates] field.
+ */
+ val DEFAULT_TEMPLATES
+ get() = mutableListOf(
+ Template("Integer", mutableListOf(IntegerScheme())),
+ Template("Decimal", mutableListOf(DecimalScheme())),
+ Template("Alphanumerical String", mutableListOf(StringScheme())),
+ Template(
+ "Personal Name",
+ mutableListOf(
+ WordScheme(
+ words = DefaultWordList.WORD_LIST_MAP["Forenames"]!!.words,
+ affixDecorator = AffixDecorator(enabled = true, descriptor = "@ "),
+ ),
+ WordScheme(words = DefaultWordList.WORD_LIST_MAP["Surnames"]!!.words),
+ )
+ ),
+ Template(
+ "Lorem Ipsum",
+ mutableListOf(
+ WordScheme(
+ words = DefaultWordList.WORD_LIST_MAP["Lorem"]!!.words,
+ capitalization = CapitalizationMode.FIRST_LETTER,
+ affixDecorator = AffixDecorator(enabled = true, descriptor = "@ "),
+ ),
+ WordScheme(
+ words = DefaultWordList.WORD_LIST_MAP["Lorem"]!!.words,
+ capitalization = CapitalizationMode.LOWER,
+ arrayDecorator = ArrayDecorator(
+ enabled = true,
+ minCount = 3,
+ maxCount = 7,
+ separator = " ",
+ affixDecorator = AffixDecorator(enabled = true, descriptor = "@."),
+ )
+ ),
+ )
+ ),
+ Template(
+ "Hex Color",
+ mutableListOf(
+ IntegerScheme(
+ minValue = 0L,
+ maxValue = 256L,
+ base = 16,
+ affixDecorator = AffixDecorator(enabled = false),
+ arrayDecorator = ArrayDecorator(
+ enabled = true,
+ minCount = 3,
+ maxCount = 3,
+ separatorEnabled = false,
+ affixDecorator = AffixDecorator(
+ enabled = true,
+ descriptor = "#@",
+ ),
+ ),
+ )
+ )
+ ),
+ Template("UUID", mutableListOf(UuidScheme())),
+ Template("Date-Time", mutableListOf(DateTimeScheme())),
+ Template(
+ "IP address",
+ mutableListOf(
+ IntegerScheme(
+ minValue = 0L,
+ maxValue = 255L,
+ arrayDecorator = ArrayDecorator(
+ enabled = true,
+ minCount = 4,
+ maxCount = 4,
+ separator = ".",
+ affixDecorator = AffixDecorator(enabled = false),
+ ),
+ ),
+ )
+ ),
+ Template(
+ "Constructor",
+ mutableListOf(
+ StringScheme(pattern = "MyClass(name = ", isRegex = false),
+ StringScheme(pattern = "\"[a-zA-Z0-9]{5,8}\"", isRegex = true),
+ StringScheme(pattern = ", value = ", isRegex = false),
+ IntegerScheme(),
+ StringScheme(pattern = ")", isRegex = false),
+ )
+ ),
+ )
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateListConfigurable.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateListConfigurable.kt
new file mode 100644
index 000000000..422aeed20
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateListConfigurable.kt
@@ -0,0 +1,88 @@
+package com.fwdekker.randomness.template
+
+import com.fwdekker.randomness.Bundle
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.options.Configurable
+import com.intellij.openapi.options.ConfigurationException
+import com.intellij.openapi.util.Disposer
+import javax.swing.JComponent
+
+
+/**
+ * Tells IntelliJ how to use a [TemplateListEditor] to edit a [TemplateList] in the settings dialog.
+ *
+ * Set [schemeToSelect] before [createComponent] is invoked to determine which template should be selected when the
+ * configurable opens.
+ *
+ * This class is separate from [TemplateListEditor] because that class creates UI components in the constructor. But
+ * configurables may be created at any time in the background, so using [TemplateListEditor] as a configurable would
+ * cause unnecessary lag.
+ *
+ * @see TemplateSettingsAction
+ */
+class TemplateListConfigurable : Configurable, Disposable {
+ /**
+ * The user interface for changing the settings, displayed in IntelliJ's settings window.
+ */
+ @Suppress("detekt:LateinitUsage") // Initialized in [createComponent]
+ lateinit var editor: TemplateListEditor private set
+
+ /**
+ * The UUID of the scheme to select after calling [createComponent].
+ */
+ var schemeToSelect: String? = null
+
+
+ /**
+ * Returns the name of the configurable as displayed in the settings window.
+ */
+ override fun getDisplayName() = "Randomness"
+
+ /**
+ * Creates a new editor and returns the root pane of the created editor.
+ */
+ override fun createComponent(): JComponent {
+ editor = TemplateListEditor(initialSelection = schemeToSelect).also { Disposer.register(this, it) }
+ return editor.rootComponent
+ }
+
+
+ /**
+ * Returns `true` if the settings were modified since they were loaded or they are invalid.
+ */
+ override fun isModified() = editor.isModified() || editor.doValidate() != null
+
+ /**
+ * Saves the changes in the settings component to the default settings object, and updates template shortcuts.
+ *
+ * @throws ConfigurationException if the changes cannot be saved
+ */
+ @Throws(ConfigurationException::class)
+ override fun apply() {
+ val validationInfo = editor.doValidate()
+ if (validationInfo != null)
+ throw ConfigurationException(validationInfo, Bundle("template_list.error.failed_to_save_settings"))
+
+ val oldList = editor.originalTemplateList.deepCopy(retainUuid = true)
+ editor.apply()
+ val newList = editor.originalTemplateList.deepCopy(retainUuid = true)
+
+ TemplateActionLoader().updateActions(oldList.templates, newList.templates)
+ }
+
+ /**
+ * Discards unsaved changes in the settings component.
+ */
+ override fun reset() = editor.reset()
+
+
+ /**
+ * Recursively disposes this configurable's resources.
+ */
+ override fun disposeUIResources() = Disposer.dispose(this)
+
+ /**
+ * Non-recursively disposes this configurable's resources.
+ */
+ override fun dispose() = Unit
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateListEditor.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateListEditor.kt
new file mode 100644
index 000000000..4375ce3fd
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateListEditor.kt
@@ -0,0 +1,263 @@
+package com.fwdekker.randomness.template
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.Scheme
+import com.fwdekker.randomness.SchemeEditor
+import com.fwdekker.randomness.Settings
+import com.fwdekker.randomness.datetime.DateTimeScheme
+import com.fwdekker.randomness.datetime.DateTimeSchemeEditor
+import com.fwdekker.randomness.decimal.DecimalScheme
+import com.fwdekker.randomness.decimal.DecimalSchemeEditor
+import com.fwdekker.randomness.integer.IntegerScheme
+import com.fwdekker.randomness.integer.IntegerSchemeEditor
+import com.fwdekker.randomness.setAll
+import com.fwdekker.randomness.string.StringScheme
+import com.fwdekker.randomness.string.StringSchemeEditor
+import com.fwdekker.randomness.ui.PreviewPanel
+import com.fwdekker.randomness.ui.addChangeListenerTo
+import com.fwdekker.randomness.uuid.UuidScheme
+import com.fwdekker.randomness.uuid.UuidSchemeEditor
+import com.fwdekker.randomness.word.WordScheme
+import com.fwdekker.randomness.word.WordSchemeEditor
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.util.Disposer
+import com.intellij.ui.JBSplitter
+import com.intellij.ui.OnePixelSplitter
+import com.intellij.ui.components.JBScrollPane
+import com.intellij.util.ui.JBEmptyBorder
+import java.awt.BorderLayout
+import java.awt.Dimension
+import java.awt.KeyboardFocusManager
+import java.beans.PropertyChangeEvent
+import java.beans.PropertyChangeListener
+import javax.swing.JComponent
+import javax.swing.JPanel
+import javax.swing.SwingUtilities
+
+
+/**
+ * Component for editing a [TemplateList]s.
+ *
+ * The editor essentially has a master-detail layout. On the left, a [TemplateJTree] shows all templates and the schemes
+ * contained within them, and on the right, a [SchemeEditor] for the currently-selected template or scheme is shown.
+ *
+ * @property originalTemplateList The templates to edit.
+ * @param initialSelection the UUID of the scheme to select initially, or `null` or an invalid UUID to select the first
+ * template
+ * @see TemplateListConfigurable
+ */
+class TemplateListEditor(
+ val originalTemplateList: TemplateList = Settings.DEFAULT.templateList,
+ initialSelection: String? = null,
+) : Disposable {
+ /**
+ * The root component of the editor.
+ */
+ val rootComponent = JPanel(BorderLayout())
+
+ private val currentTemplateList = TemplateList() // Synced with [originalTemplateList] in [reset]
+ private val templateTree = TemplateJTree(originalTemplateList, currentTemplateList)
+ private val schemeEditorPanel = JPanel(BorderLayout())
+ private var schemeEditor: SchemeEditor<*>? = null
+ private val previewPanel =
+ PreviewPanel { templateTree.selectedTemplate ?: StringScheme("") }
+ .also { Disposer.register(this, it) }
+
+
+ init {
+ val splitter = createSplitter()
+ rootComponent.add(splitter, BorderLayout.CENTER)
+
+ // Left half
+ templateTree.addTreeSelectionListener { onTreeSelection() }
+ splitter.firstComponent = templateTree.asDecoratedPanel()
+
+ // Right half
+ addChangeListenerTo(templateTree) { previewPanel.updatePreview() }
+ schemeEditorPanel.border = JBEmptyBorder(EDITOR_PANEL_MARGIN)
+ schemeEditorPanel.add(previewPanel.rootComponent, BorderLayout.SOUTH)
+
+ splitter.secondComponent = schemeEditorPanel
+
+ // Load current state
+ reset()
+ templateTree.expandAll()
+
+ // Select a scheme
+ initialSelection
+ ?.let { currentTemplateList.getSchemeByUuid(it) }
+ ?.also {
+ templateTree.selectedScheme = it
+ SwingUtilities.invokeLater { schemeEditor?.preferredFocusedComponent?.requestFocus() }
+ }
+ }
+
+ /**
+ * Invoked when an entry is (de)selected in the tree.
+ */
+ private fun onTreeSelection() {
+ // Remove old editor
+ schemeEditor?.also { editor ->
+ schemeEditorPanel.remove((schemeEditorPanel.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER))
+ schemeEditor = null
+ schemeEditorPanel.revalidate() // Hide editor immediately
+
+ Disposer.dispose(editor)
+ }
+
+ // Create new editor
+ val selectedState = templateTree.selectedScheme ?: return
+ schemeEditor = createEditor(selectedState)
+ .also { editor ->
+ editor.addChangeListener {
+ editor.apply()
+ templateTree.reload(selectedState)
+ }
+ editor.apply() // Apply validation fixes from UI
+
+ schemeEditorPanel.add(
+ JBScrollPane(editor.rootComponent).also {
+ it.border = null
+ it.preferredSize = Dimension(Int.MAX_VALUE, Int.MAX_VALUE)
+ },
+ BorderLayout.CENTER
+ )
+
+ ScrollOnFocusListener(KeyboardFocusManager.getCurrentKeyboardFocusManager(), editor).install()
+ }
+
+ // Show new editor
+ templateTree.reload(selectedState)
+ schemeEditorPanel.revalidate() // Show editor immediately
+ }
+
+ /**
+ * Creates a new splitter.
+ *
+ * If a test that depends on [TemplateListEditor] freezes for a long time or fails to initialize the UI, try
+ * setting [useTestSplitter] to `true`.
+ *
+ * @return the created splitter
+ */
+ private fun createSplitter() =
+ if (useTestSplitter) JBSplitter(false, SPLITTER_PROPORTION_KEY, DEFAULT_SPLITTER_PROPORTION)
+ else OnePixelSplitter(false, SPLITTER_PROPORTION_KEY, DEFAULT_SPLITTER_PROPORTION)
+
+ /**
+ * Creates an editor to edit [scheme].
+ */
+ private fun createEditor(scheme: Scheme) =
+ when (scheme) {
+ is IntegerScheme -> IntegerSchemeEditor(scheme)
+ is DecimalScheme -> DecimalSchemeEditor(scheme)
+ is StringScheme -> StringSchemeEditor(scheme)
+ is UuidScheme -> UuidSchemeEditor(scheme)
+ is WordScheme -> WordSchemeEditor(scheme)
+ is DateTimeScheme -> DateTimeSchemeEditor(scheme)
+ is TemplateReference -> TemplateReferenceEditor(scheme)
+ is Template -> TemplateEditor(scheme)
+ else -> error(Bundle("template_list.error.unknown_scheme_type", scheme.javaClass.canonicalName))
+ }.also { Disposer.register(this, it) }
+
+
+ /**
+ * Validates the state of the editor and indicates whether and why it is invalid.
+ *
+ * @return `null` if the state is valid, or a string explaining why the state is invalid
+ */
+ fun doValidate(): String? = currentTemplateList.doValidate()
+
+ /**
+ * Returns `true` if and only if the editor contains modifications relative to the last saved state.
+ */
+ fun isModified() = originalTemplateList != currentTemplateList
+
+ /**
+ * Saves the editor's state into [originalTemplateList].
+ *
+ * Does nothing if and only if [isModified] returns `false`.
+ */
+ fun apply() {
+ originalTemplateList.templates.setAll(currentTemplateList.deepCopy(retainUuid = true).templates)
+ originalTemplateList.applyContext((+originalTemplateList.context).copy(templateList = originalTemplateList))
+ }
+
+ /**
+ * Resets the editor's state to the last saved state.
+ *
+ * Does nothing if and only if [isModified] return `false`.
+ */
+ fun reset() {
+ currentTemplateList.templates.setAll(originalTemplateList.deepCopy(retainUuid = true).templates)
+ currentTemplateList.applyContext((+currentTemplateList.context).copy(templateList = currentTemplateList))
+
+ templateTree.reload()
+ }
+
+ /**
+ * Disposes this editor's resources.
+ */
+ override fun dispose() = Unit
+
+
+ /**
+ * Scrolls to the focused element within the [editor], registering this listener with the [focusManager], and
+ * automatically disposing itself when the [editor] is disposed.
+ */
+ private class ScrollOnFocusListener(
+ private val focusManager: KeyboardFocusManager,
+ private val editor: SchemeEditor,
+ ) : PropertyChangeListener, Disposable {
+ /**
+ * Scrolls to the newly focused element if that element is in the [editor].
+ */
+ override fun propertyChange(event: PropertyChangeEvent) {
+ val focused = event.newValue as? JComponent
+ if (focused == null || !editor.rootComponent.isAncestorOf(focused))
+ return
+
+ val target = SwingUtilities.convertRectangle(focused, focused.bounds, editor.rootComponent)
+ editor.rootComponent.scrollRectToVisible(target)
+ }
+
+
+ /**
+ * Installs this listener with the [focusManager].
+ */
+ fun install() {
+ Disposer.register(editor, this)
+ focusManager.addPropertyChangeListener("focusOwner", this)
+ }
+
+ /**
+ * Removes this listener from the [focusManager].
+ */
+ override fun dispose() = focusManager.removePropertyChangeListener("focusOwner", this)
+ }
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The key to store the user's last-used splitter proportion under.
+ */
+ const val SPLITTER_PROPORTION_KEY = "com.fwdekker.randomness.template.TemplateListEditor"
+
+ /**
+ * The default proportion of the splitter component.
+ */
+ const val DEFAULT_SPLITTER_PROPORTION = .25f
+
+ /**
+ * Pixels of margin outside the editor panel.
+ */
+ const val EDITOR_PANEL_MARGIN = 10
+
+ /**
+ * Whether [createSplitter] should use a separate kind of splitter that is more compatible with tests.
+ */
+ var useTestSplitter: Boolean = false
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateReference.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateReference.kt
new file mode 100644
index 000000000..a0c92d51a
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateReference.kt
@@ -0,0 +1,151 @@
+package com.fwdekker.randomness.template
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.CapitalizationMode
+import com.fwdekker.randomness.DataGenerationException
+import com.fwdekker.randomness.Icons
+import com.fwdekker.randomness.OverlayIcon
+import com.fwdekker.randomness.OverlayedIcon
+import com.fwdekker.randomness.Scheme
+import com.fwdekker.randomness.TypeIcon
+import com.fwdekker.randomness.affix.AffixDecorator
+import com.fwdekker.randomness.array.ArrayDecorator
+import com.intellij.ui.Gray
+import com.intellij.util.xmlb.annotations.Transient
+
+
+/**
+ * A reference to an existing template, to allow using a template as if it were a scheme.
+ *
+ * @property templateUuid The UUID of the referenced template; defaults to the first template in [TemplateList].
+ * @property capitalization The way in which the generated word should be capitalized.
+ * @property affixDecorator The affixation to apply to the generated values.
+ * @property arrayDecorator Settings that determine whether the output should be an array of values.
+ */
+data class TemplateReference(
+ var templateUuid: String? = null,
+ var capitalization: CapitalizationMode = DEFAULT_CAPITALIZATION,
+ val affixDecorator: AffixDecorator = DEFAULT_AFFIX_DECORATOR,
+ val arrayDecorator: ArrayDecorator = DEFAULT_ARRAY_DECORATOR,
+) : Scheme() {
+ override val name get() = template?.name?.let { "[$it]" } ?: Bundle("reference.title")
+ override val typeIcon get() = template?.typeIcon ?: DEFAULT_ICON
+ override val icon get() = OverlayedIcon(typeIcon, decorators.mapNotNull { it.icon } + OverlayIcon.REFERENCE)
+ override val decorators get() = listOf(arrayDecorator)
+
+ /**
+ * The [Template] in the [context]'s [TemplateList] that contains this [TemplateReference].
+ */
+ val parent: Template
+ get() = (+context).templates.single { template -> template.schemes.any { it.uuid == uuid } }
+
+ /**
+ * The [Template] that is being referenced, or `null` if it could not be found in the [context]'s [TemplateList].
+ */
+ @get:Transient
+ var template: Template?
+ get() = (+context).templates.singleOrNull { it.uuid == templateUuid }
+ set(value) {
+ templateUuid = value?.uuid
+ }
+
+
+ /**
+ * Tries to find a recursive cycle of references within the current [context] starting at [parent], returning `null`
+ * if there is no such cycle.
+ */
+ private fun getReferenceCycleOrNull(): List? {
+ fun helper(source: TemplateReference, history: List): List? =
+ if (source in history) listOf(source.parent)
+ else source.template?.schemes
+ ?.filterIsInstance()
+ ?.firstNotNullOfOrNull { helper(it, history + source) }
+ ?.let { listOf(source.parent) + it }
+
+ return helper(this, emptyList())
+ }
+
+ /**
+ * Returns `true` if and only if `this` would not be part of a cycle of recursive references in [context] after
+ * setting [template] to [target].
+ */
+ fun canReference(target: Template): Boolean {
+ val originalUuid = templateUuid
+
+ templateUuid = target.uuid
+ val createsCycle = getReferenceCycleOrNull() == null
+ templateUuid = originalUuid
+
+ return createsCycle
+ }
+
+
+ override fun generateUndecoratedStrings(count: Int) =
+ (this.template ?: throw DataGenerationException(Bundle("reference.error.generate_null")))
+ .also { it.random = random }
+ .generateStrings(count)
+ .map { capitalization.transform(it, random) }
+
+
+ override fun doValidate(): String? {
+ val cycle = getReferenceCycleOrNull()
+
+ return when {
+ templateUuid == null -> Bundle("reference.error.no_selection")
+ template == null -> Bundle("reference.error.not_found")
+ cycle != null -> Bundle("reference.error.recursion", "(${cycle.joinToString(" → ") { it.name }})")
+ else -> affixDecorator.doValidate() ?: arrayDecorator.doValidate()
+ }
+ }
+
+ /**
+ * Note that the [context] must be updated manually.
+ *
+ * @see Scheme.deepCopy
+ */
+ override fun deepCopy(retainUuid: Boolean) =
+ copy(arrayDecorator = arrayDecorator.deepCopy(retainUuid)).deepCopyTransient(retainUuid)
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The base icon for references when the reference is invalid.
+ */
+ val DEFAULT_ICON = TypeIcon(Icons.TEMPLATE, "", listOf(Gray._110))
+
+ /**
+ * The preset values for the [capitalization] field.
+ */
+ val PRESET_CAPITALIZATION = arrayOf(
+ CapitalizationMode.RETAIN,
+ CapitalizationMode.LOWER,
+ CapitalizationMode.UPPER,
+ CapitalizationMode.RANDOM,
+ CapitalizationMode.SENTENCE,
+ CapitalizationMode.FIRST_LETTER,
+ )
+
+ /**
+ * The default value of the [capitalization] field.
+ */
+ val DEFAULT_CAPITALIZATION = CapitalizationMode.RETAIN
+
+ /**
+ * The preset values for the [affixDecorator] descriptor.
+ */
+ val PRESET_AFFIX_DECORATOR_DESCRIPTORS = listOf("'", "\"", "`")
+
+ /**
+ * The default value of the [affixDecorator] field.
+ */
+ val DEFAULT_AFFIX_DECORATOR get() = AffixDecorator(enabled = false, descriptor = "\"")
+
+ /**
+ * The default value of the [arrayDecorator] field.
+ */
+ val DEFAULT_ARRAY_DECORATOR get() = ArrayDecorator()
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/template/TemplateReferenceEditor.kt b/src/main/kotlin/com/fwdekker/randomness/template/TemplateReferenceEditor.kt
new file mode 100644
index 000000000..3c119a55c
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/template/TemplateReferenceEditor.kt
@@ -0,0 +1,88 @@
+package com.fwdekker.randomness.template
+
+import com.fwdekker.randomness.Bundle
+import com.fwdekker.randomness.CapitalizationMode
+import com.fwdekker.randomness.SchemeEditor
+import com.fwdekker.randomness.affix.AffixDecoratorEditor
+import com.fwdekker.randomness.array.ArrayDecoratorEditor
+import com.fwdekker.randomness.template.TemplateReference.Companion.PRESET_AFFIX_DECORATOR_DESCRIPTORS
+import com.fwdekker.randomness.template.TemplateReference.Companion.PRESET_CAPITALIZATION
+import com.fwdekker.randomness.ui.onResetThis
+import com.fwdekker.randomness.ui.withName
+import com.fwdekker.randomness.ui.withSimpleRenderer
+import com.intellij.openapi.ui.ComboBox
+import com.intellij.ui.ColoredListCellRenderer
+import com.intellij.ui.dsl.builder.bindItem
+import com.intellij.ui.dsl.builder.panel
+import com.intellij.ui.dsl.builder.toNullableProperty
+import com.intellij.ui.dsl.gridLayout.HorizontalAlign
+import javax.swing.JList
+
+
+/**
+ * Component for editing a [TemplateReference].
+ *
+ * @param scheme the scheme to edit
+ */
+class TemplateReferenceEditor(scheme: TemplateReference) : SchemeEditor(scheme) {
+ override val rootComponent = panel {
+ group(Bundle("reference.ui.value.header")) {
+ row(Bundle("reference.ui.value.template_option")) {
+ comboBox(emptyList(), TemplateCellRenderer())
+ .onResetThis { cell ->
+ cell.component.removeAllItems()
+
+ (+scheme.context).templates
+ .filter { scheme.canReference(it) }
+ .forEach { cell.component.addItem(it) }
+ }
+ .withName("template")
+ .bindItem(scheme::template.toNullableProperty())
+ }
+
+ row(Bundle("reference.ui.value.capitalization_option")) {
+ cell(ComboBox(PRESET_CAPITALIZATION))
+ .withSimpleRenderer(CapitalizationMode::toLocalizedString)
+ .withName("capitalization")
+ .bindItem(scheme::capitalization.toNullableProperty())
+ }
+
+ row {
+ AffixDecoratorEditor(scheme.affixDecorator, PRESET_AFFIX_DECORATOR_DESCRIPTORS)
+ .also { decoratorEditors += it }
+ .let { cell(it.rootComponent) }
+ }
+ }
+
+ row {
+ ArrayDecoratorEditor(scheme.arrayDecorator)
+ .also { decoratorEditors += it }
+ .let { cell(it.rootComponent).horizontalAlign(HorizontalAlign.FILL) }
+ }
+ }
+
+
+ init {
+ reset()
+ }
+
+
+ /**
+ * Renders a template.
+ */
+ private class TemplateCellRenderer : ColoredListCellRenderer() {
+ /**
+ * Renders the [value] as its icon and name, ignoring other parameters.
+ */
+ override fun customizeCellRenderer(
+ list: JList,
+ value: Template?,
+ index: Int,
+ selected: Boolean,
+ hasFocus: Boolean,
+ ) {
+ icon = value?.icon ?: Template.DEFAULT_ICON
+ append(value?.name ?: Bundle("template.name.unknown"))
+ }
+ }
+}
diff --git a/src/main/kotlin/com/fwdekker/randomness/ui/ActivityTableModelEditor.kt b/src/main/kotlin/com/fwdekker/randomness/ui/ActivityTableModelEditor.kt
deleted file mode 100644
index 9c889658a..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/ui/ActivityTableModelEditor.kt
+++ /dev/null
@@ -1,209 +0,0 @@
-package com.fwdekker.randomness.ui
-
-import com.intellij.ide.IdeBundle
-import com.intellij.openapi.actionSystem.AnActionEvent
-import com.intellij.openapi.actionSystem.CommonShortcuts
-import com.intellij.openapi.keymap.KeymapUtil
-import com.intellij.ui.SimpleTextAttributes
-import com.intellij.ui.TableUtil
-import com.intellij.ui.ToolbarDecorator
-import com.intellij.ui.table.TableView
-import com.intellij.util.PlatformIcons
-import com.intellij.util.ui.CollectionItemEditor
-import com.intellij.util.ui.ColumnInfo
-import com.intellij.util.ui.StatusText
-import com.intellij.util.ui.table.TableModelEditor
-import javax.swing.JPanel
-import javax.swing.table.TableColumn
-
-
-/**
- * An entry in the table.
- *
- * Associates a datum with a boolean that indicates whether the datum is active. The datum can be "edited" by changing
- * its reference to another datum.
- *
- * @param T the type of datum
- * @property active whether the entry is active
- * @property datum a reference to a piece of data
- */
-data class EditableDatum(var active: Boolean, var datum: T)
-
-
-/**
- * A table that associates a certain data type with a checkbox.
- *
- * @param T the data type
- * @param columns the columns of the table, excluding the activity column
- * @param itemEditor describes what happens when a row is edited
- * @param emptyText the text to display when the table is empty
- * @property emptySubText the instruction to display when the table is empty
- * @property isCopyable returns `true` if and only if the given datum can be copied
- * @property columnAdjuster invoked on the list of columns (including the activity column) to allow adjusting their
- * properties
- */
-abstract class ActivityTableModelEditor(
- columns: Array, *>>,
- itemEditor: CollectionItemEditor>,
- emptyText: String,
- private val emptySubText: String,
- private val isCopyable: (T) -> Boolean = { true },
- private val columnAdjuster: (List) -> Unit = {}
-) : TableModelEditor>(
- arrayOf, *>>(createActivityColumn()).plus(columns),
- itemEditor,
- emptyText
-) {
- /**
- * The panel in which the table editor is present.
- */
- val panel: JPanel
-
- /**
- * All data currently in the table.
- */
- var data: Collection
- get() = model.items.map { it.datum }
- set(value) {
- model.items = value.map { EditableDatum(DEFAULT_STATE, it) }
- }
-
- /**
- * All data of which the checkbox is currently checked.
- */
- var activeData: Collection
- get() = model.items.filter { it.active }.map { it.datum }
- set(value) {
- model.items.forEachIndexed { i, item -> model.setValueAt(item.datum in value, i, 0) }
- }
-
-
- init {
- panel = createComponent()
- }
-
-
- /**
- * Creates a new `JPanel` with the table and the corresponding buttons.
- *
- * Do not use this method; instead, use the `panel` property.
- *
- * @return a new `JPanel` with the table and the corresponding buttons
- */
- final override fun createComponent(): JPanel {
- @Suppress("UNCHECKED_CAST") // Reflection, see superclass for correctness
- val table = TableModelEditor::class.java.getDeclaredField("table")
- .apply { isAccessible = true }
- .get(this) as TableView>
-
- columnAdjuster(table.columnModel.columns.toList())
-
- table.emptyText.apply {
- appendSecondaryText(emptySubText, SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES) { addItem(table) }
- appendSecondaryText(
- " (${KeymapUtil.getFirstKeyboardShortcutText(CommonShortcuts.getNew())})",
- StatusText.DEFAULT_ATTRIBUTES,
- null
- )
- }
-
- val copyAction =
- object : ToolbarDecorator.ElementActionButton(IdeBundle.message("button.copy"), PlatformIcons.COPY_ICON) {
- override fun actionPerformed(e: AnActionEvent) = copySelectedItems(table)
-
- override fun isEnabled() = table.selection.all { isCopyable(it.datum) }
- }
- // Implementation based on `TableModelEditor#createComponent`
- return TableModelEditor::class.java.getDeclaredField("toolbarDecorator")
- .apply { isAccessible = true }
- .let { it.get(this) as ToolbarDecorator }
- .setAddAction { addItem(table) }
- .setEditAction {
- if (table.selectedObject != null)
- table.editCellAt(table.selectedRow, table.selectedColumn)
- }
- .addExtraAction(copyAction)
- .createPanel()
- }
-
- /**
- * Adds a listener that is called whenever this table is updated.
- *
- * @param listener the listener to invoke
- */
- fun addChangeListener(listener: () -> Unit) {
- modelListener(object : DataChangedListener>() {
- override fun dataChanged(columnInfo: ColumnInfo, *>, rowIndex: Int) = listener()
- })
- }
-
-
- /**
- * Adds a new item to the table, brings that item into view, and applies focus to that item's first editable column.
- *
- * @param table the table to add the item to
- */
- private fun addItem(table: TableView>) {
- // Implementation based on `TableToolbarDecorator#createDefaultTableActions`
- TableUtil.stopEditing(table)
-
- table.rowCount.let { rowCount ->
- if (canCreateElement()) model.addRow(createElement())
- else model.addRow()
-
- if (rowCount == table.rowCount) return
- }
-
- val newRowIndex = model.rowCount - 1
- val firstEditableColumn = (1..model.columnCount).first { model.isCellEditable(newRowIndex, it) }
- table.setRowSelectionInterval(newRowIndex, newRowIndex)
- table.setColumnSelectionInterval(0, 0)
- table.editCellAt(newRowIndex, firstEditableColumn)
-
- TableUtil.updateScroller(table)
- table.editorComponent?.let { table.scrollRectToVisible(it.bounds) }
- table.editCellAt(newRowIndex, firstEditableColumn)
- }
-
- /**
- * Copies all selected items in the given table.
- *
- * @param table the table to copy the selected items in
- */
- private fun copySelectedItems(table: TableView>) {
- TableUtil.stopEditing(table)
-
- table.selectedObjects
- .also { if (it.isEmpty()) return }
- .forEach { model.addRow(itemEditor.clone(it, false)) }
-
- TableUtil.updateScroller(table)
- }
-
-
- /**
- * Holds constants.
- */
- companion object {
- /**
- * Whether newly added data are active by default.
- */
- const val DEFAULT_STATE = true
-
-
- /**
- * Creates a new column with checkboxes for (de)activating data.
- *
- * @return a new column with checkboxes for (de)activating data
- */
- private fun createActivityColumn() = object : EditableColumnInfo, Boolean>() {
- override fun getColumnClass() = Boolean::class.java
-
- override fun valueOf(item: EditableDatum) = item.active
-
- override fun setValue(item: EditableDatum, value: Boolean) {
- item.active = value
- }
- }
- }
-}
diff --git a/src/main/kotlin/com/fwdekker/randomness/ui/ButtonGroup.kt b/src/main/kotlin/com/fwdekker/randomness/ui/ButtonGroup.kt
deleted file mode 100644
index e39ca8bc4..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/ui/ButtonGroup.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.fwdekker.randomness.ui
-
-import javax.swing.AbstractButton
-import javax.swing.ButtonGroup
-
-
-/**
- * Executes a consumer for each button in a group.
- *
- * @param consumer the function to apply to each button
- */
-fun ButtonGroup.forEach(consumer: (AbstractButton) -> Unit) = elements.toList().forEach(consumer)
-
-/**
- * Returns the action command of the currently selected button, or `null` if no button is selected.
- *
- * @return the `String` value of the currently selected button, or `null` if no button is selected
- */
-fun ButtonGroup.getValue() = elements.toList().firstOrNull { it.isSelected }?.actionCommand
-
-/**
- * Sets the currently selected button to the button with the given action command.
- *
- * If there is no button with the given action command, all buttons will be deselected.
- * If there are multiple buttons with the given action command, any of the matching buttons may be selected.
- *
- * @param value an `Object` of which [toString] returns an action command
- */
-fun ButtonGroup.setValue(value: Any?) {
- selection?.isSelected = false
- elements.toList().firstOrNull { it.actionCommand == value?.toString() }?.isSelected = true
-}
-
-/**
- * Returns the buttons in this button group as a typed array.
- *
- * @return the buttons in this button group as a typed array
- */
-fun ButtonGroup.buttons() = elements.toList().toTypedArray()
diff --git a/src/main/kotlin/com/fwdekker/randomness/ui/InterfaceBuilderHelpers.kt b/src/main/kotlin/com/fwdekker/randomness/ui/InterfaceBuilderHelpers.kt
new file mode 100644
index 000000000..bbdef7743
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/ui/InterfaceBuilderHelpers.kt
@@ -0,0 +1,193 @@
+package com.fwdekker.randomness.ui
+
+import com.intellij.openapi.ui.ComboBox
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.dsl.builder.Cell
+import com.intellij.ui.dsl.builder.Panel
+import com.intellij.ui.dsl.builder.TopGap
+import com.intellij.ui.dsl.builder.columns
+import com.intellij.ui.dsl.builder.toMutableProperty
+import com.intellij.ui.layout.ComponentPredicate
+import com.intellij.util.ui.DialogUtil
+import java.awt.Dimension
+import javax.swing.AbstractButton
+import javax.swing.JComponent
+import javax.swing.JTextField
+import javax.swing.text.AbstractDocument
+import javax.swing.text.Document
+import javax.swing.text.DocumentFilter
+import javax.swing.text.JTextComponent
+import kotlin.reflect.KMutableProperty0
+
+
+/**
+ * Creates and returns a range of rows from [init], headed by an underlined [title] if and only if [title] is not
+ * `null`, and indented if and only if [indent] is `true`.
+ */
+fun Panel.decoratedRowRange(title: String? = null, indent: Boolean, init: Panel.() -> Unit) =
+ when {
+ title != null -> rowsRange { group(title, indent = indent, init = init).topGap(TopGap.MEDIUM) }
+ indent -> indent(init)
+ else -> rowsRange(init)
+ }
+
+
+/**
+ * Registers the [callback] to be invoked on the [JComponent] in this [Cell] when the dialog is reset, and returns
+ * `this`.
+ */
+fun Cell.onResetThis(callback: (Cell) -> Unit) = onReset { callback(this) }
+
+/**
+ * Sets the [name] of the [JComponent] in this [Cell] and returns `this`.
+ */
+fun Cell.withName(name: String) = this.also { it.component.name = name }
+
+/**
+ * Forces this [JComponent] to be [width] pixels wide.
+ */
+fun JComponent.setFixedWidth(width: Int) {
+ if (this is JTextField)
+ columns(0)
+
+ minimumSize = Dimension(width, minimumSize.height)
+ preferredSize = Dimension(width, preferredSize.height)
+ maximumSize = Dimension(width, maximumSize.height)
+}
+
+/**
+ * Forces the [JComponent] in this [Cell] to be [width] pixels wide, and returns `this`.
+ */
+fun Cell.withFixedWidth(width: Int) = this.also { it.component.setFixedWidth(width) }
+
+/**
+ * Forces this [JComponent] to be [height] pixels high.
+ */
+fun JComponent.setFixedHeight(height: Int) {
+ minimumSize = Dimension(minimumSize.width, height)
+ preferredSize = Dimension(preferredSize.width, height)
+ maximumSize = Dimension(maximumSize.width, height)
+}
+
+/**
+ * Forces the [JComponent] in this [Cell] to be [height] pixels high, and returns `this`.
+ */
+fun Cell.withFixedHeight(height: Int): Cell {
+ component.setFixedHeight(height)
+ return this
+}
+
+
+/**
+ * Loads the mnemonic for the [AbstractButton] in this [Cell] based on its [AbstractButton.text].
+ */
+fun Cell.loadMnemonic(): Cell {
+ DialogUtil.registerMnemonic(component, '&')
+ return this
+}
+
+/**
+ * Removes the mnemonic from the [AbstractButton.text] of the [AbstractButton] in this [Cell].
+ */
+fun Cell.disableMnemonic(): Cell {
+ component.text = component.text.filterNot { it == '&' }
+ return this
+}
+
+
+/**
+ * Sets the [document] of the [JTextField] in this [Cell] and returns `this`.
+ */
+fun Cell.withDocument(document: Document) = this.also { component.document = document }
+
+
+/**
+ * Sets whether the editor of the [ComboBox] in this [Cell] [isEditable], and returns `this`.
+ */
+fun Cell>.isEditable(editable: Boolean) = this.also { it.component.isEditable = editable }
+
+/**
+ * Sets the [filter] on the document of the [ComboBox] in this [Cell], and returns `this`.
+ */
+fun Cell>.withFilter(filter: DocumentFilter): Cell> {
+ ((component.editor.editorComponent as? JTextComponent)?.document as? AbstractDocument)?.documentFilter = filter
+ return this
+}
+
+/**
+ * Sets an item renderer on the [ComboBox] in this [Cell] that renders a label displaying the mapping of an item using
+ * [toString], and returns `this`.
+ */
+fun Cell>.withSimpleRenderer(toString: (E) -> String = { it.toString() }): Cell> {
+ component.setRenderer { _, value, _, _, _ -> JBLabel(toString(value)) }
+ return this
+}
+
+
+/**
+ * A predicate that always returns [output].
+ */
+class LiteralPredicate(private val output: Boolean) : ComponentPredicate() {
+ /**
+ * Does nothing, because there's no need to respond to any events to determine the output of [invoke].
+ */
+ override fun addListener(listener: (Boolean) -> Unit) = Unit
+
+ /**
+ * Returns [output].
+ */
+ override fun invoke() = output
+}
+
+/**
+ * Returns a [ComponentPredicate] that evaluates [lambda] on the value of this [JIntSpinner].
+ */
+fun JIntSpinner.hasValue(lambda: (Int) -> Boolean) =
+ object : ComponentPredicate() {
+ override fun invoke() = lambda(this@hasValue.value)
+
+ override fun addListener(listener: (Boolean) -> Unit) {
+ this@hasValue.addChangeListener { listener(invoke()) }
+ }
+ }
+
+
+/**
+ * Binds the current possibly-non-committed value of the [ComboBox] in this [Cell] to [property].
+ */
+fun Cell>.bindCurrentText(property: KMutableProperty0) =
+ bind(
+ { comboBox -> (comboBox.editor.editorComponent as? JTextComponent)?.text ?: comboBox.item.toString() },
+ { comboBox, value -> comboBox.item = value },
+ property.toMutableProperty()
+ )
+
+/**
+ * Binds the value of the [JIntSpinner] in this [Cell] to [property].
+ */
+fun Cell.bindIntValue(property: KMutableProperty0) =
+ bind(
+ { spinner -> spinner.value },
+ { spinner, value -> spinner.value = value },
+ property.toMutableProperty()
+ )
+
+/**
+ * Binds the value of the [JLongSpinner] in this [Cell] to [property].
+ */
+fun Cell.bindLongValue(property: KMutableProperty0) =
+ bind(
+ { spinner -> spinner.value },
+ { spinner, value -> spinner.value = value },
+ property.toMutableProperty()
+ )
+
+/**
+ * Binds the [Long] representation of the value of the [JDateTimeField] in this [Cell] to [property].
+ */
+fun Cell.bindDateTimeLongValue(property: KMutableProperty0) =
+ bind(
+ { field -> field.longValue },
+ { field, value -> field.longValue = value },
+ property.toMutableProperty()
+ )
diff --git a/src/main/kotlin/com/fwdekker/randomness/ui/JBPopupHelper.kt b/src/main/kotlin/com/fwdekker/randomness/ui/JBPopupHelper.kt
deleted file mode 100644
index a5bef10cf..000000000
--- a/src/main/kotlin/com/fwdekker/randomness/ui/JBPopupHelper.kt
+++ /dev/null
@@ -1,108 +0,0 @@
-package com.fwdekker.randomness.ui
-
-import com.intellij.ui.popup.list.ListPopupImpl
-import java.awt.event.ActionEvent
-import java.awt.event.KeyEvent
-import java.util.Locale
-import javax.swing.AbstractAction
-import javax.swing.KeyStroke
-
-
-/**
- * Registers actions such that actions can be selected while holding (combinations of) modifier keys.
- *
- * All combinations of modifier keys are registered for events. Additionally, the [captionModifier] function is invoked
- * every time the user presses or releases any modifier key, even while holding other modifier keys.
- *
- * Events are also registered for pressing the Enter key (with or without modifier keys) to invoke the action that is
- * currently highlighted, and for pressing one of the numbers 1-9 (with or without modifier keys) to invoke the action
- * at that index in the popup.
- *
- * @param captionModifier returns the caption to set based on the event
- */
-fun ListPopupImpl.registerModifierActions(captionModifier: (ActionEvent?) -> String) {
- val modifiers = listOf(listOf("alt"), listOf("control"), listOf("shift"))
- val optionalModifiers = listOf(listOf("")) + modifiers
-
- (modifiers * optionalModifiers * optionalModifiers).forEach { (a, b, c) ->
- registerAction(
- "${a}${b}${c}Released",
- KeyStroke.getKeyStroke("$b $c released ${a.uppercase(Locale.getDefault())}"),
- actionListener { setCaption(captionModifier(it)) }
- )
- registerAction(
- "${a}${b}${c}Pressed",
- KeyStroke.getKeyStroke("$a $b $c pressed ${a.uppercase(Locale.getDefault())}"),
- actionListener { setCaption(captionModifier(it)) }
- )
- registerAction(
- "${a}${b}${c}invokeAction",
- KeyStroke.getKeyStroke("$a $b $c pressed ENTER"),
- actionListener { event ->
- event ?: return@actionListener
-
- handleSelect(
- true,
- KeyEvent(
- component,
- event.id, event.getWhen(), event.modifiers,
- KeyEvent.VK_ENTER, KeyEvent.CHAR_UNDEFINED, KeyEvent.KEY_LOCATION_UNKNOWN
- )
- )
- }
- )
-
- @Suppress("MagicNumber") // Not worth a constant
- for (key in 1..9) {
- registerAction(
- "${a}${b}${c}invokeAction$key",
- KeyStroke.getKeyStroke("$a $b $c pressed $key"),
- actionListener { event ->
- event ?: return@actionListener
-
- list.addSelectionInterval(key - 1, key - 1)
- handleSelect(
- true,
- KeyEvent(
- component,
- event.id, event.getWhen(), event.modifiers,
- KeyEvent.VK_ENTER, KeyEvent.CHAR_UNDEFINED, KeyEvent.KEY_LOCATION_UNKNOWN
- )
- )
- }
- )
- }
- }
-}
-
-/**
- * Returns an `AbstractAction` that uses [actionPerformed] as the implementation of its `actionPerformed` method.
- *
- * @param actionPerformed the code to execute in `actionPerformed`
- * @return an `AbstractAction` that uses [actionPerformed] as the implementation of its `actionPerformed` method
- */
-private fun actionListener(actionPerformed: (ActionEvent?) -> Unit) =
- object : AbstractAction() {
- override fun actionPerformed(event: ActionEvent?) = actionPerformed(event)
- }
-
-/**
- * Returns the cartesian product of two lists.
- *
- * By requiring both lists to actually be lists of lists, this method can be chained. Consider the following examples,
- * using a simplified notation for lists for readability.
- * ```
- * $ [[1, 2]] * [[3, 4]]
- * [[1, 3], [1, 4], [2, 3], [2, 4]]
- *
- * $ [[1, 2]] * [[3, 4]] * [[5, 6]]
- * [[1, 3, 5], [1, 3, 6], [1, 4, 5], [1, 4, 6], [2, 3, 5], [2, 3, 6], [2, 4, 5], [2, 4, 6]]
- * ```
- *
- * @param E the type of inner element
- * @param other the list to multiply with
- * @return the cartesian product of `this` and [other]
- */
-@Suppress("UnusedPrivateMember") // False positive: Used as operator `*`
-private operator fun List>.times(other: List>) =
- this.flatMap { t1 -> other.map { t2 -> t1 + t2 } }
diff --git a/src/main/kotlin/com/fwdekker/randomness/ui/JDateTimeField.kt b/src/main/kotlin/com/fwdekker/randomness/ui/JDateTimeField.kt
new file mode 100644
index 000000000..9f0407e79
--- /dev/null
+++ b/src/main/kotlin/com/fwdekker/randomness/ui/JDateTimeField.kt
@@ -0,0 +1,93 @@
+package com.fwdekker.randomness.ui
+
+import com.fwdekker.randomness.Bundle
+import com.github.sisyphsu.dateparser.DateParserUtils
+import java.text.ParseException
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.ZoneOffset
+import java.time.format.DateTimeParseException
+import javax.swing.JFormattedTextField
+
+
+/**
+ * A [JFormattedTextField] for [LocalDateTime]s, supporting virtually any date-time format as its input.
+ *
+ * @param default the default [LocalDateTime] that is returned in case no [value] has ever been set
+ */
+class JDateTimeField(
+ private val default: LocalDateTime = LocalDateTime.now(),
+) : JFormattedTextField(DateTimeFormatter()) {
+ /**
+ * The current [value] represented as a [Long] of the millisecond epoch.
+ */
+ var longValue: Long
+ get() = value.toEpochMilli()
+ set(value) {
+ this.value = value.toLocalDateTime()
+ }
+
+ /**
+ * Returns the [LocalDateTime] contained in this field, or [default] otherwise.
+ */
+ override fun getValue() = super.getValue() as? LocalDateTime ?: default
+
+ /**
+ * Sets the [LocalDateTime] that is contained in this field.
+ */
+ override fun setValue(value: Any) {
+ require(value is LocalDateTime) { Bundle("datetime_field.error.invalid_type") }
+
+ super.setValue(value)
+ }
+
+
+ /**
+ * Formats a string to a [LocalDateTime] and vice versa.
+ */
+ class DateTimeFormatter : AbstractFormatter() {
+ /**
+ * Attempts to parse [text] to a [LocalDateTime] using a best guess to detect the date format used.
+ *
+ * @throws ParseException if [text] could not be converted to a [LocalDateTime]
+ */
+ override fun stringToValue(text: String?): LocalDateTime =
+ if (text.isNullOrBlank())
+ throw ParseException(Bundle("datetime_field.error.empty_string"), 0)
+ else
+ try {
+ DateParserUtils.parseDateTime(text)
+ } catch (exception: DateTimeParseException) {
+ throw ParseException(exception.message, exception.errorIndex)
+ }
+
+ /**
+ * Returns the ISO-8601-ish representation of [value], which must be a [LocalDateTime].
+ */
+ override fun valueToString(value: Any?): String =
+ if (value !is LocalDateTime) throw ParseException(Bundle("datetime_field.error.invalid_type"), 0)
+ else java.time.format.DateTimeFormatter.ofPattern(DATE_TIME_FORMAT).format(value)
+ }
+
+
+ /**
+ * Holds constants.
+ */
+ companion object {
+ /**
+ * The format used to represent [value] as a string.
+ */
+ const val DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss.SSS"
+ }
+}
+
+
+/**
+ * Converts an epoch millisecond timestamp to a [LocalDateTime] object.
+ */
+fun Long.toLocalDateTime(): LocalDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC)
+
+/**
+ * Converts this [LocalDateTime] to an epoch millisecond timestamp.
+ */
+fun LocalDateTime.toEpochMilli() = toInstant(ZoneOffset.UTC).toEpochMilli()
diff --git a/src/main/kotlin/com/fwdekker/randomness/ui/JNumberSpinners.kt b/src/main/kotlin/com/fwdekker/randomness/ui/JNumberSpinners.kt
index 67d1ddd07..97bc802c3 100644
--- a/src/main/kotlin/com/fwdekker/randomness/ui/JNumberSpinners.kt
+++ b/src/main/kotlin/com/fwdekker/randomness/ui/JNumberSpinners.kt
@@ -1,10 +1,8 @@
package com.fwdekker.randomness.ui
-import com.fwdekker.randomness.ValidationInfo
-import com.fwdekker.randomness.ui.JNumberSpinner.Companion.DEFAULT_DESCRIPTION
-import java.awt.Dimension
import java.text.DecimalFormatSymbols
import java.util.Locale
+import javax.swing.JComponent
import javax.swing.JSpinner
import javax.swing.SpinnerNumberModel
@@ -14,23 +12,17 @@ import javax.swing.SpinnerNumberModel
*
* @param T the type of number
* @param value the default value
- * @param minValue the smallest number that may be represented
- * @param maxValue the largest number that may be represented
+ * @param minValue the smallest number that may be represented, or `null` if there is no limit
+ * @param maxValue the largest number that may be represented, or `null` if there is no limit
* @param stepSize the default value to increment and decrement by
- * @param description the description to use in error messages; defaults to [DEFAULT_DESCRIPTION] if `null` is given
*/
-abstract class JNumberSpinner(value: T, minValue: T, maxValue: T, stepSize: T, description: String? = null) :
+abstract class JNumberSpinner(value: T, minValue: T?, maxValue: T?, stepSize: T) :
JSpinner(SpinnerNumberModel(value, minValue, maxValue, stepSize)) where T : Number, T : Comparable {
/**
* Transforms a [Number] into a [T].
*/
abstract val numberToT: (Number) -> T
- /**
- * The description to use in error messages.
- */
- private val description = description ?: DEFAULT_DESCRIPTION
-
/**
* A helper function to return the super class's model as an instance of [SpinnerNumberModel].
*/
@@ -38,69 +30,22 @@ abstract class JNumberSpinner