diff --git a/build.gradle.kts b/build.gradle.kts index 125978a..305d878 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,9 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.detekt) + alias(libs.plugins.shadow) alias(libs.plugins.jfx) + java } group = "com.marvinelsen" @@ -20,13 +22,19 @@ repositories { dependencies { detektPlugins(libs.detekt.formatting) - implementation(libs.chinese.transliteration) - implementation(libs.cedict.parser) - implementation(libs.sqlite.jdbc) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.html.jvm) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.javafx) + + implementation(libs.segment) + + implementation(libs.ikonli.javafx) + implementation(libs.ikonli.material2) + + implementation(libs.slf4j.nop) testImplementation(libs.kotest.core) testImplementation(libs.kotest.assertions) @@ -43,6 +51,7 @@ kotlin { javafx { version = libs.versions.javafx.get() modules("javafx.base", "javafx.graphics", "javafx.controls", "javafx.fxml", "javafx.web") +// setPlatform("mac") } detekt { @@ -50,3 +59,10 @@ detekt { allRules = false autoCorrect = true } + +tasks.jar { + manifest { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + attributes["Main-Class"] = "com.marvinelsen.willow.MainKt" + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c75a353..e271b5a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,23 +1,25 @@ [versions] kotlin = "2.0.20" detekt = "1.23.7" +shadow = "8.3.5" jfx-plugin = "0.1.0" -javafx = "22.0.1" +javafx = "23" kotest = "5.9.1" -cedict-parser = "1.0.1" -chinese-transliteration = "1.0.1" - sqlite-jdbc = "3.46.0.1" kotlinx-serialization-json = "1.7.1" kotlinx-html-jvm = "0.11.0" +kotlinx-coroutines = "1.9.0" + +segment = "0.3.1" + +ikonli-javafx = "12.3.1" + +slf4j = "2.0.16" [libraries] -chinese-transliteration = { module = "com.marvinelsen:chinese-transliteration", version.ref = "chinese-transliteration" } -cedict-parser = { module = "com.marvinelsen:cedict-parser", version.ref = "cedict-parser" } - # Kotest # See: https://kotest.io kotest-core = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } @@ -27,6 +29,14 @@ sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } kotlinx-html-jvm = { module = "org.jetbrains.kotlinx:kotlinx-html-jvm", version.ref = "kotlinx-html-jvm" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-javafx = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-javafx", version.ref = "kotlinx-coroutines" } + +segment = { module = "com.github.houbb:segment", version.ref = "segment" } +slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } + +ikonli-javafx = { module = "org.kordamp.ikonli:ikonli-javafx", version.ref = "ikonli-javafx" } +ikonli-material2 = { group = "org.kordamp.ikonli", name = "ikonli-material2-pack", version.ref = "ikonli-javafx" } # Detekt # See: https://detekt.dev @@ -42,4 +52,8 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi # See: https://detekt.dev detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +# Gradle Shadow +# See: https://gradleup.com/shadow/ +shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } + jfx = { id = "org.openjfx.javafxplugin", version.ref = "jfx-plugin" } diff --git a/src/main/kotlin/com/marvinelsen/willow/Interactor.kt b/src/main/kotlin/com/marvinelsen/willow/Interactor.kt new file mode 100644 index 0000000..77a7ef3 --- /dev/null +++ b/src/main/kotlin/com/marvinelsen/willow/Interactor.kt @@ -0,0 +1,127 @@ +package com.marvinelsen.willow + +import com.marvinelsen.willow.domain.SearchMode +import com.marvinelsen.willow.domain.SqliteDictionary +import com.marvinelsen.willow.domain.objects.DictionaryEntry +import com.marvinelsen.willow.ui.undo.Command +import com.marvinelsen.willow.ui.util.ClipboardHelper +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch + +@Suppress("TooManyFunctions") +class Interactor( + private val model: Model, + private val dictionary: SqliteDictionary, +) { + private val coroutineScope = MainScope() + + fun copyHeadwordOfSelectedEntry() { + model.selectedEntry?.let { ClipboardHelper.copyString(it.traditional) } + } + + fun copyPronunciationOfSelectedEntry() { + model.selectedEntry?.let { ClipboardHelper.copyString(it.pinyinWithToneMarks) } + } + + fun undoSelection() { + model.undoManager.undo() + } + + fun redoSelection() { + model.undoManager.redo() + } + + fun search(query: String, searchMode: SearchMode) { + coroutineScope.launch { + model.isSearching = true + model.searchResults.setAll(dictionary.search(query, searchMode)) + model.isSearching = false + } + } + + fun findWordsBeginning() { + coroutineScope.launch { + model.isFindingWordsBeginning = true + model.wordsBeginning.setAll( + model.selectedEntry?.let { + dictionary + .findWordsBeginningWith(it) + } + ) + model.isFindingWordsBeginning = false + model.finishedFindingWordsBeginning = true + } + } + + fun findWordsContaining() { + coroutineScope.launch { + model.isFindingWordsContaining = true + model.wordsContaining.setAll( + model.selectedEntry?.let { + dictionary + .findWordsContaining(it) + } + ) + model.isFindingWordsContaining = false + model.finishedFindingWordsContaining = true + } + } + + fun findCharacters() { + coroutineScope.launch { + model.isFindingCharacters = true + model.characters.setAll( + model.selectedEntry?.let { + dictionary + .findCharactersOf(it) + } + ) + model.isFindingCharacters = false + model.finishedFindingCharacters = true + } + } + + fun findSentences() { + coroutineScope.launch { + model.isFindingSentences = true + model.sentences.setAll( + model.selectedEntry?.let { + dictionary + .findExampleSentencesFor(it) + } + ) + model.isFindingSentences = false + model.finishedFindingSentences = true + } + } + + fun deepDive(entry: DictionaryEntry) { + model.undoManager.execute(object : Command { + private val previouslySelectedEntry = model.selectedEntry + + override fun execute() { + select(entry) + } + + override fun undo() { + if (previouslySelectedEntry == null) return + + select(previouslySelectedEntry) + } + }) + } + + fun normalSelect(entry: DictionaryEntry) { + model.undoManager.reset() + select(entry) + } + + private fun select(entry: DictionaryEntry) { + model.finishedFindingCharacters = false + model.finishedFindingWordsBeginning = false + model.finishedFindingWordsContaining = false + model.finishedFindingSentences = false + + model.selectedEntry = entry + } +} diff --git a/src/main/kotlin/com/marvinelsen/willow/Main.kt b/src/main/kotlin/com/marvinelsen/willow/Main.kt new file mode 100644 index 0000000..c05a38f --- /dev/null +++ b/src/main/kotlin/com/marvinelsen/willow/Main.kt @@ -0,0 +1,5 @@ +package com.marvinelsen.willow + +fun main(args: Array) { + actualMain(args) +} diff --git a/src/main/kotlin/com/marvinelsen/willow/Model.kt b/src/main/kotlin/com/marvinelsen/willow/Model.kt index 50d832c..576293c 100644 --- a/src/main/kotlin/com/marvinelsen/willow/Model.kt +++ b/src/main/kotlin/com/marvinelsen/willow/Model.kt @@ -1,62 +1,110 @@ package com.marvinelsen.willow -import com.marvinelsen.willow.domain.SearchMode -import com.marvinelsen.willow.ui.DictionaryEntryFx -import com.marvinelsen.willow.ui.util.ClipboardHelper -import com.marvinelsen.willow.ui.util.FindWordsService -import com.marvinelsen.willow.ui.util.SearchService +import com.marvinelsen.willow.domain.objects.DictionaryEntry +import com.marvinelsen.willow.domain.objects.Sentence +import com.marvinelsen.willow.ui.undo.UndoManager +import javafx.beans.property.BooleanProperty import javafx.beans.property.ObjectProperty import javafx.beans.property.ReadOnlyBooleanProperty -import javafx.beans.property.ReadOnlyObjectProperty +import javafx.beans.property.SimpleBooleanProperty import javafx.beans.property.SimpleObjectProperty import javafx.collections.FXCollections import javafx.collections.ObservableList -import javafx.event.EventHandler -class Model(private val searchService: SearchService, private val findWordsService: FindWordsService) { - private val internalSelectedEntry: ObjectProperty = SimpleObjectProperty() - private val internalSearchResults: ObservableList = FXCollections.observableArrayList() - private val internalWordsContaining: ObservableList = FXCollections.observableArrayList() +@Suppress("TooManyFunctions") +class Model( + val undoManager: UndoManager, +) { + private val _selectedEntry: ObjectProperty = SimpleObjectProperty() - val selectedEntry: ReadOnlyObjectProperty = internalSelectedEntry + private val _isSearching: BooleanProperty = SimpleBooleanProperty(false) + private val _isFindingWordsBeginning: BooleanProperty = SimpleBooleanProperty(false) + private val _isFindingWordsContaining: BooleanProperty = SimpleBooleanProperty(false) + private val _isFindingCharacters: BooleanProperty = SimpleBooleanProperty(false) + private val _isFindingSentences: BooleanProperty = SimpleBooleanProperty(false) - val searchResults: ObservableList = - FXCollections.unmodifiableObservableList(internalSearchResults) - val wordsContaining: ObservableList = - FXCollections.unmodifiableObservableList(internalWordsContaining) + private val _finishedFindingWordsBeginning: BooleanProperty = SimpleBooleanProperty(false) + private val _finishedFindingWordsContaining: BooleanProperty = SimpleBooleanProperty(false) + private val _finishedFindingCharacters: BooleanProperty = SimpleBooleanProperty(false) + private val _finishedFindingSentences: BooleanProperty = SimpleBooleanProperty(false) - val isSearching: ReadOnlyBooleanProperty = searchService.runningProperty() - val isFindingWords: ReadOnlyBooleanProperty = findWordsService.runningProperty() + val searchResults: ObservableList = FXCollections.observableArrayList() + val wordsBeginning: ObservableList = FXCollections.observableArrayList() + val wordsContaining: ObservableList = FXCollections.observableArrayList() + val characters: ObservableList = FXCollections.observableArrayList() + val sentences: ObservableList = FXCollections.observableArrayList() - init { - searchService.onSucceeded = EventHandler { - internalSearchResults.setAll(searchService.value) + val canUndo: ReadOnlyBooleanProperty = undoManager.canUndoProperty + val canRedo: ReadOnlyBooleanProperty = undoManager.canRedoProperty + + var selectedEntry: DictionaryEntry? + get() = _selectedEntry.value + set(value) { + _selectedEntry.value = value } - findWordsService.onSucceeded = EventHandler { - internalWordsContaining.setAll(findWordsService.value) + + var isSearching: Boolean + get() = _isSearching.value + set(value) { + _isSearching.value = value } - } - fun search(query: String, searchMode: SearchMode) { - searchService.searchQuery = query - searchService.searchMode = searchMode - searchService.restart() - } + var isFindingWordsBeginning: Boolean + get() = _isFindingWordsBeginning.value + set(value) { + _isFindingWordsBeginning.value = value + } - fun findWords() { - findWordsService.entry = internalSelectedEntry.value - findWordsService.restart() - } + var isFindingWordsContaining: Boolean + get() = _isFindingWordsContaining.value + set(value) { + _isFindingWordsContaining.value = value + } - fun selectEntry(entry: DictionaryEntryFx) { - internalSelectedEntry.value = entry - } + var isFindingCharacters: Boolean + get() = _isFindingCharacters.value + set(value) { + _isFindingCharacters.value = value + } - fun copyHeadwordOfSelectedEntry() { - ClipboardHelper.copyHeadword(internalSelectedEntry.get()) - } + var isFindingSentences: Boolean + get() = _isFindingSentences.value + set(value) { + _isFindingSentences.value = value + } - fun copyPronunciationOfSelectedEntry() { - ClipboardHelper.copyPronunciation(internalSelectedEntry.get()) - } + var finishedFindingWordsBeginning: Boolean + get() = _finishedFindingWordsBeginning.value + set(value) { + _finishedFindingWordsBeginning.value = value + } + + var finishedFindingWordsContaining: Boolean + get() = _finishedFindingWordsContaining.value + set(value) { + _finishedFindingWordsContaining.value = value + } + + var finishedFindingCharacters: Boolean + get() = _finishedFindingCharacters.value + set(value) { + _finishedFindingCharacters.value = value + } + + var finishedFindingSentences: Boolean + get() = _finishedFindingSentences.value + set(value) { + _finishedFindingSentences.value = value + } + + fun selectedEntryProperty(): ObjectProperty = _selectedEntry + fun isSearchingProperty(): BooleanProperty = _isSearching + fun isFindingWordsBeginningProperty(): BooleanProperty = _isFindingWordsBeginning + fun isFindingWordsContainingProperty(): BooleanProperty = _isFindingWordsContaining + fun isFindingCharactersProperty(): BooleanProperty = _isFindingCharacters + fun isFindingSentencesProperty(): BooleanProperty = _isFindingSentences + fun finishedFindingWordsBeginning(): BooleanProperty = _finishedFindingWordsBeginning + fun finishedFindingWordsContaining(): BooleanProperty = _finishedFindingWordsContaining + fun finishedFindingCharacters(): BooleanProperty = _finishedFindingCharacters + fun finishedFindingSentences(): BooleanProperty = _finishedFindingSentences } diff --git a/src/main/kotlin/com/marvinelsen/willow/WillowApplication.kt b/src/main/kotlin/com/marvinelsen/willow/WillowApplication.kt index 4a01aa5..93650e7 100644 --- a/src/main/kotlin/com/marvinelsen/willow/WillowApplication.kt +++ b/src/main/kotlin/com/marvinelsen/willow/WillowApplication.kt @@ -1,21 +1,23 @@ package com.marvinelsen.willow +import com.marvinelsen.willow.config.Config import com.marvinelsen.willow.domain.SqliteDictionary import com.marvinelsen.willow.ui.controllers.DetailsController import com.marvinelsen.willow.ui.controllers.MainController import com.marvinelsen.willow.ui.controllers.MenuController import com.marvinelsen.willow.ui.controllers.SearchController -import com.marvinelsen.willow.ui.util.FindWordsService -import com.marvinelsen.willow.ui.util.SearchService +import com.marvinelsen.willow.ui.controllers.SearchResultsController +import com.marvinelsen.willow.ui.undo.UndoManager import javafx.application.Application +import javafx.application.HostServices import javafx.fxml.FXMLLoader import javafx.scene.Scene +import javafx.scene.image.Image import javafx.scene.layout.BorderPane import javafx.scene.text.Font import javafx.stage.Stage import javafx.util.Callback import java.sql.DriverManager -import java.util.Locale import java.util.ResourceBundle class WillowApplication : Application() { @@ -28,7 +30,7 @@ class WillowApplication : Application() { private const val FONT_SIZE = 12.0 - private const val JDBC_CONNECTION_STRING = "jdbc:sqlite:dictionary.db" + private const val JDBC_CONNECTION_STRING = "jdbc:sqlite::resource:data/dictionary.db" } override fun init() { @@ -40,18 +42,23 @@ class WillowApplication : Application() { autoCommit = false } val dictionary = SqliteDictionary(connection) - val searchService = SearchService(dictionary) - val findWordsService = FindWordsService(dictionary) - val model = Model(searchService, findWordsService) + val undoManager = UndoManager() + val model = Model(undoManager) + val interactor = Interactor(model, dictionary) + val config = Config() + config.load() + + val hostServices: HostServices = hostServices val fxmlLoader = FXMLLoader() - fxmlLoader.resources = ResourceBundle.getBundle("i18n/willow", Locale.US) + fxmlLoader.resources = ResourceBundle.getBundle("i18n/willow", config.locale.value) fxmlLoader.controllerFactory = Callback { type -> when (type) { MainController::class.java -> MainController(model) - MenuController::class.java -> MenuController(model) - DetailsController::class.java -> DetailsController(model) - SearchController::class.java -> SearchController(model) + MenuController::class.java -> MenuController(model, interactor, config) + DetailsController::class.java -> DetailsController(model, interactor, config, hostServices) + SearchController::class.java -> SearchController(model, interactor) + SearchResultsController::class.java -> SearchResultsController(model, interactor, config, hostServices) else -> error("Trying to instantiate unknown controller type $type") } } @@ -59,23 +66,26 @@ class WillowApplication : Application() { val root = fxmlLoader.load(javaClass.getResourceAsStream("/fxml/main.fxml")) as BorderPane val primaryScene = Scene(root, WINDOW_WIDTH, WINDOW_HEIGHT) + // primaryScene.stylesheets.add(javaClass.getResource("/css/dark.css")?.toExternalForm()!!) primaryStage.apply { title = WINDOW_TITLE minWidth = WINDOW_MIN_WIDTH minHeight = WINDOW_MIN_HEIGHT scene = primaryScene + icons.add(Image(javaClass.getResourceAsStream("/img/icon.png"))) }.show() } private fun loadFonts() { Font.loadFont(javaClass.getResourceAsStream("/fonts/inter.ttf"), FONT_SIZE) Font.loadFont(javaClass.getResourceAsStream("/fonts/tw-kai.ttf"), FONT_SIZE) - Font.loadFont(javaClass.getResourceAsStream("/fonts/noto-sans-tc.ttf"), FONT_SIZE) + Font.loadFont(javaClass.getResourceAsStream("/fonts/noto-sans-tc-regular.ttf"), FONT_SIZE) + Font.loadFont(javaClass.getResourceAsStream("/fonts/noto-sans-tc-bold.ttf"), FONT_SIZE) } } @Suppress("SpreadOperator") -fun main(args: Array) { +fun actualMain(args: Array) { Application.launch(WillowApplication::class.java, *args) } diff --git a/src/main/kotlin/com/marvinelsen/willow/cedict/CreateDatabase.kt b/src/main/kotlin/com/marvinelsen/willow/cedict/CreateDatabase.kt deleted file mode 100644 index 5b61c09..0000000 --- a/src/main/kotlin/com/marvinelsen/willow/cedict/CreateDatabase.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.marvinelsen.willow.cedict - -import com.marvinelsen.cedict.api.CedictParser -import com.marvinelsen.chinese.transliteration.TransliterationSystem -import com.marvinelsen.chinese.transliteration.Zhuyin -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json -import java.sql.DriverManager -import java.util.zip.GZIPInputStream - -const val JDBC_CONNECTION_STRING = "jdbc:sqlite:dictionary.db" - -@Suppress("MagicNumber", "LongMethod", "MaximumLineLength", "MaxLineLength") -fun main() { - val connection = DriverManager.getConnection(JDBC_CONNECTION_STRING).apply { - autoCommit = false - } - - val statement = connection.createStatement() - statement.executeUpdate( - """ - CREATE TABLE IF NOT EXISTS cedict( - id INTEGER PRIMARY KEY, - traditional TEXT NOT NULL, - simplified TEXT NOT NULL, - pinyin_with_tone_marks TEXT NOT NULL, - pinyin_with_tone_numbers TEXT NOT NULL, - zhuyin TEXT NOT NULL, - definitions JSON NOT NULL, - character_count INTEGER NOT NULL, - CONSTRAINT character_count_gte CHECK(character_count > 0) - ); - """.trimIndent() - ) - statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_cedict_traditional ON cedict (traditional)") - statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_cedict_simplified ON cedict (simplified)") - statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_cedict_character_count ON cedict (character_count)") - - val cedictParser = CedictParser.instance - val cedictEntries = - cedictParser.parseCedict( - GZIPInputStream(object {}.javaClass.getResourceAsStream("/data/cedict_1_0_ts_utf-8_mdbg.txt.gz")!!) - ) - - val insertStatement = - connection.prepareStatement( - "INSERT OR IGNORE INTO cedict(traditional, simplified, pinyin_with_tone_marks, pinyin_with_tone_numbers, zhuyin, definitions, character_count) VALUES(?,?,?,?,?,?,?)" - ) - for (entry in cedictEntries) { - try { - insertStatement.setString(1, entry.traditional) - insertStatement.setString(2, entry.simplified) - insertStatement.setString( - 3, - entry.pinyinSyllables.joinToString( - separator = " " - ) { it.format(TransliterationSystem.PINYIN_WITH_TONE_MARKS) } - ) - insertStatement.setString( - 4, - entry.pinyinSyllables.joinToString( - separator = " " - ) { it.format(TransliterationSystem.PINYIN_WITH_TONE_NUMBERS) } - ) - insertStatement.setString( - 5, - entry.pinyinSyllables.joinToString( - separator = Zhuyin.SEPARATOR - ) { it.format(TransliterationSystem.ZHUYIN) } - ) - insertStatement.setString( - 6, - Json.encodeToString( - ListSerializer(ListSerializer(String.serializer())), - entry.definitions.map { it.glosses } - ) - ) - insertStatement.setInt(7, entry.traditional.length) - } catch (_: Exception) { - // no-op - } - - insertStatement.addBatch() - } - - insertStatement.executeBatch() - connection.commit() - - insertStatement.close() - statement.close() - connection.close() -} diff --git a/src/main/kotlin/com/marvinelsen/willow/config/Config.kt b/src/main/kotlin/com/marvinelsen/willow/config/Config.kt new file mode 100644 index 0000000..1a3b423 --- /dev/null +++ b/src/main/kotlin/com/marvinelsen/willow/config/Config.kt @@ -0,0 +1,166 @@ +package com.marvinelsen.willow.config + +import javafx.beans.property.BooleanProperty +import javafx.beans.property.IntegerProperty +import javafx.beans.property.ObjectProperty +import javafx.beans.property.SimpleBooleanProperty +import javafx.beans.property.SimpleIntegerProperty +import javafx.beans.property.SimpleObjectProperty +import java.util.Locale +import java.util.prefs.Preferences + +class Config { + companion object { + private const val LOCALE_KEY = "locale" + private const val THEME_KEY = "theme" + private const val SCRIPT_KEY = "script" + + private val DEFAULT_THEME = Theme.SYSTEM + private val DEFAULT_SCRIPT = Script.SIMPLIFIED + private val DEFAULT_LOCALE = Locale.ENGLISH + } + + private val preferences = Preferences.userNodeForPackage(this::class.java) + val searchResults = SearchResultsConfig(preferences) + val details = DetailsConfig(preferences) + + val locale: ObjectProperty = SimpleObjectProperty(DEFAULT_LOCALE) + val theme: ObjectProperty = SimpleObjectProperty(DEFAULT_THEME) + val script: ObjectProperty