From 079802302994bf71a45b5b16dae9df50563eb97e Mon Sep 17 00:00:00 2001 From: Ryan Williams Date: Sun, 8 Dec 2024 00:32:14 -0800 Subject: [PATCH] Sync --- .github/workflows/build-windows.yml | 5 +- .github/workflows/generate-symbols.yml | 142 +- .github/workflows/steam-deploy.yml | 4 +- .github/workflows/watch-df-steam.yml | 27 +- .pre-commit-config.yaml | 4 +- CMakeLists.txt | 2 +- build/build-win64-from-linux.sh | 6 +- data/base_command_counts.json | 2 +- data/blueprints/aquifer_tap.csv | 95 +- data/init/dfhack.tools.init | 1 - depends/lua/include/dfhack_llimits.h | 15 +- docs/Core.rst | 8 + docs/about/Authors.rst | 4 +- docs/about/Removed.rst | 13 + docs/dev/Lua API.rst | 217 +- docs/dev/compile/Compile.rst | 2 +- docs/dev/compile/Dependencies.rst | 6 +- docs/guides/quickfort-library-guide.rst | 4 +- docs/plugins/autobutcher.rst | 12 +- docs/plugins/dwarfvet.rst | 2 + docs/plugins/faststart.rst | 18 - docs/plugins/forceequip.rst | 2 +- .../{infiniteSky.rst => infinite-sky.rst} | 19 +- docs/plugins/preserve-rooms.rst | 5 +- docs/plugins/timestream.rst | 113 + docs/plugins/tweak.rst | 13 + library/Core.cpp | 72 +- library/LuaApi.cpp | 2 + library/LuaWrapper.cpp | 5 +- library/VersionInfoFactory.cpp | 27 +- library/include/Core.h | 2 +- library/include/DataDefs.h | 6 +- library/include/DataFuncs.h | 1 + library/include/VersionInfo.h | 20 +- library/include/VersionInfoFactory.h | 1 + library/include/modules/DFSDL.h | 1 + library/include/modules/Graphic.h | 4 +- library/include/modules/Translation.h | 2 +- library/include/modules/Units.h | 8 + library/lua/dfhack.lua | 2 +- library/lua/gui.lua | 4 +- library/lua/gui/widgets.lua | 3386 +---------------- .../lua/gui/widgets/buttons/button_group.lua | 48 + .../gui/widgets/buttons/configure_button.lua | 53 + .../lua/gui/widgets/buttons/help_button.lua | 53 + .../lua/gui/widgets/buttons/text_button.lua | 42 + library/lua/gui/widgets/common.lua | 19 + .../gui/widgets/containers/banner_panel.lua | 20 + library/lua/gui/widgets/containers/pages.lua | 63 + library/lua/gui/widgets/containers/panel.lua | 530 +++ .../gui/widgets/containers/resizing_panel.lua | 58 + library/lua/gui/widgets/containers/window.lua | 28 + .../lua/gui/widgets/dimensions_tooltip.lua | 83 + library/lua/gui/widgets/divider.lua | 91 + library/lua/gui/widgets/edit_field.lua | 234 ++ library/lua/gui/widgets/filtered_list.lua | 226 ++ .../gui/widgets/labels/cycle_hotkey_label.lua | 145 + .../lua/gui/widgets/labels/hotkey_label.lua | 70 + library/lua/gui/widgets/labels/label.lua | 556 +++ .../widgets/labels/toggle_hotkey_label.lua | 15 + .../lua/gui/widgets/labels/tooltip_label.lua | 28 + .../lua/gui/widgets/labels/wrapped_label.lua | 56 + library/lua/gui/widgets/list.lua | 388 ++ library/lua/gui/widgets/range_slider.lua | 164 + library/lua/gui/widgets/scrollbar.lua | 254 ++ library/lua/gui/widgets/tab_bar.lua | 438 +++ library/lua/gui/widgets/text_area.lua | 183 + .../gui/widgets/text_area/history_store.lua | 86 + .../widgets/text_area/text_area_content.lua | 673 ++++ .../gui/widgets/text_area/wrapped_text.lua | 70 + library/lua/gui/widgets/widget.lua | 57 + library/modules/Constructions.cpp | 1 + library/modules/Gui.cpp | 84 +- library/modules/Items.cpp | 4 +- library/modules/Units.cpp | 46 +- library/xml | 2 +- plugins/CMakeLists.txt | 6 +- plugins/autobutcher.cpp | 6 +- plugins/autoslab.cpp | 2 +- plugins/blueprint.cpp | 53 +- plugins/buildingplan/itemfilter.cpp | 1 + plugins/buildingplan/itemfilter.h | 2 + plugins/cleanowned.cpp | 2 +- plugins/faststart.cpp | 69 - plugins/fix-occupancy.cpp | 79 +- plugins/forceequip.cpp | 95 +- plugins/infinite-sky.cpp | 265 ++ plugins/infiniteSky.cpp | 174 - plugins/logistics.cpp | 2 +- plugins/lua/buildingplan/itemselection.lua | 28 +- plugins/lua/burrow.lua | 2 +- plugins/lua/dwarfvet.lua | 2 +- plugins/lua/infinite-sky.lua | 18 + plugins/lua/preserve-rooms.lua | 8 +- plugins/lua/timestream.lua | 50 + plugins/luasocket.cpp | 2 +- plugins/nestboxes.cpp | 16 +- plugins/orders.cpp | 10 +- plugins/pathable.cpp | 1 + plugins/plant.cpp | 1 + plugins/preserve-rooms.cpp | 13 +- plugins/regrass.cpp | 1 + .../remotefortressreader.cpp | 2 +- plugins/showmood.cpp | 2 +- plugins/stockpiles/OrganicMatLookup.h | 2 + plugins/stonesense | 2 +- plugins/strangemood.cpp | 2 +- plugins/tailor.cpp | 6 +- plugins/timestream.cpp | 696 ++++ plugins/tweak/tweak.cpp | 10 + .../tweak/tweaks/material-size-for-melting.h | 69 + plugins/xlsxreader.cpp | 1 + scripts | 2 +- test/core.lua | 12 + test/library/gui/widgets.TabBar.lua | 555 +++ test/library/gui/widgets.TextArea.lua | 3317 ++++++++++++++++ test/modules/job.lua | 27 + test/modules/job_fortress.lua | 22 + 118 files changed, 10875 insertions(+), 3912 deletions(-) delete mode 100644 docs/plugins/faststart.rst rename docs/plugins/{infiniteSky.rst => infinite-sky.rst} (68%) create mode 100644 docs/plugins/timestream.rst create mode 100644 library/lua/gui/widgets/buttons/button_group.lua create mode 100644 library/lua/gui/widgets/buttons/configure_button.lua create mode 100644 library/lua/gui/widgets/buttons/help_button.lua create mode 100644 library/lua/gui/widgets/buttons/text_button.lua create mode 100644 library/lua/gui/widgets/common.lua create mode 100644 library/lua/gui/widgets/containers/banner_panel.lua create mode 100644 library/lua/gui/widgets/containers/pages.lua create mode 100644 library/lua/gui/widgets/containers/panel.lua create mode 100644 library/lua/gui/widgets/containers/resizing_panel.lua create mode 100644 library/lua/gui/widgets/containers/window.lua create mode 100644 library/lua/gui/widgets/dimensions_tooltip.lua create mode 100644 library/lua/gui/widgets/divider.lua create mode 100644 library/lua/gui/widgets/edit_field.lua create mode 100644 library/lua/gui/widgets/filtered_list.lua create mode 100644 library/lua/gui/widgets/labels/cycle_hotkey_label.lua create mode 100644 library/lua/gui/widgets/labels/hotkey_label.lua create mode 100644 library/lua/gui/widgets/labels/label.lua create mode 100644 library/lua/gui/widgets/labels/toggle_hotkey_label.lua create mode 100644 library/lua/gui/widgets/labels/tooltip_label.lua create mode 100644 library/lua/gui/widgets/labels/wrapped_label.lua create mode 100644 library/lua/gui/widgets/list.lua create mode 100644 library/lua/gui/widgets/range_slider.lua create mode 100644 library/lua/gui/widgets/scrollbar.lua create mode 100644 library/lua/gui/widgets/tab_bar.lua create mode 100644 library/lua/gui/widgets/text_area.lua create mode 100644 library/lua/gui/widgets/text_area/history_store.lua create mode 100644 library/lua/gui/widgets/text_area/text_area_content.lua create mode 100644 library/lua/gui/widgets/text_area/wrapped_text.lua create mode 100644 library/lua/gui/widgets/widget.lua delete mode 100644 plugins/faststart.cpp create mode 100644 plugins/infinite-sky.cpp delete mode 100644 plugins/infiniteSky.cpp create mode 100644 plugins/lua/infinite-sky.lua create mode 100644 plugins/lua/timestream.lua create mode 100644 plugins/timestream.cpp create mode 100644 plugins/tweak/tweaks/material-size-for-melting.h create mode 100644 test/library/gui/widgets.TabBar.lua create mode 100644 test/library/gui/widgets.TextArea.lua create mode 100644 test/modules/job_fortress.lua diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 6defc7c4c5..24306b72f9 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -32,6 +32,9 @@ on: platform-files: type: boolean default: true + plugins: + type: boolean + default: true common-files: type: boolean default: true @@ -104,7 +107,7 @@ jobs: win-msvc - name: Cross-compile env: - CMAKE_EXTRA_ARGS: -DBUILD_LIBRARY=${{ inputs.platform-files }} -DBUILD_STONESENSE:BOOL=${{ inputs.stonesense }} -DBUILD_DOCS:BOOL=${{ inputs.docs }} -DBUILD_DOCS_NO_HTML:BOOL=${{ !inputs.html }} -DINSTALL_DATA_FILES:BOOL=${{ inputs.common-files }} -DINSTALL_SCRIPTS:BOOL=${{ inputs.common-files }} -DBUILD_DFLAUNCH:BOOL=${{ inputs.launchdf }} -DBUILD_TESTS:BOOL=${{ inputs.tests }} -DBUILD_XMLDUMP:BOOL=${{ inputs.xml-dump-type-sizes }} ${{ inputs.xml-dump-type-sizes && '-DINSTALL_XMLDUMP:BOOL=1' || '' }} + CMAKE_EXTRA_ARGS: -DBUILD_LIBRARY=${{ inputs.platform-files }} -DBUILD_PLUGINS:BOOL=${{ inputs.platform-files && inputs.plugins }} -DBUILD_STONESENSE:BOOL=${{ inputs.stonesense }} -DBUILD_DOCS:BOOL=${{ inputs.docs }} -DBUILD_DOCS_NO_HTML:BOOL=${{ !inputs.html }} -DINSTALL_DATA_FILES:BOOL=${{ inputs.common-files }} -DINSTALL_SCRIPTS:BOOL=${{ inputs.common-files }} -DBUILD_DFLAUNCH:BOOL=${{ inputs.launchdf }} -DBUILD_TESTS:BOOL=${{ inputs.tests }} -DBUILD_XMLDUMP:BOOL=${{ inputs.xml-dump-type-sizes }} ${{ inputs.xml-dump-type-sizes && '-DINSTALL_XMLDUMP:BOOL=1' || '' }} run: | cd build bash -x build-win64-from-linux.sh diff --git a/.github/workflows/generate-symbols.yml b/.github/workflows/generate-symbols.yml index f73d33dca4..8f13adbb4f 100644 --- a/.github/workflows/generate-symbols.yml +++ b/.github/workflows/generate-symbols.yml @@ -8,7 +8,7 @@ on: required: true default: master version: - description: DF version + description: DF version (can be "auto" if channel is steam) required: true platform: description: Target OS platform @@ -44,7 +44,7 @@ on: type: string jobs: - package: + package-linux: uses: ./.github/workflows/build-linux.yml if: inputs.platform == 'all' || inputs.platform == 'linux' with: @@ -57,12 +57,25 @@ jobs: plugins: false secrets: inherit + package-win64: + uses: ./.github/workflows/build-windows.yml + if: (inputs.platform == 'all' || inputs.platform == 'windows') && inputs.version == 'auto' + with: + dfhack_ref: ${{ github.ref }} + structures_ref: ${{ inputs.structures_ref }} + artifact-name: dfhack-symbols-windows64-build + append-date-and-hash: false + cache-id: release + cache-readonly: true + plugins: false + secrets: inherit + generate-linux: name: Generate linux64 symbols runs-on: ubuntu-latest if: inputs.platform == 'all' || inputs.platform == 'linux' needs: - - package + - package-linux steps: - name: Install dependencies run: | @@ -119,11 +132,29 @@ jobs: "+app_update 975370 $BETA_PARAMS validate" \ +quit tar xjf dfhack-symbols-linux64-build.tar.bz2 -C DF_steam - xml/symbols_gen_linux.sh ${{ inputs.version }} STEAM DF_steam + xml/symbols_gen_linux.sh ${{ inputs.version == 'auto' && '50.0' || inputs.version }} STEAM DF_steam + if [ "${{ inputs.version }}" = "auto" ]; then + while pgrep dwarfort; do + echo "waiting for DF to exit" + sleep 0.5 + done + cp xml/symbols.xml DF_steam/hack + cd DF_steam + DFHACK_DISABLE_CONSOLE=1 ./dfhack & + while ! ./dfhack-run lua 'print(scr)' | fgrep 'viewscreen_titlest' 2>/dev/null; do + echo "waiting for DF to start" + sleep 0.5 + done + df_ver=`./dfhack-run lua 'print(dfhack.gui.getDFViewscreen(true).str_version)' | ansifilter` + echo "Found version string: '$df_ver'" + echo "DETECTED_DF_VER=$df_ver" >>$GITHUB_ENV + sed -i "s/v0.50.0 linux64 STEAM/v0.$df_ver linux64 STEAM/" ../xml/symbols.xml + ./dfhack-run die || true + fi # Itch - name: Generate Itch symbols - if: inputs.channel == 'all' || inputs.channel == 'itch' + if: (inputs.channel == 'all' || inputs.channel == 'itch') && inputs.version != 'auto' env: DISPLAY: :0 ITCH_API_KEY: ${{ secrets.ITCH_API_KEY }} @@ -139,7 +170,7 @@ jobs: # Classic - name: Generate Classic symbols - if: inputs.channel == 'all' || inputs.channel == 'classic' + if: (inputs.channel == 'all' || inputs.channel == 'classic') && inputs.version != 'auto' env: DISPLAY: :0 run: | @@ -164,7 +195,7 @@ jobs: - name: Commit symbol updates uses: stefanzweifel/git-auto-commit-action@v5 with: - commit_message: Auto-update symbols + commit_message: Auto-update symbols for Linux DF version ${{ env.DETECTED_DF_VER || inputs.version }} repository: xml commit_user_name: DFHack-Urist via GitHub Actions commit_user_email: 63161697+DFHack-Urist@users.noreply.github.com @@ -227,11 +258,11 @@ jobs: +login $STEAM_USERNAME \ "+app_update 975370 $BETA_PARAMS validate" \ +quit - xml/symbols_gen_windows.sh ${{ inputs.version }} STEAM DF_steam + xml/symbols_gen_windows.sh ${{ inputs.version == 'auto' && '50.0' || inputs.version }} STEAM DF_steam # Itch - name: Generate Itch symbols - if: inputs.channel == 'all' || inputs.channel == 'itch' + if: (inputs.channel == 'all' || inputs.channel == 'itch') && inputs.version != 'auto' env: ITCH_API_KEY: ${{ secrets.ITCH_API_KEY }} run: | @@ -245,7 +276,7 @@ jobs: # Classic - name: Generate Classic symbols - if: inputs.channel == 'all' || inputs.channel == 'classic' + if: (inputs.channel == 'all' || inputs.channel == 'classic') && inputs.version != 'auto' run: | mkdir DF_classic minor=$(echo "${{ inputs.version }}" | cut -d. -f1) @@ -267,7 +298,86 @@ jobs: - name: Commit symbol updates uses: stefanzweifel/git-auto-commit-action@v5 with: - commit_message: Auto-update symbols + commit_message: Auto-update symbols for Windows DF version ${{ inputs.version }} + repository: xml + commit_user_name: DFHack-Urist via GitHub Actions + commit_user_email: 63161697+DFHack-Urist@users.noreply.github.com + + auto-ver-windows: + name: Autodetect DF version string (Windows) + if: (inputs.platform == 'all' || inputs.platform == 'windows') && (inputs.channel == 'all' || inputs.channel == 'steam') && inputs.version == 'auto' + needs: + - package-win64 + - generate-windows + runs-on: windows-latest + steps: + - name: Clone structures + uses: actions/checkout@v4 + with: + repository: DFHack/df-structures + ref: ${{ inputs.structures_ref }} + token: ${{ secrets.DFHACK_GITHUB_TOKEN }} + path: xml + - name: Download DFHack + uses: actions/download-artifact@v4 + with: + name: dfhack-symbols-windows64-build + - name: Setup steamcmd + id: steamcmd + uses: CyberAndrii/setup-steamcmd@v1 + - name: Update DF version string + env: + STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }} + STEAM_CONFIG_VDF: ${{ secrets.STEAM_CONFIG_VDF }} + STEAM_DF_TESTING: ${{ secrets.STEAM_DF_TESTING }} + STEAM_DF_ADVENTURE_TEST: ${{ secrets.STEAM_DF_ADVENTURE_TEST }} + shell: bash + run: | + mkdir DF_steam + echo "$STEAM_CONFIG_VDF" | base64 -d >${{ steps.steamcmd.outputs.directory }}/config/config.vdf + echo "DF steam branch: ${{ inputs.df_steam_branch }}" + if [ "${{ inputs.df_steam_branch }}" = "default" ]; then + BETA_PARAMS="" + elif [ "${{ inputs.df_steam_branch }}" = "testing" ]; then + BETA_PARAMS="-beta testing -betapassword $STEAM_DF_TESTING" + elif [ "${{ inputs.df_steam_branch }}" = "adventure_test" ]; then + BETA_PARAMS="-beta adventure_test -betapassword $STEAM_DF_ADVENTURE_TEST" + else + BETA_PARAMS="-beta ${{ inputs.df_steam_branch }}" + fi + ${{ steps.steamcmd.outputs.executable }} \ + +@ShutdownOnFailedCommand 1 \ + +@sSteamCmdForcePlatformType windows \ + +force_install_dir $PWD/DF_steam \ + +login $STEAM_USERNAME \ + "+app_update 975370 $BETA_PARAMS validate" \ + +quit + tar xjf dfhack-symbols-windows64-build.tar.bz2 -C DF_steam + cp xml/symbols.xml DF_steam/hack + cd DF_steam + "./Dwarf Fortress.exe" & + while ! ./dfhack-run.exe lua 'print(scr)' | fgrep 'viewscreen_titlest' 2>/dev/null; do + echo "waiting for DF to start" + sleep 0.5 + done + df_ver=`./dfhack-run.exe lua 'print(dfhack.gui.getDFViewscreen(true).str_version)'` + echo "Found version string: '$df_ver'" + echo "DETECTED_DF_VER=$df_ver" >>$GITHUB_ENV + sed -i "s/v0.50.0 win64 STEAM/v0.$df_ver win64 STEAM/" ../xml/symbols.xml + ./dfhack-run.exe die || true + - name: Merge updates + shell: bash + run: | + cd xml + if ! git diff --exit-code; then + git stash + git pull + git stash pop + fi + - name: Commit symbol updates + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: Auto-update Windows DF version to ${{ env.DETECTED_DF_VER }} in symbols repository: xml commit_user_name: DFHack-Urist via GitHub Actions commit_user_email: 63161697+DFHack-Urist@users.noreply.github.com @@ -277,7 +387,7 @@ jobs: runs-on: ubuntu-latest needs: - generate-linux - - generate-windows + - auto-ver-windows if: ${{ ! failure() }} steps: - name: Clone DFHack @@ -285,15 +395,19 @@ jobs: with: token: ${{ secrets.DFHACK_GITHUB_TOKEN }} - name: Update ref + shell: bash run: | git submodule update --init --no-single-branch library/xml cd library/xml git checkout ${{ inputs.structures_ref }} git pull + df_ver=`grep -E 'symbol-table.*STEAM' symbols.xml | head -n1 | sed -r "s/.*name='v0.([^ ]+) .*/\1/"` + echo "using DF version: $df_ver" + echo "DETECTED_DF_VER=$df_ver" >>$GITHUB_ENV - name: Commit ref update uses: stefanzweifel/git-auto-commit-action@v5 with: - commit_message: Auto-update structures ref + commit_message: Auto-update structures ref for ${{ env.DETECTED_DF_VER }} commit_user_name: DFHack-Urist via GitHub Actions commit_user_email: 63161697+DFHack-Urist@users.noreply.github.com - name: Launch steam-deploy @@ -301,4 +415,4 @@ jobs: shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh workflow run steam-deploy.yml -R DFHack/dfhack -r ${{ github.ref }} -f version=${{ inputs.version }} -f steam_branch=${{ inputs.steam_branch }} + run: gh workflow run steam-deploy.yml -R DFHack/dfhack -r ${{ github.ref }} -f version=${{ env.DETECTED_DF_VER }} -f steam_branch=${{ inputs.steam_branch }} diff --git a/.github/workflows/steam-deploy.yml b/.github/workflows/steam-deploy.yml index 5d1736b6f0..28d4ee9471 100644 --- a/.github/workflows/steam-deploy.yml +++ b/.github/workflows/steam-deploy.yml @@ -74,13 +74,15 @@ jobs: rm ${name}-depot.tar.bz2 cd .. done + - name: Get short SHA of commit + run: echo "SHORT_SHA=`echo ${{ github.sha }} | cut -c1-8`" >>$GITHUB_ENV - name: Steam deploy uses: game-ci/steam-deploy@v3 with: username: ${{ secrets.STEAM_USERNAME }} configVdf: ${{ secrets.STEAM_CONFIG_VDF}} appId: 2346660 - buildDescription: ${{ github.event.inputs && github.event.inputs.version || github.ref_name }} + buildDescription: ${{ github.event.inputs && github.event.inputs.version || github.ref_name }} (${{ env.SHORT_SHA }}) rootPath: . depot1Path: common-depot depot2Path: win64-depot diff --git a/.github/workflows/watch-df-steam.yml b/.github/workflows/watch-df-steam.yml index 0cf7f3b4f5..e2902abb6d 100644 --- a/.github/workflows/watch-df-steam.yml +++ b/.github/workflows/watch-df-steam.yml @@ -14,32 +14,27 @@ jobs: strategy: fail-fast: false matrix: - # dfhack_ref: leave blank if no structures update is desired - # structures_ref: leave blank to default to master - # steam_branch: leave blank if no steam push is desired + # df_steam_branch: which DF Steam branch to watch # platform: leave blank to default to all - # channel: leave blank to default to all; should usually be steam + # structures_ref: leave blank to default to master + # dfhack_ref: leave blank if no structures update is desired + # steam_branch: leave blank if no DFHack steam push is desired include: - df_steam_branch: public - version: public - df_steam_branch: beta - version: 51.01-beta - channel: steam - dfhack_ref: adv-beta structures_ref: adv-beta + dfhack_ref: adv-beta steam_branch: adventure-beta - df_steam_branch: testing - version: testing - channel: steam - dfhack_ref: testing structures_ref: testing + dfhack_ref: testing steps: - name: Fetch state uses: actions/cache/restore@v4 with: path: state - key: watchstate-${{ matrix.version }} - restore-keys: watchstate-${{ matrix.version }} + key: watchstate-${{ matrix.df_steam_branch }} + restore-keys: watchstate-${{ matrix.df_steam_branch }} - name: Compare branch metadata uses: nick-fields/retry@v3 with: @@ -77,9 +72,9 @@ jobs: -R DFHack/dfhack \ -r ${{ matrix.dfhack_ref }} \ -f structures_ref=${{ matrix.structures_ref }} \ - -f version=${{ matrix.version }} \ + -f version=auto \ -f platform=${{ matrix.platform }} \ - -f channel=${{ matrix.channel }} \ + -f channel=steam \ -f df_steam_branch=${{ matrix.df_steam_branch }} \ -f steam_branch=${{ matrix.steam_branch }} - name: Save state @@ -87,4 +82,4 @@ jobs: if: env.TIMESTAMP with: path: state - key: watchstate-${{ matrix.version }}-${{ env.TIMESTAMP }} + key: watchstate-${{ matrix.df_steam_branch }}-${{ env.TIMESTAMP }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a7b7d52d2..01094ed98d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: # shared across repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-added-large-files - id: check-case-conflict @@ -20,7 +20,7 @@ repos: args: ['--fix=lf'] - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.29.2 + rev: 0.30.0 hooks: - id: check-github-workflows - repo: https://github.com/Lucas-C/pre-commit-hooks diff --git a/CMakeLists.txt b/CMakeLists.txt index 242cb293e1..5f37d47925 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,7 +56,7 @@ endif() if(WIN32) set(MSVC_MIN_VER 1930) - set(MSVC_MAX_VER 1941) + set(MSVC_MAX_VER 1942) if(NOT MSVC) message(SEND_ERROR "No MSVC found! MSVC 2022 version ${MSVC_MIN_VER} to ${MSVC_MAX_VER} is required.") elseif((MSVC_VERSION LESS MSVC_MIN_VER) OR (MSVC_VERSION GREATER MSVC_MAX_VER)) diff --git a/build/build-win64-from-linux.sh b/build/build-win64-from-linux.sh index 9404188fd6..0fdc3bb756 100755 --- a/build/build-win64-from-linux.sh +++ b/build/build-win64-from-linux.sh @@ -32,9 +32,7 @@ if [[ $(id -u) -eq 0 ]]; then chown -R $builder_uid win64-cross fi -# Assumes you built a container image called dfhack-build-msvc from -# https://github.com/BenLubar/build-env/tree/master/msvc, see -# docs/dev/compile/Compile.rst. +# Pulls the MSVC build env container from the GitHub registry # # NOTE: win64-cross is mounted in /src/build due to the hardcoded `cmake ..` in # the Dockerfile @@ -44,7 +42,7 @@ if ! docker run --rm -i -v "$srcdir":/src -v "$srcdir/build/win64-cross/":/src/b -e steam_username \ -e steam_password \ --name dfhack-win \ - ghcr.io/dfhack/build-env:msvc \ + ghcr.io/dfhack/build-env:master \ bash -c "cd /src/build && dfhack-configure windows 64 Release -DCMAKE_INSTALL_PREFIX=/src/build/output -DBUILD_DOCS=1 $CMAKE_EXTRA_ARGS && dfhack-make -j$jobs install" \ ; then echo diff --git a/data/base_command_counts.json b/data/base_command_counts.json index 6acfda21b1..a05d50c1ed 100644 --- a/data/base_command_counts.json +++ b/data/base_command_counts.json @@ -98,7 +98,7 @@ "gui/mechanisms": 1, "gui/pathable": 1, "hotkeys": 1, - "infiniteSky": 1, + "infinite-sky": 1, "force": 1, "hermit": 1, "strangemood": 1, diff --git a/data/blueprints/aquifer_tap.csv b/data/blueprints/aquifer_tap.csv index 16611266fb..b6675cee28 100644 --- a/data/blueprints/aquifer_tap.csv +++ b/data/blueprints/aquifer_tap.csv @@ -7,52 +7,79 @@ Here's the procedure: "" 2) Dig a one-tile-wide tunnel from where you want the water to end up (e.g. your well cistern) to an area on the same z-level directly below the target light aquifer. Dig a one-tile-wide diagonal segment in this tunnel near the cistern side so water that will eventually flow through the tunnel is depressurized. "" -"3) From the end of that tunnel, go down one z-level, enable damp dig mode in the dig toolbar, then dig a staircase straight up so that the top is in the lowest aquifer level (a tile with a two-drop icon). Change the top staircase tile (the down-only stairs) to a ""blueprint"" tile (default hotkey: m-L) so your miners don't dig it yet." +"3) Pause the game. From the end of that tunnel, go down one z-level, enable damp dig mode in the dig toolbar, then designate for digging a staircase straight up so that the top is in the lowest aquifer level (a tile with a two-drop icon). If you only have one layer of aquifer, you should end the staircase one level below the aquifer so when we dig the tap, it will extend up into the aquifer level. Your tunnel should connect to the staircase one z-level above the bottom of the staircase." "" -"4) From the bottom of the staircase (the z-level below where the water will flow to your cisterns), dig a straight, one-tile wide tunnel to the closest edge of the map. This is your emergency drainage tunnel. Smooth the map edge tile and carve a fortification. The water can flow through the fortification and off the map, allowing the dwarves to dig out the aquifer tap without drowning." +"4) Apply this blueprint (gui/quickfort aquifer_tap /dig) to the z-level at the top of the staircase. The tiles will be designated in ""damp dig"" mode so your miners can dig it out without the damp tiles canceling the digging designations. This blueprint designates ramps for digging so two layers of aquifer can contribute to the water collector. It also changes the staircase tile below the tap to a ""blueprint"" tile so your miners don't dig the tap before your drainage tunnel is ready." "" -5) Place a lever-controlled floodgate in the drainage tunnel and open the floodgate. Place the lever somewhere else in your fort so that it will remain dry and accessible. +"5) You can now unpause the game. From the bottom of the staircase (the z-level below where the water will flow to your cisterns), dig a straight, one-tile wide tunnel to the closest edge of the map. This is your emergency drainage tunnel. Smooth the map edge tile and carve a fortification. The water can flow through the fortification and off the map, allowing the dwarves to dig out the aquifer tap without drowning." "" -"6) If you want, haul away any boulders in the tunnels and/or smooth the tiles (e.g. mark them for dumping -- hotkey i-p -- and wait for them to be dumped). You won't be able to access any of this area once it fills up with water!" +6) Place a lever-controlled floodgate in the drainage tunnel and open the floodgate. Place the lever somewhere else in your fort so that it will remain dry and accessible. "" -"7) Apply this blueprint (gui/quickfort aquifer_tap /dig) to the z-level above the top of the staircase, inside the lowest aquifer level. The tiles will be designated in ""damp dig"" mode so your miners can dig it out without the damp tiles canceling the digging designations. This blueprint designates ramps for digging so two layers of aquifer can contribute to the water collector." +"7) If you want, haul away any boulders in the tunnels and/or smooth the tiles (e.g. mark them for dumping -- hotkey i-p -- and wait for them to be dumped). Enable prioritize in gui/control-panel to focus dwarves on dumping tasks and make it go faster. You won't be able to access any of this area once it fills up with water!" "" -"8) Dig out the tap. You can haul away any boulders and remove the ramps if you like. The water will safely flow down the staircase, through the open floodgate, down the drainage tunnel, and off the map as long as the floodgate is open." +"8) Convert the ""blueprint"" stairway tile to a regular up/down stair dig designation to allow your miners to dig out the tap. You can haul away any boulders and remove the ramps if you like. There is no rush. The water will safely flow down the staircase, through the open floodgate, down the drainage tunnel, and off the map as long as the floodgate is open." +"8b) Sometimes, DF gets into a bad state with mining designations and miners will refuse to dig the stairway tile. If this happens to you, enter mining mode, enable the keyboard cursor if it's not already enabled (hotkey: Alt-k), highlight the undug stair designation, and run dig-now here in gui/launcher. You might also have to do this for the down stair designation in the center of the aquifer tap. Your miners should be able to handle the rest without assistance." -"9) Once everything is dug out and all dwarves are out of the waterways, close the floodgate. Your cisterns will fill with water. Since the waterway to your cisterns is depressurized, the cisterns will stay forever full, but will not flood." +"9) Once everything is dug out and all dwarves are out of the waterways, close the floodgate. Your cisterns will fill with water. Since the waterway to your cisterns is depressurized (due to the diagonal tunnel you dug), the cisterns will stay forever full, but will not flood." "" A diagram might be useful. Here is a vertical view through the z-levels. This blueprint goes at the top: "" -"j <- down stairs, center of this blueprint" -i +"j <- down stairs, center of this blueprint" +"i <- up/down stairs, initially in ""blueprint mode"" to prevent digging before drainage is ready" "... <- up/down stairs, make this as tall as you need" i -i <- cistern outlet level +i <- cistern outlet level with diagonal tunnel to depressurize "u <- up stairs, drainage level" "" "Good luck! If done right, this method is the safest way to supply your fort with clean water." #dig label(dig) start(13 13 center of tap) light aquifer water collector - -,,,,,,,,,,,,mdr -,,,,,,,,,,,,mdr -,,,,,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr -,,,,,,,,,,,,mdr -,,,mdr,,,,,,,,,mdr,,,,,,,,,mdr -,,,mdr,,,,,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,,,,,mdr -,,,mdr,,,,,,,,,mdr,,,,,,,,,mdr -,,,mdr,,,mdr,,,,,,mdr,,,,,,mdr,,,mdr -,,,mdr,,,mdr,,,,,mdr,mdr,mdr,,,,,mdr,,,mdr -,,,mdr,,,mdr,,,,,,mdr,,,,,,mdr,,,mdr -,,,mdr,,,mdr,,,mdr,,,mdr,,,mdr,,,mdr,,,mdr -,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdj,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr -,,,mdr,,,mdr,,,mdr,,,mdr,,,mdr,,,mdr,,,mdr -,,,mdr,,,mdr,,,,,,mdr,,,,,,mdr,,,mdr -,,,mdr,,,mdr,,,,,mdr,mdr,mdr,,,,,mdr,,,mdr -,,,mdr,,,mdr,,,,,,mdr,,,,,,mdr,,,mdr -,,,mdr,,,,,,,,,mdr,,,,,,,,,mdr -,,,mdr,,,,,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,,,,,mdr -,,,mdr,,,,,,,,,mdr,,,,,,,,,mdr -,,,,,,,,,,,,mdr -,,,,,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr,mdr -,,,,,,,,,,,,mdr -,,,,,,,,,,,,mdr +,,,,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,mdr3,,,,,,,,,,,, +,,,,,,,,,,,,mdr3,,,,,,,,,,,, +,,,,,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,,,,, +,,,,,,,,,,,,mdr3,,,,,,,,,,,, +,,,mdr3,,,,,,,,,mdr3,,,,,,,,,mdr3,,, +,,,mdr3,,,,,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,,,,,mdr3,,, +,,,mdr3,,,,,,,,,mdr3,,,,,,,,,mdr3,,, +,,,mdr3,,,mdr3,,,,,,mdr3,,,,,,mdr3,,,mdr3,,, +,,,mdr3,,,mdr3,,,,,mdr3,mdr3,mdr3,,,,,mdr3,,,mdr3,,, +,,,mdr3,,,mdr3,,,,,,mdr3,,,,,,mdr3,,,mdr3,,, +,,,mdr3,,,mdr3,,,mdr3,,,mdr3,,,mdr3,,,mdr3,,,mdr3,,, +,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdj3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3, +,,,mdr3,,,mdr3,,,mdr3,,,mdr3,,,mdr3,,,mdr3,,,mdr3,,, +,,,mdr3,,,mdr3,,,,,,mdr3,,,,,,mdr3,,,mdr3,,, +,,,mdr3,,,mdr3,,,,,mdr3,mdr3,mdr3,,,,,mdr3,,,mdr3,,, +,,,mdr3,,,mdr3,,,,,,mdr3,,,,,,mdr3,,,mdr3,,, +,,,mdr3,,,,,,,,,mdr3,,,,,,,,,mdr3,,, +,,,mdr3,,,,,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,,,,,mdr3,,, +,,,mdr3,,,,,,,,,mdr3,,,,,,,,,mdr3,,, +,,,,,,,,,,,,mdr3,,,,,,,,,,,, +,,,,,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,mdr3,,,,, +,,,,,,,,,,,,mdr3,,,,,,,,,,,, +,,,,,,,,,,,,mdr3,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,,,,, +#>,,,,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,`,,,,,,,,,,,, +,,,,,,,,,,,,`,,,,,,,,,,,, +,,,,,`,`,`,`,`,`,`,`,`,`,`,`,`,`,`,,,,, +,,,,,,,,,,,,`,,,,,,,,,,,, +,,,`,,,,,,,,,`,,,,,,,,,`,,, +,,,`,,,,,`,`,`,`,`,`,`,`,`,,,,,`,,, +,,,`,,,,,,,,,`,,,,,,,,,`,,, +,,,`,,,`,,,,,,`,,,,,,`,,,`,,, +,,,`,,,`,,,,,`,`,`,,,,,`,,,`,,, +,,,`,,,`,,,,,,`,,,,,,`,,,`,,, +,,,`,,,`,,,`,,,`,,,`,,,`,,,`,,, +,`,`,`,`,`,`,`,`,`,`,`,mbmdi3,`,`,`,`,`,`,`,`,`,`,`, +,,,`,,,`,,,`,,,`,,,`,,,`,,,`,,, +,,,`,,,`,,,,,,`,,,,,,`,,,`,,, +,,,`,,,`,,,,,`,`,`,,,,,`,,,`,,, +,,,`,,,`,,,,,,`,,,,,,`,,,`,,, +,,,`,,,,,,,,,`,,,,,,,,,`,,, +,,,`,,,,,`,`,`,`,`,`,`,`,`,,,,,`,,, +,,,`,,,,,,,,,`,,,,,,,,,`,,, +,,,,,,,,,,,,`,,,,,,,,,,,, +,,,,,`,`,`,`,`,`,`,`,`,`,`,`,`,`,`,,,,, +,,,,,,,,,,,,`,,,,,,,,,,,, +,,,,,,,,,,,,`,,,,,,,,,,,, diff --git a/data/init/dfhack.tools.init b/data/init/dfhack.tools.init index 440fcac3c9..baa26d3ee4 100644 --- a/data/init/dfhack.tools.init +++ b/data/init/dfhack.tools.init @@ -7,7 +7,6 @@ # Enable system services enable buildingplan enable burrow -enable faststart enable logistics enable overlay enable preserve-rooms diff --git a/depends/lua/include/dfhack_llimits.h b/depends/lua/include/dfhack_llimits.h index f8ca518190..73aa52ef0b 100644 --- a/depends/lua/include/dfhack_llimits.h +++ b/depends/lua/include/dfhack_llimits.h @@ -54,8 +54,19 @@ struct lua_extra_state { #define lua_lock(L) EnterCriticalSection(luai_mutex(L)) #define lua_unlock(L) LeaveCriticalSection(luai_mutex(L)) #else -#define luai_userstateopen(L) luai_mutex(L) = (mutex_t*)malloc(sizeof(mutex_t)); *luai_mutex(L) = PTHREAD_MUTEX_INITIALIZER -#define luai_userstateclose(L) lua_unlock(L); pthread_mutex_destroy(luai_mutex(L)); free(luai_mutex(L)) +#define luai_userstateopen(L) do { \ + luai_mutex(L) = (mutex_t*)malloc(sizeof(mutex_t)); \ + pthread_mutexattr_t attr; \ + pthread_mutexattr_init(&attr); \ + pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); \ + pthread_mutex_init(luai_mutex(L), &attr); \ + pthread_mutexattr_destroy(&attr); \ + } while (0) +#define luai_userstateclose(L) do { \ + lua_unlock(L); \ + pthread_mutex_destroy(luai_mutex(L)); \ + free(luai_mutex(L)); \ + } while (0) #define lua_lock(L) pthread_mutex_lock(luai_mutex(L)) #define lua_unlock(L) pthread_mutex_unlock(luai_mutex(L)) #endif diff --git a/docs/Core.rst b/docs/Core.rst index d6ccb5b9d2..b386e5ea04 100644 --- a/docs/Core.rst +++ b/docs/Core.rst @@ -334,6 +334,14 @@ used regardless of whether you run Dwarf Fortress from its own app or DFHack's. counted towards your hours, see the DFHack stub launcher ``--nowait`` option below. +- ``--skip-size-check``: DFHack normally verifies the sizes of important game + on startup and shuts down if a discrepancy is detected. This is intended to + reduce the risk of misalignments in these structures leading to crashes or + other misbehavior. This option bypasses the check. This option should + normally only be used to facilitate DFHack development. This option will + **not** enable DFHack to be used usefully with a version of DF with which + DFHack has not been aligned. + Options passed to the DFHack Steam stub launcher ------------------------------------------------ diff --git a/docs/about/Authors.rst b/docs/about/Authors.rst index 5311f4029a..1ff055cb71 100644 --- a/docs/about/Authors.rst +++ b/docs/about/Authors.rst @@ -57,7 +57,7 @@ David Timm dtimm Dean Golden LightHardt Deon dhthwy dhthwy -dikbut Tjudge1 +dikbutdagrate Tjudge1 Dmitrii Kurkin Kurkin DoctorVanGogh DoctorVanGogh Donald Ruegsegger hashaash @@ -147,6 +147,7 @@ Myk Taylor myk002 Najeeb Al-Shabibi master-spike napagokc napagokc Neil Little nmlittle +Nicholas McDaniel NicksWorld Nick Rart nickrart comestible Nicolas Ayala nicolasayala Nik Nyby nikolas @@ -172,6 +173,7 @@ PopnROFL PopnROFL potato ppaawwll ppaawwll 🐇🐇🐇🐇 Priit Laes plaes +Przemysław Skrobot frogi16 psychowico wiktor-obrebski Putnam Putnam3145 quarque2 quarque2 diff --git a/docs/about/Removed.rst b/docs/about/Removed.rst index 4c04ac72d8..cfbb42ece2 100644 --- a/docs/about/Removed.rst +++ b/docs/about/Removed.rst @@ -153,6 +153,13 @@ embark-tools Replaced by `gui/embark-anywhere`. Other functionality was replaced by the DF v50 UI. +.. _faststart: + +faststart +========= +Sped up the initial DF load sequence. Removed since Bay 12 rewrote the startup +sequence and it is now sufficiently fast on its own. + .. _fix-armory: fix-armory @@ -283,6 +290,12 @@ max-wave ======== Set population cap based on parameters. Merged into `pop-control`. +.. _modtools/force: + +modtools/force +============== +Merged into `force`. + .. _mousequery: mousequery diff --git a/docs/dev/Lua API.rst b/docs/dev/Lua API.rst index b98b6b2586..767be140ac 100644 --- a/docs/dev/Lua API.rst +++ b/docs/dev/Lua API.rst @@ -315,6 +315,10 @@ All types and the global object have the following features: All compound types (structs, classes, unions, and the global object) support: +* ``type._union`` + + ``true`` if the type represents a union, otherwise ``nil``. + * ``type._fields`` Contains a table mapping field names to descriptions of the type's fields, @@ -947,7 +951,7 @@ can be omitted. * ``dfhack.TranslateName(name[,in_english[,only_last_name]])`` - Convert a language_name or only the last name part to string. + Convert a ``df.language_name`` (or only the last name part) to string. * ``dfhack.df2utf(string)`` @@ -1703,6 +1707,11 @@ Units module ``dfhack.units.isOwnCiv`` or another appropriate predicate on the unit in question. +* ``dfhack.units.setPathGoal(unit, pos, goal)`` + + Set target coordinates and goal (of type ``df.unit_path_goal``) for the given + unit. In case of a change, also clears the unit's current path. + * ``dfhack.units.create(race, caste)`` Creates a new unit from scratch. The unit will be added to the @@ -5348,11 +5357,9 @@ If the panel has already been maximized in this fashion, then it will jump to its minimum size. Both jumps respect the resizable edges defined by the ``resize_anchors`` attribute. -The time duration that a double click can span is defined by the global variable -``DOUBLE_CLICK_MS``. The default value is ``500`` and can be changed by the end -user with a command like:: - - :lua require('gui.widgets').DOUBLE_CLICK_MS=1000 +The time duration that a double click can span can be controlled via the +`control-panel` or `gui/control-panel` interfaces (``Mouse double click speed`` +option). It defaults to 500 ms. Window class ------------ @@ -5511,6 +5518,155 @@ The ``EditField`` class also provides the following functions: Inserts the given text at the current cursor position. +TextArea class +-------------- + +Subclass of Panel; implements a multi-line text field with features such as +text wrapping, mouse control, text selection, clipboard support, history, +and typical text editor shortcuts. + +Cursor Behavior +~~~~~~~~~~~~~~~ + +The cursor in the ``TextArea`` class is index-based, starting from 1, +consistent with Lua's text indexing conventions. + +Each character, including newlines (``string.char(10)``), +occupies a single index in the text content. + +Cursor movement and position are fully aware of line breaks, +meaning they count as one unit in the offset. + +The cursor always points to the position between characters, +with 1 being the position before the first character and +``#text + 1`` representing the position after the last character. + +Cursor positions are preserved during text operations like insertion, +deletion, or replacement. If changes affect the cursor's position, +it will be adjusted to the nearest valid index. + +TextArea Attributes: + +* ``init_text``: The initial text content for the text area. + +* ``init_cursor``: The initial cursor position within the text content. + If not specified, defaults to end of the text (length of ``init_text`` + 1). + +* ``text_pen``: Optional pen used to draw the text. Default is ``COLOR_LIGHTCYAN``. + +* ``select_pen``: Optional pen used for text selection. Default is ``COLOR_CYAN``. + +* ``ignore_keys``: List of input keys to ignore. + Functions similarly to the ``ignore_keys`` attribute in the ``EditField`` class. + +* ``on_text_change``: Callback function called whenever the text changes. + The function signature should be ``on_text_change(new_text, old_text)``. + +* ``on_cursor_change``: Callback function called whenever the cursor position changes. + Expected function signature is ``on_cursor_change(new_cursor, old_cursor)``. + +* ``one_line_mode``: If set to ``true``, disables multi-line text features. + In this mode the :kbd:`Enter` key is not handled by the widget + as if it were included in ``ignore_keys``. + If multiline text (including ``\n`` chars) is pasted into the widget, newlines are removed. + +TextArea Functions: + +* ``textarea:getText()`` + + Returns the current text content of the ``TextArea`` widget as a string. + "\n" characters (``string.char(10)``) should be interpreted as new lines + +* ``textarea:setText(text)`` + + Sets the content of the ``TextArea`` to the specified string ``text``. + The cursor position will not be adjusted, so should be set separately. + +* ``textarea:getCursor()`` + + Returns the current cursor position within the text content. + The position is represented as a single integer, starting from 1. + +* ``textarea:setCursor(cursor)`` + + Sets the cursor position within the text content. + +* ``textarea:scrollToCursor()`` + + Scrolls the text area view to ensure that the current cursor position is visible. + This happens automatically when the user interactively moves the cursor or + pastes text into the widget, but may need to be called when ``setCursor`` is + called programmatically. + +* ``textarea:clearHistory()`` + + Clears undo/redo history of the widget. + +Functionality +~~~~~~~~~~~~~ + +The TextArea widget provides a familiar and intuitive text editing experience with baseline features such as: + +- Text Wrapping: Automatically fits text within the display area. +- Mouse and Keyboard Support: Standard keys like :kbd:`Home`, :kbd:`End`, :kbd:`Backspace`, and :kbd:`Delete` are supported, + along with gestures like double-click to select a word or triple-click to select a line. +- Clipboard Operations: copy, cut, and paste, + with intuitive defaults when no text is selected. +- Undo/Redo: :kbd:`Ctrl` + :kbd:`Z` and :kbd:`Ctrl` + :kbd:`Y` for quick changes. +- Additional features include advanced navigation, line management, + and smooth scrolling for handling long text efficiently. + +Detailed list: + +- Cursor Control: Navigate through text using arrow keys (Left, Right, Up, + and Down) for precise cursor placement. +- Mouse Control: Use the mouse to position the cursor within the text, + providing an alternative to keyboard navigation. +- Text Selection: Select text with the mouse, with support for replacing or + removing selected text. +- Select Word/Line: Use double click to select current word, or triple click to + select current line. +- Move By Word: Use :kbd:`Ctrl` + :kbd:`Left` and :kbd:`Ctrl` + :kbd:`Right` to + move the cursor one word back or forward. +- Line Navigation: :kbd:`Home` moves the cursor to the beginning of the current + line, and :kbd:`End` moves it to the end. +- Jump to Beginning/End: Quickly move the cursor to the beginning or end of the + text using :kbd:`Ctrl` + :kbd:`Home` and :kbd:`Ctrl` + :kbd:`End`. +- Longest X Position Memory: The cursor remembers the longest x position when + moving up or down, making vertical navigation more intuitive. +- New Lines: Easily insert new lines using the :kbd:`Enter` key, supporting + multiline text input. +- Text Wrapping: Text automatically wraps within the editor, ensuring lines fit + within the display without manual adjustments. +- Scrolling for long text entries. +- Backspace Support: Use the backspace key to delete characters to the left of + the cursor. +- Delete Character: :kbd:`Delete` deletes the character under the cursor. +- Delete Current Line: :kbd:`Ctrl` + :kbd:`U` deletes the entire current line + where the cursor is located. +- Delete Rest of Line: :kbd:`Ctrl` + :kbd:`K` deletes text from the cursor to + the end of the line. +- Delete Last Word: :kbd:`Ctrl` + :kbd:`W` removes the word immediately before + the cursor. +- Select All: Select entire text by :kbd:`Ctrl` + :kbd:`A`. +- Undo/Redo: Undo/Redo changes by :kbd:`Ctrl` + :kbd:`Z` / :kbd:`Ctrl` + + :kbd:`Y`. +- Clipboard Operations: Perform OS clipboard cut, copy, and paste operations on + selected text, allowing you to paste the copied content into other + applications. +- Copy Text: Use :kbd:`Ctrl` + :kbd:`C` to copy selected text. + - copy selected text, if available + - if no text is selected it copy the entire current line, including the + terminating newline if present +- Cut Text: Use :kbd:`Ctrl` + :kbd:`X` to cut selected text. + - cut selected text, if available + - if no text is selected it will cut the entire current line, including the + terminating newline if present +- Paste Text: Use :kbd:`Ctrl` + :kbd:`V` to paste text from the clipboard into + the editor. + - replace selected text, if available + - If no text is selected, paste text in the cursor position + Scrollbar class --------------- @@ -5554,16 +5710,14 @@ while scrolling will result in faster movement. You can click and drag the scrollbar to scroll to a specific spot, or you can click and hold on the end arrows or in the unfilled portion of the scrollbar to scroll multiple times, just like in a normal browser scrollbar. The speed of -scroll events when the mouse button is held down is controlled by two global -variables: +scroll events when the mouse button is held down can be controlled +via the `control-panel` or `gui/control-panel` interfaces: -:``SCROLL_INITIAL_DELAY_MS``: The delay before the second scroll event. -:``SCROLL_DELAY_MS``: The delay between further scroll events. +1. The delay before the second scroll event is the ``Mouse initial scroll repeat + delay`` setting (default is 300 ms) -The defaults are 300 and 20, respectively, but they can be overridden by the -user in their :file:`dfhack-config/init/dfhack.init` file, for example:: - - :lua require('gui.widgets').SCROLL_DELAY_MS = 100 +2. The delay between further scroll events is the ``Mouse scroll repeat delay`` option + (default is 20 ms) Label class ----------- @@ -6194,9 +6348,16 @@ TabBar class ------------ This widget implements a set of one or more tabs to allow navigation between groups -of content. Tabs automatically wrap on the width of the window and will continue -rendering on the next line(s) if all tabs cannot fit on a single line. - +of content. + +:wrap: If true, tabs automatically wrap on the width of the window and will + continue rendering on the next line(s) if all tabs cannot fit on a single line. + If false, tabs will be truncated and can be scrolled using ``scroll_key`` + and ``scroll_key_back``, mouse wheel or by clicking on the scroll labels + that will automatically appear on the left and right sides of the tab bar + as needed. When clicking on a tab or using ``key`` or ``key_back`` to switch tabs, + the selected tab will be scrolled into view if it is not already visible. + Defaults to true. :key: Specifies a keybinding that can be used to switch to the next tab. Defaults to ``CUSTOM_CTRL_T``. :key_back: Specifies a keybinding that can be used to switch to the previous @@ -6222,6 +6383,28 @@ rendering on the next line(s) if all tabs cannot fit on a single line. itself as the second. The default implementation, which will handle most situations, returns ``self.active_tab_pens``, if ``self.get_cur_page() == idx``, otherwise returns ``self.inactive_tab_pens``. +:scroll_key: Specifies a keybinding that can be used to scroll the tabs to the right. + Defaults to ``CUSTOM_ALT_T``. +:scroll_key_back: Specifies a keybinding that can be used to scroll the tabs to the left. + Defaults to ``CUSTOM_ALT_Y``. +:scroll_left_text: The text to display on the left scroll label. + Defaults to "<<<". +:scroll_right_text: The text to display on the right scroll label. + Defaults to ">>>". +:scroll_label_text_pen: The pen to use for the scroll label text. + Defaults to ``Label`` default. +:scroll_label_text_hpen: The pen to use for the scroll label text when hovered. + Defaults to ``scroll_label_text_pen`` with the background + and foreground colors swapped. +:scroll_step: The number of units to scroll tabs by. + Defaults to 10. +:fast_scroll_multiplier: The multiplier for fast scrolling (holding shift). + Defaults to 3. +:scroll_into_view_offset: After a selected tab is scrolled into view, this offset + is added to the scroll position to ensure the tab is + not flush against the edge of the tab bar, allowing + some space for the user to see the next tab. + Defaults to 5. Tab class --------- diff --git a/docs/dev/compile/Compile.rst b/docs/dev/compile/Compile.rst index 2ad7a29796..19fbce2c1e 100644 --- a/docs/dev/compile/Compile.rst +++ b/docs/dev/compile/Compile.rst @@ -303,7 +303,7 @@ Step 1: prepare a build container On your Linux host, install and run the docker daemon and then run these commands:: xhost +local:root - docker run -it --env="DISPLAY" --env="QT_X11_NO_MITSHM=1" --volume=/tmp/.X11-unix:/tmp/.X11-unix --user buildmaster --name dfhack-win ghcr.io/dfhack/build-env:msvc + docker run -it --env="DISPLAY" --env="QT_X11_NO_MITSHM=1" --volume=/tmp/.X11-unix:/tmp/.X11-unix --user buildmaster --name dfhack-win ghcr.io/dfhack/build-env:master The ``xhost`` command and ``--env`` parameters are there so you can eventually run Dwarf Fortress from the container and have it display on your host. diff --git a/docs/dev/compile/Dependencies.rst b/docs/dev/compile/Dependencies.rst index a5354ef5ec..b42a833785 100644 --- a/docs/dev/compile/Dependencies.rst +++ b/docs/dev/compile/Dependencies.rst @@ -69,20 +69,20 @@ Here are some package install commands for various distributions: * On Arch Linux:: - pacman -Sy gcc cmake ccmake ninja git dwarffortress zlib perl-xml-libxml perl-xml-libxslt + pacman -Sy gcc cmake ccache ninja git dwarffortress zlib perl-xml-libxml perl-xml-libxslt * The ``dwarffortress`` package provides the necessary SDL packages. * For the required Perl modules: ``perl-xml-libxml`` and ``perl-xml-libxslt`` (or through ``cpan``) * On Ubuntu:: - apt-get install gcc cmake ninja-build git zlib1g-dev libsdl2-dev libxml-libxml-perl libxml-libxslt-perl + apt-get install gcc cmake ccache ninja-build git zlib1g-dev libsdl2-dev libxml-libxml-perl libxml-libxslt-perl * Other Debian-based distributions should have similar requirements. * On Fedora:: - yum install gcc-c++ cmake ninja-build git zlib-devel SDL2-devel perl-core perl-XML-LibXML perl-XML-LibXSLT ruby + yum install gcc-c++ cmake ccache ninja-build git zlib-devel SDL2-devel perl-core perl-XML-LibXML perl-XML-LibXSLT ruby To build DFHack, you need GCC 10 or newer. Note that extremely new GCC versions may not have been used to build DFHack yet, so if you run into issues with diff --git a/docs/guides/quickfort-library-guide.rst b/docs/guides/quickfort-library-guide.rst index 37d1dd24e8..e363fe0987 100644 --- a/docs/guides/quickfort-library-guide.rst +++ b/docs/guides/quickfort-library-guide.rst @@ -45,7 +45,9 @@ and a convenient `checklist `__ that you can use to track your progress. -If you like, you can download a fully built Dreamfort-based fort from +If you'd like a visual demonstration, there is a `series of videos on YouTube `__ +that walk you through the entire process of building a Dreamfort-based +fortress. You can also download a fully built Dreamfort-based fort from :dffd:`dffd <15434>`, load it, and explore it interactively. Here are annotated screenshots of the major Dreamfort levels (or click `here diff --git a/docs/plugins/autobutcher.rst b/docs/plugins/autobutcher.rst index b6133fbf21..b429f57542 100644 --- a/docs/plugins/autobutcher.rst +++ b/docs/plugins/autobutcher.rst @@ -7,6 +7,9 @@ autobutcher This plugin monitors how many pets you have of each gender and age and assigns excess livestock for slaughter. See `gui/autobutcher` for an in-game interface. +Common configuration tweaks can be enabled in `gui/control-panel` in the +Automation -> Autostart tab. + Units are protected from being automatically butchered if they are: * Untamed @@ -34,10 +37,7 @@ Usage ----- ``enable autobutcher`` - Start processing livestock according to the configuration. Note that - no races are watched by default. You have to add the ones you want to - monitor with ``autobutcher watch``, ``autobutcher target`` or - ``autobutcher autowatch``. + Start processing livestock according to the per-race targets. ``autobutcher [list]`` Print status and current settings, including the watchlist. This is the default command if autobutcher is run without parameters. @@ -91,8 +91,8 @@ Though not all the races listed there are tameable/butcherable. savegame, you can export the commands required to recreate your settings. To export, open an external terminal in the DF directory, and run - ``dfhack-run autobutcher list_export > filename.txt``. To import, load your - new save and run ``script filename.txt`` in the DFHack terminal. + ``dfhack-run autobutcher list_export > autobutcher.txt``. To import, load + your new save and run ``script autobutcher.txt`` in `gui/launcher`. Examples -------- diff --git a/docs/plugins/dwarfvet.rst b/docs/plugins/dwarfvet.rst index 94fb7c20aa..f160a7bd82 100644 --- a/docs/plugins/dwarfvet.rst +++ b/docs/plugins/dwarfvet.rst @@ -16,6 +16,8 @@ unassigned from the pasture while in treatment. The animal will be reassigned to its original pasture shortly after recovery. Animals that are on restraints or in cages will not be designated for treatment. +Animals require an empty tile of floor to rest on while recovering, so your +hospital must have some unoccupied floor space in order for animals to use it. You can enable ``dwarfvet`` in `gui/control-panel`, and you can choose to start ``dwarfvet`` automatically in new forts in the ``Gameplay`` -> ``Autostart`` diff --git a/docs/plugins/faststart.rst b/docs/plugins/faststart.rst deleted file mode 100644 index b39269e01b..0000000000 --- a/docs/plugins/faststart.rst +++ /dev/null @@ -1,18 +0,0 @@ -faststart -========= - -.. dfhack-tool:: - :summary: Makes the main menu appear sooner. - :tags: dfhack interface - :no-command: - -This plugin accelerates the initial "Loading..." screen that appears when the -game first starts, so you don't have to wait as long before the Main Menu -appears and you can start playing. - -Usage ------ - -:: - - enable faststart diff --git a/docs/plugins/forceequip.rst b/docs/plugins/forceequip.rst index 18661195a1..26ab378eec 100644 --- a/docs/plugins/forceequip.rst +++ b/docs/plugins/forceequip.rst @@ -3,7 +3,7 @@ forceequip .. dfhack-tool:: :summary: Move items into a unit's inventory. - :tags: unavailable + :tags: adventure fort animals items military units This tool is typically used to equip specific clothing/armor items onto a dwarf, but can also be used to put armor onto a war animal or to add unusual items diff --git a/docs/plugins/infiniteSky.rst b/docs/plugins/infinite-sky.rst similarity index 68% rename from docs/plugins/infiniteSky.rst rename to docs/plugins/infinite-sky.rst index 0c14804afd..625bf71786 100644 --- a/docs/plugins/infiniteSky.rst +++ b/docs/plugins/infinite-sky.rst @@ -1,9 +1,11 @@ -infiniteSky -=========== +.. _infinitesky: + +infinite-sky +============ .. dfhack-tool:: - :summary: Automatically allocate new z-levels of sky - :tags: unavailable + :summary: Automatically allocate new z-levels of sky. + :tags: fort auto design map If enabled, this plugin will automatically allocate new z-levels of sky at the top of the map as you build up. Or it can allocate one or many additional levels @@ -12,13 +14,14 @@ at your command. Usage ----- -``enable infiniteSky`` +``enable infinite-sky`` Enables monitoring of constructions. If you build anything in the second highest z-level, it will allocate one more sky level. You can build stairs up as high as you like! -``infiniteSky []`` - Raise the sky by n z-levels. If run without parameters, raises the sky by - one z-level. +``infinite-sky`` + Print current status. +``infinite-sky `` + Raise the sky by n z-levels. .. warning:: diff --git a/docs/plugins/preserve-rooms.rst b/docs/plugins/preserve-rooms.rst index f084c2c3b5..26e570186f 100644 --- a/docs/plugins/preserve-rooms.rst +++ b/docs/plugins/preserve-rooms.rst @@ -19,9 +19,8 @@ This tool mitigates both issues. It records when units leave the map and reserves their assigned bedrooms, offices, etc. for them. The zones will be disabled in their absence (so other units don't steal them), and will be re-enabled and reassigned to them when they appear back on the map. If they die -away from the fort, the zone will become unreserved and available for reuse. If -they are captured and held prisoner, their room will continue to be reserved in -their name in the (optimistic) hope of their safe return. +away from the fort (or are captured), the zone will become unreserved and +available for reuse. When you click on an assignable zone, you will also now have the option to associate the room with a noble or administrative role. The room will be diff --git a/docs/plugins/timestream.rst b/docs/plugins/timestream.rst new file mode 100644 index 0000000000..8b6b8eba6c --- /dev/null +++ b/docs/plugins/timestream.rst @@ -0,0 +1,113 @@ +timestream +========== + +.. dfhack-tool:: + :summary: Fix FPS death. + :tags: fort gameplay fps + +Do you remember when you first start a new fort, your initial 7 dwarves zip +around the screen and get things done so quickly? As a player, you never had +to wait for your initial dwarves to move across the map. Do you wish that your +fort of 200 dwarves and 800 animals could be as zippy? This tool can help. + +``timestream`` keeps the game running quickly by tweaking the game simulation +according to the frames per second that your computer can support. This means +that your dwarves spend the same amount of time relative to the in-game +calendar to do their tasks, but the time that you, the player, have to wait for +the dwarves to do get things done is reduced. The result is that the dwarves in +your fully developed fort appear as energetic as the dwarves in a newly created +fort, and mature forts are much more fun to play. + +Note that whereas your dwarves zip around like you're running at 100 FPS, the +vanilla onscreen FPS counter, if enabled, will still show a lower number. See +the `Technical details`_ section below if you're interested in what's going on +under the hood. + +Usage +----- + +:: + + enable timestream + timestream [status] + timestream set + timestream reset + +Examples +-------- + +``enable timestream`` + Start adjusting the simulation to run at the currently configured apparent + FPS (default is whatever you have the FPS cap set to in the DF settings, + which is usually 100). + +``timestream set fps 50`` + Tweak the simulation so it runs at an apparent 50 frames per second. + +``timestream reset`` + Reset settings to defaults: the vanilla FPS cap with no calendar speed + advantage or disadvantage. + +Settings +-------- + +:fps: Set the target simulated FPS. The default target FPS is whatever you have + the FPS cap set to in the DF settings, and the minimum is 10. Setting the + target FPS *below* your current actual FPS will have no effect. You have + to set the vanilla FPS cap for that. Set a target FPS of -1 to make no + adjustment at all to the apparent FPS of the game. + +Technical details +----------------- + +So what is this magic? How does this tool make it look like the game is +suddenly running so much faster? + +Maybe an analogy would help. Pretend you're standing at the bottom of a +staircase and you want to walk up the stairs. You can walk up one stair every +second, and there are 100 stairs, so it will take you 100 seconds to walk up +all the stairs. + +Now let's use the Hand of Armok and fiddle with reality a bit. Let's say that +instead of walking up one step, you walk up 5 steps at once. At the same time +we move the wall clock 5 seconds ahead. If you look at the clock after reaching +the top of the stairs, it will still look like it took 100 seconds, but you did +it all in fewer "steps". + +That's essentially what ``timestream`` is doing to the game. All "actions" in +DF have counters associated with them. For example, when a dwarf wants to walk +to the next tile, a counter is initialized to 8. Every "tick" of the game (the +"frame" in FPS) decrements that counter by 1. When the counter gets to zero, +the dwarf appears on the next tile. + +When ``timestream`` is active, it monitors all those counters and makes them +decrement more per tick. It then balances things out by proportionally +advancing the in-game calendar. Therefore, more "happens" per step, and DF has +to simulate fewer "steps" for the same amount of work to get done. + +The cost of this simplification is that the world becomes less "smooth". As the +discrepancy between the actual and simulated FPS grows, more and more dwarves +will move to their next tiles at *exactly* the same time. Moreover, the rate of +action completion per unit is effectively capped at the granularity of the +simulation, so very fast units (say, those in a martial trance) will lose some +of their advantage. + +Limitations +----------- + +DF does critial game tasks every 10 calendar ticks that must not be skipped, so +`timestream` cannot advance more than 9 ticks at a time. This puts an upper +limit on how much `timestream` can help. With the default target of 100 FPS, +the game will start showing signs of slowdown if the real FPS drops below about +15. The interface will also become less responsive to mouse gestures as the +real FPS drops. + +Finally, not all aspects of the game are perfectly adjusted. For example, +armies on world map will move at the same (real-time) rate regardless of +changes that ``timestream`` is making to the calendar. + +Here is a (possibly incomplete) list of game elements that are not adjusted by +``timestream`` and will appear "slow" in-game: + +- Army movement across the world map (including raids sent out from the fort) +- Liquid movement and evaporation diff --git a/docs/plugins/tweak.rst b/docs/plugins/tweak.rst index 996b0ec103..7c72f946c7 100644 --- a/docs/plugins/tweak.rst +++ b/docs/plugins/tweak.rst @@ -53,6 +53,19 @@ Commands Names filled waterskins, flasks, and vials according to their contents, the same way other containers such as barrels, bins, and cages are named. (:bug:`4914`) +``realistic-melting`` + Makes amortized metal bar returns for melting uniform across all item types. + Affects weapons, shields, armor parts, tools, and trap components. The target + amount of metal produced by melting is 95% of the metal used for production + of the item. Each level of wear decreases melt return by a further 10%. The game + has a fixed granularity of 0.3 for metal bar returns, so individual items will + randomly return an amount that may be above or below the target. For example + a metal cap with item size 1 will produce 0.9 of a bar with a 16.6% chance of + producing an additional 0.3 of a bar. Over time, the average return for melting + these types of caps will be ~0.95 of a bar. Calculations for melting return are + done for items with base game production cost. Melting return might not be + calculated correctly for modded items or items created in custom reactions + that don't respect vanilla production costs. (:bug:`6027`) ``named-codices`` Displays titles for books instead of the default material description. ``partial-items`` diff --git a/library/Core.cpp b/library/Core.cpp index 01756ef0fa..ca36bcab18 100644 --- a/library/Core.cpp +++ b/library/Core.cpp @@ -93,6 +93,7 @@ using namespace DFHack; using namespace df::enums; using df::global::init; using df::global::world; +using std::string; // FIXME: A lot of code in one file, all doing different things... there's something fishy about it. @@ -452,13 +453,16 @@ void get_commands(color_ostream &con, std::vector &commands) { con.printerr("Failed Lua call to helpdb.get_commands.\n"); } - Lua::GetVector(L, commands); + Lua::GetVector(L, commands, top + 1); } static bool try_autocomplete(color_ostream &con, const std::string &first, std::string &completed) { std::vector commands, possible; + // restore call to get_commands once we have updated the core lock to a deferred lock + // so calling Lua from the console thread won't deadlock if Lua is currently busy + //get_commands(con, commands); for (auto &command : commands) if (command.substr(0, first.size()) == first) possible.push_back(command); @@ -1492,7 +1496,7 @@ Core::Core() : color_ostream::log_errors_to_stderr = true; }; -void Core::fatal (std::string output) +void Core::fatal (std::string output, const char * title) { errorstate = true; std::stringstream out; @@ -1509,7 +1513,9 @@ void Core::fatal (std::string output) fprintf(stderr, "%s\n", out.str().c_str()); out << "Check file stderr.log for details.\n"; std::cout << "DFHack fatal error: " << out.str() << std::endl; - DFSDL::DFSDL_ShowSimpleMessageBox(0x10 /* SDL_MESSAGEBOX_ERROR */, "DFHack error!", out.str().c_str(), NULL); + if (!title) + title = "DFHack error!"; + DFSDL::DFSDL_ShowSimpleMessageBox(0x10 /* SDL_MESSAGEBOX_ERROR */, title, out.str().c_str(), NULL); bool is_headless = bool(getenv("DFHACK_HEADLESS")); if (is_headless) @@ -1611,7 +1617,27 @@ bool Core::InitMainThread() { } else { - fatal("Not a known DF version.\n"); + std::stringstream msg; + msg << "Not a supported DF version.\n" + "\n" + "Please make sure that you have a version of DFHack installed that\n" + "matches the version of Dwarf Fortress.\n" + "\n" + "DFHack version: " << Version::dfhack_version() << "\n" + "\n"; + auto supported_versions = vif->getVersionInfosForCurOs(); + if (supported_versions.size()) { + msg << "Dwarf Fortress releases supported by this version of DFHack:\n\n"; + for (auto & sv : supported_versions) { + string ver = sv->getVersion(); + if (ver.starts_with("v0.")) { // translate "v0.50" to the standard format: "v50" + ver = "v" + ver.substr(3); + } + msg << " " << ver << "\n"; + } + msg << "\n"; + } + fatal(msg.str(), "DFHack version mismatch"); } errorstate = true; return false; @@ -1622,6 +1648,44 @@ bool Core::InitMainThread() { // Init global object pointers df::global::InitGlobals(); + // check key game structure sizes against the global table + // this check is (silently) skipped if either `game` or `global_table` is not defined + // to faciliate the linux symbol discovery process (which runs without any symbols) + // or if --skip-size-check is discovered on the command line + + if (df::global::global_table && df::global::game && + df::global::game->command_line.original.find("--skip-size-check") == std::string::npos) + { + std::stringstream msg; + bool gt_error = false; + static const std::map sizechecks{ + { "world", sizeof(df::world) }, + { "game", sizeof(df::gamest) }, + { "plotinfo", sizeof(df::plotinfost) }, + }; + + for (auto& gte : *df::global::global_table) + { + // this will exit the loop when the terminator is hit, in the event the global table size in structures is incorrect + if (gte.address == nullptr || gte.name == nullptr) + break; + std::string name{ gte.name }; + if (sizechecks.contains(name) && gte.size != sizechecks.at(name)) + { + msg << "Global '" << name << "' size mismatch: is " << gte.size << ", expected " << sizechecks.at(name) << "\n"; + gt_error = true; + } + } + + if (gt_error) + { + msg << "DFHack cannot safely run under these conditions.\n"; + fatal(msg.str(), "DFHack fatal error"); + errorstate = true; + return false; + } + } + perf_counters.reset(); return true; diff --git a/library/LuaApi.cpp b/library/LuaApi.cpp index 79bb63cd87..fab7f48b60 100644 --- a/library/LuaApi.cpp +++ b/library/LuaApi.cpp @@ -43,6 +43,7 @@ distribution. #include "modules/Burrows.h" #include "modules/Constructions.h" #include "modules/Designations.h" +#include "modules/DFSDL.h" #include "modules/EventManager.h" #include "modules/Filesystem.h" #include "modules/Gui.h" @@ -2065,6 +2066,7 @@ static const LuaWrapper::FunctionReg dfhack_units_module[] = { WRAPM(Units, getIdentity), WRAPM(Units, getNemesis), WRAPM(Units, makeown), + WRAPM(Units, setPathGoal), WRAPM(Units, create), WRAPM(Units, getPhysicalAttrValue), WRAPM(Units, getMentalAttrValue), diff --git a/library/LuaWrapper.cpp b/library/LuaWrapper.cpp index 9e6847bbe9..608ef95683 100644 --- a/library/LuaWrapper.cpp +++ b/library/LuaWrapper.cpp @@ -1638,8 +1638,11 @@ static void RenderType(lua_State *state, const compound_identity *node) switch (node->type()) { - case IDTYPE_STRUCT: case IDTYPE_UNION: // TODO: change this to union-type? what relies on this? + lua_pushboolean(state, true); + lua_setfield(state, ftable, "_union"); + // fall through + case IDTYPE_STRUCT: lua_pushstring(state, "struct-type"); lua_setfield(state, ftable, "_kind"); IndexStatics(state, ix_meta, ftable, (struct_identity*)node); diff --git a/library/VersionInfoFactory.cpp b/library/VersionInfoFactory.cpp index 70425e3ffb..b1f0472c61 100644 --- a/library/VersionInfoFactory.cpp +++ b/library/VersionInfoFactory.cpp @@ -29,7 +29,6 @@ distribution. #include #include #include -using namespace std; #include "VersionInfoFactory.h" #include "VersionInfo.h" @@ -37,10 +36,15 @@ using namespace std; #include "Memory.h" #include "MemAccess.h" #include "PluginManager.h" -using namespace DFHack; #include +using namespace DFHack; +using std::cerr; +using std::endl; +using std::string; +using std::vector; + VersionInfoFactory::VersionInfoFactory() { error = false; @@ -77,6 +81,25 @@ std::shared_ptr VersionInfoFactory::getVersionInfoByPETimesta return nullptr; } +std::vector> VersionInfoFactory::getVersionInfosForCurOs() const { + static const OSType expected = VersionInfo::getCurOS(); + static const string zeroMd5 = "00000000000000000000000000000000"; + + std::vector> ret; + + for (const auto& version : versions) { + if (version->getOS() == expected + && version->getVersion().find("LOCAL") == string::npos + && !version->hasPE(0) + && !version->hasMD5(zeroMd5)) + { + ret.emplace_back(version); + } + } + + return ret; +} + static uintptr_t to_addr(const char * cstr) { if (sizeof(uintptr_t) == sizeof(unsigned long)) return strtoul(cstr, 0, 0); diff --git a/library/include/Core.h b/library/include/Core.h index d64b05970c..88ed02ce4d 100644 --- a/library/include/Core.h +++ b/library/include/Core.h @@ -269,7 +269,7 @@ namespace DFHack void operator=(Core const&); // Don't implement // report error to user while failing - void fatal (std::string output); + void fatal (std::string output, const char * title = NULL); // 1 = fatal failure bool errorstate; diff --git a/library/include/DataDefs.h b/library/include/DataDefs.h index 5d284b02de..4fb0563414 100644 --- a/library/include/DataDefs.h +++ b/library/include/DataDefs.h @@ -33,11 +33,7 @@ distribution. #include #include "BitArray.h" - -// Stop some MS stupidity -#ifdef interface - #undef interface -#endif +#include "Export.h" typedef struct lua_State lua_State; diff --git a/library/include/DataFuncs.h b/library/include/DataFuncs.h index 7748331624..36d756471f 100644 --- a/library/include/DataFuncs.h +++ b/library/include/DataFuncs.h @@ -30,6 +30,7 @@ distribution. #include #include +#include "ColorText.h" #include "DataIdentity.h" #include "LuaWrapper.h" diff --git a/library/include/VersionInfo.h b/library/include/VersionInfo.h index 9e4af787e6..fa35a03868 100644 --- a/library/include/VersionInfo.h +++ b/library/include/VersionInfo.h @@ -75,6 +75,17 @@ namespace DFHack OS = rhs.OS; }; + static OSType getCurOS() { +#if defined(_WIN32) + const OSType expected = OS_WINDOWS; +#elif defined(_DARWIN) + const OSType expected = OS_APPLE; +#else + const OSType expected = OS_LINUX; +#endif + return expected; + } + uintptr_t getBase () const { return base; }; intptr_t getRebaseDelta() const { return rebase_delta; } void setBase (const uintptr_t _base) { base = _base; }; @@ -171,13 +182,8 @@ namespace DFHack }; void ValidateOS() { -#if defined(_WIN32) - const OSType expected = OS_WINDOWS; -#elif defined(_DARWIN) - const OSType expected = OS_APPLE; -#else - const OSType expected = OS_LINUX; -#endif + static const OSType expected = getCurOS(); + if (expected != getOS()) { std::cerr << "OS mismatch; resetting to " << int(expected) << std::endl; setOS(expected); diff --git a/library/include/VersionInfoFactory.h b/library/include/VersionInfoFactory.h index bd4b1180e3..060d622ecd 100644 --- a/library/include/VersionInfoFactory.h +++ b/library/include/VersionInfoFactory.h @@ -42,6 +42,7 @@ namespace DFHack bool isInErrorState() const {return error;}; std::shared_ptr getVersionInfoByMD5(std::string md5string) const; std::shared_ptr getVersionInfoByPETimestamp(uintptr_t timestamp) const; + std::vector> getVersionInfosForCurOs() const; // trash existing list void clear(); private: diff --git a/library/include/modules/DFSDL.h b/library/include/modules/DFSDL.h index f826e554e0..0fcd17b322 100644 --- a/library/include/modules/DFSDL.h +++ b/library/include/modules/DFSDL.h @@ -1,5 +1,6 @@ #pragma once +#include "Error.h" #include "Export.h" #include "ColorText.h" diff --git a/library/include/modules/Graphic.h b/library/include/modules/Graphic.h index 22a5d49136..9fa498a7cf 100644 --- a/library/include/modules/Graphic.h +++ b/library/include/modules/Graphic.h @@ -33,11 +33,13 @@ distribution. #include #include "Export.h" #include "Module.h" -#include "DFSDL.h" #include namespace DFHack { + // forward declaration used here instead of including DFSDL.h to reduce inclusion loading + struct DFTileSurface; + class DFHACK_EXPORT Graphic : public Module { public: diff --git a/library/include/modules/Translation.h b/library/include/modules/Translation.h index 9f8dacc3c7..8de74e71d7 100644 --- a/library/include/modules/Translation.h +++ b/library/include/modules/Translation.h @@ -56,7 +56,7 @@ DFHACK_EXPORT void setNickname(df::language_name *name, std::string nick); DFHACK_EXPORT std::string capitalize(const std::string &str, bool all_words = false); // translate a name using the loaded dictionaries -DFHACK_EXPORT std::string TranslateName (const df::language_name * name, bool inEnglish = true, +DFHACK_EXPORT std::string TranslateName (const df::language_name * name, bool inEnglish = false, bool onlyLastPart = false); } } diff --git a/library/include/modules/Units.h b/library/include/modules/Units.h index 7eaa61ec62..f398d135f8 100644 --- a/library/include/modules/Units.h +++ b/library/include/modules/Units.h @@ -42,6 +42,7 @@ distribution. #include "df/physical_attribute_type.h" #include "df/unit_action.h" #include "df/unit_action_type_group.h" +#include "df/unit_path_goal.h" #include @@ -238,6 +239,13 @@ DFHACK_EXPORT bool unassignTrainer(df::unit *unit); /// to determine if the makeown operation was successful. DFHACK_EXPORT void makeown(df::unit *unit); +/// set appropriate labors on a unit based on current work detail settings +/// (uses Bay12-provided algorithm) +DFHACK_EXPORT void setAutomaticProfessions(df::unit* unit); + +// Set the units target location and goal, clearing any existing goal or path +DFHACK_EXPORT void setPathGoal(df::unit *unit, df::coord pos, df::unit_path_goal goal); + /// Create new unit and add to all units vector (but not active). No HF, nemesis, pos, /// labors, or group associations. Will have race, caste, name, soul, body, and mind. DFHACK_EXPORT df::unit *create(int16_t race, int16_t caste); diff --git a/library/lua/dfhack.lua b/library/lua/dfhack.lua index bbf4fb4a66..ef08fb7f4b 100644 --- a/library/lua/dfhack.lua +++ b/library/lua/dfhack.lua @@ -668,7 +668,7 @@ end ---@nodiscard ---@param self string ---@param width number ----@param opts {return_as_table:boolean, keep_trailing_spaces:boolean} +---@param opts {return_as_table:boolean, keep_trailing_spaces:boolean, keep_original_newlines:boolean} ---@return string|string[] function string:wrap(width, opts) width, opts = width or 72, opts or {} diff --git a/library/lua/gui.lua b/library/lua/gui.lua index 8b40c34012..bca0bba256 100644 --- a/library/lua/gui.lua +++ b/library/lua/gui.lua @@ -1127,7 +1127,7 @@ function ZScreen:onInput(keys) self:dismiss() else local passit = self.pass_pause and keys.D_PAUSE - if not passit and self.pass_mouse_clicks then + if not passit and self.pass_mouse_clicks and not has_mouse then if keys.CONTEXT_SCROLL_UP or keys.CONTEXT_SCROLL_DOWN or keys.CONTEXT_SCROLL_PAGEUP or keys.CONTEXT_SCROLL_PAGEDOWN then passit = true @@ -1164,7 +1164,7 @@ end function ZScreen:isMouseOver() for _,sv in ipairs(self.subviews) do - if sv.visible and sv:getMouseFramePos() then return true end + if utils.getval(sv.visible) and sv:getMouseFramePos() then return true end end end diff --git a/library/lua/gui/widgets.lua b/library/lua/gui/widgets.lua index a1ae69899e..8a5daab7b2 100644 --- a/library/lua/gui/widgets.lua +++ b/library/lua/gui/widgets.lua @@ -2,3338 +2,60 @@ local _ENV = mkmodule('gui.widgets') -local gui = require('gui') -local guidm = require('gui.dwarfmode') -local textures = require('gui.textures') -local utils = require('utils') - -local getval = utils.getval -local to_pen = dfhack.pen.parse - ----@param view gui.View ----@param vis boolean -local function show_view(view,vis) - if view then - view.visible = vis - end -end - ----@param tab? table ----@param idx integer ----@return any|integer -local function map_opttab(tab,idx) - if tab then - return tab[idx] - else - return idx - end -end - ----@enum STANDARDSCROLL -STANDARDSCROLL = { - STANDARDSCROLL_UP = -1, - KEYBOARD_CURSOR_UP = -1, - STANDARDSCROLL_DOWN = 1, - KEYBOARD_CURSOR_DOWN = 1, - STANDARDSCROLL_PAGEUP = '-page', - KEYBOARD_CURSOR_UP_FAST = '-page', - STANDARDSCROLL_PAGEDOWN = '+page', - KEYBOARD_CURSOR_DOWN_FAST = '+page', -} - ------------- --- Widget -- ------------- - ----@class widgets.Widget.frame ----@field l? integer Gap between the left edge of the frame and the parent. ----@field t? integer Gap between the top edge of the frame and the parent. ----@field r? integer Gap between the right edge of the frame and the parent. ----@field b? integer Gap between the bottom edge of the frame and the parent. ----@field w? integer Desired width ----@field h? integer Desired height - ----@class widgets.Widget.inset ----@field l? integer Left margin ----@field t? integer Top margin ----@field r? integer Right margin ----@field b? integer Bottom margin ----@field x? integer Left/right margin (if `l` and/or `r` are ommited) ----@field y? integer Top/bottom margin (if `t` and/or `b` are ommited) - ----@class widgets.Widget.attrs: gui.View.attrs ----@field frame? widgets.Widget.frame ----@field frame_inset? widgets.Widget.inset|integer ----@field frame_background? dfhack.pen - ----@class widgets.Widget.attrs.partial: widgets.Widget.attrs - ----@class widgets.Widget: gui.View ----@field super gui.View ----@field ATTRS widgets.Widget.attrs|fun(attributes: widgets.Widget.attrs.partial) ----@overload fun(init_table: widgets.Widget.attrs.partial): self -Widget = defclass(Widget, gui.View) - -Widget.ATTRS { - frame = DEFAULT_NIL, - frame_inset = DEFAULT_NIL, - frame_background = DEFAULT_NIL, -} - ----@param parent_rect { width: integer, height: integer } ----@return any -function Widget:computeFrame(parent_rect) - local sw, sh = parent_rect.width, parent_rect.height - return gui.compute_frame_body(sw, sh, self.frame, self.frame_inset) -end - ----@param dc gui.Painter ----@param rect { x1: integer, y1: integer, x2: integer, y2: integer } -function Widget:onRenderFrame(dc, rect) - if self.frame_background then - dc:fill(rect, self.frame_background) - end -end - -------------- --- Divider -- -------------- - ----@class widgets.Divider.attrs: widgets.Widget.attrs ----@field frame_style gui.Frame|fun(): gui.Frame ----@field interior boolean ----@field frame_style_t? false|gui.Frame|fun(): gui.Frame ----@field frame_style_b? false|gui.Frame|fun(): gui.Frame ----@field frame_style_l? false|gui.Frame|fun(): gui.Frame ----@field frame_style_r? false|gui.Frame|fun(): gui.Frame ----@field interior_t? boolean ----@field interior_b? boolean ----@field interior_l? boolean ----@field interior_r? boolean - ----@class widgets.Divider.attrs.partial: widgets.Divider.attrs - ----@class widgets.Divider: widgets.Widget, widgets.Divider.attrs ----@field super widgets.Widget ----@field ATTRS widgets.Divider.attrs|fun(attributes: widgets.Divider.attrs.partial) ----@overload fun(init_table: widgets.Divider.attrs.partial): self -Divider = defclass(Divider, Widget) - -Divider.ATTRS{ - frame_style=gui.FRAME_THIN, - interior=false, - frame_style_t=DEFAULT_NIL, - interior_t=DEFAULT_NIL, - frame_style_b=DEFAULT_NIL, - interior_b=DEFAULT_NIL, - frame_style_l=DEFAULT_NIL, - interior_l=DEFAULT_NIL, - frame_style_r=DEFAULT_NIL, - interior_r=DEFAULT_NIL, -} - -local function divider_get_val(self, base_name, edge_name) - local val = self[base_name..'_'..edge_name] - if val ~= nil then return val end - return self[base_name] -end - -local function divider_get_junction_pen(self, edge_name) - local interior = divider_get_val(self, 'interior', edge_name) - local pen_name = ('%sT%s_frame_pen'):format(edge_name, interior and 'i' or 'e') - local frame_style = divider_get_val(self, 'frame_style', edge_name) - if type(frame_style) == 'function' then - frame_style = frame_style() - end - return frame_style[pen_name] -end - ----@param dc gui.Painter -function Divider:onRenderBody(dc) - local rect, style = self.frame_rect, self.frame_style - if type(style) == 'function' then - style = style() - end - - if rect.height == 1 and rect.width == 1 then - dc:seek(0, 0):char(nil, style.x_frame_pen) - elseif rect.width == 1 then - local fill_start, fill_end = 0, rect.height-1 - if self.frame_style_t ~= false then - fill_start = 1 - dc:seek(0, 0):char(nil, divider_get_junction_pen(self, 't')) - end - if self.frame_style_b ~= false then - fill_end = rect.height-2 - dc:seek(0, rect.height-1):char(nil, divider_get_junction_pen(self, 'b')) - end - dc:fill(0, fill_start, 0, fill_end, style.v_frame_pen) - else - local fill_start, fill_end = 0, rect.width-1 - if self.frame_style_l ~= false then - fill_start = 1 - dc:seek(0, 0):char(nil, divider_get_junction_pen(self, 'l')) - end - if self.frame_style_r ~= false then - fill_end = rect.width-2 - dc:seek(rect.width-1, 0):char(nil, divider_get_junction_pen(self, 'r')) - end - dc:fill(fill_start, 0, fill_end, 0, style.h_frame_pen) - end -end - ------------ --- Panel -- ------------ - -DOUBLE_CLICK_MS = 500 - ----@class widgets.Panel.attrs: widgets.Widget.attrs ----@field frame_style? gui.Frame|fun(): gui.Frame ----@field frame_title? string ----@field on_render? fun(painter: gui.Painter) Called from `onRenderBody`. ----@field on_layout? fun(frame_body: any) Called from `postComputeFrame`. ----@field draggable boolean ----@field drag_anchors? { title: boolean, frame: boolean, body: boolean } ----@field drag_bound 'frame'|'body' ----@field on_drag_begin? fun() ----@field on_drag_end? fun(success: boolean, new_frame: gui.Frame) ----@field resizable boolean ----@field resize_anchors? { t: boolean, l: boolean, r: boolean, b: boolean } ----@field resize_min? { w: integer, h: integer } ----@field on_resize_begin? fun() ----@field on_resize_end? fun(success: boolean, new_frame: gui.Frame) ----@field autoarrange_subviews boolean ----@field autoarrange_gap integer ----@field kbd_get_pos? fun(): df.coord2d ----@field saved_frame? table ----@field saved_frame_rect? table ----@field drag_offset? table ----@field resize_edge? string ----@field last_title_click_ms number - ----@class widgets.Panel.attrs.partial: widgets.Panel.attrs - ----@class widgets.Panel.initTable: widgets.Panel.attrs.partial ----@field subviews? gui.View[] - ----@class widgets.Panel: widgets.Widget, widgets.Panel.attrs ----@field super widgets.Widget ----@field ATTRS widgets.Panel.attrs|fun(attributes: widgets.Panel.attrs.partial) ----@overload fun(init_table: widgets.Panel.initTable): self -Panel = defclass(Panel, Widget) - -Panel.ATTRS { - frame_style = DEFAULT_NIL, -- as in gui.FramedScreen - frame_title = DEFAULT_NIL, -- as in gui.FramedScreen - on_render = DEFAULT_NIL, - on_layout = DEFAULT_NIL, - draggable = false, - drag_anchors = DEFAULT_NIL, - drag_bound = 'frame', -- or 'body' - on_drag_begin = DEFAULT_NIL, - on_drag_end = DEFAULT_NIL, - resizable = false, - resize_anchors = DEFAULT_NIL, - resize_min = DEFAULT_NIL, - on_resize_begin = DEFAULT_NIL, - on_resize_end = DEFAULT_NIL, - no_force_pause_badge = false, - autoarrange_subviews = false, -- whether to automatically lay out subviews - autoarrange_gap = 0, -- how many blank lines to insert between widgets -} - ----@param self widgets.Panel ----@param args widgets.Panel.initTable -function Panel:init(args) - if not self.drag_anchors then - self.drag_anchors = {title=true, frame=not self.resizable, body=true} - end - if not self.resize_anchors then - self.resize_anchors = {t=false, l=true, r=true, b=true} - end - self.resize_min = self.resize_min or {} - self.resize_min.w = self.resize_min.w or (self.frame or {}).w or 5 - self.resize_min.h = self.resize_min.h or (self.frame or {}).h or 5 - - self.kbd_get_pos = nil -- fn when we are in keyboard dragging mode - self.saved_frame = nil -- copy of frame when dragging started - self.saved_frame_rect = nil -- copy of frame_rect when dragging started - self.drag_offset = nil -- relative pos of held panel tile - self.resize_edge = nil -- which dimension is being resized? - - self.last_title_click_ms = 0 -- used to track double-clicking on the title - self:addviews(args.subviews) -end - -local function Panel_update_frame(self, frame, clear_state) - if clear_state then - self.kbd_get_pos = nil - self.saved_frame = nil - self.saved_frame_rect = nil - self.drag_offset = nil - self.resize_edge = nil - end - if not frame then return end - if self.frame.l == frame.l and self.frame.r == frame.r - and self.frame.t == frame.t and self.frame.b == frame.b - and self.frame.w == frame.w and self.frame.h == frame.h then - return - end - self.frame = frame - self:updateLayout() -end - --- dim: the name of the dimension var (i.e. 'h' or 'w') --- anchor: the name of the anchor var (i.e. 't', 'b', 'l', or 'r') --- opposite_anchor: the name of the anchor var for the opposite edge --- max_dim: how big this panel can get from its current pos and fit in parent --- wanted_dim: how big the player is trying to make the panel --- max_anchor: max value of the frame anchor for the edge that is being resized --- wanted_anchor: how small the player is trying to make the anchor value -local function Panel_resize_edge_base(frame, resize_min, dim, anchor, - opposite_anchor, max_dim, wanted_dim, - max_anchor, wanted_anchor) - frame[dim] = math.max(resize_min[dim], math.min(max_dim, wanted_dim)) - if frame[anchor] or not frame[opposite_anchor] then - frame[anchor] = math.max(0, math.min(max_anchor, wanted_anchor)) - end -end - -local function Panel_resize_edge(frame, resize_min, dim, anchor, - opposite_anchor, dim_base, dim_ref, anchor_ref, - dim_far, mouse_ref) - local dim_sign = (anchor == 't' or anchor == 'l') and 1 or -1 - local max_dim = dim_base - dim_ref + 1 - local wanted_dim = dim_sign * (dim_far - mouse_ref) + 1 - local max_anchor = dim_base - resize_min[dim] - dim_ref + 1 - local wanted_anchor = dim_sign * (mouse_ref - anchor_ref) - Panel_resize_edge_base(frame, resize_min, dim, anchor, opposite_anchor, - max_dim, wanted_dim, max_anchor, wanted_anchor) -end - -local function Panel_resize_frame(self, mouse_pos) - local frame, resize_min = copyall(self.frame), self.resize_min - local parent_rect = self.frame_parent_rect - local ref_rect = self.saved_frame_rect - if self.resize_edge:find('t') then - Panel_resize_edge(frame, resize_min, 'h', 't', 'b', ref_rect.y2, - parent_rect.y1, parent_rect.y1, ref_rect.y2, mouse_pos.y) - end - if self.resize_edge:find('b') then - Panel_resize_edge(frame, resize_min, 'h', 'b', 't', parent_rect.y2, - ref_rect.y1, parent_rect.y2, ref_rect.y1, mouse_pos.y) - end - if self.resize_edge:find('l') then - Panel_resize_edge(frame, resize_min, 'w', 'l', 'r', ref_rect.x2, - parent_rect.x1, parent_rect.x1, ref_rect.x2, mouse_pos.x) - end - if self.resize_edge:find('r') then - Panel_resize_edge(frame, resize_min, 'w', 'r', 'l', parent_rect.x2, - ref_rect.x1, parent_rect.x2, ref_rect.x1, mouse_pos.x) - end - return frame -end - -local function Panel_drag_frame(self, mouse_pos) - local frame = copyall(self.frame) - local parent_rect = self.frame_parent_rect - local frame_rect = gui.mkdims_wh( - self.frame_rect.x1+parent_rect.x1, - self.frame_rect.y1+parent_rect.y1, - self.frame_rect.width, - self.frame_rect.height - ) - local bound_rect = self.drag_bound == 'body' and self.frame_body - or frame_rect - local offset = self.drag_offset - local max_width = parent_rect.width - (bound_rect.x2-frame_rect.x1+1) - local max_height = parent_rect.height - (bound_rect.y2-frame_rect.y1+1) - if frame.t or not frame.b then - local min_pos = frame_rect.y1 - bound_rect.y1 - local requested_pos = mouse_pos.y - parent_rect.y1 - offset.y - frame.t = math.max(min_pos, math.min(max_height, requested_pos)) - end - if frame.b or not frame.t then - local min_pos = bound_rect.y2 - frame_rect.y2 - local requested_pos = parent_rect.y2 - mouse_pos.y + offset.y - - (frame_rect.y2 - frame_rect.y1) - frame.b = math.max(min_pos, math.min(max_height, requested_pos)) - end - if frame.l or not frame.r then - local min_pos = frame_rect.x1 - bound_rect.x1 - local requested_pos = mouse_pos.x - parent_rect.x1 - offset.x - frame.l = math.max(min_pos, math.min(max_width, requested_pos)) - end - if frame.r or not frame.l then - local min_pos = bound_rect.x2 - frame_rect.x2 - local requested_pos = parent_rect.x2 - mouse_pos.x + offset.x - - (frame_rect.x2 - frame_rect.x1) - frame.r = math.max(min_pos, math.min(max_width, requested_pos)) - end - return frame -end - -local function Panel_make_frame(self, mouse_pos) - mouse_pos = mouse_pos or xy2pos(dfhack.screen.getMousePos()) - return self.resize_edge and Panel_resize_frame(self, mouse_pos) - or Panel_drag_frame(self, mouse_pos) -end - -local function Panel_begin_drag(self, drag_offset, resize_edge) - Panel_update_frame(self, nil, true) - self.drag_offset = drag_offset or {x=0, y=0} - self.resize_edge = resize_edge - self.saved_frame = copyall(self.frame) - self.saved_frame_rect = gui.mkdims_wh( - self.frame_rect.x1+self.frame_parent_rect.x1, - self.frame_rect.y1+self.frame_parent_rect.y1, - self.frame_rect.width, - self.frame_rect.height) - self.prev_focus_owner = self.focus_group.cur - self:setFocus(true) - if self.resize_edge then - self:onResizeBegin() - else - self:onDragBegin() - end -end - -local function Panel_end_drag(self, frame, success) - success = not not success - if self.prev_focus_owner then - self.prev_focus_owner:setFocus(true) - else - self:setFocus(false) - end - local resize_edge = self.resize_edge - Panel_update_frame(self, frame, true) - if resize_edge then - self:onResizeEnd(success, self.frame) - else - self:onDragEnd(success, self.frame) - end -end - -local function Panel_on_double_click(self) - local a = self.resize_anchors - local can_vert, can_horiz = a.t or a.b, a.l or a.r - if not can_vert and not can_horiz then return false end - local f, rmin = self.frame, self.resize_min - local maximized = f.t == 0 and f.b == 0 and f.l == 0 and f.r == 0 - local frame - if maximized then - frame = { - t=not can_vert and f.t or nil, - l=not can_horiz and f.l or nil, - b=not can_vert and f.b or nil, - r=not can_horiz and f.r or nil, - w=can_vert and rmin.w or f.w, - h=can_horiz and rmin.h or f.h, - } - else - frame = { - t=can_vert and 0 or f.t, - l=can_horiz and 0 or f.l, - b=can_vert and 0 or f.b, - r=can_horiz and 0 or f.r - } - end - Panel_update_frame(self, frame, true) -end - ----@alias widgets.Keys ----| '_STRING' ----| '_MOUSE_L' ----| '_MOUSE_L_DOWN' ----| '_MOUSE_R' ----| '_MOUSE_R_DOWN' ----| '_MOUSE_M' ----| '_MOUSE_M_DOWN' - ----@param keys table ----@return boolean|nil -function Panel:onInput(keys) - if self.kbd_get_pos then - if keys.SELECT or keys.LEAVESCREEN or keys._MOUSE_R then - Panel_end_drag(self, not keys.SELECT and self.saved_frame or nil, - not not keys.SELECT) - return true - end - for code in pairs(keys) do - local dx, dy = guidm.get_movement_delta(code, 1, 10) - if dx then - local kbd_pos = self.kbd_get_pos() - kbd_pos.x = kbd_pos.x + dx - kbd_pos.y = kbd_pos.y + dy - Panel_update_frame(self, Panel_make_frame(self, kbd_pos)) - return true - end - end - return - end - if self.drag_offset then - if keys._MOUSE_R then - Panel_end_drag(self, self.saved_frame) - elseif keys._MOUSE_L_DOWN then - Panel_update_frame(self, Panel_make_frame(self)) - end - return true - end - if Panel.super.onInput(self, keys) then - return true - end - if not keys._MOUSE_L then return end - local x,y = self:getMouseFramePos() - if not x then return end - - if self.resizable and y == 0 then - local now_ms = dfhack.getTickCount() - if now_ms - self.last_title_click_ms <= DOUBLE_CLICK_MS then - self.last_title_click_ms = 0 - if Panel_on_double_click(self) then return true end - else - self.last_title_click_ms = now_ms - end - end - - local resize_edge = nil - if self.resizable then - local rect = gui.mkdims_wh( - self.frame_rect.x1+self.frame_parent_rect.x1, - self.frame_rect.y1+self.frame_parent_rect.y1, - self.frame_rect.width, - self.frame_rect.height) - if self.resize_anchors.r and self.resize_anchors.b - and x == rect.x2 - rect.x1 and y == rect.y2 - rect.y1 then - resize_edge = 'rb' - elseif self.resize_anchors.l and self.resize_anchors.b - and x == 0 and y == rect.y2 - rect.y1 then - resize_edge = 'lb' - elseif self.resize_anchors.r and self.resize_anchors.t - and x == rect.x2 - rect.x1 and y == 0 then - resize_edge = 'rt' - elseif self.resize_anchors.r and self.resize_anchors.t - and x == 0 and y == 0 then - resize_edge = 'lt' - elseif self.resize_anchors.r and x == rect.x2 - rect.x1 then - resize_edge = 'r' - elseif self.resize_anchors.l and x == 0 then - resize_edge = 'l' - elseif self.resize_anchors.b and y == rect.y2 - rect.y1 then - resize_edge = 'b' - elseif self.resize_anchors.t and y == 0 then - resize_edge = 't' - end - end - - local is_dragging = false - if not resize_edge and self.draggable then - local on_body = self:getMousePos() - is_dragging = (self.drag_anchors.title and self.frame_style and y == 0) - or (self.drag_anchors.frame and not on_body) -- includes inset - or (self.drag_anchors.body and on_body) - end - - if resize_edge or is_dragging then - Panel_begin_drag(self, {x=x, y=y}, resize_edge) - return true - end -end - ----@param enabled boolean -function Panel:setKeyboardDragEnabled(enabled) - if (enabled and self.kbd_get_pos) - or (not enabled and not self.kbd_get_pos) then - return - end - if enabled then - local kbd_get_pos = function() - return { - x=self.frame_rect.x1+self.frame_parent_rect.x1, - y=self.frame_rect.y1+self.frame_parent_rect.y1 - } - end - Panel_begin_drag(self) - self.kbd_get_pos = kbd_get_pos - else - Panel_end_drag(self) - end -end - -local function Panel_get_resize_data(self) - local resize_anchors = self.resize_anchors - local frame_rect = gui.mkdims_wh( - self.frame_rect.x1+self.frame_parent_rect.x1, - self.frame_rect.y1+self.frame_parent_rect.y1, - self.frame_rect.width, - self.frame_rect.height) - if resize_anchors.r and resize_anchors.b then - return 'rb', function() - return {x=frame_rect.x2, y=frame_rect.y2} end - elseif resize_anchors.l and resize_anchors.b then - return 'lb', function() - return {x=frame_rect.x1, y=frame_rect.y2} end - elseif resize_anchors.r and resize_anchors.t then - return 'rt', function() - return {x=frame_rect.x2, y=frame_rect.y1} end - elseif resize_anchors.l and resize_anchors.t then - return 'lt', function() - return {x=frame_rect.x1, y=frame_rect.y1} end - elseif resize_anchors.b then - return 'b', function() - return {x=(frame_rect.x1+frame_rect.x2)//2, - y=frame_rect.y2} end - elseif resize_anchors.r then - return 'r', function() - return {x=frame_rect.x2, - y=(frame_rect.y1+frame_rect.y2)//2} end - elseif resize_anchors.l then - return 'l', function() - return {x=frame_rect.x1, - y=(frame_rect.y1+frame_rect.y2)//2} end - elseif resize_anchors.t then - return 't', function() - return {x=(frame_rect.x1+frame_rect.x2)//2, - y=frame_rect.y1} end - end -end - ----@param enabled boolean -function Panel:setKeyboardResizeEnabled(enabled) - if (enabled and self.kbd_get_pos) - or (not enabled and not self.kbd_get_pos) then - return - end - if enabled then - local resize_edge, kbd_get_pos = Panel_get_resize_data(self) - if not resize_edge then - dfhack.printerr('cannot resize window: no anchors are enabled') - else - Panel_begin_drag(self, kbd_get_pos(), resize_edge) - self.kbd_get_pos = kbd_get_pos - end - else - Panel_end_drag(self) - end -end - -function Panel:onRenderBody(dc) - if self.on_render then self.on_render(dc) end -end - -function Panel:computeFrame(parent_rect) - local sw, sh = parent_rect.width, parent_rect.height - if self.frame then - if self.frame.t and self.frame.h and self.frame.t + self.frame.h > sh then - self.frame.t = math.max(0, sh - self.frame.h) - end - if self.frame.b and self.frame.h and self.frame.b + self.frame.h > sh then - self.frame.b = math.max(0, sh - self.frame.h) - end - if self.frame.l and self.frame.w and self.frame.l + self.frame.w > sw then - self.frame.l = math.max(0, sw - self.frame.w) - end - if self.frame.r and self.frame.w and self.frame.r + self.frame.w > sw then - self.frame.r = math.max(0, sw - self.frame.w) - end - end - return gui.compute_frame_body(sw, sh, self.frame, self.frame_inset, - self.frame_style and 1 or 0) -end - -function Panel:postComputeFrame(body) - if self.on_layout then self.on_layout(body) end -end - --- if self.autoarrange_subviews is true, lay out visible subviews vertically, --- adding gaps between widgets according to self.autoarrange_gap. -function Panel:postUpdateLayout() - -- don't leave artifacts behind on the parent screen when we move - gui.Screen.request_full_screen_refresh = true - - if not self.autoarrange_subviews then return end - - local gap = self.autoarrange_gap - local y = 0 - for _,subview in ipairs(self.subviews) do - if not subview.frame then goto continue end - subview.frame.t = y - if getval(subview.visible) then - y = y + (subview.frame.h or 0) + gap - end - ::continue:: - end - - -- let widgets adjust to their new positions - self:updateSubviewLayout() -end - -function Panel:onRenderFrame(dc, rect) - Panel.super.onRenderFrame(self, dc, rect) - if not self.frame_style then return end - local inactive = self.parent_view and self.parent_view.hasFocus - and not self.parent_view:hasFocus() - local pause_forced = not self.no_force_pause_badge and safe_index(self.parent_view, 'force_pause') - gui.paint_frame(dc, rect, self.frame_style, self.frame_title, inactive, - pause_forced, self.resizable) - if self.kbd_get_pos then - local pos = self.kbd_get_pos() - local pen = to_pen{fg=COLOR_GREEN, bg=COLOR_BLACK} - dc:seek(pos.x, pos.y):pen(pen):char(string.char(0xDB)) - end - if self.drag_offset and not self.kbd_get_pos - and df.global.enabler.mouse_lbut_down == 0 then - Panel_end_drag(self, nil, true) - end -end - -function Panel:onDragBegin() - if self.on_drag_begin then self.on_drag_begin() end -end - -function Panel:onDragEnd(success, new_frame) - if self.on_drag_end then self.on_drag_end(success, new_frame) end -end - -function Panel:onResizeBegin() - if self.on_resize_begin then self.on_resize_begin() end -end - -function Panel:onResizeEnd(success, new_frame) - if self.on_resize_end then self.on_resize_end(success, new_frame) end -end - ------------- --- Window -- ------------- - ----@class widgets.Window.attrs: widgets.Panel.attrs ----@field frame_style gui.Frame|fun(): gui.Frame ----@field frame_background dfhack.color|dfhack.pen ----@field frame_inset integer - ----@class widgets.Window.attrs.partial: widgets.Window.attrs - ----@class widgets.Window: widgets.Panel, widgets.Window.attrs ----@field super widgets.Panel ----@field ATTRS widgets.Window.attrs|fun(attributes: widgets.Window.attrs.partial) ----@overload fun(init_table: widgets.Window.attrs.partial): self -Window = defclass(Window, Panel) - -Window.ATTRS { - frame_style = gui.WINDOW_FRAME, - frame_background = gui.CLEAR_PEN, - frame_inset = 1, - draggable = true, -} - -------------------- --- ResizingPanel -- -------------------- - ----@class widgets.ResizingPanel.attrs: widgets.Panel.attrs ----@field auto_height boolean ----@field auto_width boolean - ----@class widgets.ResizingPanel.attrs.partial: widgets.ResizingPanel.attrs - ----@class widgets.ResizingPanel: widgets.Panel, widgets.ResizingPanel.attrs ----@field super widgets.Panel ----@field ATTRS widgets.ResizingPanel.attrs|fun(attributes: widgets.ResizingPanel.attrs.partial) ----@overload fun(init_table: widgets.ResizingPanel.attrs.partial): self -ResizingPanel = defclass(ResizingPanel, Panel) - -ResizingPanel.ATTRS{ - auto_height = true, - auto_width = false, -} - --- adjust our frame dimensions according to positions and sizes of our subviews -function ResizingPanel:postUpdateLayout(frame_body) - local w, h = 0, 0 - for _,s in ipairs(self.subviews) do - if getval(s.visible) then - w = math.max(w, (s.frame and s.frame.l or 0) + - (s.frame and s.frame.w or frame_body.width)) - h = math.max(h, (s.frame and s.frame.t or 0) + - (s.frame and s.frame.h or frame_body.height)) - end - end - local l,t,r,b = gui.parse_inset(self.frame_inset) - w = w + l + r - h = h + t + b - if self.frame_style then - w = w + 2 - h = h + 2 - end - if not self.frame then self.frame = {} end - local oldw, oldh = self.frame.w, self.frame.h - if not self.auto_height then h = oldh end - if not self.auto_width then w = oldw end - self.frame.w, self.frame.h = w, h - if not self._updateLayoutGuard and (oldw ~= w or oldh ~= h) then - self._updateLayoutGuard = true -- protect against infinite loops - self:updateLayout() -- our frame has changed, we need to fully refresh - end - self._updateLayoutGuard = nil -end - ------------ --- Pages -- ------------ - ----@class widgets.Pages.initTable: widgets.Panel.attrs ----@field selected? integer|string - ----@class widgets.Pages: widgets.Panel ----@field super widgets.Panel ----@overload fun(attributes: widgets.Pages.initTable): self -Pages = defclass(Pages, Panel) - ----@param self widgets.Pages ----@param args widgets.Pages.initTable -function Pages:init(args) - for _,v in ipairs(self.subviews) do - v.visible = false - end - self:setSelected(args.selected or 1) -end - ----@param idx integer|string -function Pages:setSelected(idx) - if type(idx) ~= 'number' then - local key = idx - if type(idx) == 'string' then - key = self.subviews[key] - end - idx = utils.linear_index(self.subviews, key) - if not idx then - error('Unknown page: '..tostring(key)) - end - end - - show_view(self.subviews[self.selected], false) - self.selected = math.min(math.max(1, idx), #self.subviews) - show_view(self.subviews[self.selected], true) -end - ----@return integer index ----@return gui.View child -function Pages:getSelected() - return self.selected, self.subviews[self.selected] -end - ----@return gui.View child -function Pages:getSelectedPage() - return self.subviews[self.selected] -end - ----------------- --- Edit field -- ----------------- - ----@class widgets.EditField.attrs: widgets.Widget.attrs ----@field label_text? string ----@field text string ----@field text_pen? dfhack.color|dfhack.pen ----@field on_char? function ----@field on_change? function ----@field on_submit? function ----@field on_submit2? function ----@field key? string ----@field key_sep? string ----@field modal boolean ----@field ignore_keys? string[] - ----@class widgets.EditField.attrs.partial: widgets.EditField.attrs - ----@class widgets.EditField: widgets.Widget, widgets.EditField.attrs ----@field super widgets.Widget ----@field ATTRS widgets.EditField.attrs|fun(attributes: widgets.EditField.attrs.partial) ----@overload fun(init_table: widgets.EditField.attrs.partial): self -EditField = defclass(EditField, Widget) - -EditField.ATTRS{ - label_text = DEFAULT_NIL, - text = '', - text_pen = DEFAULT_NIL, - on_char = DEFAULT_NIL, - on_change = DEFAULT_NIL, - on_submit = DEFAULT_NIL, - on_submit2 = DEFAULT_NIL, - key = DEFAULT_NIL, - key_sep = DEFAULT_NIL, - modal = false, - ignore_keys = DEFAULT_NIL, -} - ----@param init_table widgets.EditField.attrs -function EditField:preinit(init_table) - init_table.frame = init_table.frame or {} - init_table.frame.h = init_table.frame.h or 1 -end - -function EditField:init() - local function on_activate() - self.saved_text = self.text - self:setFocus(true) - end - - self.start_pos = 1 - self.cursor = #self.text + 1 - - self:addviews{HotkeyLabel{frame={t=0,l=0}, - key=self.key, - key_sep=self.key_sep, - label=self.label_text, - on_activate=self.key and on_activate or nil}} -end - -function EditField:getPreferredFocusState() - return not self.key -end - -function EditField:setCursor(cursor) - if not cursor or cursor > #self.text then - self.cursor = #self.text + 1 - return - end - self.cursor = math.max(1, cursor) -end - -function EditField:setText(text, cursor) - local old = self.text - self.text = text - self:setCursor(cursor) - if self.on_change and text ~= old then - self.on_change(self.text, old) - end -end - -function EditField:postUpdateLayout() - self.text_offset = self.subviews[1]:getTextWidth() -end - ----@param dc gui.Painter -function EditField:onRenderBody(dc) - dc:pen(self.text_pen or COLOR_LIGHTCYAN) - - local cursor_char = '_' - if not getval(self.active) or not self.focus or gui.blink_visible(300) then - cursor_char = (self.cursor > #self.text) and ' ' or - self.text:sub(self.cursor, self.cursor) - end - local txt = self.text:sub(1, self.cursor - 1) .. cursor_char .. - self.text:sub(self.cursor + 1) - local max_width = dc.width - self.text_offset - self.start_pos = 1 - if #txt > max_width then - -- get the substring in the vicinity of the cursor - max_width = max_width - 2 - local half_width = math.floor(max_width/2) - local start_pos = math.max(1, self.cursor-half_width) - local end_pos = math.min(#txt, self.cursor+half_width-1) - if self.cursor + half_width > #txt then - start_pos = #txt - (max_width - 1) - end - if self.cursor - half_width <= 1 then - end_pos = max_width + 1 - end - self.start_pos = start_pos > 1 and start_pos - 1 or start_pos - txt = ('%s%s%s'):format(start_pos == 1 and '' or string.char(27), - txt:sub(start_pos, end_pos), - end_pos == #txt and '' or string.char(26)) - end - dc:advance(self.text_offset):string(txt) -end - -function EditField:insert(text) - local old = self.text - self:setText(old:sub(1,self.cursor-1)..text..old:sub(self.cursor), - self.cursor + #text) -end - -function EditField:onInput(keys) - if not self.focus then - -- only react to our hotkey - return self:inputToSubviews(keys) - end - - if self.ignore_keys then - for _,ignore_key in ipairs(self.ignore_keys) do - if keys[ignore_key] then return false end - end - end - - if self.key and (keys.LEAVESCREEN or keys._MOUSE_R) then - self:setText(self.saved_text) - self:setFocus(false) - return true - end - - if keys.SELECT or keys.SELECT_ALL then - if self.key then - self:setFocus(false) - end - if keys.SELECT_ALL then - if self.on_submit2 then - self.on_submit2(self.text) - return true - end - else - if self.on_submit then - self.on_submit(self.text) - return true - end - end - return not not self.key - elseif keys.CUSTOM_DELETE then - local old = self.text - local del_pos = self.cursor - if del_pos <= #old then - self:setText(old:sub(1, del_pos-1) .. old:sub(del_pos+1), del_pos) - end - return true - elseif keys._STRING then - local old = self.text - if keys._STRING == 0 then - -- handle backspace - local del_pos = self.cursor - 1 - if del_pos > 0 then - self:setText(old:sub(1, del_pos-1) .. old:sub(del_pos+1), del_pos) - end - else - local cv = string.char(keys._STRING) - if not self.on_char or self.on_char(cv, old) then - self:insert(cv) - elseif self.on_char then - return self.modal - end - end - return true - elseif keys.KEYBOARD_CURSOR_LEFT then - self:setCursor(self.cursor - 1) - return true - elseif keys.CUSTOM_CTRL_LEFT then -- back one word - local _, prev_word_end = self.text:sub(1, self.cursor-1): - find('.*[%w_%-][^%w_%-]') - self:setCursor(prev_word_end or 1) - return true - elseif keys.CUSTOM_HOME then - self:setCursor(1) - return true - elseif keys.KEYBOARD_CURSOR_RIGHT then - self:setCursor(self.cursor + 1) - return true - elseif keys.CUSTOM_CTRL_RIGHT then -- forward one word - local _,next_word_start = self.text:find('[^%w_%-][%w_%-]', self.cursor) - self:setCursor(next_word_start) - return true - elseif keys.CUSTOM_END then - self:setCursor() - return true - elseif keys.CUSTOM_CTRL_C then - dfhack.internal.setClipboardTextCp437(self.text) - return true - elseif keys.CUSTOM_CTRL_X then - dfhack.internal.setClipboardTextCp437(self.text) - self:setText('') - return true - elseif keys.CUSTOM_CTRL_V then - self:insert(dfhack.internal.getClipboardTextCp437()) - return true - elseif keys._MOUSE_L_DOWN then - local mouse_x = self:getMousePos() - if mouse_x then - self:setCursor(self.start_pos + mouse_x - (self.text_offset or 0)) - return true - end - end - - -- if we're modal, then unconditionally eat all the input - return self.modal -end - ---------------- --- Scrollbar -- ---------------- - -SCROLL_INITIAL_DELAY_MS = 300 -SCROLL_DELAY_MS = 20 - ----@class widgets.Scrollbar.attrs: widgets.Widget.attrs ----@field on_scroll? fun(new_top_elem?: integer) - ----@class widgets.Scrollbar.attrs.partial: widgets.Scrollbar.attrs - ----@class widgets.Scrollbar: widgets.Widget, widgets.Scrollbar.attrs ----@field super widgets.Widget ----@field ATTRS widgets.Scrollbar.attrs|fun(attributes: widgets.Scrollbar.attrs.partial) ----@overload fun(init_table: widgets.Scrollbar.attrs.partial): self -Scrollbar = defclass(Scrollbar, Widget) - -Scrollbar.ATTRS{ - on_scroll = DEFAULT_NIL, -} - ----@param init_table widgets.Scrollbar.attrs.partial -function Scrollbar:preinit(init_table) - init_table.frame = init_table.frame or {} - init_table.frame.w = init_table.frame.w or 2 -end - -function Scrollbar:init() - self.last_scroll_ms = 0 - self.is_first_click = false - self.scroll_spec = nil - self.is_dragging = false -- index of the scrollbar tile that we're dragging - self:update(1, 1, 1) -end - -local function scrollbar_get_max_pos_and_height(scrollbar) - local frame_body = scrollbar.frame_body - local scrollbar_body_height = (frame_body and frame_body.height or 3) - 2 - - local height = math.max(2, math.floor( - (math.min(scrollbar.elems_per_page, scrollbar.num_elems) * scrollbar_body_height) / - scrollbar.num_elems)) - - return scrollbar_body_height - height, height -end - --- calculate and cache the number of tiles of empty space above the top of the --- scrollbar and the number of tiles the scrollbar should occupy to represent --- the percentage of text that is on the screen. --- if elems_per_page or num_elems are not specified, the last values passed to --- Scrollbar:update() are used. -function Scrollbar:update(top_elem, elems_per_page, num_elems) - if not top_elem then error('must specify index of new top element') end - elems_per_page = elems_per_page or self.elems_per_page - num_elems = num_elems or self.num_elems - self.top_elem = top_elem - self.elems_per_page, self.num_elems = elems_per_page, num_elems - - local max_pos, height = scrollbar_get_max_pos_and_height(self) - local pos = (num_elems == elems_per_page) and 0 or - math.ceil(((top_elem-1) * max_pos) / - (num_elems - elems_per_page)) - - self.bar_offset, self.bar_height = pos, height -end - -local function scrollbar_do_drag(scrollbar) - local x,y = dfhack.screen.getMousePos() - if not y then return end - x,y = scrollbar.frame_body:localXY(x, y) - local cur_pos = y - scrollbar.is_dragging - local max_top = scrollbar.num_elems - scrollbar.elems_per_page + 1 - local max_pos = scrollbar_get_max_pos_and_height(scrollbar) - local new_top_elem = math.floor(cur_pos * max_top / max_pos) + 1 - new_top_elem = math.max(1, math.min(new_top_elem, max_top)) - if new_top_elem ~= scrollbar.top_elem then - scrollbar.on_scroll(new_top_elem) - end -end - -local function scrollbar_is_visible(scrollbar) - return scrollbar.elems_per_page < scrollbar.num_elems -end - -local SBSO = df.global.init.scrollbar_texpos[0] --Scroll Bar Spritesheet Offset / change this to point to a different spritesheet (ui themes, anyone? :p) -local SCROLLBAR_UP_LEFT_PEN = to_pen{tile=SBSO+0, ch=47, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_UP_RIGHT_PEN = to_pen{tile=SBSO+1, ch=92, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_DOWN_LEFT_PEN = to_pen{tile=SBSO+24, ch=92, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_DOWN_RIGHT_PEN = to_pen{tile=SBSO+25, ch=47, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_UP_LEFT_PEN = to_pen{tile=SBSO+6, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_UP_RIGHT_PEN = to_pen{tile=SBSO+7, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_LEFT_PEN = to_pen{tile=SBSO+30, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_RIGHT_PEN = to_pen{tile=SBSO+31, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_UP_LEFT_PEN = to_pen{tile=SBSO+10, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_UP_RIGHT_PEN = to_pen{tile=SBSO+11, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_DOWN_LEFT_PEN = to_pen{tile=SBSO+22, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_DOWN_RIGHT_PEN = to_pen{tile=SBSO+23, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_LEFT_PEN = to_pen{tile=SBSO+18, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_RIGHT_PEN = to_pen{tile=SBSO+19, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_DOWN_LEFT_PEN = to_pen{tile=SBSO+42, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_DOWN_RIGHT_PEN = to_pen{tile=SBSO+43, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_2TALL_UP_LEFT_PEN = to_pen{tile=SBSO+26, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_2TALL_UP_RIGHT_PEN = to_pen{tile=SBSO+27, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_2TALL_DOWN_LEFT_PEN = to_pen{tile=SBSO+38, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_2TALL_DOWN_RIGHT_PEN = to_pen{tile=SBSO+39, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} -local SCROLLBAR_UP_LEFT_HOVER_PEN = to_pen{tile=SBSO+2, ch=47, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_UP_RIGHT_HOVER_PEN = to_pen{tile=SBSO+3, ch=92, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_DOWN_LEFT_HOVER_PEN = to_pen{tile=SBSO+14, ch=92, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_DOWN_RIGHT_HOVER_PEN = to_pen{tile=SBSO+15, ch=47, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_UP_LEFT_HOVER_PEN = to_pen{tile=SBSO+8, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_UP_RIGHT_HOVER_PEN = to_pen{tile=SBSO+9, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_LEFT_HOVER_PEN = to_pen{tile=SBSO+32, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_RIGHT_HOVER_PEN = to_pen{tile=SBSO+33, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_UP_LEFT_HOVER_PEN = to_pen{tile=SBSO+34, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_UP_RIGHT_HOVER_PEN = to_pen{tile=SBSO+35, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_DOWN_LEFT_HOVER_PEN = to_pen{tile=SBSO+46, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_DOWN_RIGHT_HOVER_PEN = to_pen{tile=SBSO+47, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_LEFT_HOVER_PEN = to_pen{tile=SBSO+20, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_CENTER_RIGHT_HOVER_PEN = to_pen{tile=SBSO+21, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_DOWN_LEFT_HOVER_PEN = to_pen{tile=SBSO+44, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_DOWN_RIGHT_HOVER_PEN = to_pen{tile=SBSO+45, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_2TALL_UP_LEFT_HOVER_PEN = to_pen{tile=SBSO+28, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_2TALL_UP_RIGHT_HOVER_PEN = to_pen{tile=SBSO+29, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_2TALL_DOWN_LEFT_HOVER_PEN = to_pen{tile=SBSO+40, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_2TALL_DOWN_RIGHT_HOVER_PEN = to_pen{tile=SBSO+41, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} -local SCROLLBAR_BAR_BG_LEFT_PEN = to_pen{tile=SBSO+12, ch=176, fg=COLOR_DARKGREY, bg=COLOR_BLACK} -local SCROLLBAR_BAR_BG_RIGHT_PEN = to_pen{tile=SBSO+13, ch=176, fg=COLOR_DARKGREY, bg=COLOR_BLACK} - -function Scrollbar:onRenderBody(dc) - -- don't draw if all elements are visible - if not scrollbar_is_visible(self) then - return - end - -- determine which elements should be highlighted - local _,hover_y = self:getMousePos() - local hover_up, hover_down, hover_bar = false, false, false - if hover_y == 0 then - hover_up = true - elseif hover_y == dc.height-1 then - hover_down = true - elseif hover_y then - hover_bar = true - end - -- render up arrow - dc:seek(0, 0) - dc:char(nil, hover_up and SCROLLBAR_UP_LEFT_HOVER_PEN or SCROLLBAR_UP_LEFT_PEN) - dc:char(nil, hover_up and SCROLLBAR_UP_RIGHT_HOVER_PEN or SCROLLBAR_UP_RIGHT_PEN) - -- render scrollbar body - local starty = self.bar_offset + 1 - local endy = self.bar_offset + self.bar_height - local midy = (starty + endy)/2 - for y=1,dc.height-2 do - dc:seek(0, y) - if y >= starty and y <= endy then - if y == starty and y <= midy - 1 then - dc:char(nil, hover_bar and SCROLLBAR_BAR_UP_LEFT_HOVER_PEN or SCROLLBAR_BAR_UP_LEFT_PEN) - dc:char(nil, hover_bar and SCROLLBAR_BAR_UP_RIGHT_HOVER_PEN or SCROLLBAR_BAR_UP_RIGHT_PEN) - elseif y == midy - 0.5 and self.bar_height == 2 then - dc:char(nil, hover_bar and SCROLLBAR_BAR_2TALL_UP_LEFT_HOVER_PEN or SCROLLBAR_BAR_2TALL_UP_LEFT_PEN) - dc:char(nil, hover_bar and SCROLLBAR_BAR_2TALL_UP_RIGHT_HOVER_PEN or SCROLLBAR_BAR_2TALL_UP_RIGHT_PEN) - elseif y == midy + 0.5 and self.bar_height == 2 then - dc:char(nil, hover_bar and SCROLLBAR_BAR_2TALL_DOWN_LEFT_HOVER_PEN or SCROLLBAR_BAR_2TALL_DOWN_LEFT_PEN) - dc:char(nil, hover_bar and SCROLLBAR_BAR_2TALL_DOWN_RIGHT_HOVER_PEN or SCROLLBAR_BAR_2TALL_DOWN_RIGHT_PEN) - elseif y == midy - 0.5 then - dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_UP_LEFT_HOVER_PEN or SCROLLBAR_BAR_CENTER_UP_LEFT_PEN) - dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_UP_RIGHT_HOVER_PEN or SCROLLBAR_BAR_CENTER_UP_RIGHT_PEN) - elseif y == midy + 0.5 then - dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_DOWN_LEFT_HOVER_PEN or SCROLLBAR_BAR_CENTER_DOWN_LEFT_PEN) - dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_DOWN_RIGHT_HOVER_PEN or SCROLLBAR_BAR_CENTER_DOWN_RIGHT_PEN) - elseif y == midy then - dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_LEFT_HOVER_PEN or SCROLLBAR_BAR_CENTER_LEFT_PEN) - dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_RIGHT_HOVER_PEN or SCROLLBAR_BAR_CENTER_RIGHT_PEN) - elseif y == endy and y >= midy + 1 then - dc:char(nil, hover_bar and SCROLLBAR_BAR_DOWN_LEFT_HOVER_PEN or SCROLLBAR_BAR_DOWN_LEFT_PEN) - dc:char(nil, hover_bar and SCROLLBAR_BAR_DOWN_RIGHT_HOVER_PEN or SCROLLBAR_BAR_DOWN_RIGHT_PEN) - else - dc:char(nil, hover_bar and SCROLLBAR_BAR_LEFT_HOVER_PEN or SCROLLBAR_BAR_LEFT_PEN) - dc:char(nil, hover_bar and SCROLLBAR_BAR_RIGHT_HOVER_PEN or SCROLLBAR_BAR_RIGHT_PEN) - end - else - dc:char(nil, SCROLLBAR_BAR_BG_LEFT_PEN) - dc:char(nil, SCROLLBAR_BAR_BG_RIGHT_PEN) - end - end - -- render down arrow - dc:seek(0, dc.height-1) - dc:char(nil, hover_down and SCROLLBAR_DOWN_LEFT_HOVER_PEN or SCROLLBAR_DOWN_LEFT_PEN) - dc:char(nil, hover_down and SCROLLBAR_DOWN_RIGHT_HOVER_PEN or SCROLLBAR_DOWN_RIGHT_PEN) - if not self.on_scroll then return end - -- manage state for dragging and continuous scrolling - if self.is_dragging then - scrollbar_do_drag(self) - end - if df.global.enabler.mouse_lbut_down == 0 then - self.last_scroll_ms = 0 - self.is_dragging = false - self.scroll_spec = nil - return - end - if self.last_scroll_ms == 0 then return end - local now = dfhack.getTickCount() - local delay = self.is_first_click and - SCROLL_INITIAL_DELAY_MS or SCROLL_DELAY_MS - if now - self.last_scroll_ms >= delay then - self.is_first_click = false - self.on_scroll(self.scroll_spec) - self.last_scroll_ms = now - end -end - -function Scrollbar:onInput(keys) - if not self.on_scroll or not scrollbar_is_visible(self) then - return false - end - - if self.parent_view and self.parent_view:getMousePos() then - if keys.CONTEXT_SCROLL_UP then - self.on_scroll('up_small') - return true - elseif keys.CONTEXT_SCROLL_DOWN then - self.on_scroll('down_small') - return true - elseif keys.CONTEXT_SCROLL_PAGEUP then - self.on_scroll('up_large') - return true - elseif keys.CONTEXT_SCROLL_PAGEDOWN then - self.on_scroll('down_large') - return true - end - end - if not keys._MOUSE_L then return false end - local _,y = self:getMousePos() - if not y then return false end - local scroll_spec = nil - if y == 0 then scroll_spec = 'up_small' - elseif y == self.frame_body.height - 1 then scroll_spec = 'down_small' - elseif y <= self.bar_offset then scroll_spec = 'up_large' - elseif y > self.bar_offset + self.bar_height then scroll_spec = 'down_large' - else - self.is_dragging = y - self.bar_offset - return true - end - self.scroll_spec = scroll_spec - self.on_scroll(scroll_spec) - -- reset continuous scroll state - self.is_first_click = true - self.last_scroll_ms = dfhack.getTickCount() - return true -end - ------------ --- Label -- ------------ - -function parse_label_text(obj) - local text = obj.text or {} - if type(text) ~= 'table' then - text = { text } - end - local curline = nil - local out = { } - local active = nil - local idtab = nil - for _,v in ipairs(text) do - local vv - if type(v) == 'string' then - vv = v:split(NEWLINE) - else - vv = { v } - end - - for i = 1,#vv do - local cv = vv[i] - if i > 1 then - if not curline then - table.insert(out, {}) - end - curline = nil - end - if cv ~= '' then - if not curline then - curline = {} - table.insert(out, curline) - end - - if type(cv) == 'string' then - table.insert(curline, { text = cv }) - else - table.insert(curline, cv) - - if cv.on_activate then - active = active or {} - table.insert(active, cv) - end - - if cv.id then - idtab = idtab or {} - idtab[cv.id] = cv - end - end - end - end - end - obj.text_lines = out - obj.text_active = active - obj.text_ids = idtab -end - -local function is_disabled(token) - return (token.disabled ~= nil and getval(token.disabled)) or - (token.enabled ~= nil and not getval(token.enabled)) -end - --- Make the hover pen -- that is a pen that should render elements that has the --- mouse hovering over it. if hpen is specified, it just checks the fields and --- returns it (in parsed pen form) -local function make_hpen(pen, hpen) - if not hpen then - pen = to_pen(pen) - - -- Swap the foreground and background - hpen = dfhack.pen.make(pen.bg, nil, pen.fg + (pen.bold and 8 or 0)) - end - - -- text_hpen needs a character in order to paint the background using - -- Painter:fill(), so let's make it paint a space to show the background - -- color - local hpen_parsed = to_pen(hpen) - hpen_parsed.ch = string.byte(' ') - return hpen_parsed -end - ----@param obj any ----@param dc gui.Painter ----@param x0 integer ----@param y0 integer ----@param pen dfhack.pen|dfhack.color|fun(): dfhack.pen|dfhack.color ----@param dpen dfhack.pen|dfhack.color|fun(): dfhack.pen|dfhack.color ----@param disabled boolean ----@param hpen dfhack.pen|dfhack.color|fun(): dfhack.pen|dfhack.color ----@param hovered boolean -function render_text(obj,dc,x0,y0,pen,dpen,disabled,hpen,hovered) - pen, dpen, hpen = getval(pen), getval(dpen), getval(hpen) - local width = 0 - for iline = dc and obj.start_line_num or 1, #obj.text_lines do - local x, line = 0, obj.text_lines[iline] - if dc then - local offset = (obj.start_line_num or 1) - 1 - local y = y0 + iline - offset - 1 - -- skip text outside of the containing frame - if y > dc.height - 1 then break end - dc:seek(x+x0, y) - end - for _,token in ipairs(line) do - token.line = iline - token.x1 = x - - if token.gap then - x = x + token.gap - if dc then - dc:advance(token.gap) - end - end - - if token.tile or (hovered and token.htile) then - x = x + 1 - if dc then - local tile = hovered and getval(token.htile or token.tile) or getval(token.tile) - local tile_pen = tonumber(tile) and to_pen{tile=tile} or tile - dc:char(nil, tile_pen) - if token.width then - dc:advance(token.width-1) - end - end - end - - if token.text or token.key then - local text = ''..(getval(token.text) or '') - local keypen = to_pen(token.key_pen or COLOR_LIGHTGREEN) - - if dc then - local tpen = getval(token.pen) - local dcpen = to_pen(tpen or pen) - - -- If disabled, figure out which dpen to use - if disabled or is_disabled(token) then - dcpen = to_pen(getval(token.dpen) or tpen or dpen) - if keypen.fg ~= COLOR_BLACK then - keypen.bold = false - end - - -- if hovered *and* disabled, combine both effects - if hovered then - dcpen = make_hpen(dcpen) - end - elseif hovered then - dcpen = make_hpen(dcpen, getval(token.hpen) or hpen) - end - - dc:pen(dcpen) - end - local width = getval(token.width) - local padstr - if width then - x = x + width - if #text > width then - text = string.sub(text,1,width) - else - if token.pad_char then - padstr = string.rep(token.pad_char,width-#text) - end - if dc and token.rjustify then - if padstr then dc:string(padstr) else dc:advance(width-#text) end - end - end - else - x = x + #text - end - - if token.key then - if type(token.key) == 'string' and not df.interface_key[token.key] then - error('Invalid interface_key: ' .. token.key) - end - local keystr = gui.getKeyDisplay(token.key) - local sep = token.key_sep or '' - - x = x + #keystr - - if sep:startswith('()') then - if dc then - dc:string(text) - dc:string(' ('):string(keystr,keypen) - dc:string(sep:sub(2)) - end - x = x + 1 + #sep - else - if dc then - dc:string(keystr,keypen):string(sep):string(text) - end - x = x + #sep - end - else - if dc then - dc:string(text) - end - end - - if width and dc and not token.rjustify then - if padstr then dc:string(padstr) else dc:advance(width-#text) end - end - end - - token.x2 = x - end - width = math.max(width, x) - end - obj.text_width = width -end - -function check_text_keys(self, keys) - if self.text_active then - for _,item in ipairs(self.text_active) do - if item.key and keys[item.key] and not is_disabled(item) then - item.on_activate() - return true - end - end - end -end - ----@class widgets.LabelToken ----@field text string|fun(): string ----@field gap? integer ----@field tile? integer|dfhack.pen ----@field htile? integer|dfhack.pen ----@field width? integer|fun(): integer ----@field pad_char? string ----@field key? string ----@field key_sep? string ----@field disabled? boolean|fun(): boolean ----@field enabled? boolean|fun(): boolean ----@field pen? dfhack.color|dfhack.pen ----@field dpen? dfhack.color|dfhack.pen ----@field hpen? dfhack.color|dfhack.pen ----@field on_activiate? fun() ----@field id? string ----@field line? any Internal use only ----@field x1? any Internal use only ----@field x2? any Internal use only - ----@class widgets.Label.attrs: widgets.Widget.attrs ----@field text? string|widgets.LabelToken[] ----@field text_pen dfhack.color|dfhack.pen ----@field text_dpen dfhack.color|dfhack.pen ----@field text_hpen? dfhack.color|dfhack.pen ----@field disabled? boolean|fun(): boolean ----@field enabled? boolean|fun(): boolean ----@field auto_height boolean ----@field auto_width boolean ----@field on_click? function ----@field on_rclick? function ----@field scroll_keys table - ----@class widgets.Label.attrs.partial: widgets.Label.attrs - ----@class widgets.Label: widgets.Widget, widgets.Label.attrs ----@field super widgets.Widget ----@field ATTRS widgets.Label.attrs|fun(attributes: widgets.Label.attrs.partial) ----@overload fun(init_table: widgets.Label.attrs.partial): self -Label = defclass(Label, Widget) - -Label.ATTRS{ - text_pen = COLOR_WHITE, - text_dpen = COLOR_DARKGREY, -- disabled - text_hpen = DEFAULT_NIL, -- hover - default is to invert the fg/bg colors - disabled = DEFAULT_NIL, - enabled = DEFAULT_NIL, - auto_height = true, - auto_width = false, - on_click = DEFAULT_NIL, - on_rclick = DEFAULT_NIL, - scroll_keys = STANDARDSCROLL, -} - ----@param args widgets.Label.attrs.partial -function Label:init(args) - self.scrollbar = Scrollbar{ - frame={r=0}, - on_scroll=self:callback('on_scrollbar')} - - self:addviews{self.scrollbar} - - self:setText(args.text or self.text) -end - -local function update_label_scrollbar(label) - local body_height = label.frame_body and label.frame_body.height or 1 - label.scrollbar:update(label.start_line_num, body_height, - label:getTextHeight()) -end - -function Label:setText(text) - self.start_line_num = 1 - self.text = text - parse_label_text(self) - - if self.auto_height then - self.frame = self.frame or {} - self.frame.h = self:getTextHeight() - end - - update_label_scrollbar(self) -end - -function Label:preUpdateLayout() - if self.auto_width then - self.frame = self.frame or {} - self.frame.w = self:getTextWidth() - end -end - -function Label:postUpdateLayout() - update_label_scrollbar(self) -end - -function Label:itemById(id) - if self.text_ids then - return self.text_ids[id] - end -end - -function Label:getTextHeight() - return #self.text_lines -end - -function Label:getTextWidth() - render_text(self) - return self.text_width -end - --- Overridden by subclasses that also want to add new mouse handlers, see --- HotkeyLabel. -function Label:shouldHover() - return self.on_click or self.on_rclick -end - -function Label:onRenderBody(dc) - local text_pen = self.text_pen - local hovered = self:getMousePos() and self:shouldHover() - render_text(self,dc,0,0,text_pen,self.text_dpen,is_disabled(self), self.text_hpen, hovered) -end - -function Label:on_scrollbar(scroll_spec) - local v = 0 - if tonumber(scroll_spec) then - v = scroll_spec - self.start_line_num - elseif scroll_spec == 'down_large' then - v = '+halfpage' - elseif scroll_spec == 'up_large' then - v = '-halfpage' - elseif scroll_spec == 'down_small' then - v = 1 - elseif scroll_spec == 'up_small' then - v = -1 - end - - self:scroll(v) -end - -function Label:scroll(nlines) - if not nlines then return end - local text_height = math.max(1, self:getTextHeight()) - if type(nlines) == 'string' then - if nlines == '+page' then - nlines = self.frame_body.height - elseif nlines == '-page' then - nlines = -self.frame_body.height - elseif nlines == '+halfpage' then - nlines = math.ceil(self.frame_body.height/2) - elseif nlines == '-halfpage' then - nlines = -math.ceil(self.frame_body.height/2) - elseif nlines == 'home' then - nlines = 1 - self.start_line_num - elseif nlines == 'end' then - nlines = text_height - else - error(('unhandled scroll keyword: "%s"'):format(nlines)) - end - end - local n = self.start_line_num + nlines - n = math.min(n, text_height - self.frame_body.height + 1) - n = math.max(n, 1) - nlines = n - self.start_line_num - self.start_line_num = n - update_label_scrollbar(self) - return nlines -end - -function Label:onInput(keys) - if is_disabled(self) then return false end - if self:inputToSubviews(keys) then - return true - end - if keys._MOUSE_L and self:getMousePos() and self.on_click then - self.on_click() - return true - end - if keys._MOUSE_R and self:getMousePos() and self.on_rclick then - self.on_rclick() - return true - end - for k,v in pairs(self.scroll_keys) do - if keys[k] and 0 ~= self:scroll(v) then - return true - end - end - return check_text_keys(self, keys) -end - --------------------------------- --- makeButtonLabelText --- - -local function get_button_token_hover_ch(spec, x, y, ch) - local ch_hover = ch - if spec.chars_hover then - local row = spec.chars_hover[y] - if type(row) == 'string' then - ch_hover = row:sub(x, x) - else - ch_hover = row[x] - end - end - return ch_hover -end - -local function get_button_token_base_pens(spec, x, y) - local pen, pen_hover = COLOR_GRAY, COLOR_WHITE - if spec.pens then - pen = type(spec.pens) == 'table' and (safe_index(spec.pens, y, x) or spec.pens[y]) or spec.pens - if spec.pens_hover then - pen_hover = type(spec.pens_hover) == 'table' and (safe_index(spec.pens_hover, y, x) or spec.pens_hover[y]) or spec.pens_hover - else - pen_hover = pen - end - end - return pen, pen_hover -end - -local function get_button_tileset_idx(spec, x, y, tileset_offset, tileset_stride) - local idx = (tileset_offset or 1) - idx = idx + (x - 1) - idx = idx + (y - 1) * (tileset_stride or #spec.chars[1]) - return idx -end - -local function get_asset_tile(asset, x, y) - return dfhack.screen.findGraphicsTile(asset.page, asset.x+x-1, asset.y+y-1) -end - -local function get_button_token_tiles(spec, x, y) - local tile = safe_index(spec.tiles_override, y, x) - local tile_hover = safe_index(spec.tiles_hover_override, y, x) or tile - if not tile and spec.tileset then - local tileset = spec.tileset - local idx = get_button_tileset_idx(spec, x, y, spec.tileset_offset, spec.tileset_stride) - tile = dfhack.textures.getTexposByHandle(tileset[idx]) - if spec.tileset_hover then - local tileset_hover = spec.tileset_hover - local idx_hover = get_button_tileset_idx(spec, x, y, - spec.tileset_hover_offset or spec.tileset_offset, - spec.tileset_hover_stride or spec.tileset_stride) - tile_hover = dfhack.textures.getTexposByHandle(tileset_hover[idx_hover]) - else - tile_hover = tile - end - end - if not tile and spec.asset then - tile = get_asset_tile(spec.asset, x, y) - if spec.asset_hover then - tile_hover = get_asset_tile(spec.asset_hover, x, y) - else - tile_hover = tile - end - end - return tile, tile_hover -end - -local function get_button_token_pen(base_pen, tile, ch) - local pen = dfhack.pen.make(base_pen) - pen.tile = tile - pen.ch = ch - return pen -end - -local function get_button_token_pens(spec, x, y, ch, ch_hover) - local base_pen, base_pen_hover = get_button_token_base_pens(spec, x, y) - local tile, tile_hover = get_button_token_tiles(spec, x, y) - return get_button_token_pen(base_pen, tile, ch), get_button_token_pen(base_pen_hover, tile_hover, ch_hover) -end - -local function make_button_token(spec, x, y, ch) - local ch_hover = get_button_token_hover_ch(spec, x, y, ch) - local pen, pen_hover = get_button_token_pens(spec, x, y, ch, ch_hover) - return { - tile=pen, - htile=pen_hover, - width=1, - } -end - ----@class widgets.ButtonLabelSpec ----@field chars (string|string[])[] ----@field chars_hover? (string|string[])[] ----@field pens? dfhack.color|dfhack.color[][] ----@field pens_hover? dfhack.color|dfhack.color[][] ----@field tiles_override? integer[][] ----@field tiles_hover_override? integer[][] ----@field tileset? TexposHandle[] ----@field tileset_hover? TexposHandle[] ----@field tileset_offset? integer ----@field tileset_hover_offset? integer ----@field tileset_stride? integer ----@field tileset_hover_stride? integer ----@field asset? {page: string, x: integer, y: integer} ----@field asset_hover? {page: string, x: integer, y: integer} - ----@nodiscard ----@param spec widgets.ButtonLabelSpec ----@return widgets.LabelToken[] -function makeButtonLabelText(spec) - local tokens = {} - for y, row in ipairs(spec.chars) do - if type(row) == 'string' then - local x = 1 - for ch in row:gmatch('.') do - table.insert(tokens, make_button_token(spec, x, y, ch)) - x = x + 1 - end - else - for x, ch in ipairs(row) do - table.insert(tokens, make_button_token(spec, x, y, ch)) - end - end - if y < #spec.chars then - table.insert(tokens, NEWLINE) - end - end - return tokens -end - ------------------- --- WrappedLabel -- ------------------- - ----@class widgets.WrappedLabel.attrs: widgets.Label.attrs ----@field text_to_wrap? string|string[]|fun(): string|string[] ----@field indent integer - ----@class widgets.WrappedLabel.attrs.partial: widgets.WrappedLabel.attrs - ----@class widgets.WrappedLabel: widgets.Label, widgets.WrappedLabel.attrs ----@field super widgets.Label ----@field ATTRS widgets.WrappedLabel.attrs|fun(attributes: widgets.WrappedLabel.attrs.partial) ----@overload fun(init_table: widgets.WrappedLabel.attrs.partial): self -WrappedLabel = defclass(WrappedLabel, Label) - -WrappedLabel.ATTRS{ - text_to_wrap=DEFAULT_NIL, - indent=0, -} - -function WrappedLabel:getWrappedText(width) - -- 0 width can happen if the parent has 0 width - if not self.text_to_wrap or width <= 0 then return nil end - local text_to_wrap = getval(self.text_to_wrap) - if type(text_to_wrap) == 'table' then - text_to_wrap = table.concat(text_to_wrap, NEWLINE) - end - return text_to_wrap:wrap(width - self.indent, {return_as_table=true}) -end - -function WrappedLabel:preUpdateLayout() - self.saved_start_line_num = self.start_line_num -end - --- we can't set the text in init() since we may not yet have a frame that we --- can get wrapping bounds from. -function WrappedLabel:postComputeFrame() - local wrapped_text = self:getWrappedText(self.frame_body.width-3) - if not wrapped_text then return end - local text = {} - for _,line in ipairs(wrapped_text) do - table.insert(text, {gap=self.indent, text=line}) - -- a trailing newline will get ignored so we don't have to manually trim - table.insert(text, NEWLINE) - end - self:setText(text) - self:scroll(self.saved_start_line_num - 1) -end - ------------------- --- TooltipLabel -- ------------------- - ----@class widgets.TooltipLabel.attrs: widgets.WrappedLabel.attrs ----@field show_tooltip? boolean|fun(): boolean - ----@class widgets.TooltipLabel.attrs.partial: widgets.TooltipLabel.attrs - ----@class widgets.TooltipLabel: widgets.WrappedLabel, widgets.TooltipLabel.attrs ----@field super widgets.WrappedLabel ----@field ATTRS widgets.TooltipLabel.attrs|fun(attributes: widgets.TooltipLabel.attrs.partial) ----@overload fun(init_table: widgets.TooltipLabel.attrs.partial): self -TooltipLabel = defclass(TooltipLabel, WrappedLabel) - -TooltipLabel.ATTRS{ - show_tooltip=DEFAULT_NIL, - indent=2, - text_pen=COLOR_GREY, -} - -function TooltipLabel:init() - self.visible = self.show_tooltip -end - ------------------ --- HotkeyLabel -- ------------------ - ----@class widgets.HotkeyLabel.attrs: widgets.Label.attrs ----@field key? string ----@field key_sep string ----@field label? string|fun(): string ----@field on_activate? function - ----@class widgets.HotkeyLabel.attrs.partial: widgets.HotkeyLabel.attrs - ----@class widgets.HotkeyLabel: widgets.Label, widgets.HotkeyLabel.attrs ----@field super widgets.Label ----@field ATTRS widgets.HotkeyLabel.attrs|fun(attributes: widgets.HotkeyLabel.attrs.partial) ----@overload fun(init_table: widgets.HotkeyLabel.attrs.partial): self -HotkeyLabel = defclass(HotkeyLabel, Label) - -HotkeyLabel.ATTRS{ - key=DEFAULT_NIL, - key_sep=': ', - label=DEFAULT_NIL, - on_activate=DEFAULT_NIL, -} - -function HotkeyLabel:init() - self:initializeLabel() -end - -function HotkeyLabel:setOnActivate(on_activate) - self.on_activate = on_activate - self:initializeLabel() -end - -function HotkeyLabel:setLabel(label) - self.label = label - self:initializeLabel() -end - -function HotkeyLabel:shouldHover() - -- When on_activate is set, text should also hover on mouseover - return self.on_activate or HotkeyLabel.super.shouldHover(self) -end - -function HotkeyLabel:initializeLabel() - self:setText{{key=self.key, key_sep=self.key_sep, text=self.label, - on_activate=self.on_activate}} -end - -function HotkeyLabel:onInput(keys) - if HotkeyLabel.super.onInput(self, keys) then - return true - elseif keys._MOUSE_L and self:getMousePos() and self.on_activate - and not is_disabled(self) then - self.on_activate() - return true - end -end - ----------------- --- HelpButton -- ----------------- - ----@class widgets.HelpButton.attrs: widgets.Panel.attrs ----@field command? string - ----@class widgets.HelpButton.attrs.partial: widgets.HelpButton.attrs - ----@class widgets.HelpButton: widgets.Panel, widgets.HelpButton.attrs ----@field super widgets.Panel ----@field ATTRS widgets.HelpButton.attrs|fun(attributes: widgets.HelpButton.attrs.partial) ----@overload fun(init_table: widgets.HelpButton.attrs.partial): self -HelpButton = defclass(HelpButton, Panel) - -HelpButton.ATTRS{ - frame={t=0, r=1, w=3, h=1}, - command=DEFAULT_NIL, -} - -local button_pen_left = to_pen{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')} -local button_pen_right = to_pen{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')} -local help_pen_center = to_pen{ - tile=curry(textures.tp_control_panel, 9) or nil, ch=string.byte('?')} -local configure_pen_center = dfhack.pen.parse{ - tile=curry(textures.tp_control_panel, 10) or nil, ch=15} -- gear/masterwork symbol - -function HelpButton:init() - self.frame.w = self.frame.w or 3 - self.frame.h = self.frame.h or 1 - - local command = self.command .. ' ' - - self:addviews{ - Label{ - frame={t=0, l=0, w=3, h=1}, - text={ - {tile=button_pen_left}, - {tile=help_pen_center}, - {tile=button_pen_right}, - }, - on_click=function() dfhack.run_command('gui/launcher', command) end, - }, - } -end - ---------------------- --- ConfigureButton -- ---------------------- - ----@class widgets.ConfigureButton.attrs: widgets.Panel.attrs ----@field on_click? function - ----@class widgets.ConfigureButton.attrs.partial: widgets.ConfigureButton.attrs - ----@class widgets.ConfigureButton: widgets.Panel, widgets.ConfigureButton.attrs ----@field super widgets.Panel ----@field ATTRS widgets.ConfigureButton.attrs|fun(attributes: widgets.ConfigureButton.attrs.partial) ----@overload fun(init_table: widgets.ConfigureButton.attrs.partial): self -ConfigureButton = defclass(ConfigureButton, Panel) - -ConfigureButton.ATTRS{ - on_click=DEFAULT_NIL, -} - -function ConfigureButton:preinit(init_table) - init_table.frame = init_table.frame or {} - init_table.frame.h = init_table.frame.h or 1 - init_table.frame.w = init_table.frame.w or 3 -end - -function ConfigureButton:init() - self:addviews{ - Label{ - frame={t=0, l=0, w=3, h=1}, - text={ - {tile=button_pen_left}, - {tile=configure_pen_center}, - {tile=button_pen_right}, - }, - on_click=self.on_click, - }, - } -end - ------------------ --- BannerPanel -- ------------------ - ----@class widgets.BannerPanel: widgets.Panel ----@field super widgets.Panel -BannerPanel = defclass(BannerPanel, Panel) - ----@param dc gui.Painter -function BannerPanel:onRenderBody(dc) - dc:pen(COLOR_RED) - for y=0,self.frame_rect.height-1 do - dc:seek(0, y):char('[') - dc:seek(self.frame_rect.width-1):char(']') - end -end - ----------------- --- TextButton -- ----------------- - ----@class widgets.TextButton.initTable: widgets.Panel.attrs.partial, widgets.HotkeyLabel.attrs.partial - ----@class widgets.TextButton: widgets.BannerPanel ----@field super widgets.BannerPanel ----@overload fun(init_table: widgets.TextButton.initTable): self -TextButton = defclass(TextButton, BannerPanel) - ----@param info widgets.TextButton.initTable -function TextButton:init(info) - self.label = HotkeyLabel{ - frame={t=0, l=1, r=1}, - key=info.key, - key_sep=info.key_sep, - label=info.label, - on_activate=info.on_activate, - text_pen=info.text_pen, - text_dpen=info.text_dpen, - text_hpen=info.text_hpen, - disabled=info.disabled, - enabled=info.enabled, - auto_height=info.auto_height, - auto_width=info.auto_width, - on_click=info.on_click, - on_rclick=info.on_rclick, - scroll_keys=info.scroll_keys, - } - - self:addviews{self.label} -end - -function TextButton:setLabel(label) - self.label:setLabel(label) -end - ----------------------- --- CycleHotkeyLabel -- ----------------------- - ----@class widgets.CycleHotkeyLabel.attrs: widgets.Label.attrs ----@field key? string ----@field key_back? string ----@field key_sep string ----@field label? string|fun(): string ----@field label_width? integer ----@field label_below boolean ----@field option_gap integer ----@field options? { label: string, value: string, pen: dfhack.pen|nil }[] ----@field initial_option integer|string ----@field on_change? fun(new_option_value: integer|string, old_option_value: integer|string) - ----@class widgets.CycleHotkeyLabel.attrs.partial: widgets.CycleHotkeyLabel.attrs - ----@class widgets.CycleHotkeyLabel: widgets.Label, widgets.CycleHotkeyLabel.attrs ----@field super widgets.Label ----@field ATTRS widgets.CycleHotkeyLabel.attrs|fun(attributes: widgets.CycleHotkeyLabel.attrs.partial) ----@overload fun(init_table: widgets.CycleHotkeyLabel.attrs.partial) -CycleHotkeyLabel = defclass(CycleHotkeyLabel, Label) - -CycleHotkeyLabel.ATTRS{ - key=DEFAULT_NIL, - key_back=DEFAULT_NIL, - key_sep=': ', - option_gap=1, - label=DEFAULT_NIL, - label_width=DEFAULT_NIL, - label_below=false, - options=DEFAULT_NIL, - initial_option=1, - on_change=DEFAULT_NIL, -} - -function CycleHotkeyLabel:init() - self:setOption(self.initial_option) - - if self.label_below then - self.option_gap = self.option_gap + (self.key_back and 1 or 0) + (self.key and 2 or 0) - end - - self:setText{ - self.key_back ~= nil and {key=self.key_back, key_sep='', width=0, on_activate=self:callback('cycle', true)} or {}, - {key=self.key, key_sep=self.key_sep, text=self.label, width=self.label_width, - on_activate=self:callback('cycle')}, - self.label_below and NEWLINE or '', - {gap=self.option_gap, text=self:callback('getOptionLabel'), - pen=self:callback('getOptionPen')}, - } -end - --- CycleHotkeyLabels are always clickable and therefore should always change --- color when hovered. -function CycleHotkeyLabel:shouldHover() - return true -end - -function CycleHotkeyLabel:cycle(backwards) - local old_option_idx = self.option_idx - if self.option_idx == #self.options and not backwards then - self.option_idx = 1 - elseif self.option_idx == 1 and backwards then - self.option_idx = #self.options - else - self.option_idx = self.option_idx + (not backwards and 1 or -1) - end - if self.on_change then - self.on_change(self:getOptionValue(), - self:getOptionValue(old_option_idx)) - end -end - -function CycleHotkeyLabel:setOption(value_or_index, call_on_change) - local option_idx = nil - for i in ipairs(self.options) do - if value_or_index == self:getOptionValue(i) then - option_idx = i - break - end - end - if not option_idx then - if self.options[value_or_index] then - option_idx = value_or_index - end - end - if not option_idx then - option_idx = 1 - end - local old_option_idx = self.option_idx - self.option_idx = option_idx - if call_on_change and self.on_change then - self.on_change(self:getOptionValue(), - self:getOptionValue(old_option_idx)) - end -end - -local function cyclehotkeylabel_getOptionElem(self, option_idx, key, require_key) - option_idx = option_idx or self.option_idx - local option = self.options[option_idx] - if type(option) == 'table' then - return option[key] - end - return not require_key and option or nil -end - -function CycleHotkeyLabel:getOptionLabel(option_idx) - return getval(cyclehotkeylabel_getOptionElem(self, option_idx, 'label')) -end - -function CycleHotkeyLabel:getOptionValue(option_idx) - return cyclehotkeylabel_getOptionElem(self, option_idx, 'value') -end - -function CycleHotkeyLabel:getOptionPen(option_idx) - local pen = getval(cyclehotkeylabel_getOptionElem(self, option_idx, 'pen', true)) - if type(pen) == 'string' then return nil end - return pen -end - -function CycleHotkeyLabel:onInput(keys) - if CycleHotkeyLabel.super.onInput(self, keys) then - return true - elseif keys._MOUSE_L and not is_disabled(self) then - local x = self:getMousePos() - if x then - self:cycle(self.key_back and x == 0) - return true - end - end -end - ------------------ --- ButtonGroup -- ------------------ - ----@class widgets.ButtonGroup.attrs: widgets.CycleHotkeyLabel.attrs - ----@class widgets.ButtonGroup.initTable: widgets.ButtonGroup.attrs ----@field button_specs widgets.ButtonLabelSpec[] ----@field button_specs_selected widgets.ButtonLabelSpec[] - ----@class widgets.ButtonGroup: widgets.ButtonGroup.attrs, widgets.CycleHotkeyLabel ----@field super widgets.CycleHotkeyLabel ----@overload fun(init_table: widgets.ButtonGroup.initTable): self -ButtonGroup = defclass(ButtonGroup, CycleHotkeyLabel) - -ButtonGroup.ATTRS{ - auto_height=false, -} - -function ButtonGroup:init(info) - ensure_key(self, 'frame').h = #info.button_specs[1].chars + 2 - - local start = 0 - for idx in ipairs(self.options) do - local opt_val = self:getOptionValue(idx) - local width = #info.button_specs[idx].chars[1] - self:addviews{ - Label{ - frame={t=2, l=start, w=width}, - text=makeButtonLabelText(info.button_specs[idx]), - on_click=function() self:setOption(opt_val, true) end, - visible=function() return self:getOptionValue() ~= opt_val end, - }, - Label{ - frame={t=2, l=start, w=width}, - text=makeButtonLabelText(info.button_specs_selected[idx]), - on_click=function() self:setOption(opt_val, false) end, - visible=function() return self:getOptionValue() == opt_val end, - }, - } - start = start + width - end -end - ------------------------ --- ToggleHotkeyLabel -- ------------------------ - ----@class widgets.ToggleHotkeyLabel: widgets.CycleHotkeyLabel ----@field super widgets.CycleHotkeyLabel -ToggleHotkeyLabel = defclass(ToggleHotkeyLabel, CycleHotkeyLabel) -ToggleHotkeyLabel.ATTRS{ - options={{label='On', value=true, pen=COLOR_GREEN}, - {label='Off', value=false}}, -} - ----------- --- List -- ----------- - ----@class widgets.ListChoice ----@field text string|widgets.LabelToken[] ----@field key string ----@field search_key? string ----@field icon? string|dfhack.pen|fun(): string|dfhack.pen ----@field icon_pen? dfhack.pen - ----@class widgets.List.attrs: widgets.Widget.attrs ----@field choices widgets.ListChoice[] ----@field selected integer ----@field text_pen dfhack.color|dfhack.pen ----@field text_hpen? dfhack.color|dfhack.pen ----@field cursor_pen dfhack.color|dfhack.pen ----@field inactive_pen? dfhack.color|dfhack.pen ----@field icon_pen? dfhack.color|dfhack.pen ----@field on_select? function ----@field on_submit? function ----@field on_submit2? function ----@field on_double_click? function ----@field on_double_click2? function ----@field row_height integer ----@field scroll_keys table ----@field icon_width? integer ----@field page_top integer ----@field page_size integer ----@field scrollbar widgets.Scrollbar ----@field last_select_click_ms integer - ----@class widgets.List.attrs.partial: widgets.List.attrs - ----@class widgets.List: widgets.Widget, widgets.List.attrs ----@field super widgets.Widget ----@field ATTRS widgets.List.attrs|fun(attributes: widgets.List.attrs.partial) ----@overload fun(init_table: widgets.List.attrs.partial): self -List = defclass(List, Widget) - -List.ATTRS{ - text_pen = COLOR_CYAN, - text_hpen = DEFAULT_NIL, -- hover color, defaults to inverting the FG/BG pens for each text object - cursor_pen = COLOR_LIGHTCYAN, - inactive_pen = DEFAULT_NIL, - icon_pen = DEFAULT_NIL, - on_select = DEFAULT_NIL, - on_submit = DEFAULT_NIL, - on_submit2 = DEFAULT_NIL, - on_double_click = DEFAULT_NIL, - on_double_click2 = DEFAULT_NIL, - row_height = 1, - scroll_keys = STANDARDSCROLL, - icon_width = DEFAULT_NIL, -} - ----@param self widgets.List ----@param info widgets.List.attrs.partial -function List:init(info) - self.page_top = 1 - self.page_size = 1 - self.scrollbar = Scrollbar{ - frame={r=0}, - on_scroll=self:callback('on_scrollbar')} - - self:addviews{self.scrollbar} - - if info.choices then - self:setChoices(info.choices, info.selected) - else - self.choices = {} - self.selected = 1 - end - - self.last_select_click_ms = 0 -- used to track double-clicking on an item -end - -function List:setChoices(choices, selected) - self.choices = {} - - for i,v in ipairs(choices or {}) do - local l = utils.clone(v); - if type(v) ~= 'table' then - l = { text = v } - else - l.text = v.text or v.caption or v[1] - end - parse_label_text(l) - self.choices[i] = l - end - - self:setSelected(selected) - - -- Check if page_top needs to be adjusted - if #self.choices - self.page_size < 0 then - self.page_top = 1 - elseif self.page_top > #self.choices - self.page_size + 1 then - self.page_top = #self.choices - self.page_size + 1 - end -end - -function List:setSelected(selected) - self.selected = selected or self.selected or 1 - self:moveCursor(0, true) - return self.selected -end - -function List:getChoices() - return self.choices -end - -function List:getSelected() - if #self.choices > 0 then - return self.selected, self.choices[self.selected] - end -end - -function List:getContentWidth() - local width = 0 - for i,v in ipairs(self.choices) do - render_text(v) - local roww = v.text_width - if v.key then - roww = roww + 3 + #gui.getKeyDisplay(v.key) - end - width = math.max(width, roww) - end - return width + (self.icon_width or 0) -end - -function List:getContentHeight() - return #self.choices * self.row_height -end - -local function update_list_scrollbar(list) - list.scrollbar:update(list.page_top, list.page_size, #list.choices) -end - -function List:postComputeFrame(body) - local row_count = body.height // self.row_height - self.page_size = math.max(1, row_count) - - local num_choices = #self.choices - if num_choices == 0 then - self.page_top = 1 - update_list_scrollbar(self) - return - end - - if self.page_top > num_choices - self.page_size + 1 then - self.page_top = math.max(1, num_choices - self.page_size + 1) - end - - update_list_scrollbar(self) -end - -function List:postUpdateLayout() - update_list_scrollbar(self) -end - -function List:moveCursor(delta, force_cb) - local cnt = #self.choices - - if cnt < 1 then - self.page_top = 1 - self.selected = 1 - update_list_scrollbar(self) - if force_cb and self.on_select then - self.on_select(nil,nil) - end - return - end - - local off = self.selected+delta-1 - local ds = math.abs(delta) - - if ds > 1 then - if off >= cnt+ds-1 then - off = 0 - else - off = math.min(cnt-1, off) - end - if off <= -ds then - off = cnt-1 - else - off = math.max(0, off) - end - end - - local buffer = 1 + math.min(4, math.floor(self.page_size/10)) - - self.selected = 1 + off % cnt - if (self.selected - buffer) < self.page_top then - self.page_top = math.max(1, self.selected - buffer) - elseif (self.selected + buffer + 1) > (self.page_top + self.page_size) then - local max_page_top = cnt - self.page_size + 1 - self.page_top = math.max(1, - math.min(max_page_top, self.selected - self.page_size + buffer + 1)) - end - update_list_scrollbar(self) - - if (force_cb or delta ~= 0) and self.on_select then - self.on_select(self:getSelected()) - end -end - -function List:on_scrollbar(scroll_spec) - local v = 0 - if tonumber(scroll_spec) then - v = scroll_spec - self.page_top - elseif scroll_spec == 'down_large' then - v = math.ceil(self.page_size / 2) - elseif scroll_spec == 'up_large' then - v = -math.ceil(self.page_size / 2) - elseif scroll_spec == 'down_small' then - v = 1 - elseif scroll_spec == 'up_small' then - v = -1 - end - - local max_page_top = math.max(1, #self.choices - self.page_size + 1) - self.page_top = math.max(1, math.min(max_page_top, self.page_top + v)) - update_list_scrollbar(self) -end - -function List:onRenderBody(dc) - local choices = self.choices - local top = self.page_top - local iend = math.min(#choices, top+self.page_size-1) - local iw = self.icon_width - - local function paint_icon(icon, obj) - if type(icon) ~= 'string' then - dc:char(nil,icon) - else - if current then - dc:string(icon, obj.icon_pen or self.icon_pen or cur_pen) - else - dc:string(icon, obj.icon_pen or self.icon_pen or cur_dpen) - end - end - end - - local hoveridx = self:getIdxUnderMouse() - for i = top,iend do - local obj = choices[i] - local current = (i == self.selected) - local hovered = (i == hoveridx) - -- cur_pen and cur_dpen can't be integers or background colors get - -- messed up in render_text for subsequent renders - local cur_pen = to_pen(self.cursor_pen) - local cur_dpen = to_pen(self.text_pen) - local active_pen = (current and cur_pen or cur_dpen) - - if not getval(self.active) then - cur_pen = self.inactive_pen or self.cursor_pen - end - - local y = (i - top)*self.row_height - local icon = getval(obj.icon) - - if iw and icon then - dc:seek(0, y):pen(active_pen) - paint_icon(icon, obj) - end - - render_text(obj, dc, iw or 0, y, cur_pen, cur_dpen, not current, self.text_hpen, hovered) - - local ip = dc.width - - if obj.key then - local keystr = gui.getKeyDisplay(obj.key) - ip = ip-3-#keystr - dc:seek(ip,y):pen(self.text_pen) - dc:string('('):string(keystr,COLOR_LIGHTGREEN):string(')') - end - - if icon and not iw then - dc:seek(ip-1,y):pen(active_pen) - paint_icon(icon, obj) - end - end -end - -function List:getIdxUnderMouse() - if self.scrollbar:getMousePos() then return end - local _,mouse_y = self:getMousePos() - if mouse_y and #self.choices > 0 and - mouse_y < (#self.choices-self.page_top+1) * self.row_height then - return self.page_top + math.floor(mouse_y/self.row_height) - end -end - -function List:submit() - if self.on_submit and #self.choices > 0 then - self.on_submit(self:getSelected()) - return true - end -end - -function List:submit2() - if self.on_submit2 and #self.choices > 0 then - self.on_submit2(self:getSelected()) - return true - end -end - -function List:double_click() - if #self.choices == 0 then return end - local cb = dfhack.internal.getModifiers().shift and - self.on_double_click2 or self.on_double_click - if cb then - cb(self:getSelected()) - return true - end -end - -function List:onInput(keys) - if self:inputToSubviews(keys) then - return true - end - if keys.SELECT then - return self:submit() - elseif keys.SELECT_ALL then - return self:submit2() - elseif keys._MOUSE_L then - local idx = self:getIdxUnderMouse() - if idx then - local now_ms = dfhack.getTickCount() - if idx ~= self:getSelected() then - self.last_select_click_ms = now_ms - else - if now_ms - self.last_select_click_ms <= DOUBLE_CLICK_MS then - self.last_select_click_ms = 0 - if self:double_click() then return true end - else - self.last_select_click_ms = now_ms - end - end - - self:setSelected(idx) - if dfhack.internal.getModifiers().shift then - self:submit2() - else - self:submit() - end - return true - end - else - for k,v in pairs(self.scroll_keys) do - if keys[k] then - if v == '+page' then - v = self.page_size - elseif v == '-page' then - v = -self.page_size - end - - self:moveCursor(v) - return true - end - end - - for i,v in ipairs(self.choices) do - if v.key and keys[v.key] then - self:setSelected(i) - self:submit() - return true - end - end - - local current = self.choices[self.selected] - if current then - return check_text_keys(current, keys) - end - end -end - -------------------- --- Filtered List -- -------------------- - - ----@class widgets.FilteredList.attrs: widgets.Widget.attrs ----@field choices widgets.ListChoice[] ----@field selected? integer ----@field edit_pen dfhack.color|dfhack.pen ----@field edit_below boolean ----@field edit_key? string ----@field edit_ignore_keys? string[] ----@field edit_on_char? function ----@field edit_on_change? function ----@field list widgets.List ----@field edit widgets.EditField ----@field not_found widgets.Label - ----@class widgets.FilteredList.attrs.partial: widgets.FilteredList.attrs - ----@class widgets.FilteredList.initTable: widgets.FilteredList.attrs.partial, widgets.List.attrs.partial, widgets.EditField.attrs.partial ----@field not_found_label? string - ----@class widgets.FilteredList: widgets.Widget, widgets.FilteredList.attrs ----@field super widgets.Widget ----@field ATTRS widgets.FilteredList.attrs|fun(attributes: widgets.FilteredList.attrs.partial) ----@overload fun(init_table: widgets.FilteredList.initTable): self -FilteredList = defclass(FilteredList, Widget) - -FilteredList.ATTRS { - edit_below = false, - edit_key = DEFAULT_NIL, - edit_ignore_keys = DEFAULT_NIL, - edit_on_char = DEFAULT_NIL, - edit_on_change = DEFAULT_NIL, -} - ----@param self widgets.FilteredList ----@param info widgets.FilteredList.initTable -function FilteredList:init(info) - local on_change = self:callback('onFilterChange') - if self.edit_on_change then - on_change = function(text) - self.edit_on_change(text) - self:onFilterChange(text) - end - end - - self.edit = EditField{ - text_pen = info.edit_pen or info.cursor_pen, - frame = { l = info.icon_width, t = 0, h = 1 }, - on_change = on_change, - on_char = self.edit_on_char, - key = self.edit_key, - ignore_keys = self.edit_ignore_keys, - } - self.list = List{ - frame = { t = 2 }, - text_pen = info.text_pen, - cursor_pen = info.cursor_pen, - inactive_pen = info.inactive_pen, - icon_pen = info.icon_pen, - row_height = info.row_height, - scroll_keys = info.scroll_keys, - icon_width = info.icon_width, - } - if self.edit_below then - self.edit.frame = { l = info.icon_width, b = 0, h = 1 } - self.list.frame = { t = 0, b = 2 } - end - if info.on_select then - self.list.on_select = function() - return info.on_select(self:getSelected()) - end - end - if info.on_submit then - self.list.on_submit = function() - return info.on_submit(self:getSelected()) - end - end - if info.on_submit2 then - self.list.on_submit2 = function() - return info.on_submit2(self:getSelected()) - end - end - if info.on_double_click then - self.list.on_double_click = function() - return info.on_double_click(self:getSelected()) - end - end - if info.on_double_click2 then - self.list.on_double_click2 = function() - return info.on_double_click2(self:getSelected()) - end - end - self.not_found = Label{ - visible = true, - text = info.not_found_label or 'No matches', - text_pen = COLOR_LIGHTRED, - frame = { l = info.icon_width, t = self.list.frame.t }, - } - self:addviews{ self.edit, self.list, self.not_found } - if info.choices then - self:setChoices(info.choices, info.selected) - else - self.choices = {} - end -end - -function FilteredList:getChoices() - return self.choices -end - -function FilteredList:getVisibleChoices() - return self.list.choices -end - -function FilteredList:setChoices(choices, pos) - choices = choices or {} - self.edit:setText('') - self.list:setChoices(choices, pos) - self.choices = self.list.choices - self.not_found.visible = (#choices == 0) -end - -function FilteredList:submit() - return self.list:submit() -end - -function FilteredList:submit2() - return self.list:submit2() -end - -function FilteredList:canSubmit() - return not self.not_found.visible -end - -function FilteredList:getSelected() - local i,v = self.list:getSelected() - if i then - return map_opttab(self.choice_index, i), v - end -end - -function FilteredList:getContentWidth() - return self.list:getContentWidth() -end - -function FilteredList:getContentHeight() - return self.list:getContentHeight() + 2 -end - -function FilteredList:getFilter() - return self.edit.text, self.list.choices -end - -function FilteredList:setFilter(filter, pos) - local choices = self.choices - local cidx = nil - - filter = filter or '' - if filter ~= self.edit.text then - self.edit:setText(filter) - end - - if filter ~= '' then - local tokens = filter:split() - local ipos = pos - - choices = {} - cidx = {} - pos = nil - - for i,v in ipairs(self.choices) do - local search_key = v.search_key - if not search_key then - if type(v.text) ~= 'table' then - search_key = v.text - else - local texts = {} - for _,token in ipairs(v.text) do - table.insert(texts, - type(token) == 'string' and token - or getval(token.text) or '') - end - search_key = table.concat(texts, ' ') - end - end - if utils.search_text(search_key, tokens) then - table.insert(choices, v) - cidx[#choices] = i - if ipos == i then - pos = #choices - end - end - end - end - - self.choice_index = cidx - self.list:setChoices(choices, pos) - self.not_found.visible = (#choices == 0) -end - -function FilteredList:onFilterChange(text) - self:setFilter(text) -end - ----@class widgets.TabPens ----@field text_mode_tab_pen dfhack.pen ----@field text_mode_label_pen dfhack.pen ----@field lt dfhack.pen ----@field lt2 dfhack.pen ----@field t dfhack.pen ----@field rt2 dfhack.pen ----@field rt dfhack.pen ----@field lb dfhack.pen ----@field lb2 dfhack.pen ----@field b dfhack.pen ----@field rb2 dfhack.pen ----@field rb dfhack.pen - -local TSO = df.global.init.tabs_texpos[0] -- tab spritesheet offset -local DEFAULT_ACTIVE_TAB_PENS = { - text_mode_tab_pen=to_pen{fg=COLOR_YELLOW}, - text_mode_label_pen=to_pen{fg=COLOR_WHITE}, - lt=to_pen{tile=TSO+5, write_to_lower=true}, - lt2=to_pen{tile=TSO+6, write_to_lower=true}, - t=to_pen{tile=TSO+7, fg=COLOR_BLACK, write_to_lower=true, top_of_text=true}, - rt2=to_pen{tile=TSO+8, write_to_lower=true}, - rt=to_pen{tile=TSO+9, write_to_lower=true}, - lb=to_pen{tile=TSO+15, write_to_lower=true}, - lb2=to_pen{tile=TSO+16, write_to_lower=true}, - b=to_pen{tile=TSO+17, fg=COLOR_BLACK, write_to_lower=true, bottom_of_text=true}, - rb2=to_pen{tile=TSO+18, write_to_lower=true}, - rb=to_pen{tile=TSO+19, write_to_lower=true}, -} - -local DEFAULT_INACTIVE_TAB_PENS = { - text_mode_tab_pen=to_pen{fg=COLOR_BROWN}, - text_mode_label_pen=to_pen{fg=COLOR_DARKGREY}, - lt=to_pen{tile=TSO+0, write_to_lower=true}, - lt2=to_pen{tile=TSO+1, write_to_lower=true}, - t=to_pen{tile=TSO+2, fg=COLOR_WHITE, write_to_lower=true, top_of_text=true}, - rt2=to_pen{tile=TSO+3, write_to_lower=true}, - rt=to_pen{tile=TSO+4, write_to_lower=true}, - lb=to_pen{tile=TSO+10, write_to_lower=true}, - lb2=to_pen{tile=TSO+11, write_to_lower=true}, - b=to_pen{tile=TSO+12, fg=COLOR_WHITE, write_to_lower=true, bottom_of_text=true}, - rb2=to_pen{tile=TSO+13, write_to_lower=true}, - rb=to_pen{tile=TSO+14, write_to_lower=true}, -} - ---------- --- Tab -- ---------- - ----@class widgets.Tab.attrs: widgets.Widget.attrs ----@field id? string|integer ----@field label string ----@field on_select? function ----@field get_pens? fun(): widgets.TabPens - ----@class widgets.Tab.attrs.partial: widgets.Tab.attrs - ----@class widgets.Tab.initTable: widgets.Tab.attrs ----@field label string - ----@class widgets.Tab: widgets.Widget, widgets.Tab.attrs ----@field super widgets.Widget ----@field ATTRS widgets.Tab.attrs|fun(attributes: widgets.Tab.attrs.partial) ----@overload fun(init_table: widgets.Tab.initTable): self -Tab = defclass(Tabs, Widget) -Tab.ATTRS{ - id=DEFAULT_NIL, - label=DEFAULT_NIL, - on_select=DEFAULT_NIL, - get_pens=DEFAULT_NIL, -} - -function Tab:preinit(init_table) - init_table.frame = init_table.frame or {} - init_table.frame.w = #init_table.label + 4 - init_table.frame.h = 2 -end - -function Tab:onRenderBody(dc) - local pens = self.get_pens() - dc:seek(0, 0) - if dfhack.screen.inGraphicsMode() then - dc:char(nil, pens.lt):char(nil, pens.lt2) - for i=1,#self.label do - dc:char(self.label:sub(i,i), pens.t) - end - dc:char(nil, pens.rt2):char(nil, pens.rt) - dc:seek(0, 1) - dc:char(nil, pens.lb):char(nil, pens.lb2) - for i=1,#self.label do - dc:char(self.label:sub(i,i), pens.b) - end - dc:char(nil, pens.rb2):char(nil, pens.rb) - else - local tp = pens.text_mode_tab_pen - dc:char(' ', tp):char('/', tp) - for i=1,#self.label do - dc:char('-', tp) - end - dc:char('\\', tp):char(' ', tp) - dc:seek(0, 1) - dc:char('/', tp):char('-', tp) - dc:string(self.label, pens.text_mode_label_pen) - dc:char('-', tp):char('\\', tp) - end -end - -function Tab:onInput(keys) - if Tab.super.onInput(self, keys) then return true end - if keys._MOUSE_L and self:getMousePos() then - self.on_select(self.id) - return true - end -end - -------------- --- Tab Bar -- -------------- - ----@class widgets.TabBar.attrs: widgets.ResizingPanel.attrs ----@field labels string[] ----@field on_select? function ----@field get_cur_page? function ----@field active_tab_pens widgets.TabPens ----@field inactive_tab_pens widgets.TabPens ----@field get_pens? fun(index: integer, tabbar: self): widgets.TabPens ----@field key string ----@field key_back string - ----@class widgets.TabBar.attrs.partial: widgets.TabBar.attrs - ----@class widgets.TabBar.initTable: widgets.TabBar.attrs ----@field labels string[] - ----@class widgets.TabBar: widgets.ResizingPanel, widgets.TabBar.attrs ----@field super widgets.ResizingPanel ----@field ATTRS widgets.TabBar.attrs|fun(attribute: widgets.TabBar.attrs.partial) ----@overload fun(init_table: widgets.TabBar.initTable): self -TabBar = defclass(TabBar, ResizingPanel) -TabBar.ATTRS{ - labels=DEFAULT_NIL, - on_select=DEFAULT_NIL, - get_cur_page=DEFAULT_NIL, - active_tab_pens=DEFAULT_ACTIVE_TAB_PENS, - inactive_tab_pens=DEFAULT_INACTIVE_TAB_PENS, - get_pens=DEFAULT_NIL, - key='CUSTOM_CTRL_T', - key_back='CUSTOM_CTRL_Y', -} - ----@param self widgets.TabBar -function TabBar:init() - for idx,label in ipairs(self.labels) do - self:addviews{ - Tab{ - frame={t=0, l=0}, - id=idx, - label=label, - on_select=self.on_select, - get_pens=self.get_pens and function() - return self.get_pens(idx, self) - end or function() - if self.get_cur_page() == idx then - return self.active_tab_pens - end - - return self.inactive_tab_pens - end, - } - } - end -end - -function TabBar:postComputeFrame(body) - local t, l, width = 0, 0, body.width - for _,tab in ipairs(self.subviews) do - if l > 0 and l + tab.frame.w > width then - t = t + 2 - l = 0 - end - tab.frame.t = t - tab.frame.l = l - l = l + tab.frame.w - end -end - -function TabBar:onInput(keys) - if TabBar.super.onInput(self, keys) then return true end - if self.key and keys[self.key] then - local zero_idx = self.get_cur_page() - 1 - local next_zero_idx = (zero_idx + 1) % #self.labels - self.on_select(next_zero_idx + 1) - return true - end - if self.key_back and keys[self.key_back] then - local zero_idx = self.get_cur_page() - 1 - local prev_zero_idx = (zero_idx - 1) % #self.labels - self.on_select(prev_zero_idx + 1) - return true - end -end - --------------------------------- --- RangeSlider --------------------------------- - ----@class widgets.RangeSlider.attrs: widgets.Widget.attrs ----@field num_stops integer ----@field get_left_idx_fn? function ----@field get_right_idx_fn? function ----@field on_left_change? fun(index: integer) ----@field on_right_change? fun(index: integer) - ----@class widgets.RangeSlider.attrs.partial: widgets.RangeSlider.attrs - ----@class widgets.RangeSlider.initTable: widgets.RangeSlider.attrs ----@field num_stops integer - ----@class widgets.RangeSlider: widgets.Widget, widgets.RangeSlider.attrs ----@field super widgets.Widget ----@field ATTRS widgets.RangeSlider.attrs|fun(attributes: widgets.RangeSlider.attrs.partial) ----@overload fun(init_table: widgets.RangeSlider.initTable): self -RangeSlider = defclass(RangeSlider, Widget) -RangeSlider.ATTRS{ - num_stops=DEFAULT_NIL, - get_left_idx_fn=DEFAULT_NIL, - get_right_idx_fn=DEFAULT_NIL, - on_left_change=DEFAULT_NIL, - on_right_change=DEFAULT_NIL, -} - -function RangeSlider:preinit(init_table) - init_table.frame = init_table.frame or {} - init_table.frame.h = init_table.frame.h or 1 -end - -function RangeSlider:init() - if self.num_stops < 2 then error('too few RangeSlider stops') end - self.is_dragging_target = nil -- 'left', 'right', or 'both' - self.is_dragging_idx = nil -- offset from leftmost dragged tile -end - -local function rangeslider_get_width_per_idx(self) - return math.max(5, (self.frame_body.width-7) // (self.num_stops-1)) -end - -function RangeSlider:onInput(keys) - if not keys._MOUSE_L then return false end - local x = self:getMousePos() - if not x then return false end - local left_idx, right_idx = self.get_left_idx_fn(), self.get_right_idx_fn() - local width_per_idx = rangeslider_get_width_per_idx(self) - local left_pos = width_per_idx*(left_idx-1) - local right_pos = width_per_idx*(right_idx-1) + 4 - if x < left_pos then - self.on_left_change(self.get_left_idx_fn() - 1) - elseif x < left_pos+3 then - self.is_dragging_target = 'left' - self.is_dragging_idx = x - left_pos - elseif x < right_pos then - self.is_dragging_target = 'both' - self.is_dragging_idx = x - left_pos - elseif x < right_pos+3 then - self.is_dragging_target = 'right' - self.is_dragging_idx = x - right_pos - else - self.on_right_change(self.get_right_idx_fn() + 1) - end - return true -end - -local function rangeslider_do_drag(self, width_per_idx) - local x = self.frame_body:localXY(dfhack.screen.getMousePos()) - local cur_pos = x - self.is_dragging_idx - cur_pos = math.max(0, cur_pos) - cur_pos = math.min(width_per_idx*(self.num_stops-1)+7, cur_pos) - local offset = self.is_dragging_target == 'right' and -2 or 1 - local new_idx = math.max(0, cur_pos+offset)//width_per_idx + 1 - local new_left_idx, new_right_idx - if self.is_dragging_target == 'right' then - new_right_idx = new_idx - else - new_left_idx = new_idx - if self.is_dragging_target == 'both' then - new_right_idx = new_left_idx + self.get_right_idx_fn() - self.get_left_idx_fn() - if new_right_idx > self.num_stops then - return - end - end - end - if new_left_idx and new_left_idx ~= self.get_left_idx_fn() then - if not new_right_idx and new_left_idx > self.get_right_idx_fn() then - self.on_right_change(new_left_idx) - end - self.on_left_change(new_left_idx) - end - if new_right_idx and new_right_idx ~= self.get_right_idx_fn() then - if new_right_idx < self.get_left_idx_fn() then - self.on_left_change(new_right_idx) - end - self.on_right_change(new_right_idx) - end -end - -local SLIDER_LEFT_END = to_pen{ch=198, fg=COLOR_GREY, bg=COLOR_BLACK} -local SLIDER_TRACK = to_pen{ch=205, fg=COLOR_GREY, bg=COLOR_BLACK} -local SLIDER_TRACK_SELECTED = to_pen{ch=205, fg=COLOR_LIGHTGREEN, bg=COLOR_BLACK} -local SLIDER_TRACK_STOP = to_pen{ch=216, fg=COLOR_GREY, bg=COLOR_BLACK} -local SLIDER_TRACK_STOP_SELECTED = to_pen{ch=216, fg=COLOR_LIGHTGREEN, bg=COLOR_BLACK} -local SLIDER_RIGHT_END = to_pen{ch=181, fg=COLOR_GREY, bg=COLOR_BLACK} -local SLIDER_TAB_LEFT = to_pen{ch=60, fg=COLOR_BLACK, bg=COLOR_YELLOW} -local SLIDER_TAB_CENTER = to_pen{ch=9, fg=COLOR_BLACK, bg=COLOR_YELLOW} -local SLIDER_TAB_RIGHT = to_pen{ch=62, fg=COLOR_BLACK, bg=COLOR_YELLOW} - -function RangeSlider:onRenderBody(dc, rect) - local left_idx, right_idx = self.get_left_idx_fn(), self.get_right_idx_fn() - local width_per_idx = rangeslider_get_width_per_idx(self) - -- draw track - dc:seek(1,0) - dc:char(nil, SLIDER_LEFT_END) - dc:char(nil, SLIDER_TRACK) - for stop_idx=1,self.num_stops-1 do - local track_stop_pen = SLIDER_TRACK_STOP_SELECTED - local track_pen = SLIDER_TRACK_SELECTED - if left_idx > stop_idx or right_idx < stop_idx then - track_stop_pen = SLIDER_TRACK_STOP - track_pen = SLIDER_TRACK - elseif right_idx == stop_idx then - track_pen = SLIDER_TRACK - end - dc:char(nil, track_stop_pen) - for i=2,width_per_idx do - dc:char(nil, track_pen) - end - end - if right_idx >= self.num_stops then - dc:char(nil, SLIDER_TRACK_STOP_SELECTED) - else - dc:char(nil, SLIDER_TRACK_STOP) - end - dc:char(nil, SLIDER_TRACK) - dc:char(nil, SLIDER_RIGHT_END) - -- draw tabs - dc:seek(width_per_idx*(left_idx-1)) - dc:char(nil, SLIDER_TAB_LEFT) - dc:char(nil, SLIDER_TAB_CENTER) - dc:char(nil, SLIDER_TAB_RIGHT) - dc:seek(width_per_idx*(right_idx-1)+4) - dc:char(nil, SLIDER_TAB_LEFT) - dc:char(nil, SLIDER_TAB_CENTER) - dc:char(nil, SLIDER_TAB_RIGHT) - -- manage dragging - if self.is_dragging_target then - rangeslider_do_drag(self, width_per_idx) - end - if df.global.enabler.mouse_lbut_down == 0 then - self.is_dragging_target = nil - self.is_dragging_idx = nil - end -end - --------------------------------- --- DimensionsTooltip --------------------------------- - ----@class widgets.DimensionsTooltip.attrs: widgets.ResizingPanel.attrs ----@field display_offset? df.coord2d ----@field get_anchor_pos_fn fun(): df.coord? - ----@class widgets.DimensionsTooltip: widgets.ResizingPanel ----@field super widgets.ResizingPanel ----@overload fun(init_table: widgets.DimensionsTooltip.attrs): self -DimensionsTooltip = defclass(DimensionsTooltip, ResizingPanel) - -DimensionsTooltip.ATTRS{ - frame_style=gui.FRAME_THIN, - frame_background=gui.CLEAR_PEN, - no_force_pause_badge=true, - auto_width=true, - display_offset={x=3, y=3}, - get_anchor_pos_fn=DEFAULT_NIL, -} - -local function get_cur_area_dims(anchor_pos) - local mouse_pos = dfhack.gui.getMousePos(true) - if not mouse_pos or not anchor_pos then return 1, 1, 1 end - - -- clamp to map edges (you can start a selection from out of bounds) - mouse_pos = xyz2pos( - math.max(0, math.min(df.global.world.map.x_count-1, mouse_pos.x)), - math.max(0, math.min(df.global.world.map.y_count-1, mouse_pos.y)), - math.max(0, math.min(df.global.world.map.z_count-1, mouse_pos.z))) - anchor_pos = xyz2pos( - math.max(0, math.min(df.global.world.map.x_count-1, anchor_pos.x)), - math.max(0, math.min(df.global.world.map.y_count-1, anchor_pos.y)), - math.max(0, math.min(df.global.world.map.z_count-1, anchor_pos.z))) - - return math.abs(mouse_pos.x - anchor_pos.x) + 1, - math.abs(mouse_pos.y - anchor_pos.y) + 1, - math.abs(mouse_pos.z - anchor_pos.z) + 1 -end - -function DimensionsTooltip:init() - ensure_key(self, 'frame').w = 17 - self.frame.h = 4 - - self.label = Label{ - frame={t=0}, - auto_width=true, - text={ - { - text=function() - local anchor_pos = self.get_anchor_pos_fn() - return ('%dx%dx%d'):format(get_cur_area_dims(anchor_pos)) - end - } - }, - } - - self:addviews{ - Panel{ - -- set minimum size for tooltip frame so the DFHack frame badge fits - frame={t=0, l=0, w=7, h=2}, - }, - self.label, - } -end - -function DimensionsTooltip:render(dc) - local x, y = dfhack.screen.getMousePos() - if not x or not self.get_anchor_pos_fn() then return end - local sw, sh = dfhack.screen.getWindowSize() - local frame_width = math.max(9, self.label:getTextWidth() + 2) - self.frame.l = math.min(x + self.display_offset.x, sw - frame_width) - self.frame.t = math.min(y + self.display_offset.y, sh - self.frame.h) - self:updateLayout() - DimensionsTooltip.super.render(self, dc) +local common = require('gui.widgets.common') + +Widget = require('gui.widgets.widget') +Divider = require('gui.widgets.divider') +Panel = require('gui.widgets.containers.panel') +Window = require('gui.widgets.containers.window') +ResizingPanel = require('gui.widgets.containers.resizing_panel') +Pages = require('gui.widgets.containers.pages') +EditField = require('gui.widgets.edit_field') +HotkeyLabel = require('gui.widgets.labels.hotkey_label') +Label = require('gui.widgets.labels.label') +Scrollbar = require('gui.widgets.scrollbar') +WrappedLabel = require('gui.widgets.labels.wrapped_label') +TooltipLabel = require('gui.widgets.labels.tooltip_label') +HelpButton = require('gui.widgets.buttons.help_button') +ConfigureButton = require('gui.widgets.buttons.configure_button') +BannerPanel = require('gui.widgets.containers.banner_panel') +TextButton = require('gui.widgets.buttons.text_button') +CycleHotkeyLabel = require('gui.widgets.labels.cycle_hotkey_label') +ButtonGroup = require('gui.widgets.buttons.button_group') +ToggleHotkeyLabel = require('gui.widgets.labels.toggle_hotkey_label') +List = require('gui.widgets.list') +FilteredList = require('gui.widgets.filtered_list') +TabBar = require('gui.widgets.tab_bar') +RangeSlider = require('gui.widgets.range_slider') +DimensionsTooltip = require('gui.widgets.dimensions_tooltip') +TextArea = require('gui.widgets.text_area') + +Tab = TabBar.Tab +makeButtonLabelText = Label.makeButtonLabelText +STANDARDSCROLL = common.STANDARDSCROLL + +---@return boolean +function getDoubleClickMs() + return common.DOUBLE_CLICK_MS +end +function setDoubleClickMs(value) + common.DOUBLE_CLICK_MS = value +end + +---@return boolean +function getScrollInitialDelayMs() + return common.SCROLL_INITIAL_DELAY_MS +end +function setScrollInitialDelayMs(value) + common.SCROLL_INITIAL_DELAY_MS = value +end + +---@return boolean +function getScrollDelayMs() + return common.SCROLL_DELAY_MS +end +function setScrollDelayMs(value) + common.SCROLL_DELAY_MS = value end return _ENV diff --git a/library/lua/gui/widgets/buttons/button_group.lua b/library/lua/gui/widgets/buttons/button_group.lua new file mode 100644 index 0000000000..033f498472 --- /dev/null +++ b/library/lua/gui/widgets/buttons/button_group.lua @@ -0,0 +1,48 @@ +local CycleHotkeyLabel = require('gui.widgets.labels.cycle_hotkey_label') +local Label = require('gui.widgets.labels.label') + +----------------- +-- ButtonGroup -- +----------------- + +---@class widgets.ButtonGroup.attrs: widgets.CycleHotkeyLabel.attrs + +---@class widgets.ButtonGroup.initTable: widgets.ButtonGroup.attrs +---@field button_specs widgets.ButtonLabelSpec[] +---@field button_specs_selected widgets.ButtonLabelSpec[] + +---@class widgets.ButtonGroup: widgets.ButtonGroup.attrs, widgets.CycleHotkeyLabel +---@field super widgets.CycleHotkeyLabel +---@overload fun(init_table: widgets.ButtonGroup.initTable): self +ButtonGroup = defclass(ButtonGroup, CycleHotkeyLabel) + +ButtonGroup.ATTRS{ + auto_height=false, +} + +function ButtonGroup:init(info) + ensure_key(self, 'frame').h = #info.button_specs[1].chars + 2 + + local start = 0 + for idx in ipairs(self.options) do + local opt_val = self:getOptionValue(idx) + local width = #info.button_specs[idx].chars[1] + self:addviews{ + Label{ + frame={t=2, l=start, w=width}, + text=Label.makeButtonLabelText(info.button_specs[idx]), + on_click=function() self:setOption(opt_val, true) end, + visible=function() return self:getOptionValue() ~= opt_val end, + }, + Label{ + frame={t=2, l=start, w=width}, + text=Label.makeButtonLabelText(info.button_specs_selected[idx]), + on_click=function() self:setOption(opt_val, false) end, + visible=function() return self:getOptionValue() == opt_val end, + }, + } + start = start + width + end +end + +return ButtonGroup diff --git a/library/lua/gui/widgets/buttons/configure_button.lua b/library/lua/gui/widgets/buttons/configure_button.lua new file mode 100644 index 0000000000..b93b1e18b2 --- /dev/null +++ b/library/lua/gui/widgets/buttons/configure_button.lua @@ -0,0 +1,53 @@ +local textures = require('gui.textures') +local Panel = require('gui.widgets.containers.panel') +local Label = require('gui.widgets.labels.label') + +local to_pen = dfhack.pen.parse + +local button_pen_left = to_pen{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')} +local button_pen_right = to_pen{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')} +local configure_pen_center = to_pen{ + tile=curry(textures.tp_control_panel, 10) or nil, ch=15} -- gear/masterwork symbol + +--------------------- +-- ConfigureButton -- +--------------------- + +---@class widgets.ConfigureButton.attrs: widgets.Panel.attrs +---@field on_click? function + +---@class widgets.ConfigureButton.attrs.partial: widgets.ConfigureButton.attrs + +---@class widgets.ConfigureButton: widgets.Panel, widgets.ConfigureButton.attrs +---@field super widgets.Panel +---@field ATTRS widgets.ConfigureButton.attrs|fun(attributes: widgets.ConfigureButton.attrs.partial) +---@overload fun(init_table: widgets.ConfigureButton.attrs.partial): self +ConfigureButton = defclass(ConfigureButton, Panel) + +ConfigureButton.ATTRS{ + on_click=DEFAULT_NIL, +} + +function ConfigureButton:preinit(init_table) + init_table.frame = init_table.frame or {} + init_table.frame.h = init_table.frame.h or 1 + init_table.frame.w = init_table.frame.w or 3 +end + +function ConfigureButton:init() + self:addviews{ + Label{ + frame={t=0, l=0, w=3, h=1}, + text={ + {tile=button_pen_left}, + {tile=configure_pen_center}, + {tile=button_pen_right}, + }, + on_click=self.on_click, + }, + } +end + +return ConfigureButton diff --git a/library/lua/gui/widgets/buttons/help_button.lua b/library/lua/gui/widgets/buttons/help_button.lua new file mode 100644 index 0000000000..9f9b7dc989 --- /dev/null +++ b/library/lua/gui/widgets/buttons/help_button.lua @@ -0,0 +1,53 @@ +local textures = require('gui.textures') +local Panel = require('gui.widgets.containers.panel') +local Label = require('gui.widgets.labels.label') + +local to_pen = dfhack.pen.parse + +---------------- +-- HelpButton -- +---------------- + +---@class widgets.HelpButton.attrs: widgets.Panel.attrs +---@field command? string + +---@class widgets.HelpButton.attrs.partial: widgets.HelpButton.attrs + +---@class widgets.HelpButton: widgets.Panel, widgets.HelpButton.attrs +---@field super widgets.Panel +---@field ATTRS widgets.HelpButton.attrs|fun(attributes: widgets.HelpButton.attrs.partial) +---@overload fun(init_table: widgets.HelpButton.attrs.partial): self +HelpButton = defclass(HelpButton, Panel) + +HelpButton.ATTRS{ + frame={t=0, r=1, w=3, h=1}, + command=DEFAULT_NIL, +} + +local button_pen_left = to_pen{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')} +local button_pen_right = to_pen{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')} +local help_pen_center = to_pen{ + tile=curry(textures.tp_control_panel, 9) or nil, ch=string.byte('?')} + +function HelpButton:init() + self.frame.w = self.frame.w or 3 + self.frame.h = self.frame.h or 1 + + local command = self.command .. ' ' + + self:addviews{ + Label{ + frame={t=0, l=0, w=3, h=1}, + text={ + {tile=button_pen_left}, + {tile=help_pen_center}, + {tile=button_pen_right}, + }, + on_click=function() dfhack.run_command('gui/launcher', command) end, + }, + } +end + +return HelpButton diff --git a/library/lua/gui/widgets/buttons/text_button.lua b/library/lua/gui/widgets/buttons/text_button.lua new file mode 100644 index 0000000000..2fa7928be9 --- /dev/null +++ b/library/lua/gui/widgets/buttons/text_button.lua @@ -0,0 +1,42 @@ +local BannerPanel = require('gui.widgets.containers.banner_panel') +local HotkeyLabel = require('gui.widgets.labels.hotkey_label') + +---------------- +-- TextButton -- +---------------- + +---@class widgets.TextButton.initTable: widgets.Panel.attrs.partial, widgets.HotkeyLabel.attrs.partial + +---@class widgets.TextButton: widgets.BannerPanel +---@field super widgets.BannerPanel +---@overload fun(init_table: widgets.TextButton.initTable): self +TextButton = defclass(TextButton, BannerPanel) + +---@param info widgets.TextButton.initTable +function TextButton:init(info) + self.label = HotkeyLabel{ + frame={t=0, l=1, r=1}, + key=info.key, + key_sep=info.key_sep, + label=info.label, + on_activate=info.on_activate, + text_pen=info.text_pen, + text_dpen=info.text_dpen, + text_hpen=info.text_hpen, + disabled=info.disabled, + enabled=info.enabled, + auto_height=info.auto_height, + auto_width=info.auto_width, + on_click=info.on_click, + on_rclick=info.on_rclick, + scroll_keys=info.scroll_keys, + } + + self:addviews{self.label} +end + +function TextButton:setLabel(label) + self.label:setLabel(label) +end + +return TextButton diff --git a/library/lua/gui/widgets/common.lua b/library/lua/gui/widgets/common.lua new file mode 100644 index 0000000000..f69c99de85 --- /dev/null +++ b/library/lua/gui/widgets/common.lua @@ -0,0 +1,19 @@ +local _ENV = mkmodule('gui.widgets.common') + +DOUBLE_CLICK_MS = 500 + +---@enum STANDARDSCROLL +STANDARDSCROLL = { + STANDARDSCROLL_UP = -1, + KEYBOARD_CURSOR_UP = -1, + STANDARDSCROLL_DOWN = 1, + KEYBOARD_CURSOR_DOWN = 1, + STANDARDSCROLL_PAGEUP = '-page', + KEYBOARD_CURSOR_UP_FAST = '-page', + STANDARDSCROLL_PAGEDOWN = '+page', + KEYBOARD_CURSOR_DOWN_FAST = '+page', +} +SCROLL_INITIAL_DELAY_MS = 300 +SCROLL_DELAY_MS = 20 + +return _ENV diff --git a/library/lua/gui/widgets/containers/banner_panel.lua b/library/lua/gui/widgets/containers/banner_panel.lua new file mode 100644 index 0000000000..68d78d74e8 --- /dev/null +++ b/library/lua/gui/widgets/containers/banner_panel.lua @@ -0,0 +1,20 @@ +local Panel = require('gui.widgets.containers.panel') + +----------------- +-- BannerPanel -- +----------------- + +---@class widgets.BannerPanel: widgets.Panel +---@field super widgets.Panel +BannerPanel = defclass(BannerPanel, Panel) + +---@param dc gui.Painter +function BannerPanel:onRenderBody(dc) + dc:pen(COLOR_RED) + for y=0,self.frame_rect.height-1 do + dc:seek(0, y):char('[') + dc:seek(self.frame_rect.width-1):char(']') + end +end + +return BannerPanel diff --git a/library/lua/gui/widgets/containers/pages.lua b/library/lua/gui/widgets/containers/pages.lua new file mode 100644 index 0000000000..73b82a5da5 --- /dev/null +++ b/library/lua/gui/widgets/containers/pages.lua @@ -0,0 +1,63 @@ +local gui = require('gui') +local utils = require('utils') +local Panel = require('gui.widgets.containers.panel') + +---@param view gui.View +---@param vis boolean +local function show_view(view,vis) + if view then + view.visible = vis + end +end + +----------- +-- Pages -- +----------- + +---@class widgets.Pages.initTable: widgets.Panel.attrs +---@field selected? integer|string + +---@class widgets.Pages: widgets.Panel +---@field super widgets.Panel +---@overload fun(attributes: widgets.Pages.initTable): self +Pages = defclass(Pages, Panel) + +---@param self widgets.Pages +---@param args widgets.Pages.initTable +function Pages:init(args) + for _,v in ipairs(self.subviews) do + v.visible = false + end + self:setSelected(args.selected or 1) +end + +---@param idx integer|string +function Pages:setSelected(idx) + if type(idx) ~= 'number' then + local key = idx + if type(idx) == 'string' then + key = self.subviews[key] + end + idx = utils.linear_index(self.subviews, key) + if not idx then + error('Unknown page: '..tostring(key)) + end + end + + show_view(self.subviews[self.selected], false) + self.selected = math.min(math.max(1, idx), #self.subviews) + show_view(self.subviews[self.selected], true) +end + +---@return integer index +---@return gui.View child +function Pages:getSelected() + return self.selected, self.subviews[self.selected] +end + +---@return gui.View child +function Pages:getSelectedPage() + return self.subviews[self.selected] +end + +return Pages diff --git a/library/lua/gui/widgets/containers/panel.lua b/library/lua/gui/widgets/containers/panel.lua new file mode 100644 index 0000000000..2125331353 --- /dev/null +++ b/library/lua/gui/widgets/containers/panel.lua @@ -0,0 +1,530 @@ +local gui = require('gui') +local utils = require('utils') +local guidm = require('gui.dwarfmode') +local common = require('gui.widgets.common') +local Widget = require('gui.widgets.widget') + +local getval = utils.getval +local to_pen = dfhack.pen.parse + +----------- +-- Panel -- +----------- + +---@class widgets.Panel.attrs: widgets.Widget.attrs +---@field frame_style? gui.Frame|fun(): gui.Frame +---@field frame_title? string +---@field on_render? fun(painter: gui.Painter) Called from `onRenderBody`. +---@field on_layout? fun(frame_body: any) Called from `postComputeFrame`. +---@field draggable boolean +---@field drag_anchors? { title: boolean, frame: boolean, body: boolean } +---@field drag_bound 'frame'|'body' +---@field on_drag_begin? fun() +---@field on_drag_end? fun(success: boolean, new_frame: gui.Frame) +---@field resizable boolean +---@field resize_anchors? { t: boolean, l: boolean, r: boolean, b: boolean } +---@field resize_min? { w: integer, h: integer } +---@field on_resize_begin? fun() +---@field on_resize_end? fun(success: boolean, new_frame: gui.Frame) +---@field autoarrange_subviews boolean +---@field autoarrange_gap integer +---@field kbd_get_pos? fun(): df.coord2d +---@field saved_frame? table +---@field saved_frame_rect? table +---@field drag_offset? table +---@field resize_edge? string +---@field last_title_click_ms number + +---@class widgets.Panel.attrs.partial: widgets.Panel.attrs + +---@class widgets.Panel.initTable: widgets.Panel.attrs.partial +---@field subviews? gui.View[] + +---@class widgets.Panel: widgets.Widget, widgets.Panel.attrs +---@field super widgets.Widget +---@field ATTRS widgets.Panel.attrs|fun(attributes: widgets.Panel.attrs.partial) +---@overload fun(init_table: widgets.Panel.initTable): self +Panel = defclass(Panel, Widget) + +Panel.ATTRS { + frame_style = DEFAULT_NIL, -- as in gui.FramedScreen + frame_title = DEFAULT_NIL, -- as in gui.FramedScreen + on_render = DEFAULT_NIL, + on_layout = DEFAULT_NIL, + draggable = false, + drag_anchors = DEFAULT_NIL, + drag_bound = 'frame', -- or 'body' + on_drag_begin = DEFAULT_NIL, + on_drag_end = DEFAULT_NIL, + resizable = false, + resize_anchors = DEFAULT_NIL, + resize_min = DEFAULT_NIL, + on_resize_begin = DEFAULT_NIL, + on_resize_end = DEFAULT_NIL, + no_force_pause_badge = false, + autoarrange_subviews = false, -- whether to automatically lay out subviews + autoarrange_gap = 0, -- how many blank lines to insert between widgets +} + +---@param self widgets.Panel +---@param args widgets.Panel.initTable +function Panel:init(args) + if not self.drag_anchors then + self.drag_anchors = {title=true, frame=true, body=true} + end + if not self.resize_anchors then + self.resize_anchors = {t=false, l=true, r=true, b=true} + end + self.resize_min = self.resize_min or {} + self.resize_min.w = self.resize_min.w or (self.frame or {}).w or 5 + self.resize_min.h = self.resize_min.h or (self.frame or {}).h or 5 + + self.kbd_get_pos = nil -- fn when we are in keyboard dragging mode + self.saved_frame = nil -- copy of frame when dragging started + self.saved_frame_rect = nil -- copy of frame_rect when dragging started + self.drag_offset = nil -- relative pos of held panel tile + self.resize_edge = nil -- which dimension is being resized? + + self.last_title_click_ms = 0 -- used to track double-clicking on the title + self:addviews(args.subviews) +end + +local function Panel_update_frame(self, frame, clear_state) + if clear_state then + self.kbd_get_pos = nil + self.saved_frame = nil + self.saved_frame_rect = nil + self.drag_offset = nil + self.resize_edge = nil + end + if not frame then return end + if self.frame.l == frame.l and self.frame.r == frame.r + and self.frame.t == frame.t and self.frame.b == frame.b + and self.frame.w == frame.w and self.frame.h == frame.h then + return + end + self.frame = frame + self:updateLayout() +end + +-- dim: the name of the dimension var (i.e. 'h' or 'w') +-- anchor: the name of the anchor var (i.e. 't', 'b', 'l', or 'r') +-- opposite_anchor: the name of the anchor var for the opposite edge +-- max_dim: how big this panel can get from its current pos and fit in parent +-- wanted_dim: how big the player is trying to make the panel +-- max_anchor: max value of the frame anchor for the edge that is being resized +-- wanted_anchor: how small the player is trying to make the anchor value +local function Panel_resize_edge_base(frame, resize_min, dim, anchor, + opposite_anchor, max_dim, wanted_dim, + max_anchor, wanted_anchor) + frame[dim] = math.max(resize_min[dim], math.min(max_dim, wanted_dim)) + if frame[anchor] or not frame[opposite_anchor] then + frame[anchor] = math.max(0, math.min(max_anchor, wanted_anchor)) + end +end + +local function Panel_resize_edge(frame, resize_min, dim, anchor, + opposite_anchor, dim_base, dim_ref, anchor_ref, + dim_far, mouse_ref) + local dim_sign = (anchor == 't' or anchor == 'l') and 1 or -1 + local max_dim = dim_base - dim_ref + 1 + local wanted_dim = dim_sign * (dim_far - mouse_ref) + 1 + local max_anchor = dim_base - resize_min[dim] - dim_ref + 1 + local wanted_anchor = dim_sign * (mouse_ref - anchor_ref) + Panel_resize_edge_base(frame, resize_min, dim, anchor, opposite_anchor, + max_dim, wanted_dim, max_anchor, wanted_anchor) +end + +local function Panel_resize_frame(self, mouse_pos) + local frame, resize_min = copyall(self.frame), self.resize_min + local parent_rect = self.frame_parent_rect + local ref_rect = self.saved_frame_rect + if self.resize_edge:find('t') then + Panel_resize_edge(frame, resize_min, 'h', 't', 'b', ref_rect.y2, + parent_rect.y1, parent_rect.y1, ref_rect.y2, mouse_pos.y) + end + if self.resize_edge:find('b') then + Panel_resize_edge(frame, resize_min, 'h', 'b', 't', parent_rect.y2, + ref_rect.y1, parent_rect.y2, ref_rect.y1, mouse_pos.y) + end + if self.resize_edge:find('l') then + Panel_resize_edge(frame, resize_min, 'w', 'l', 'r', ref_rect.x2, + parent_rect.x1, parent_rect.x1, ref_rect.x2, mouse_pos.x) + end + if self.resize_edge:find('r') then + Panel_resize_edge(frame, resize_min, 'w', 'r', 'l', parent_rect.x2, + ref_rect.x1, parent_rect.x2, ref_rect.x1, mouse_pos.x) + end + return frame +end + +local function Panel_drag_frame(self, mouse_pos) + local frame = copyall(self.frame) + local parent_rect = self.frame_parent_rect + local frame_rect = gui.mkdims_wh( + self.frame_rect.x1+parent_rect.x1, + self.frame_rect.y1+parent_rect.y1, + self.frame_rect.width, + self.frame_rect.height + ) + local bound_rect = self.drag_bound == 'body' and self.frame_body + or frame_rect + local offset = self.drag_offset + local max_width = parent_rect.width - (bound_rect.x2-frame_rect.x1+1) + local max_height = parent_rect.height - (bound_rect.y2-frame_rect.y1+1) + if frame.t or not frame.b then + local min_pos = frame_rect.y1 - bound_rect.y1 + local requested_pos = mouse_pos.y - parent_rect.y1 - offset.y + frame.t = math.max(min_pos, math.min(max_height, requested_pos)) + end + if frame.b or not frame.t then + local min_pos = bound_rect.y2 - frame_rect.y2 + local requested_pos = parent_rect.y2 - mouse_pos.y + offset.y - + (frame_rect.y2 - frame_rect.y1) + frame.b = math.max(min_pos, math.min(max_height, requested_pos)) + end + if frame.l or not frame.r then + local min_pos = frame_rect.x1 - bound_rect.x1 + local requested_pos = mouse_pos.x - parent_rect.x1 - offset.x + frame.l = math.max(min_pos, math.min(max_width, requested_pos)) + end + if frame.r or not frame.l then + local min_pos = bound_rect.x2 - frame_rect.x2 + local requested_pos = parent_rect.x2 - mouse_pos.x + offset.x - + (frame_rect.x2 - frame_rect.x1) + frame.r = math.max(min_pos, math.min(max_width, requested_pos)) + end + return frame +end + +local function Panel_make_frame(self, mouse_pos) + mouse_pos = mouse_pos or xy2pos(dfhack.screen.getMousePos()) + return self.resize_edge and Panel_resize_frame(self, mouse_pos) + or Panel_drag_frame(self, mouse_pos) +end + +local function Panel_begin_drag(self, drag_offset, resize_edge) + Panel_update_frame(self, nil, true) + self.drag_offset = drag_offset or {x=0, y=0} + self.resize_edge = resize_edge + self.saved_frame = copyall(self.frame) + self.saved_frame_rect = gui.mkdims_wh( + self.frame_rect.x1+self.frame_parent_rect.x1, + self.frame_rect.y1+self.frame_parent_rect.y1, + self.frame_rect.width, + self.frame_rect.height) + self.prev_focus_owner = self.focus_group.cur + self:setFocus(true) + if self.resize_edge then + self:onResizeBegin() + else + self:onDragBegin() + end +end + +local function Panel_end_drag(self, frame, success) + success = not not success + if self.prev_focus_owner then + self.prev_focus_owner:setFocus(true) + else + self:setFocus(false) + end + local resize_edge = self.resize_edge + Panel_update_frame(self, frame, true) + if resize_edge then + self:onResizeEnd(success, self.frame) + else + self:onDragEnd(success, self.frame) + end +end + +local function Panel_on_double_click(self) + local a = self.resize_anchors + local can_vert, can_horiz = a.t or a.b, a.l or a.r + if not can_vert and not can_horiz then return false end + local f, rmin = self.frame, self.resize_min + local maximized = f.t == 0 and f.b == 0 and f.l == 0 and f.r == 0 + local frame + if maximized then + frame = { + t=not can_vert and f.t or nil, + l=not can_horiz and f.l or nil, + b=not can_vert and f.b or nil, + r=not can_horiz and f.r or nil, + w=can_vert and rmin.w or f.w, + h=can_horiz and rmin.h or f.h, + } + else + frame = { + t=can_vert and 0 or f.t, + l=can_horiz and 0 or f.l, + b=can_vert and 0 or f.b, + r=can_horiz and 0 or f.r + } + end + Panel_update_frame(self, frame, true) +end + +---@alias widgets.Keys +---| '_STRING' +---| '_MOUSE_L' +---| '_MOUSE_L_DOWN' +---| '_MOUSE_R' +---| '_MOUSE_R_DOWN' +---| '_MOUSE_M' +---| '_MOUSE_M_DOWN' + +---@param keys table +---@return boolean|nil +function Panel:onInput(keys) + if self.kbd_get_pos then + if keys.SELECT or keys.LEAVESCREEN or keys._MOUSE_R then + Panel_end_drag(self, not keys.SELECT and self.saved_frame or nil, + not not keys.SELECT) + return true + end + for code in pairs(keys) do + local dx, dy = guidm.get_movement_delta(code, 1, 10) + if dx then + local kbd_pos = self.kbd_get_pos() + kbd_pos.x = kbd_pos.x + dx + kbd_pos.y = kbd_pos.y + dy + Panel_update_frame(self, Panel_make_frame(self, kbd_pos)) + return true + end + end + return + end + if self.drag_offset then + if keys._MOUSE_R then + Panel_end_drag(self, self.saved_frame) + elseif keys._MOUSE_L_DOWN then + Panel_update_frame(self, Panel_make_frame(self)) + end + return true + end + if Panel.super.onInput(self, keys) then + return true + end + if not keys._MOUSE_L then return end + local x,y = self:getMouseFramePos() + if not x then return end + + if self.resizable and y == 0 then + local now_ms = dfhack.getTickCount() + if now_ms - self.last_title_click_ms <= common.DOUBLE_CLICK_MS then + self.last_title_click_ms = 0 + if Panel_on_double_click(self) then return true end + else + self.last_title_click_ms = now_ms + end + end + + local resize_edge = nil + if self.resizable then + local rect = gui.mkdims_wh( + self.frame_rect.x1+self.frame_parent_rect.x1, + self.frame_rect.y1+self.frame_parent_rect.y1, + self.frame_rect.width, + self.frame_rect.height) + if self.resize_anchors.r and self.resize_anchors.b + and x == rect.x2 - rect.x1 and y == rect.y2 - rect.y1 then + resize_edge = 'rb' + elseif self.resize_anchors.l and self.resize_anchors.b + and x == 0 and y == rect.y2 - rect.y1 then + resize_edge = 'lb' + elseif self.resize_anchors.r and self.resize_anchors.t + and x == rect.x2 - rect.x1 and y == 0 then + resize_edge = 'rt' + elseif self.resize_anchors.r and self.resize_anchors.t + and x == 0 and y == 0 then + resize_edge = 'lt' + elseif self.resize_anchors.r and x == rect.x2 - rect.x1 then + resize_edge = 'r' + elseif self.resize_anchors.l and x == 0 then + resize_edge = 'l' + elseif self.resize_anchors.b and y == rect.y2 - rect.y1 then + resize_edge = 'b' + elseif self.resize_anchors.t and y == 0 then + resize_edge = 't' + end + end + + local is_dragging = false + if not resize_edge and self.draggable then + local on_body = self:getMousePos() + is_dragging = (self.drag_anchors.title and self.frame_style and y == 0) + or (self.drag_anchors.frame and not on_body) -- includes inset + or (self.drag_anchors.body and on_body) + end + + if resize_edge or is_dragging then + Panel_begin_drag(self, {x=x, y=y}, resize_edge) + return true + end +end + +---@param enabled boolean +function Panel:setKeyboardDragEnabled(enabled) + if (enabled and self.kbd_get_pos) + or (not enabled and not self.kbd_get_pos) then + return + end + if enabled then + local kbd_get_pos = function() + return { + x=self.frame_rect.x1+self.frame_parent_rect.x1, + y=self.frame_rect.y1+self.frame_parent_rect.y1 + } + end + Panel_begin_drag(self) + self.kbd_get_pos = kbd_get_pos + else + Panel_end_drag(self) + end +end + +local function Panel_get_resize_data(self) + local resize_anchors = self.resize_anchors + local frame_rect = gui.mkdims_wh( + self.frame_rect.x1+self.frame_parent_rect.x1, + self.frame_rect.y1+self.frame_parent_rect.y1, + self.frame_rect.width, + self.frame_rect.height) + if resize_anchors.r and resize_anchors.b then + return 'rb', function() + return {x=frame_rect.x2, y=frame_rect.y2} end + elseif resize_anchors.l and resize_anchors.b then + return 'lb', function() + return {x=frame_rect.x1, y=frame_rect.y2} end + elseif resize_anchors.r and resize_anchors.t then + return 'rt', function() + return {x=frame_rect.x2, y=frame_rect.y1} end + elseif resize_anchors.l and resize_anchors.t then + return 'lt', function() + return {x=frame_rect.x1, y=frame_rect.y1} end + elseif resize_anchors.b then + return 'b', function() + return {x=(frame_rect.x1+frame_rect.x2)//2, + y=frame_rect.y2} end + elseif resize_anchors.r then + return 'r', function() + return {x=frame_rect.x2, + y=(frame_rect.y1+frame_rect.y2)//2} end + elseif resize_anchors.l then + return 'l', function() + return {x=frame_rect.x1, + y=(frame_rect.y1+frame_rect.y2)//2} end + elseif resize_anchors.t then + return 't', function() + return {x=(frame_rect.x1+frame_rect.x2)//2, + y=frame_rect.y1} end + end +end + +---@param enabled boolean +function Panel:setKeyboardResizeEnabled(enabled) + if (enabled and self.kbd_get_pos) + or (not enabled and not self.kbd_get_pos) then + return + end + if enabled then + local resize_edge, kbd_get_pos = Panel_get_resize_data(self) + if not resize_edge then + dfhack.printerr('cannot resize window: no anchors are enabled') + else + Panel_begin_drag(self, kbd_get_pos(), resize_edge) + self.kbd_get_pos = kbd_get_pos + end + else + Panel_end_drag(self) + end +end + +function Panel:onRenderBody(dc) + if self.on_render then self.on_render(dc) end +end + +function Panel:computeFrame(parent_rect) + local sw, sh = parent_rect.width, parent_rect.height + if self.frame then + if self.frame.t and self.frame.h and self.frame.t + self.frame.h > sh then + self.frame.t = math.max(0, sh - self.frame.h) + end + if self.frame.b and self.frame.h and self.frame.b + self.frame.h > sh then + self.frame.b = math.max(0, sh - self.frame.h) + end + if self.frame.l and self.frame.w and self.frame.l + self.frame.w > sw then + self.frame.l = math.max(0, sw - self.frame.w) + end + if self.frame.r and self.frame.w and self.frame.r + self.frame.w > sw then + self.frame.r = math.max(0, sw - self.frame.w) + end + end + return gui.compute_frame_body(sw, sh, self.frame, self.frame_inset, + self.frame_style and 1 or 0) +end + +function Panel:postComputeFrame(body) + if self.on_layout then self.on_layout(body) end +end + +-- if self.autoarrange_subviews is true, lay out visible subviews vertically, +-- adding gaps between widgets according to self.autoarrange_gap. +function Panel:postUpdateLayout() + -- don't leave artifacts behind on the parent screen when we move + gui.Screen.request_full_screen_refresh = true + + if not self.autoarrange_subviews then return end + + local gap = self.autoarrange_gap + local y = 0 + for _,subview in ipairs(self.subviews) do + if not subview.frame then goto continue end + subview.frame.t = y + if getval(subview.visible) then + y = y + (subview.frame.h or 0) + gap + end + ::continue:: + end + + -- let widgets adjust to their new positions + self:updateSubviewLayout() +end + +function Panel:onRenderFrame(dc, rect) + Panel.super.onRenderFrame(self, dc, rect) + if not self.frame_style then return end + local inactive = self.parent_view and self.parent_view.hasFocus + and not self.parent_view:hasFocus() + local pause_forced = not self.no_force_pause_badge and safe_index(self.parent_view, 'force_pause') + gui.paint_frame(dc, rect, self.frame_style, self.frame_title, inactive, + pause_forced, self.resizable) + if self.kbd_get_pos then + local pos = self.kbd_get_pos() + local pen = to_pen{fg=COLOR_GREEN, bg=COLOR_BLACK} + dc:seek(pos.x, pos.y):pen(pen):char(string.char(0xDB)) + end + if self.drag_offset and not self.kbd_get_pos + and df.global.enabler.mouse_lbut_down == 0 then + Panel_end_drag(self, nil, true) + end +end + +function Panel:onDragBegin() + if self.on_drag_begin then self.on_drag_begin() end +end + +function Panel:onDragEnd(success, new_frame) + if self.on_drag_end then self.on_drag_end(success, new_frame) end +end + +function Panel:onResizeBegin() + if self.on_resize_begin then self.on_resize_begin() end +end + +function Panel:onResizeEnd(success, new_frame) + if self.on_resize_end then self.on_resize_end(success, new_frame) end +end + +return Panel diff --git a/library/lua/gui/widgets/containers/resizing_panel.lua b/library/lua/gui/widgets/containers/resizing_panel.lua new file mode 100644 index 0000000000..b20268ba08 --- /dev/null +++ b/library/lua/gui/widgets/containers/resizing_panel.lua @@ -0,0 +1,58 @@ +local gui = require('gui') +local utils = require('utils') +local Panel = require('gui.widgets.containers.panel') + +local getval = utils.getval + +------------------- +-- ResizingPanel -- +------------------- + +---@class widgets.ResizingPanel.attrs: widgets.Panel.attrs +---@field auto_height boolean +---@field auto_width boolean + +---@class widgets.ResizingPanel.attrs.partial: widgets.ResizingPanel.attrs + +---@class widgets.ResizingPanel: widgets.Panel, widgets.ResizingPanel.attrs +---@field super widgets.Panel +---@field ATTRS widgets.ResizingPanel.attrs|fun(attributes: widgets.ResizingPanel.attrs.partial) +---@overload fun(init_table: widgets.ResizingPanel.attrs.partial): self +ResizingPanel = defclass(ResizingPanel, Panel) + +ResizingPanel.ATTRS{ + auto_height = true, + auto_width = false, +} + +-- adjust our frame dimensions according to positions and sizes of our subviews +function ResizingPanel:postUpdateLayout(frame_body) + local w, h = 0, 0 + for _,s in ipairs(self.subviews) do + if getval(s.visible) then + w = math.max(w, (s.frame and s.frame.l or 0) + + (s.frame and s.frame.w or frame_body.width)) + h = math.max(h, (s.frame and s.frame.t or 0) + + (s.frame and s.frame.h or frame_body.height)) + end + end + local l,t,r,b = gui.parse_inset(self.frame_inset) + w = w + l + r + h = h + t + b + if self.frame_style then + w = w + 2 + h = h + 2 + end + if not self.frame then self.frame = {} end + local oldw, oldh = self.frame.w, self.frame.h + if not self.auto_height then h = oldh end + if not self.auto_width then w = oldw end + self.frame.w, self.frame.h = w, h + if not self._updateLayoutGuard and (oldw ~= w or oldh ~= h) then + self._updateLayoutGuard = true -- protect against infinite loops + self:updateLayout() -- our frame has changed, we need to fully refresh + end + self._updateLayoutGuard = nil +end + +return ResizingPanel diff --git a/library/lua/gui/widgets/containers/window.lua b/library/lua/gui/widgets/containers/window.lua new file mode 100644 index 0000000000..941ad8cc48 --- /dev/null +++ b/library/lua/gui/widgets/containers/window.lua @@ -0,0 +1,28 @@ +local gui = require('gui') +local Panel = require('gui.widgets.containers.panel') + +------------ +-- Window -- +------------ + +---@class widgets.Window.attrs: widgets.Panel.attrs +---@field frame_style gui.Frame|fun(): gui.Frame +---@field frame_background dfhack.color|dfhack.pen +---@field frame_inset integer + +---@class widgets.Window.attrs.partial: widgets.Window.attrs + +---@class widgets.Window: widgets.Panel, widgets.Window.attrs +---@field super widgets.Panel +---@field ATTRS widgets.Window.attrs|fun(attributes: widgets.Window.attrs.partial) +---@overload fun(init_table: widgets.Window.attrs.partial): self +Window = defclass(Window, Panel) + +Window.ATTRS { + frame_style = gui.WINDOW_FRAME, + frame_background = gui.CLEAR_PEN, + frame_inset = 1, + draggable = true, +} + +return Window diff --git a/library/lua/gui/widgets/dimensions_tooltip.lua b/library/lua/gui/widgets/dimensions_tooltip.lua new file mode 100644 index 0000000000..6114c26d58 --- /dev/null +++ b/library/lua/gui/widgets/dimensions_tooltip.lua @@ -0,0 +1,83 @@ +local gui = require('gui') +local ResizingPanel = require('gui.widgets.containers.resizing_panel') +local Panel = require('gui.widgets.containers.panel') + +-------------------------------- +-- DimensionsTooltip +-------------------------------- + +---@class widgets.DimensionsTooltip.attrs: widgets.ResizingPanel.attrs +---@field display_offset? df.coord2d +---@field get_anchor_pos_fn fun(): df.coord? + +---@class widgets.DimensionsTooltip: widgets.ResizingPanel +---@field super widgets.ResizingPanel +---@overload fun(init_table: widgets.DimensionsTooltip.attrs): self +DimensionsTooltip = defclass(DimensionsTooltip, ResizingPanel) + +DimensionsTooltip.ATTRS{ + frame_style=gui.FRAME_THIN, + frame_background=gui.CLEAR_PEN, + no_force_pause_badge=true, + auto_width=true, + display_offset={x=3, y=3}, + get_anchor_pos_fn=DEFAULT_NIL, +} + +local function get_cur_area_dims(anchor_pos) + local mouse_pos = dfhack.gui.getMousePos(true) + if not mouse_pos or not anchor_pos then return 1, 1, 1 end + + -- clamp to map edges (you can start a selection from out of bounds) + mouse_pos = xyz2pos( + math.max(0, math.min(df.global.world.map.x_count-1, mouse_pos.x)), + math.max(0, math.min(df.global.world.map.y_count-1, mouse_pos.y)), + math.max(0, math.min(df.global.world.map.z_count-1, mouse_pos.z))) + anchor_pos = xyz2pos( + math.max(0, math.min(df.global.world.map.x_count-1, anchor_pos.x)), + math.max(0, math.min(df.global.world.map.y_count-1, anchor_pos.y)), + math.max(0, math.min(df.global.world.map.z_count-1, anchor_pos.z))) + + return math.abs(mouse_pos.x - anchor_pos.x) + 1, + math.abs(mouse_pos.y - anchor_pos.y) + 1, + math.abs(mouse_pos.z - anchor_pos.z) + 1 +end + +function DimensionsTooltip:init() + ensure_key(self, 'frame').w = 17 + self.frame.h = 4 + + self.label = Label{ + frame={t=0}, + auto_width=true, + text={ + { + text=function() + local anchor_pos = self.get_anchor_pos_fn() + return ('%dx%dx%d'):format(get_cur_area_dims(anchor_pos)) + end + } + }, + } + + self:addviews{ + Panel{ + -- set minimum size for tooltip frame so the DFHack frame badge fits + frame={t=0, l=0, w=7, h=2}, + }, + self.label, + } +end + +function DimensionsTooltip:render(dc) + local x, y = dfhack.screen.getMousePos() + if not x or not self.get_anchor_pos_fn() then return end + local sw, sh = dfhack.screen.getWindowSize() + local frame_width = math.max(9, self.label:getTextWidth() + 2) + self.frame.l = math.min(x + self.display_offset.x, sw - frame_width) + self.frame.t = math.min(y + self.display_offset.y, sh - self.frame.h) + self:updateLayout() + DimensionsTooltip.super.render(self, dc) +end + +return DimensionsTooltip diff --git a/library/lua/gui/widgets/divider.lua b/library/lua/gui/widgets/divider.lua new file mode 100644 index 0000000000..a10a7a5d6b --- /dev/null +++ b/library/lua/gui/widgets/divider.lua @@ -0,0 +1,91 @@ +local gui = require('gui') +local Widget = require('gui.widgets.widget') + +------------- +-- Divider -- +------------- + +---@class widgets.Divider.attrs: widgets.Widget.attrs +---@field frame_style gui.Frame|fun(): gui.Frame +---@field interior boolean +---@field frame_style_t? false|gui.Frame|fun(): gui.Frame +---@field frame_style_b? false|gui.Frame|fun(): gui.Frame +---@field frame_style_l? false|gui.Frame|fun(): gui.Frame +---@field frame_style_r? false|gui.Frame|fun(): gui.Frame +---@field interior_t? boolean +---@field interior_b? boolean +---@field interior_l? boolean +---@field interior_r? boolean + +---@class widgets.Divider.attrs.partial: widgets.Divider.attrs + +---@class widgets.Divider: widgets.Widget, widgets.Divider.attrs +---@field super widgets.Widget +---@field ATTRS widgets.Divider.attrs|fun(attributes: widgets.Divider.attrs.partial) +---@overload fun(init_table: widgets.Divider.attrs.partial): self +Divider = defclass(Divider, Widget) + +Divider.ATTRS{ + frame_style=gui.FRAME_THIN, + interior=false, + frame_style_t=DEFAULT_NIL, + interior_t=DEFAULT_NIL, + frame_style_b=DEFAULT_NIL, + interior_b=DEFAULT_NIL, + frame_style_l=DEFAULT_NIL, + interior_l=DEFAULT_NIL, + frame_style_r=DEFAULT_NIL, + interior_r=DEFAULT_NIL, +} + +local function divider_get_val(self, base_name, edge_name) + local val = self[base_name..'_'..edge_name] + if val ~= nil then return val end + return self[base_name] +end + +local function divider_get_junction_pen(self, edge_name) + local interior = divider_get_val(self, 'interior', edge_name) + local pen_name = ('%sT%s_frame_pen'):format(edge_name, interior and 'i' or 'e') + local frame_style = divider_get_val(self, 'frame_style', edge_name) + if type(frame_style) == 'function' then + frame_style = frame_style() + end + return frame_style[pen_name] +end + +---@param dc gui.Painter +function Divider:onRenderBody(dc) + local rect, style = self.frame_rect, self.frame_style + if type(style) == 'function' then + style = style() + end + + if rect.height == 1 and rect.width == 1 then + dc:seek(0, 0):char(nil, style.x_frame_pen) + elseif rect.width == 1 then + local fill_start, fill_end = 0, rect.height-1 + if self.frame_style_t ~= false then + fill_start = 1 + dc:seek(0, 0):char(nil, divider_get_junction_pen(self, 't')) + end + if self.frame_style_b ~= false then + fill_end = rect.height-2 + dc:seek(0, rect.height-1):char(nil, divider_get_junction_pen(self, 'b')) + end + dc:fill(0, fill_start, 0, fill_end, style.v_frame_pen) + else + local fill_start, fill_end = 0, rect.width-1 + if self.frame_style_l ~= false then + fill_start = 1 + dc:seek(0, 0):char(nil, divider_get_junction_pen(self, 'l')) + end + if self.frame_style_r ~= false then + fill_end = rect.width-2 + dc:seek(rect.width-1, 0):char(nil, divider_get_junction_pen(self, 'r')) + end + dc:fill(fill_start, 0, fill_end, 0, style.h_frame_pen) + end +end + +return Divider diff --git a/library/lua/gui/widgets/edit_field.lua b/library/lua/gui/widgets/edit_field.lua new file mode 100644 index 0000000000..8457b88f51 --- /dev/null +++ b/library/lua/gui/widgets/edit_field.lua @@ -0,0 +1,234 @@ +local gui = require('gui') +local utils = require('utils') +local Widget = require('gui.widgets.widget') +local HotkeyLabel = require('gui.widgets.labels.hotkey_label') + +local getval = utils.getval + +---------------- +-- Edit field -- +---------------- + +---@class widgets.EditField.attrs: widgets.Widget.attrs +---@field label_text? string +---@field text string +---@field text_pen? dfhack.color|dfhack.pen +---@field on_char? function +---@field on_change? function +---@field on_submit? function +---@field on_submit2? function +---@field key? string +---@field key_sep? string +---@field modal boolean +---@field ignore_keys? string[] + +---@class widgets.EditField.attrs.partial: widgets.EditField.attrs + +---@class widgets.EditField: widgets.Widget, widgets.EditField.attrs +---@field super widgets.Widget +---@field ATTRS widgets.EditField.attrs|fun(attributes: widgets.EditField.attrs.partial) +---@overload fun(init_table: widgets.EditField.attrs.partial): self +EditField = defclass(EditField, Widget) + +EditField.ATTRS{ + label_text = DEFAULT_NIL, + text = '', + text_pen = DEFAULT_NIL, + on_char = DEFAULT_NIL, + on_change = DEFAULT_NIL, + on_submit = DEFAULT_NIL, + on_submit2 = DEFAULT_NIL, + key = DEFAULT_NIL, + key_sep = DEFAULT_NIL, + modal = false, + ignore_keys = DEFAULT_NIL, +} + +---@param init_table widgets.EditField.attrs +function EditField:preinit(init_table) + init_table.frame = init_table.frame or {} + init_table.frame.h = init_table.frame.h or 1 +end + +function EditField:init() + local function on_activate() + self.saved_text = self.text + self:setFocus(true) + end + + self.start_pos = 1 + self.cursor = #self.text + 1 + + self:addviews{HotkeyLabel{frame={t=0,l=0}, + key=self.key, + key_sep=self.key_sep, + label=self.label_text, + on_activate=self.key and on_activate or nil}} +end + +function EditField:getPreferredFocusState() + return not self.key +end + +function EditField:setCursor(cursor) + if not cursor or cursor > #self.text then + self.cursor = #self.text + 1 + return + end + self.cursor = math.max(1, cursor) +end + +function EditField:setText(text, cursor) + local old = self.text + self.text = text + self:setCursor(cursor) + if self.on_change and text ~= old then + self.on_change(self.text, old) + end +end + +function EditField:postUpdateLayout() + self.text_offset = self.subviews[1]:getTextWidth() +end + +---@param dc gui.Painter +function EditField:onRenderBody(dc) + dc:pen(self.text_pen or COLOR_LIGHTCYAN) + + local cursor_char = '_' + if not getval(self.active) or not self.focus or gui.blink_visible(300) then + cursor_char = (self.cursor > #self.text) and ' ' or + self.text:sub(self.cursor, self.cursor) + end + local txt = self.text:sub(1, self.cursor - 1) .. cursor_char .. + self.text:sub(self.cursor + 1) + local max_width = dc.width - self.text_offset + self.start_pos = 1 + if #txt > max_width then + -- get the substring in the vicinity of the cursor + max_width = max_width - 2 + local half_width = math.floor(max_width/2) + local start_pos = math.max(1, self.cursor-half_width) + local end_pos = math.min(#txt, self.cursor+half_width-1) + if self.cursor + half_width > #txt then + start_pos = #txt - (max_width - 1) + end + if self.cursor - half_width <= 1 then + end_pos = max_width + 1 + end + self.start_pos = start_pos > 1 and start_pos - 1 or start_pos + txt = ('%s%s%s'):format(start_pos == 1 and '' or string.char(27), + txt:sub(start_pos, end_pos), + end_pos == #txt and '' or string.char(26)) + end + dc:advance(self.text_offset):string(txt) +end + +function EditField:insert(text) + local old = self.text + self:setText(old:sub(1,self.cursor-1)..text..old:sub(self.cursor), + self.cursor + #text) +end + +function EditField:onInput(keys) + if not self.focus then + -- only react to our hotkey + return self:inputToSubviews(keys) + end + + if self.ignore_keys then + for _,ignore_key in ipairs(self.ignore_keys) do + if keys[ignore_key] then return false end + end + end + + if self.key and (keys.LEAVESCREEN or keys._MOUSE_R) then + self:setText(self.saved_text) + self:setFocus(false) + return true + end + + if keys.SELECT or keys.SELECT_ALL then + if self.key then + self:setFocus(false) + end + if keys.SELECT_ALL then + if self.on_submit2 then + self.on_submit2(self.text) + return true + end + else + if self.on_submit then + self.on_submit(self.text) + return true + end + end + return not not self.key + elseif keys.CUSTOM_DELETE then + local old = self.text + local del_pos = self.cursor + if del_pos <= #old then + self:setText(old:sub(1, del_pos-1) .. old:sub(del_pos+1), del_pos) + end + return true + elseif keys._STRING then + local old = self.text + if keys._STRING == 0 then + -- handle backspace + local del_pos = self.cursor - 1 + if del_pos > 0 then + self:setText(old:sub(1, del_pos-1) .. old:sub(del_pos+1), del_pos) + end + else + local cv = string.char(keys._STRING) + if not self.on_char or self.on_char(cv, old) then + self:insert(cv) + elseif self.on_char then + return self.modal + end + end + return true + elseif keys.KEYBOARD_CURSOR_LEFT then + self:setCursor(self.cursor - 1) + return true + elseif keys.CUSTOM_CTRL_LEFT then -- back one word + local _, prev_word_end = self.text:sub(1, self.cursor-1): + find('.*[%w_%-][^%w_%-]') + self:setCursor(prev_word_end or 1) + return true + elseif keys.CUSTOM_HOME then + self:setCursor(1) + return true + elseif keys.KEYBOARD_CURSOR_RIGHT then + self:setCursor(self.cursor + 1) + return true + elseif keys.CUSTOM_CTRL_RIGHT then -- forward one word + local _,next_word_start = self.text:find('[^%w_%-][%w_%-]', self.cursor) + self:setCursor(next_word_start) + return true + elseif keys.CUSTOM_END then + self:setCursor() + return true + elseif keys.CUSTOM_CTRL_C then + dfhack.internal.setClipboardTextCp437(self.text) + return true + elseif keys.CUSTOM_CTRL_X then + dfhack.internal.setClipboardTextCp437(self.text) + self:setText('') + return true + elseif keys.CUSTOM_CTRL_V then + self:insert(dfhack.internal.getClipboardTextCp437()) + return true + elseif keys._MOUSE_L_DOWN then + local mouse_x = self:getMousePos() + if mouse_x then + self:setCursor(self.start_pos + mouse_x - (self.text_offset or 0)) + return true + end + end + + -- if we're modal, then unconditionally eat all the input + return self.modal +end + +return EditField diff --git a/library/lua/gui/widgets/filtered_list.lua b/library/lua/gui/widgets/filtered_list.lua new file mode 100644 index 0000000000..20d67881d3 --- /dev/null +++ b/library/lua/gui/widgets/filtered_list.lua @@ -0,0 +1,226 @@ +local utils = require('utils') +local Widget = require('gui.widgets.widget') +local EditField = require('gui.widgets.edit_field') +local List = require('gui.widgets.list') + +local getval = utils.getval + +---@param tab? table +---@param idx integer +---@return any|integer +local function map_opttab(tab,idx) + if tab then + return tab[idx] + else + return idx + end +end + +------------------- +-- Filtered List -- +------------------- + + +---@class widgets.FilteredList.attrs: widgets.Widget.attrs +---@field choices widgets.ListChoice[] +---@field selected? integer +---@field edit_pen dfhack.color|dfhack.pen +---@field edit_below boolean +---@field edit_key? string +---@field edit_ignore_keys? string[] +---@field edit_on_char? function +---@field edit_on_change? function +---@field list widgets.List +---@field edit widgets.EditField +---@field not_found widgets.Label + +---@class widgets.FilteredList.attrs.partial: widgets.FilteredList.attrs + +---@class widgets.FilteredList.initTable: widgets.FilteredList.attrs.partial, widgets.List.attrs.partial, widgets.EditField.attrs.partial +---@field not_found_label? string + +---@class widgets.FilteredList: widgets.Widget, widgets.FilteredList.attrs +---@field super widgets.Widget +---@field ATTRS widgets.FilteredList.attrs|fun(attributes: widgets.FilteredList.attrs.partial) +---@overload fun(init_table: widgets.FilteredList.initTable): self +FilteredList = defclass(FilteredList, Widget) + +FilteredList.ATTRS { + edit_below = false, + edit_key = DEFAULT_NIL, + edit_ignore_keys = DEFAULT_NIL, + edit_on_char = DEFAULT_NIL, + edit_on_change = DEFAULT_NIL, +} + +---@param self widgets.FilteredList +---@param info widgets.FilteredList.initTable +function FilteredList:init(info) + local on_change = self:callback('onFilterChange') + if self.edit_on_change then + on_change = function(text) + self.edit_on_change(text) + self:onFilterChange(text) + end + end + + self.edit = EditField{ + text_pen = info.edit_pen or info.cursor_pen, + frame = { l = info.icon_width, t = 0, h = 1 }, + on_change = on_change, + on_char = self.edit_on_char, + key = self.edit_key, + ignore_keys = self.edit_ignore_keys, + } + self.list = List{ + frame = { t = 2 }, + text_pen = info.text_pen, + cursor_pen = info.cursor_pen, + inactive_pen = info.inactive_pen, + icon_pen = info.icon_pen, + row_height = info.row_height, + scroll_keys = info.scroll_keys, + icon_width = info.icon_width, + } + if self.edit_below then + self.edit.frame = { l = info.icon_width, b = 0, h = 1 } + self.list.frame = { t = 0, b = 2 } + end + if info.on_select then + self.list.on_select = function() + return info.on_select(self:getSelected()) + end + end + if info.on_submit then + self.list.on_submit = function() + return info.on_submit(self:getSelected()) + end + end + if info.on_submit2 then + self.list.on_submit2 = function() + return info.on_submit2(self:getSelected()) + end + end + if info.on_double_click then + self.list.on_double_click = function() + return info.on_double_click(self:getSelected()) + end + end + if info.on_double_click2 then + self.list.on_double_click2 = function() + return info.on_double_click2(self:getSelected()) + end + end + self.not_found = Label{ + visible = true, + text = info.not_found_label or 'No matches', + text_pen = COLOR_LIGHTRED, + frame = { l = info.icon_width, t = self.list.frame.t }, + } + self:addviews{ self.edit, self.list, self.not_found } + if info.choices then + self:setChoices(info.choices, info.selected) + else + self.choices = {} + end +end + +function FilteredList:getChoices() + return self.choices +end + +function FilteredList:getVisibleChoices() + return self.list.choices +end + +function FilteredList:setChoices(choices, pos) + choices = choices or {} + self.edit:setText('') + self.list:setChoices(choices, pos) + self.choices = self.list.choices + self.not_found.visible = (#choices == 0) +end + +function FilteredList:submit() + return self.list:submit() +end + +function FilteredList:submit2() + return self.list:submit2() +end + +function FilteredList:canSubmit() + return not self.not_found.visible +end + +function FilteredList:getSelected() + local i,v = self.list:getSelected() + if i then + return map_opttab(self.choice_index, i), v + end +end + +function FilteredList:getContentWidth() + return self.list:getContentWidth() +end + +function FilteredList:getContentHeight() + return self.list:getContentHeight() + 2 +end + +function FilteredList:getFilter() + return self.edit.text, self.list.choices +end + +function FilteredList:setFilter(filter, pos) + local choices = self.choices + local cidx = nil + + filter = filter or '' + if filter ~= self.edit.text then + self.edit:setText(filter) + end + + if filter ~= '' then + local tokens = filter:split() + local ipos = pos + + choices = {} + cidx = {} + pos = nil + + for i,v in ipairs(self.choices) do + local search_key = v.search_key + if not search_key then + if type(v.text) ~= 'table' then + search_key = v.text + else + local texts = {} + for _,token in ipairs(v.text) do + table.insert(texts, + type(token) == 'string' and token + or getval(token.text) or '') + end + search_key = table.concat(texts, ' ') + end + end + if utils.search_text(search_key, tokens) then + table.insert(choices, v) + cidx[#choices] = i + if ipos == i then + pos = #choices + end + end + end + end + + self.choice_index = cidx + self.list:setChoices(choices, pos) + self.not_found.visible = (#choices == 0) +end + +function FilteredList:onFilterChange(text) + self:setFilter(text) +end + +return FilteredList diff --git a/library/lua/gui/widgets/labels/cycle_hotkey_label.lua b/library/lua/gui/widgets/labels/cycle_hotkey_label.lua new file mode 100644 index 0000000000..df4bf38d59 --- /dev/null +++ b/library/lua/gui/widgets/labels/cycle_hotkey_label.lua @@ -0,0 +1,145 @@ +local utils = require('utils') +local Label = require('gui.widgets.labels.label') + +local getval = utils.getval + +local function is_disabled(token) + return (token.disabled ~= nil and getval(token.disabled)) or + (token.enabled ~= nil and not getval(token.enabled)) +end + +---------------------- +-- CycleHotkeyLabel -- +---------------------- + +---@class widgets.CycleHotkeyLabel.attrs: widgets.Label.attrs +---@field key? string +---@field key_back? string +---@field key_sep string +---@field label? string|fun(): string +---@field label_width? integer +---@field label_below boolean +---@field option_gap integer +---@field options? { label: string, value: string, pen: dfhack.pen|nil }[] +---@field initial_option integer|string +---@field on_change? fun(new_option_value: integer|string, old_option_value: integer|string) + +---@class widgets.CycleHotkeyLabel.attrs.partial: widgets.CycleHotkeyLabel.attrs + +---@class widgets.CycleHotkeyLabel: widgets.Label, widgets.CycleHotkeyLabel.attrs +---@field super widgets.Label +---@field ATTRS widgets.CycleHotkeyLabel.attrs|fun(attributes: widgets.CycleHotkeyLabel.attrs.partial) +---@overload fun(init_table: widgets.CycleHotkeyLabel.attrs.partial) +CycleHotkeyLabel = defclass(CycleHotkeyLabel, Label) + +CycleHotkeyLabel.ATTRS{ + key=DEFAULT_NIL, + key_back=DEFAULT_NIL, + key_sep=': ', + option_gap=1, + label=DEFAULT_NIL, + label_width=DEFAULT_NIL, + label_below=false, + options=DEFAULT_NIL, + initial_option=1, + on_change=DEFAULT_NIL, +} + +function CycleHotkeyLabel:init() + self:setOption(self.initial_option) + + if self.label_below then + self.option_gap = self.option_gap + (self.key_back and 1 or 0) + (self.key and 2 or 0) + end + + self:setText{ + self.key_back ~= nil and {key=self.key_back, key_sep='', width=0, on_activate=self:callback('cycle', true)} or {}, + {key=self.key, key_sep=self.key_sep, text=self.label, width=self.label_width, + on_activate=self:callback('cycle')}, + self.label_below and NEWLINE or '', + {gap=self.option_gap, text=self:callback('getOptionLabel'), + pen=self:callback('getOptionPen')}, + } +end + +-- CycleHotkeyLabels are always clickable and therefore should always change +-- color when hovered. +function CycleHotkeyLabel:shouldHover() + return true +end + +function CycleHotkeyLabel:cycle(backwards) + local old_option_idx = self.option_idx + if self.option_idx == #self.options and not backwards then + self.option_idx = 1 + elseif self.option_idx == 1 and backwards then + self.option_idx = #self.options + else + self.option_idx = self.option_idx + (not backwards and 1 or -1) + end + if self.on_change then + self.on_change(self:getOptionValue(), + self:getOptionValue(old_option_idx)) + end +end + +function CycleHotkeyLabel:setOption(value_or_index, call_on_change) + local option_idx = nil + for i in ipairs(self.options) do + if value_or_index == self:getOptionValue(i) then + option_idx = i + break + end + end + if not option_idx then + if self.options[value_or_index] then + option_idx = value_or_index + end + end + if not option_idx then + option_idx = 1 + end + local old_option_idx = self.option_idx + self.option_idx = option_idx + if call_on_change and self.on_change then + self.on_change(self:getOptionValue(), + self:getOptionValue(old_option_idx)) + end +end + +local function cyclehotkeylabel_getOptionElem(self, option_idx, key, require_key) + option_idx = option_idx or self.option_idx + local option = self.options[option_idx] + if type(option) == 'table' then + return option[key] + end + return not require_key and option or nil +end + +function CycleHotkeyLabel:getOptionLabel(option_idx) + return getval(cyclehotkeylabel_getOptionElem(self, option_idx, 'label')) +end + +function CycleHotkeyLabel:getOptionValue(option_idx) + return cyclehotkeylabel_getOptionElem(self, option_idx, 'value') +end + +function CycleHotkeyLabel:getOptionPen(option_idx) + local pen = getval(cyclehotkeylabel_getOptionElem(self, option_idx, 'pen', true)) + if type(pen) == 'string' then return nil end + return pen +end + +function CycleHotkeyLabel:onInput(keys) + if CycleHotkeyLabel.super.onInput(self, keys) then + return true + elseif keys._MOUSE_L and not is_disabled(self) then + local x = self:getMousePos() + if x then + self:cycle(self.key_back and x == 0) + return true + end + end +end + +return CycleHotkeyLabel diff --git a/library/lua/gui/widgets/labels/hotkey_label.lua b/library/lua/gui/widgets/labels/hotkey_label.lua new file mode 100644 index 0000000000..2070cba95f --- /dev/null +++ b/library/lua/gui/widgets/labels/hotkey_label.lua @@ -0,0 +1,70 @@ +local utils = require('utils') +local Label = require('gui.widgets.labels.label') + +local getval = utils.getval + +local function is_disabled(token) + return (token.disabled ~= nil and getval(token.disabled)) or + (token.enabled ~= nil and not getval(token.enabled)) +end + +----------------- +-- HotkeyLabel -- +----------------- + +---@class widgets.HotkeyLabel.attrs: widgets.Label.attrs +---@field key? string +---@field key_sep string +---@field label? string|fun(): string +---@field on_activate? function + +---@class widgets.HotkeyLabel.attrs.partial: widgets.HotkeyLabel.attrs + +---@class widgets.HotkeyLabel: widgets.Label, widgets.HotkeyLabel.attrs +---@field super widgets.Label +---@field ATTRS widgets.HotkeyLabel.attrs|fun(attributes: widgets.HotkeyLabel.attrs.partial) +---@overload fun(init_table: widgets.HotkeyLabel.attrs.partial): self +HotkeyLabel = defclass(HotkeyLabel, Label) + +HotkeyLabel.ATTRS{ + key=DEFAULT_NIL, + key_sep=': ', + label=DEFAULT_NIL, + on_activate=DEFAULT_NIL, +} + +function HotkeyLabel:init() + self:initializeLabel() +end + +function HotkeyLabel:setOnActivate(on_activate) + self.on_activate = on_activate + self:initializeLabel() +end + +function HotkeyLabel:setLabel(label) + self.label = label + self:initializeLabel() +end + +function HotkeyLabel:shouldHover() + -- When on_activate is set, text should also hover on mouseover + return self.on_activate or HotkeyLabel.super.shouldHover(self) +end + +function HotkeyLabel:initializeLabel() + self:setText{{key=self.key, key_sep=self.key_sep, text=self.label, + on_activate=self.on_activate}} +end + +function HotkeyLabel:onInput(keys) + if HotkeyLabel.super.onInput(self, keys) then + return true + elseif keys._MOUSE_L and self:getMousePos() and self.on_activate + and not is_disabled(self) then + self.on_activate() + return true + end +end + +return HotkeyLabel diff --git a/library/lua/gui/widgets/labels/label.lua b/library/lua/gui/widgets/labels/label.lua new file mode 100644 index 0000000000..3ea25b5137 --- /dev/null +++ b/library/lua/gui/widgets/labels/label.lua @@ -0,0 +1,556 @@ +local gui = require('gui') +local utils = require('utils') +local common = require('gui.widgets.common') +local Widget = require('gui.widgets.widget') +local Scrollbar = require('gui.widgets.scrollbar') + +local getval = utils.getval +local to_pen = dfhack.pen.parse + +----------- +-- Label -- +----------- + +function parse_label_text(obj) + local text = obj.text or {} + if type(text) ~= 'table' then + text = { text } + end + local curline = nil + local out = { } + local active = nil + local idtab = nil + for _,v in ipairs(text) do + local vv + if type(v) == 'string' then + vv = v:split(NEWLINE) + else + vv = { v } + end + + for i = 1,#vv do + local cv = vv[i] + if i > 1 then + if not curline then + table.insert(out, {}) + end + curline = nil + end + if cv ~= '' then + if not curline then + curline = {} + table.insert(out, curline) + end + + if type(cv) == 'string' then + table.insert(curline, { text = cv }) + else + table.insert(curline, cv) + + if cv.on_activate then + active = active or {} + table.insert(active, cv) + end + + if cv.id then + idtab = idtab or {} + idtab[cv.id] = cv + end + end + end + end + end + obj.text_lines = out + obj.text_active = active + obj.text_ids = idtab +end + +local function is_disabled(token) + return (token.disabled ~= nil and getval(token.disabled)) or + (token.enabled ~= nil and not getval(token.enabled)) +end + +-- Make the hover pen -- that is a pen that should render elements that has the +-- mouse hovering over it. if hpen is specified, it just checks the fields and +-- returns it (in parsed pen form) +local function make_hpen(pen, hpen) + if not hpen then + pen = to_pen(pen) + + -- Swap the foreground and background + hpen = dfhack.pen.make(pen.bg, nil, pen.fg + (pen.bold and 8 or 0)) + end + + -- text_hpen needs a character in order to paint the background using + -- Painter:fill(), so let's make it paint a space to show the background + -- color + local hpen_parsed = to_pen(hpen) + hpen_parsed.ch = string.byte(' ') + return hpen_parsed +end + +---@param obj any +---@param dc gui.Painter +---@param x0 integer +---@param y0 integer +---@param pen dfhack.pen|dfhack.color|fun(): dfhack.pen|dfhack.color +---@param dpen dfhack.pen|dfhack.color|fun(): dfhack.pen|dfhack.color +---@param disabled boolean +---@param hpen dfhack.pen|dfhack.color|fun(): dfhack.pen|dfhack.color +---@param hovered boolean +function render_text(obj,dc,x0,y0,pen,dpen,disabled,hpen,hovered) + pen, dpen, hpen = getval(pen), getval(dpen), getval(hpen) + local width = 0 + for iline = dc and obj.start_line_num or 1, #obj.text_lines do + local x, line = 0, obj.text_lines[iline] + if dc then + local offset = (obj.start_line_num or 1) - 1 + local y = y0 + iline - offset - 1 + -- skip text outside of the containing frame + if y > dc.height - 1 then break end + dc:seek(x+x0, y) + end + for _,token in ipairs(line) do + token.line = iline + token.x1 = x + + if token.gap then + x = x + token.gap + if dc then + dc:advance(token.gap) + end + end + + if token.tile or (hovered and token.htile) then + x = x + 1 + if dc then + local tile = hovered and getval(token.htile or token.tile) or getval(token.tile) + local tile_pen = tonumber(tile) and to_pen{tile=tile} or tile + dc:char(nil, tile_pen) + if token.width then + dc:advance(token.width-1) + end + end + end + + if token.text or token.key then + local text = ''..(getval(token.text) or '') + local keypen = to_pen(token.key_pen or COLOR_LIGHTGREEN) + + if dc then + local tpen = getval(token.pen) + local dcpen = to_pen(tpen or pen) + + -- If disabled, figure out which dpen to use + if disabled or is_disabled(token) then + dcpen = to_pen(getval(token.dpen) or tpen or dpen) + if keypen.fg ~= COLOR_BLACK then + keypen.bold = false + end + + -- if hovered *and* disabled, combine both effects + if hovered then + dcpen = make_hpen(dcpen) + end + elseif hovered then + dcpen = make_hpen(dcpen, getval(token.hpen) or hpen) + end + + dc:pen(dcpen) + end + local width = getval(token.width) + local padstr + if width then + x = x + width + if #text > width then + text = string.sub(text,1,width) + else + if token.pad_char then + padstr = string.rep(token.pad_char,width-#text) + end + if dc and token.rjustify then + if padstr then dc:string(padstr) else dc:advance(width-#text) end + end + end + else + x = x + #text + end + + if token.key then + if type(token.key) == 'string' and not df.interface_key[token.key] then + error('Invalid interface_key: ' .. token.key) + end + local keystr = gui.getKeyDisplay(token.key) + local sep = token.key_sep or '' + + x = x + #keystr + + if sep:startswith('()') then + if dc then + dc:string(text) + dc:string(' ('):string(keystr,keypen) + dc:string(sep:sub(2)) + end + x = x + 1 + #sep + else + if dc then + dc:string(keystr,keypen):string(sep):string(text) + end + x = x + #sep + end + else + if dc then + dc:string(text) + end + end + + if width and dc and not token.rjustify then + if padstr then dc:string(padstr) else dc:advance(width-#text) end + end + end + + token.x2 = x + end + width = math.max(width, x) + end + obj.text_width = width +end + +function check_text_keys(self, keys) + if self.text_active then + for _,item in ipairs(self.text_active) do + if item.key and keys[item.key] and not is_disabled(item) then + item.on_activate() + return true + end + end + end +end + +---@class widgets.LabelToken +---@field text string|fun(): string +---@field gap? integer +---@field tile? integer|dfhack.pen +---@field htile? integer|dfhack.pen +---@field width? integer|fun(): integer +---@field pad_char? string +---@field key? string +---@field key_sep? string +---@field disabled? boolean|fun(): boolean +---@field enabled? boolean|fun(): boolean +---@field pen? dfhack.color|dfhack.pen +---@field dpen? dfhack.color|dfhack.pen +---@field hpen? dfhack.color|dfhack.pen +---@field on_activiate? fun() +---@field id? string +---@field line? any Internal use only +---@field x1? any Internal use only +---@field x2? any Internal use only + +---@class widgets.Label.attrs: widgets.Widget.attrs +---@field text? string|widgets.LabelToken[] +---@field text_pen dfhack.color|dfhack.pen +---@field text_dpen dfhack.color|dfhack.pen +---@field text_hpen? dfhack.color|dfhack.pen +---@field disabled? boolean|fun(): boolean +---@field enabled? boolean|fun(): boolean +---@field auto_height boolean +---@field auto_width boolean +---@field on_click? function +---@field on_rclick? function +---@field scroll_keys table + +---@class widgets.Label.attrs.partial: widgets.Label.attrs + +---@class widgets.Label: widgets.Widget, widgets.Label.attrs +---@field super widgets.Widget +---@field ATTRS widgets.Label.attrs|fun(attributes: widgets.Label.attrs.partial) +---@overload fun(init_table: widgets.Label.attrs.partial): self +Label = defclass(Label, Widget) + +Label.ATTRS{ + text_pen = COLOR_WHITE, + text_dpen = COLOR_DARKGREY, -- disabled + text_hpen = DEFAULT_NIL, -- hover - default is to invert the fg/bg colors + disabled = DEFAULT_NIL, + enabled = DEFAULT_NIL, + auto_height = true, + auto_width = false, + on_click = DEFAULT_NIL, + on_rclick = DEFAULT_NIL, + scroll_keys = common.STANDARDSCROLL, +} + +---@param args widgets.Label.attrs.partial +function Label:init(args) + self.scrollbar = Scrollbar{ + frame={r=0}, + on_scroll=self:callback('on_scrollbar')} + + self:addviews{self.scrollbar} + + self:setText(args.text or self.text) +end + +local function update_label_scrollbar(label) + local body_height = label.frame_body and label.frame_body.height or 1 + label.scrollbar:update(label.start_line_num, body_height, + label:getTextHeight()) +end + +function Label:setText(text) + self.start_line_num = 1 + self.text = text + parse_label_text(self) + + if self.auto_height then + self.frame = self.frame or {} + self.frame.h = self:getTextHeight() + end + + update_label_scrollbar(self) +end + +function Label:preUpdateLayout() + if self.auto_width then + self.frame = self.frame or {} + self.frame.w = self:getTextWidth() + end +end + +function Label:postUpdateLayout() + update_label_scrollbar(self) +end + +function Label:itemById(id) + if self.text_ids then + return self.text_ids[id] + end +end + +function Label:getTextHeight() + return #self.text_lines +end + +function Label:getTextWidth() + render_text(self) + return self.text_width +end + +-- Overridden by subclasses that also want to add new mouse handlers, see +-- HotkeyLabel. +function Label:shouldHover() + return self.on_click or self.on_rclick +end + +function Label:onRenderBody(dc) + local text_pen = self.text_pen + local hovered = self:getMousePos() and self:shouldHover() + render_text(self,dc,0,0,text_pen,self.text_dpen,is_disabled(self), self.text_hpen, hovered) +end + +function Label:on_scrollbar(scroll_spec) + local v = 0 + if tonumber(scroll_spec) then + v = scroll_spec - self.start_line_num + elseif scroll_spec == 'down_large' then + v = '+halfpage' + elseif scroll_spec == 'up_large' then + v = '-halfpage' + elseif scroll_spec == 'down_small' then + v = 1 + elseif scroll_spec == 'up_small' then + v = -1 + end + + self:scroll(v) +end + +function Label:scroll(nlines) + if not nlines then return end + local text_height = math.max(1, self:getTextHeight()) + if type(nlines) == 'string' then + if nlines == '+page' then + nlines = self.frame_body.height + elseif nlines == '-page' then + nlines = -self.frame_body.height + elseif nlines == '+halfpage' then + nlines = math.ceil(self.frame_body.height/2) + elseif nlines == '-halfpage' then + nlines = -math.ceil(self.frame_body.height/2) + elseif nlines == 'home' then + nlines = 1 - self.start_line_num + elseif nlines == 'end' then + nlines = text_height + else + error(('unhandled scroll keyword: "%s"'):format(nlines)) + end + end + local n = self.start_line_num + nlines + n = math.min(n, text_height - self.frame_body.height + 1) + n = math.max(n, 1) + nlines = n - self.start_line_num + self.start_line_num = n + update_label_scrollbar(self) + return nlines +end + +function Label:onInput(keys) + if is_disabled(self) then return false end + if self:inputToSubviews(keys) then + return true + end + if keys._MOUSE_L and self:getMousePos() and self.on_click then + self.on_click() + return true + end + if keys._MOUSE_R and self:getMousePos() and self.on_rclick then + self.on_rclick() + return true + end + for k,v in pairs(self.scroll_keys) do + if keys[k] and 0 ~= self:scroll(v) then + return true + end + end + return check_text_keys(self, keys) +end + +-------------------------------- +-- makeButtonLabelText +-- + +local function get_button_token_hover_ch(spec, x, y, ch) + local ch_hover = ch + if spec.chars_hover then + local row = spec.chars_hover[y] + if type(row) == 'string' then + ch_hover = row:sub(x, x) + else + ch_hover = row[x] + end + end + return ch_hover +end + +local function get_button_token_base_pens(spec, x, y) + local pen, pen_hover = COLOR_GRAY, COLOR_WHITE + if spec.pens then + pen = type(spec.pens) == 'table' and (safe_index(spec.pens, y, x) or spec.pens[y]) or spec.pens + if spec.pens_hover then + pen_hover = type(spec.pens_hover) == 'table' and (safe_index(spec.pens_hover, y, x) or spec.pens_hover[y]) or spec.pens_hover + else + pen_hover = pen + end + end + return pen, pen_hover +end + +local function get_button_tileset_idx(spec, x, y, tileset_offset, tileset_stride) + local idx = (tileset_offset or 1) + idx = idx + (x - 1) + idx = idx + (y - 1) * (tileset_stride or #spec.chars[1]) + return idx +end + +local function get_asset_tile(asset, x, y) + return dfhack.screen.findGraphicsTile(asset.page, asset.x+x-1, asset.y+y-1) +end + +local function get_button_token_tiles(spec, x, y) + local tile = safe_index(spec.tiles_override, y, x) + local tile_hover = safe_index(spec.tiles_hover_override, y, x) or tile + if not tile and spec.tileset then + local tileset = spec.tileset + local idx = get_button_tileset_idx(spec, x, y, spec.tileset_offset, spec.tileset_stride) + tile = dfhack.textures.getTexposByHandle(tileset[idx]) + if spec.tileset_hover then + local tileset_hover = spec.tileset_hover + local idx_hover = get_button_tileset_idx(spec, x, y, + spec.tileset_hover_offset or spec.tileset_offset, + spec.tileset_hover_stride or spec.tileset_stride) + tile_hover = dfhack.textures.getTexposByHandle(tileset_hover[idx_hover]) + else + tile_hover = tile + end + end + if not tile and spec.asset then + tile = get_asset_tile(spec.asset, x, y) + if spec.asset_hover then + tile_hover = get_asset_tile(spec.asset_hover, x, y) + else + tile_hover = tile + end + end + return tile, tile_hover +end + +local function get_button_token_pen(base_pen, tile, ch) + local pen = dfhack.pen.make(base_pen) + pen.tile = tile + pen.ch = ch + return pen +end + +local function get_button_token_pens(spec, x, y, ch, ch_hover) + local base_pen, base_pen_hover = get_button_token_base_pens(spec, x, y) + local tile, tile_hover = get_button_token_tiles(spec, x, y) + return get_button_token_pen(base_pen, tile, ch), get_button_token_pen(base_pen_hover, tile_hover, ch_hover) +end + +local function make_button_token(spec, x, y, ch) + local ch_hover = get_button_token_hover_ch(spec, x, y, ch) + local pen, pen_hover = get_button_token_pens(spec, x, y, ch, ch_hover) + return { + tile=pen, + htile=pen_hover, + width=1, + } +end + +---@class widgets.ButtonLabelSpec +---@field chars (string|string[])[] +---@field chars_hover? (string|string[])[] +---@field pens? dfhack.color|dfhack.color[][] +---@field pens_hover? dfhack.color|dfhack.color[][] +---@field tiles_override? integer[][] +---@field tiles_hover_override? integer[][] +---@field tileset? TexposHandle[] +---@field tileset_hover? TexposHandle[] +---@field tileset_offset? integer +---@field tileset_hover_offset? integer +---@field tileset_stride? integer +---@field tileset_hover_stride? integer +---@field asset? {page: string, x: integer, y: integer} +---@field asset_hover? {page: string, x: integer, y: integer} + +---@nodiscard +---@param spec widgets.ButtonLabelSpec +---@return widgets.LabelToken[] +function makeButtonLabelText(spec) + local tokens = {} + for y, row in ipairs(spec.chars) do + if type(row) == 'string' then + local x = 1 + for ch in row:gmatch('.') do + table.insert(tokens, make_button_token(spec, x, y, ch)) + x = x + 1 + end + else + for x, ch in ipairs(row) do + table.insert(tokens, make_button_token(spec, x, y, ch)) + end + end + if y < #spec.chars then + table.insert(tokens, NEWLINE) + end + end + return tokens +end + +Label.makeButtonLabelText = makeButtonLabelText +Label.parse_label_text = parse_label_text +Label.render_text = render_text +Label.check_text_keys = check_text_keys + +return Label diff --git a/library/lua/gui/widgets/labels/toggle_hotkey_label.lua b/library/lua/gui/widgets/labels/toggle_hotkey_label.lua new file mode 100644 index 0000000000..d2e61cedb7 --- /dev/null +++ b/library/lua/gui/widgets/labels/toggle_hotkey_label.lua @@ -0,0 +1,15 @@ +local CycleHotkeyLabel = require('gui.widgets.labels.cycle_hotkey_label') + +----------------------- +-- ToggleHotkeyLabel -- +----------------------- + +---@class widgets.ToggleHotkeyLabel: widgets.CycleHotkeyLabel +---@field super widgets.CycleHotkeyLabel +ToggleHotkeyLabel = defclass(ToggleHotkeyLabel, CycleHotkeyLabel) +ToggleHotkeyLabel.ATTRS{ + options={{label='On', value=true, pen=COLOR_GREEN}, + {label='Off', value=false}}, +} + +return ToggleHotkeyLabel diff --git a/library/lua/gui/widgets/labels/tooltip_label.lua b/library/lua/gui/widgets/labels/tooltip_label.lua new file mode 100644 index 0000000000..7d0ff7ca5d --- /dev/null +++ b/library/lua/gui/widgets/labels/tooltip_label.lua @@ -0,0 +1,28 @@ +local WrappedLabel = require('gui.widgets.labels.wrapped_label') + +------------------ +-- TooltipLabel -- +------------------ + +---@class widgets.TooltipLabel.attrs: widgets.WrappedLabel.attrs +---@field show_tooltip? boolean|fun(): boolean + +---@class widgets.TooltipLabel.attrs.partial: widgets.TooltipLabel.attrs + +---@class widgets.TooltipLabel: widgets.WrappedLabel, widgets.TooltipLabel.attrs +---@field super widgets.WrappedLabel +---@field ATTRS widgets.TooltipLabel.attrs|fun(attributes: widgets.TooltipLabel.attrs.partial) +---@overload fun(init_table: widgets.TooltipLabel.attrs.partial): self +TooltipLabel = defclass(TooltipLabel, WrappedLabel) + +TooltipLabel.ATTRS{ + show_tooltip=DEFAULT_NIL, + indent=2, + text_pen=COLOR_GREY, +} + +function TooltipLabel:init() + self.visible = self.show_tooltip +end + +return TooltipLabel diff --git a/library/lua/gui/widgets/labels/wrapped_label.lua b/library/lua/gui/widgets/labels/wrapped_label.lua new file mode 100644 index 0000000000..f291b59a4d --- /dev/null +++ b/library/lua/gui/widgets/labels/wrapped_label.lua @@ -0,0 +1,56 @@ +local utils = require('utils') +local Label = require('gui.widgets.labels.label') + +local getval = utils.getval + +------------------ +-- WrappedLabel -- +------------------ + +---@class widgets.WrappedLabel.attrs: widgets.Label.attrs +---@field text_to_wrap? string|string[]|fun(): string|string[] +---@field indent integer + +---@class widgets.WrappedLabel.attrs.partial: widgets.WrappedLabel.attrs + +---@class widgets.WrappedLabel: widgets.Label, widgets.WrappedLabel.attrs +---@field super widgets.Label +---@field ATTRS widgets.WrappedLabel.attrs|fun(attributes: widgets.WrappedLabel.attrs.partial) +---@overload fun(init_table: widgets.WrappedLabel.attrs.partial): self +WrappedLabel = defclass(WrappedLabel, Label) + +WrappedLabel.ATTRS{ + text_to_wrap=DEFAULT_NIL, + indent=0, +} + +function WrappedLabel:getWrappedText(width) + -- 0 width can happen if the parent has 0 width + if not self.text_to_wrap or width <= 0 then return nil end + local text_to_wrap = getval(self.text_to_wrap) + if type(text_to_wrap) == 'table' then + text_to_wrap = table.concat(text_to_wrap, NEWLINE) + end + return text_to_wrap:wrap(width - self.indent, {return_as_table=true}) +end + +function WrappedLabel:preUpdateLayout() + self.saved_start_line_num = self.start_line_num +end + +-- we can't set the text in init() since we may not yet have a frame that we +-- can get wrapping bounds from. +function WrappedLabel:postComputeFrame() + local wrapped_text = self:getWrappedText(self.frame_body.width-3) + if not wrapped_text then return end + local text = {} + for _,line in ipairs(wrapped_text) do + table.insert(text, {gap=self.indent, text=line}) + -- a trailing newline will get ignored so we don't have to manually trim + table.insert(text, NEWLINE) + end + self:setText(text) + self:scroll(self.saved_start_line_num - 1) +end + +return WrappedLabel diff --git a/library/lua/gui/widgets/list.lua b/library/lua/gui/widgets/list.lua new file mode 100644 index 0000000000..84b9715f12 --- /dev/null +++ b/library/lua/gui/widgets/list.lua @@ -0,0 +1,388 @@ +local gui = require('gui') +local utils = require('utils') +local common = require('gui.widgets.common') +local Widget = require('gui.widgets.widget') +local Scrollbar = require('gui.widgets.scrollbar') +local Label = require('gui.widgets.labels.label') + +local getval = utils.getval +local to_pen = dfhack.pen.parse + +---------- +-- List -- +---------- + +---@class widgets.ListChoice +---@field text string|widgets.LabelToken[] +---@field key string +---@field search_key? string +---@field icon? string|dfhack.pen|fun(): string|dfhack.pen +---@field icon_pen? dfhack.pen + +---@class widgets.List.attrs: widgets.Widget.attrs +---@field choices widgets.ListChoice[] +---@field selected integer +---@field text_pen dfhack.color|dfhack.pen +---@field text_hpen? dfhack.color|dfhack.pen +---@field cursor_pen dfhack.color|dfhack.pen +---@field inactive_pen? dfhack.color|dfhack.pen +---@field icon_pen? dfhack.color|dfhack.pen +---@field on_select? function +---@field on_submit? function +---@field on_submit2? function +---@field on_double_click? function +---@field on_double_click2? function +---@field row_height integer +---@field scroll_keys table +---@field icon_width? integer +---@field page_top integer +---@field page_size integer +---@field scrollbar widgets.Scrollbar +---@field last_select_click_ms integer + +---@class widgets.List.attrs.partial: widgets.List.attrs + +---@class widgets.List: widgets.Widget, widgets.List.attrs +---@field super widgets.Widget +---@field ATTRS widgets.List.attrs|fun(attributes: widgets.List.attrs.partial) +---@overload fun(init_table: widgets.List.attrs.partial): self +List = defclass(List, Widget) + +List.ATTRS{ + text_pen = COLOR_CYAN, + text_hpen = DEFAULT_NIL, -- hover color, defaults to inverting the FG/BG pens for each text object + cursor_pen = COLOR_LIGHTCYAN, + inactive_pen = DEFAULT_NIL, + icon_pen = DEFAULT_NIL, + on_select = DEFAULT_NIL, + on_submit = DEFAULT_NIL, + on_submit2 = DEFAULT_NIL, + on_double_click = DEFAULT_NIL, + on_double_click2 = DEFAULT_NIL, + row_height = 1, + scroll_keys = common.STANDARDSCROLL, + icon_width = DEFAULT_NIL, +} + +---@param self widgets.List +---@param info widgets.List.attrs.partial +function List:init(info) + self.page_top = 1 + self.page_size = 1 + self.scrollbar = Scrollbar{ + frame={r=0}, + on_scroll=self:callback('on_scrollbar')} + + self:addviews{self.scrollbar} + + if info.choices then + self:setChoices(info.choices, info.selected) + else + self.choices = {} + self.selected = 1 + end + + self.last_select_click_ms = 0 -- used to track double-clicking on an item +end + +function List:setChoices(choices, selected) + self.choices = {} + + for i,v in ipairs(choices or {}) do + local l = utils.clone(v); + if type(v) ~= 'table' then + l = { text = v } + else + l.text = v.text or v.caption or v[1] + end + Label.parse_label_text(l) + self.choices[i] = l + end + + self:setSelected(selected) + + -- Check if page_top needs to be adjusted + if #self.choices - self.page_size < 0 then + self.page_top = 1 + elseif self.page_top > #self.choices - self.page_size + 1 then + self.page_top = #self.choices - self.page_size + 1 + end +end + +function List:setSelected(selected) + self.selected = selected or self.selected or 1 + self:moveCursor(0, true) + return self.selected +end + +function List:getChoices() + return self.choices +end + +function List:getSelected() + if #self.choices > 0 then + return self.selected, self.choices[self.selected] + end +end + +function List:getContentWidth() + local width = 0 + for i,v in ipairs(self.choices) do + Label.render_text(v) + local roww = v.text_width + if v.key then + roww = roww + 3 + #gui.getKeyDisplay(v.key) + end + width = math.max(width, roww) + end + return width + (self.icon_width or 0) +end + +function List:getContentHeight() + return #self.choices * self.row_height +end + +local function update_list_scrollbar(list) + list.scrollbar:update(list.page_top, list.page_size, #list.choices) +end + +function List:postComputeFrame(body) + local row_count = body.height // self.row_height + self.page_size = math.max(1, row_count) + + local num_choices = #self.choices + if num_choices == 0 then + self.page_top = 1 + update_list_scrollbar(self) + return + end + + if self.page_top > num_choices - self.page_size + 1 then + self.page_top = math.max(1, num_choices - self.page_size + 1) + end + + update_list_scrollbar(self) +end + +function List:postUpdateLayout() + update_list_scrollbar(self) +end + +function List:moveCursor(delta, force_cb) + local cnt = #self.choices + + if cnt < 1 then + self.page_top = 1 + self.selected = 1 + update_list_scrollbar(self) + if force_cb and self.on_select then + self.on_select(nil,nil) + end + return + end + + local off = self.selected+delta-1 + local ds = math.abs(delta) + + if ds > 1 then + if off >= cnt+ds-1 then + off = 0 + else + off = math.min(cnt-1, off) + end + if off <= -ds then + off = cnt-1 + else + off = math.max(0, off) + end + end + + local buffer = 1 + math.min(4, math.floor(self.page_size/10)) + + self.selected = 1 + off % cnt + if (self.selected - buffer) < self.page_top then + self.page_top = math.max(1, self.selected - buffer) + elseif (self.selected + buffer + 1) > (self.page_top + self.page_size) then + local max_page_top = cnt - self.page_size + 1 + self.page_top = math.max(1, + math.min(max_page_top, self.selected - self.page_size + buffer + 1)) + end + update_list_scrollbar(self) + + if (force_cb or delta ~= 0) and self.on_select then + self.on_select(self:getSelected()) + end +end + +function List:on_scrollbar(scroll_spec) + local v = 0 + if tonumber(scroll_spec) then + v = scroll_spec - self.page_top + elseif scroll_spec == 'down_large' then + v = math.ceil(self.page_size / 2) + elseif scroll_spec == 'up_large' then + v = -math.ceil(self.page_size / 2) + elseif scroll_spec == 'down_small' then + v = 1 + elseif scroll_spec == 'up_small' then + v = -1 + end + + local max_page_top = math.max(1, #self.choices - self.page_size + 1) + self.page_top = math.max(1, math.min(max_page_top, self.page_top + v)) + update_list_scrollbar(self) +end + +function List:onRenderBody(dc) + local choices = self.choices + local top = self.page_top + local iend = math.min(#choices, top+self.page_size-1) + local iw = self.icon_width + + local function paint_icon(icon, obj) + if type(icon) ~= 'string' then + dc:char(nil,icon) + else + if current then + dc:string(icon, obj.icon_pen or self.icon_pen or cur_pen) + else + dc:string(icon, obj.icon_pen or self.icon_pen or cur_dpen) + end + end + end + + local hoveridx = self:getIdxUnderMouse() + for i = top,iend do + local obj = choices[i] + local current = (i == self.selected) + local hovered = (i == hoveridx) + -- cur_pen and cur_dpen can't be integers or background colors get + -- messed up in render_text for subsequent renders + local cur_pen = to_pen(self.cursor_pen) + local cur_dpen = to_pen(self.text_pen) + local active_pen = (current and cur_pen or cur_dpen) + + if not getval(self.active) then + cur_pen = self.inactive_pen or self.cursor_pen + end + + local y = (i - top)*self.row_height + local icon = getval(obj.icon) + + if iw and icon then + dc:seek(0, y):pen(active_pen) + paint_icon(icon, obj) + end + + Label.render_text(obj, dc, iw or 0, y, cur_pen, cur_dpen, not current, self.text_hpen, hovered) + + local ip = dc.width + + if obj.key then + local keystr = gui.getKeyDisplay(obj.key) + ip = ip-3-#keystr + dc:seek(ip,y):pen(self.text_pen) + dc:string('('):string(keystr,COLOR_LIGHTGREEN):string(')') + end + + if icon and not iw then + dc:seek(ip-1,y):pen(active_pen) + paint_icon(icon, obj) + end + end +end + +function List:getIdxUnderMouse() + if self.scrollbar:getMousePos() then return end + local _,mouse_y = self:getMousePos() + if mouse_y and #self.choices > 0 and + mouse_y < (#self.choices-self.page_top+1) * self.row_height then + return self.page_top + math.floor(mouse_y/self.row_height) + end +end + +function List:submit() + if self.on_submit and #self.choices > 0 then + self.on_submit(self:getSelected()) + return true + end +end + +function List:submit2() + if self.on_submit2 and #self.choices > 0 then + self.on_submit2(self:getSelected()) + return true + end +end + +function List:double_click() + if #self.choices == 0 then return end + local cb = dfhack.internal.getModifiers().shift and + self.on_double_click2 or self.on_double_click + if cb then + cb(self:getSelected()) + return true + end +end + +function List:onInput(keys) + if self:inputToSubviews(keys) then + return true + end + if keys.SELECT then + return self:submit() + elseif keys.SELECT_ALL then + return self:submit2() + elseif keys._MOUSE_L then + local idx = self:getIdxUnderMouse() + if idx then + local now_ms = dfhack.getTickCount() + if idx ~= self:getSelected() then + self.last_select_click_ms = now_ms + else + if now_ms - self.last_select_click_ms <= common.DOUBLE_CLICK_MS then + self.last_select_click_ms = 0 + if self:double_click() then return true end + else + self.last_select_click_ms = now_ms + end + end + + self:setSelected(idx) + if dfhack.internal.getModifiers().shift then + self:submit2() + else + self:submit() + end + return true + end + else + for k,v in pairs(self.scroll_keys) do + if keys[k] then + if v == '+page' then + v = self.page_size + elseif v == '-page' then + v = -self.page_size + end + + self:moveCursor(v) + return true + end + end + + for i,v in ipairs(self.choices) do + if v.key and keys[v.key] then + self:setSelected(i) + self:submit() + return true + end + end + + local current = self.choices[self.selected] + if current then + return Label.check_text_keys(current, keys) + end + end +end + +return List diff --git a/library/lua/gui/widgets/range_slider.lua b/library/lua/gui/widgets/range_slider.lua new file mode 100644 index 0000000000..bd1f9cb3de --- /dev/null +++ b/library/lua/gui/widgets/range_slider.lua @@ -0,0 +1,164 @@ +local Widget = require('gui.widgets.widget') + +local to_pen = dfhack.pen.parse + +-------------------------------- +-- RangeSlider +-------------------------------- + +---@class widgets.RangeSlider.attrs: widgets.Widget.attrs +---@field num_stops integer +---@field get_left_idx_fn? function +---@field get_right_idx_fn? function +---@field on_left_change? fun(index: integer) +---@field on_right_change? fun(index: integer) + +---@class widgets.RangeSlider.attrs.partial: widgets.RangeSlider.attrs + +---@class widgets.RangeSlider.initTable: widgets.RangeSlider.attrs +---@field num_stops integer + +---@class widgets.RangeSlider: widgets.Widget, widgets.RangeSlider.attrs +---@field super widgets.Widget +---@field ATTRS widgets.RangeSlider.attrs|fun(attributes: widgets.RangeSlider.attrs.partial) +---@overload fun(init_table: widgets.RangeSlider.initTable): self +RangeSlider = defclass(RangeSlider, Widget) +RangeSlider.ATTRS{ + num_stops=DEFAULT_NIL, + get_left_idx_fn=DEFAULT_NIL, + get_right_idx_fn=DEFAULT_NIL, + on_left_change=DEFAULT_NIL, + on_right_change=DEFAULT_NIL, +} + +function RangeSlider:preinit(init_table) + init_table.frame = init_table.frame or {} + init_table.frame.h = init_table.frame.h or 1 +end + +function RangeSlider:init() + if self.num_stops < 2 then error('too few RangeSlider stops') end + self.is_dragging_target = nil -- 'left', 'right', or 'both' + self.is_dragging_idx = nil -- offset from leftmost dragged tile +end + +local function rangeslider_get_width_per_idx(self) + return math.max(5, (self.frame_body.width-7) // (self.num_stops-1)) +end + +function RangeSlider:onInput(keys) + if not keys._MOUSE_L then return false end + local x = self:getMousePos() + if not x then return false end + local left_idx, right_idx = self.get_left_idx_fn(), self.get_right_idx_fn() + local width_per_idx = rangeslider_get_width_per_idx(self) + local left_pos = width_per_idx*(left_idx-1) + local right_pos = width_per_idx*(right_idx-1) + 4 + if x < left_pos then + self.on_left_change(self.get_left_idx_fn() - 1) + elseif x < left_pos+3 then + self.is_dragging_target = 'left' + self.is_dragging_idx = x - left_pos + elseif x < right_pos then + self.is_dragging_target = 'both' + self.is_dragging_idx = x - left_pos + elseif x < right_pos+3 then + self.is_dragging_target = 'right' + self.is_dragging_idx = x - right_pos + else + self.on_right_change(self.get_right_idx_fn() + 1) + end + return true +end + +local function rangeslider_do_drag(self, width_per_idx) + local x = self.frame_body:localXY(dfhack.screen.getMousePos()) + local cur_pos = x - self.is_dragging_idx + cur_pos = math.max(0, cur_pos) + cur_pos = math.min(width_per_idx*(self.num_stops-1)+7, cur_pos) + local offset = self.is_dragging_target == 'right' and -2 or 1 + local new_idx = math.max(0, cur_pos+offset)//width_per_idx + 1 + local new_left_idx, new_right_idx + if self.is_dragging_target == 'right' then + new_right_idx = new_idx + else + new_left_idx = new_idx + if self.is_dragging_target == 'both' then + new_right_idx = new_left_idx + self.get_right_idx_fn() - self.get_left_idx_fn() + if new_right_idx > self.num_stops then + return + end + end + end + if new_left_idx and new_left_idx ~= self.get_left_idx_fn() then + if not new_right_idx and new_left_idx > self.get_right_idx_fn() then + self.on_right_change(new_left_idx) + end + self.on_left_change(new_left_idx) + end + if new_right_idx and new_right_idx ~= self.get_right_idx_fn() then + if new_right_idx < self.get_left_idx_fn() then + self.on_left_change(new_right_idx) + end + self.on_right_change(new_right_idx) + end +end + +local SLIDER_LEFT_END = to_pen{ch=198, fg=COLOR_GREY, bg=COLOR_BLACK} +local SLIDER_TRACK = to_pen{ch=205, fg=COLOR_GREY, bg=COLOR_BLACK} +local SLIDER_TRACK_SELECTED = to_pen{ch=205, fg=COLOR_LIGHTGREEN, bg=COLOR_BLACK} +local SLIDER_TRACK_STOP = to_pen{ch=216, fg=COLOR_GREY, bg=COLOR_BLACK} +local SLIDER_TRACK_STOP_SELECTED = to_pen{ch=216, fg=COLOR_LIGHTGREEN, bg=COLOR_BLACK} +local SLIDER_RIGHT_END = to_pen{ch=181, fg=COLOR_GREY, bg=COLOR_BLACK} +local SLIDER_TAB_LEFT = to_pen{ch=60, fg=COLOR_BLACK, bg=COLOR_YELLOW} +local SLIDER_TAB_CENTER = to_pen{ch=9, fg=COLOR_BLACK, bg=COLOR_YELLOW} +local SLIDER_TAB_RIGHT = to_pen{ch=62, fg=COLOR_BLACK, bg=COLOR_YELLOW} + +function RangeSlider:onRenderBody(dc, rect) + local left_idx, right_idx = self.get_left_idx_fn(), self.get_right_idx_fn() + local width_per_idx = rangeslider_get_width_per_idx(self) + -- draw track + dc:seek(1,0) + dc:char(nil, SLIDER_LEFT_END) + dc:char(nil, SLIDER_TRACK) + for stop_idx=1,self.num_stops-1 do + local track_stop_pen = SLIDER_TRACK_STOP_SELECTED + local track_pen = SLIDER_TRACK_SELECTED + if left_idx > stop_idx or right_idx < stop_idx then + track_stop_pen = SLIDER_TRACK_STOP + track_pen = SLIDER_TRACK + elseif right_idx == stop_idx then + track_pen = SLIDER_TRACK + end + dc:char(nil, track_stop_pen) + for i=2,width_per_idx do + dc:char(nil, track_pen) + end + end + if right_idx >= self.num_stops then + dc:char(nil, SLIDER_TRACK_STOP_SELECTED) + else + dc:char(nil, SLIDER_TRACK_STOP) + end + dc:char(nil, SLIDER_TRACK) + dc:char(nil, SLIDER_RIGHT_END) + -- draw tabs + dc:seek(width_per_idx*(left_idx-1)) + dc:char(nil, SLIDER_TAB_LEFT) + dc:char(nil, SLIDER_TAB_CENTER) + dc:char(nil, SLIDER_TAB_RIGHT) + dc:seek(width_per_idx*(right_idx-1)+4) + dc:char(nil, SLIDER_TAB_LEFT) + dc:char(nil, SLIDER_TAB_CENTER) + dc:char(nil, SLIDER_TAB_RIGHT) + -- manage dragging + if self.is_dragging_target then + rangeslider_do_drag(self, width_per_idx) + end + if df.global.enabler.mouse_lbut_down == 0 then + self.is_dragging_target = nil + self.is_dragging_idx = nil + end +end + +return RangeSlider diff --git a/library/lua/gui/widgets/scrollbar.lua b/library/lua/gui/widgets/scrollbar.lua new file mode 100644 index 0000000000..7be7128db5 --- /dev/null +++ b/library/lua/gui/widgets/scrollbar.lua @@ -0,0 +1,254 @@ +local common = require('gui.widgets.common') +local Widget = require('gui.widgets.widget') + +local to_pen = dfhack.pen.parse + +--------------- +-- Scrollbar -- +--------------- + +---@class widgets.Scrollbar.attrs: widgets.Widget.attrs +---@field on_scroll? fun(new_top_elem?: integer) + +---@class widgets.Scrollbar.attrs.partial: widgets.Scrollbar.attrs + +---@class widgets.Scrollbar: widgets.Widget, widgets.Scrollbar.attrs +---@field super widgets.Widget +---@field ATTRS widgets.Scrollbar.attrs|fun(attributes: widgets.Scrollbar.attrs.partial) +---@overload fun(init_table: widgets.Scrollbar.attrs.partial): self +Scrollbar = defclass(Scrollbar, Widget) + +Scrollbar.ATTRS{ + on_scroll = DEFAULT_NIL, +} + +---@param init_table widgets.Scrollbar.attrs.partial +function Scrollbar:preinit(init_table) + init_table.frame = init_table.frame or {} + init_table.frame.w = init_table.frame.w or 2 +end + +function Scrollbar:init() + self.last_scroll_ms = 0 + self.is_first_click = false + self.scroll_spec = nil + self.is_dragging = false -- index of the scrollbar tile that we're dragging + self:update(1, 1, 1) +end + +local function scrollbar_get_max_pos_and_height(scrollbar) + local frame_body = scrollbar.frame_body + local scrollbar_body_height = (frame_body and frame_body.height or 3) - 2 + + local height = math.max(2, math.floor( + (math.min(scrollbar.elems_per_page, scrollbar.num_elems) * scrollbar_body_height) / + scrollbar.num_elems)) + + return scrollbar_body_height - height, height +end + +-- calculate and cache the number of tiles of empty space above the top of the +-- scrollbar and the number of tiles the scrollbar should occupy to represent +-- the percentage of text that is on the screen. +-- if elems_per_page or num_elems are not specified, the last values passed to +-- Scrollbar:update() are used. +function Scrollbar:update(top_elem, elems_per_page, num_elems) + if not top_elem then error('must specify index of new top element') end + elems_per_page = elems_per_page or self.elems_per_page + num_elems = num_elems or self.num_elems + self.top_elem = top_elem + self.elems_per_page, self.num_elems = elems_per_page, num_elems + + local max_pos, height = scrollbar_get_max_pos_and_height(self) + local pos = (num_elems == elems_per_page) and 0 or + math.ceil(((top_elem-1) * max_pos) / + (num_elems - elems_per_page)) + + self.bar_offset, self.bar_height = pos, height +end + +local function scrollbar_do_drag(scrollbar) + local x,y = dfhack.screen.getMousePos() + if not y then return end + x,y = scrollbar.frame_body:localXY(x, y) + local cur_pos = y - scrollbar.is_dragging + local max_top = scrollbar.num_elems - scrollbar.elems_per_page + 1 + local max_pos = scrollbar_get_max_pos_and_height(scrollbar) + local new_top_elem = math.floor(cur_pos * max_top / max_pos) + 1 + new_top_elem = math.max(1, math.min(new_top_elem, max_top)) + if new_top_elem ~= scrollbar.top_elem then + scrollbar.on_scroll(new_top_elem) + end +end + +local function scrollbar_is_visible(scrollbar) + return scrollbar.elems_per_page < scrollbar.num_elems +end + +local SBSO = df.global.init.scrollbar_texpos[0] --Scroll Bar Spritesheet Offset / change this to point to a different spritesheet (ui themes, anyone? :p) +local SCROLLBAR_UP_LEFT_PEN = to_pen{tile=SBSO+0, ch=47, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_UP_RIGHT_PEN = to_pen{tile=SBSO+1, ch=92, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_DOWN_LEFT_PEN = to_pen{tile=SBSO+24, ch=92, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_DOWN_RIGHT_PEN = to_pen{tile=SBSO+25, ch=47, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_UP_LEFT_PEN = to_pen{tile=SBSO+6, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_UP_RIGHT_PEN = to_pen{tile=SBSO+7, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_LEFT_PEN = to_pen{tile=SBSO+30, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_RIGHT_PEN = to_pen{tile=SBSO+31, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_UP_LEFT_PEN = to_pen{tile=SBSO+10, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_UP_RIGHT_PEN = to_pen{tile=SBSO+11, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_DOWN_LEFT_PEN = to_pen{tile=SBSO+22, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_DOWN_RIGHT_PEN = to_pen{tile=SBSO+23, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_LEFT_PEN = to_pen{tile=SBSO+18, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_RIGHT_PEN = to_pen{tile=SBSO+19, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_DOWN_LEFT_PEN = to_pen{tile=SBSO+42, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_DOWN_RIGHT_PEN = to_pen{tile=SBSO+43, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_2TALL_UP_LEFT_PEN = to_pen{tile=SBSO+26, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_2TALL_UP_RIGHT_PEN = to_pen{tile=SBSO+27, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_2TALL_DOWN_LEFT_PEN = to_pen{tile=SBSO+38, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_2TALL_DOWN_RIGHT_PEN = to_pen{tile=SBSO+39, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK} +local SCROLLBAR_UP_LEFT_HOVER_PEN = to_pen{tile=SBSO+2, ch=47, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_UP_RIGHT_HOVER_PEN = to_pen{tile=SBSO+3, ch=92, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_DOWN_LEFT_HOVER_PEN = to_pen{tile=SBSO+14, ch=92, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_DOWN_RIGHT_HOVER_PEN = to_pen{tile=SBSO+15, ch=47, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_UP_LEFT_HOVER_PEN = to_pen{tile=SBSO+8, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_UP_RIGHT_HOVER_PEN = to_pen{tile=SBSO+9, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_LEFT_HOVER_PEN = to_pen{tile=SBSO+32, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_RIGHT_HOVER_PEN = to_pen{tile=SBSO+33, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_UP_LEFT_HOVER_PEN = to_pen{tile=SBSO+34, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_UP_RIGHT_HOVER_PEN = to_pen{tile=SBSO+35, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_DOWN_LEFT_HOVER_PEN = to_pen{tile=SBSO+46, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_DOWN_RIGHT_HOVER_PEN = to_pen{tile=SBSO+47, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_LEFT_HOVER_PEN = to_pen{tile=SBSO+20, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_CENTER_RIGHT_HOVER_PEN = to_pen{tile=SBSO+21, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_DOWN_LEFT_HOVER_PEN = to_pen{tile=SBSO+44, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_DOWN_RIGHT_HOVER_PEN = to_pen{tile=SBSO+45, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_2TALL_UP_LEFT_HOVER_PEN = to_pen{tile=SBSO+28, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_2TALL_UP_RIGHT_HOVER_PEN = to_pen{tile=SBSO+29, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_2TALL_DOWN_LEFT_HOVER_PEN = to_pen{tile=SBSO+40, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_2TALL_DOWN_RIGHT_HOVER_PEN = to_pen{tile=SBSO+41, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK} +local SCROLLBAR_BAR_BG_LEFT_PEN = to_pen{tile=SBSO+12, ch=176, fg=COLOR_DARKGREY, bg=COLOR_BLACK} +local SCROLLBAR_BAR_BG_RIGHT_PEN = to_pen{tile=SBSO+13, ch=176, fg=COLOR_DARKGREY, bg=COLOR_BLACK} + +function Scrollbar:onRenderBody(dc) + -- don't draw if all elements are visible + if not scrollbar_is_visible(self) then + return + end + -- determine which elements should be highlighted + local _,hover_y = self:getMousePos() + local hover_up, hover_down, hover_bar = false, false, false + if hover_y == 0 then + hover_up = true + elseif hover_y == dc.height-1 then + hover_down = true + elseif hover_y then + hover_bar = true + end + -- render up arrow + dc:seek(0, 0) + dc:char(nil, hover_up and SCROLLBAR_UP_LEFT_HOVER_PEN or SCROLLBAR_UP_LEFT_PEN) + dc:char(nil, hover_up and SCROLLBAR_UP_RIGHT_HOVER_PEN or SCROLLBAR_UP_RIGHT_PEN) + -- render scrollbar body + local starty = self.bar_offset + 1 + local endy = self.bar_offset + self.bar_height + local midy = (starty + endy)/2 + for y=1,dc.height-2 do + dc:seek(0, y) + if y >= starty and y <= endy then + if y == starty and y <= midy - 1 then + dc:char(nil, hover_bar and SCROLLBAR_BAR_UP_LEFT_HOVER_PEN or SCROLLBAR_BAR_UP_LEFT_PEN) + dc:char(nil, hover_bar and SCROLLBAR_BAR_UP_RIGHT_HOVER_PEN or SCROLLBAR_BAR_UP_RIGHT_PEN) + elseif y == midy - 0.5 and self.bar_height == 2 then + dc:char(nil, hover_bar and SCROLLBAR_BAR_2TALL_UP_LEFT_HOVER_PEN or SCROLLBAR_BAR_2TALL_UP_LEFT_PEN) + dc:char(nil, hover_bar and SCROLLBAR_BAR_2TALL_UP_RIGHT_HOVER_PEN or SCROLLBAR_BAR_2TALL_UP_RIGHT_PEN) + elseif y == midy + 0.5 and self.bar_height == 2 then + dc:char(nil, hover_bar and SCROLLBAR_BAR_2TALL_DOWN_LEFT_HOVER_PEN or SCROLLBAR_BAR_2TALL_DOWN_LEFT_PEN) + dc:char(nil, hover_bar and SCROLLBAR_BAR_2TALL_DOWN_RIGHT_HOVER_PEN or SCROLLBAR_BAR_2TALL_DOWN_RIGHT_PEN) + elseif y == midy - 0.5 then + dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_UP_LEFT_HOVER_PEN or SCROLLBAR_BAR_CENTER_UP_LEFT_PEN) + dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_UP_RIGHT_HOVER_PEN or SCROLLBAR_BAR_CENTER_UP_RIGHT_PEN) + elseif y == midy + 0.5 then + dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_DOWN_LEFT_HOVER_PEN or SCROLLBAR_BAR_CENTER_DOWN_LEFT_PEN) + dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_DOWN_RIGHT_HOVER_PEN or SCROLLBAR_BAR_CENTER_DOWN_RIGHT_PEN) + elseif y == midy then + dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_LEFT_HOVER_PEN or SCROLLBAR_BAR_CENTER_LEFT_PEN) + dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_RIGHT_HOVER_PEN or SCROLLBAR_BAR_CENTER_RIGHT_PEN) + elseif y == endy and y >= midy + 1 then + dc:char(nil, hover_bar and SCROLLBAR_BAR_DOWN_LEFT_HOVER_PEN or SCROLLBAR_BAR_DOWN_LEFT_PEN) + dc:char(nil, hover_bar and SCROLLBAR_BAR_DOWN_RIGHT_HOVER_PEN or SCROLLBAR_BAR_DOWN_RIGHT_PEN) + else + dc:char(nil, hover_bar and SCROLLBAR_BAR_LEFT_HOVER_PEN or SCROLLBAR_BAR_LEFT_PEN) + dc:char(nil, hover_bar and SCROLLBAR_BAR_RIGHT_HOVER_PEN or SCROLLBAR_BAR_RIGHT_PEN) + end + else + dc:char(nil, SCROLLBAR_BAR_BG_LEFT_PEN) + dc:char(nil, SCROLLBAR_BAR_BG_RIGHT_PEN) + end + end + -- render down arrow + dc:seek(0, dc.height-1) + dc:char(nil, hover_down and SCROLLBAR_DOWN_LEFT_HOVER_PEN or SCROLLBAR_DOWN_LEFT_PEN) + dc:char(nil, hover_down and SCROLLBAR_DOWN_RIGHT_HOVER_PEN or SCROLLBAR_DOWN_RIGHT_PEN) + if not self.on_scroll then return end + -- manage state for dragging and continuous scrolling + if self.is_dragging then + scrollbar_do_drag(self) + end + if df.global.enabler.mouse_lbut_down == 0 then + self.last_scroll_ms = 0 + self.is_dragging = false + self.scroll_spec = nil + return + end + if self.last_scroll_ms == 0 then return end + local now = dfhack.getTickCount() + local delay = self.is_first_click and + common.SCROLL_INITIAL_DELAY_MS or common.SCROLL_DELAY_MS + if now - self.last_scroll_ms >= delay then + self.is_first_click = false + self.on_scroll(self.scroll_spec) + self.last_scroll_ms = now + end +end + +function Scrollbar:onInput(keys) + if not self.on_scroll or not scrollbar_is_visible(self) then + return false + end + + if self.parent_view and self.parent_view:getMousePos() then + if keys.CONTEXT_SCROLL_UP then + self.on_scroll('up_small') + return true + elseif keys.CONTEXT_SCROLL_DOWN then + self.on_scroll('down_small') + return true + elseif keys.CONTEXT_SCROLL_PAGEUP then + self.on_scroll('up_large') + return true + elseif keys.CONTEXT_SCROLL_PAGEDOWN then + self.on_scroll('down_large') + return true + end + end + if not keys._MOUSE_L then return false end + local _,y = self:getMousePos() + if not y then return false end + local scroll_spec = nil + if y == 0 then scroll_spec = 'up_small' + elseif y == self.frame_body.height - 1 then scroll_spec = 'down_small' + elseif y <= self.bar_offset then scroll_spec = 'up_large' + elseif y > self.bar_offset + self.bar_height then scroll_spec = 'down_large' + else + self.is_dragging = y - self.bar_offset + return true + end + self.scroll_spec = scroll_spec + self.on_scroll(scroll_spec) + -- reset continuous scroll state + self.is_first_click = true + self.last_scroll_ms = dfhack.getTickCount() + return true +end + +return Scrollbar diff --git a/library/lua/gui/widgets/tab_bar.lua b/library/lua/gui/widgets/tab_bar.lua new file mode 100644 index 0000000000..574e1d3a58 --- /dev/null +++ b/library/lua/gui/widgets/tab_bar.lua @@ -0,0 +1,438 @@ +local Widget = require('gui.widgets.widget') +local ResizingPanel = require('gui.widgets.containers.resizing_panel') +local Label = require('gui.widgets.labels.label') +local Panel = require('gui.widgets.containers.panel') +local utils = require('utils') + +local to_pen = dfhack.pen.parse + +---@class widgets.TabPens +---@field text_mode_tab_pen dfhack.pen +---@field text_mode_label_pen dfhack.pen +---@field lt dfhack.pen +---@field lt2 dfhack.pen +---@field t dfhack.pen +---@field rt2 dfhack.pen +---@field rt dfhack.pen +---@field lb dfhack.pen +---@field lb2 dfhack.pen +---@field b dfhack.pen +---@field rb2 dfhack.pen +---@field rb dfhack.pen + +local TSO = df.global.init.tabs_texpos[0] -- tab spritesheet offset +local DEFAULT_ACTIVE_TAB_PENS = { + text_mode_tab_pen=to_pen{fg=COLOR_YELLOW}, + text_mode_label_pen=to_pen{fg=COLOR_WHITE}, + lt=to_pen{tile=TSO+5, write_to_lower=true}, + lt2=to_pen{tile=TSO+6, write_to_lower=true}, + t=to_pen{tile=TSO+7, fg=COLOR_BLACK, write_to_lower=true, top_of_text=true}, + rt2=to_pen{tile=TSO+8, write_to_lower=true}, + rt=to_pen{tile=TSO+9, write_to_lower=true}, + lb=to_pen{tile=TSO+15, write_to_lower=true}, + lb2=to_pen{tile=TSO+16, write_to_lower=true}, + b=to_pen{tile=TSO+17, fg=COLOR_BLACK, write_to_lower=true, bottom_of_text=true}, + rb2=to_pen{tile=TSO+18, write_to_lower=true}, + rb=to_pen{tile=TSO+19, write_to_lower=true}, +} + +local DEFAULT_INACTIVE_TAB_PENS = { + text_mode_tab_pen=to_pen{fg=COLOR_BROWN}, + text_mode_label_pen=to_pen{fg=COLOR_DARKGREY}, + lt=to_pen{tile=TSO+0, write_to_lower=true}, + lt2=to_pen{tile=TSO+1, write_to_lower=true}, + t=to_pen{tile=TSO+2, fg=COLOR_WHITE, write_to_lower=true, top_of_text=true}, + rt2=to_pen{tile=TSO+3, write_to_lower=true}, + rt=to_pen{tile=TSO+4, write_to_lower=true}, + lb=to_pen{tile=TSO+10, write_to_lower=true}, + lb2=to_pen{tile=TSO+11, write_to_lower=true}, + b=to_pen{tile=TSO+12, fg=COLOR_WHITE, write_to_lower=true, bottom_of_text=true}, + rb2=to_pen{tile=TSO+13, write_to_lower=true}, + rb=to_pen{tile=TSO+14, write_to_lower=true}, +} + +--------- +-- Tab -- +--------- + +---@class widgets.Tab.attrs: widgets.Widget.attrs +---@field id? string|integer +---@field label string +---@field on_select? function +---@field get_pens? fun(): widgets.TabPens + +---@class widgets.Tab.attrs.partial: widgets.Tab.attrs + +---@class widgets.Tab.initTable: widgets.Tab.attrs +---@field label string + +---@class widgets.Tab: widgets.Widget, widgets.Tab.attrs +---@field super widgets.Widget +---@field ATTRS widgets.Tab.attrs|fun(attributes: widgets.Tab.attrs.partial) +---@overload fun(init_table: widgets.Tab.initTable): self +Tab = defclass(Tabs, Widget) +Tab.ATTRS{ + id=DEFAULT_NIL, + label=DEFAULT_NIL, + on_select=DEFAULT_NIL, + get_pens=DEFAULT_NIL, +} + +function Tab:preinit(init_table) + init_table.frame = init_table.frame or {} + init_table.frame.w = #init_table.label + 4 + init_table.frame.h = 2 +end + +function Tab:onRenderBody(dc) + local pens = self.get_pens() + dc:seek(0, 0) + if dfhack.screen.inGraphicsMode() then + dc:char(nil, pens.lt):char(nil, pens.lt2) + for i=1,#self.label do + dc:char(self.label:sub(i,i), pens.t) + end + dc:char(nil, pens.rt2):char(nil, pens.rt) + dc:seek(0, 1) + dc:char(nil, pens.lb):char(nil, pens.lb2) + for i=1,#self.label do + dc:char(self.label:sub(i,i), pens.b) + end + dc:char(nil, pens.rb2):char(nil, pens.rb) + else + local tp = pens.text_mode_tab_pen + dc:char(' ', tp):char('/', tp) + for i=1,#self.label do + dc:char('-', tp) + end + dc:char('\\', tp):char(' ', tp) + dc:seek(0, 1) + dc:char('/', tp):char('-', tp) + dc:string(self.label, pens.text_mode_label_pen) + dc:char('-', tp):char('\\', tp) + end +end + +function Tab:onInput(keys) + if Tab.super.onInput(self, keys) then return true end + if keys._MOUSE_L and self:getMousePos() then + self.on_select(self.id) + return true + end +end + +------------- +-- Tab Bar -- +------------- + +---@class widgets.TabBar.attrs: widgets.ResizingPanel.attrs +---@field labels string[] +---@field on_select? function +---@field get_cur_page? function +---@field active_tab_pens widgets.TabPens +---@field inactive_tab_pens widgets.TabPens +---@field get_pens? fun(index: integer, tabbar: self): widgets.TabPens +---@field key string +---@field key_back string +---@field wrap boolean +---@field scroll_step integer +---@field scroll_key string +---@field scroll_key_back string +---@field fast_scroll_modifier integer +---@field scroll_into_view_offset integer +---@field scroll_label_text_pen dfhack.pen +---@field scroll_label_text_hpen dfhack.pen + +---@class widgets.TabBar.attrs.partial: widgets.TabBar.attrs + +---@class widgets.TabBar.initTable: widgets.TabBar.attrs +---@field labels string[] + +---@class widgets.TabBar: widgets.ResizingPanel, widgets.TabBar.attrs +---@field super widgets.ResizingPanel +---@field ATTRS widgets.TabBar.attrs|fun(attribute: widgets.TabBar.attrs.partial) +---@overload fun(init_table: widgets.TabBar.initTable): self +TabBar = defclass(TabBar, ResizingPanel) +TabBar.ATTRS{ + labels=DEFAULT_NIL, + on_select=DEFAULT_NIL, + get_cur_page=DEFAULT_NIL, + active_tab_pens=DEFAULT_ACTIVE_TAB_PENS, + inactive_tab_pens=DEFAULT_INACTIVE_TAB_PENS, + get_pens=DEFAULT_NIL, + key='CUSTOM_CTRL_T', + key_back='CUSTOM_CTRL_Y', + wrap = true, + scroll_step = 10, + scroll_key = 'CUSTOM_ALT_T', + scroll_key_back = 'CUSTOM_ALT_Y', + fast_scroll_modifier = 3, + scroll_into_view_offset = 5, + scroll_label_text_pen = DEFAULT_NIL, + scroll_label_text_hpen = DEFAULT_NIL, +} + +local TO_THE_RIGHT = string.char(16) +local TO_THE_LEFT = string.char(17) + +---@param self widgets.TabBar +function TabBar:init() + self.scrollable = false + self.scroll_offset = 0 + self.first_render = true + + local panel = Panel{ + view_id='TabBar__tabs', + frame={t=0, l=0, h=2}, + frame_inset={l=0}, + } + + for idx,label in ipairs(self.labels) do + panel:addviews{ + Tab{ + frame={t=0, l=0}, + id=idx, + label=label, + on_select=function() + self.scrollTabIntoView(self, idx) + self.on_select(idx) + end, + get_pens=self.get_pens and function() + return self.get_pens(idx, self) + end or function() + if self.get_cur_page() == idx then + return self.active_tab_pens + end + + return self.inactive_tab_pens + end, + } + } + end + + self:addviews{panel} + + if not self.wrap then + self:addviews{ + Label{ + view_id='TabBar__scroll_left', + frame={t=0, l=0, w=1}, + text_pen=self.scroll_label_text_pen, + text_hpen=self.scroll_label_text_hpen, + text=TO_THE_LEFT, + visible = false, + on_click=function() + self:scrollLeft() + end, + }, + Label{ + view_id='TabBar__scroll_right', + frame={t=0, l=0, w=1}, + text_pen=self.scroll_label_text_pen, + text_hpen=self.scroll_label_text_hpen, + text=TO_THE_RIGHT, + visible = false, + on_click=function() + self:scrollRight() + end, + }, + } + end +end + +function TabBar:updateScrollElements() + self:showScrollLeft() + self:showScrollRight() + self:updateTabPanelPosition() +end + +function TabBar:leftScrollVisible() + return self.scroll_offset < 0 +end + +function TabBar:showScrollLeft() + if self.wrap then return end + self:scrollLeftElement().visible = self:leftScrollVisible() +end + +function TabBar:rightScrollVisible() + return self.scroll_offset > self.offset_to_show_last_tab +end + +function TabBar:showScrollRight() + if self.wrap then return end + self:scrollRightElement().visible = self:rightScrollVisible() +end + +function TabBar:updateTabPanelPosition() + self:tabsElement().frame_inset.l = self.scroll_offset + self:tabsElement():updateLayout(self.frame_body) +end + +function TabBar:tabsElement() + return self.subviews.TabBar__tabs +end + +function TabBar:scrollLeftElement() + return self.subviews.TabBar__scroll_left +end + +function TabBar:scrollRightElement() + return self.subviews.TabBar__scroll_right +end + +function TabBar:scrollTabIntoView(idx) + if self.wrap or not self.scrollable then return end + + local tab = self:tabsElement().subviews[idx] + local tab_l = tab.frame.l + local tab_r = tab.frame.l + tab.frame.w + local tabs_l = self:tabsElement().frame.l + local tabs_r = tabs_l + self.frame_body.width + local scroll_offset = self.scroll_offset + + if tab_l < tabs_l - scroll_offset then + self.scroll_offset = tabs_l - tab_l + self.scroll_into_view_offset + elseif tab_r > tabs_r - scroll_offset then + self.scroll_offset = self.scroll_offset - (tab_r - tabs_r) - self.scroll_into_view_offset + end + + self:capScrollOffset() + self:updateScrollElements() +end + +function TabBar:capScrollOffset() + if self.scroll_offset > 0 then + self.scroll_offset = 0 + elseif self.scroll_offset < self.offset_to_show_last_tab then + self.scroll_offset = self.offset_to_show_last_tab + end +end + +function TabBar:scrollRight(alternate_step) + if not self:scrollRightElement().visible then return end + + self.scroll_offset = self.scroll_offset - (alternate_step and alternate_step or self.scroll_step) + + self:capScrollOffset() + self:updateScrollElements() +end + +function TabBar:scrollLeft(alternate_step) + if not self:scrollLeftElement().visible then return end + + self.scroll_offset = self.scroll_offset + (alternate_step and alternate_step or self.scroll_step) + + self:capScrollOffset() + self:updateScrollElements() +end + +function TabBar:isMouseOver() + for _, sv in ipairs(self:tabsElement().subviews) do + if utils.getval(sv.visible) and sv:getMouseFramePos() then return true end + end +end + +function TabBar:postComputeFrame(body) + local all_tabs_width = 0 + + local t, l, width = 0, 0, body.width + self.scrollable = false + + self.last_post_compute_width = self.post_compute_width or 0 + self.post_compute_width = width + + local tab_rows = 1 + for _,tab in ipairs(self:tabsElement().subviews) do + tab.visible = true + if l > 0 and l + tab.frame.w > width then + if self.wrap then + t = t + 2 + l = 0 + tab_rows = tab_rows + 1 + else + self.scrollable = true + end + end + tab.frame.t = t + tab.frame.l = l + l = l + tab.frame.w + all_tabs_width = all_tabs_width + tab.frame.w + end + + self.offset_to_show_last_tab = -(all_tabs_width - self.post_compute_width) + + if self.scrollable and not self.wrap then + self:scrollRightElement().frame.l = width - 1 + if self.last_post_compute_width ~= self.post_compute_width then + self.scroll_offset = 0 + end + end + + if self.first_render then + self.first_render = false + if not self.wrap then + self:scrollTabIntoView(self.get_cur_page()) + end + end + + -- we have to calculate the height of this based on the number of tab rows we will have + -- so that autoarrange_subviews will work correctly + self:tabsElement().frame.h = tab_rows * 2 + + self:updateScrollElements() +end + +function TabBar:fastStep() + return self.scroll_step * self.fast_scroll_modifier +end + +function TabBar:onInput(keys) + if TabBar.super.onInput(self, keys) then return true end + if not self.wrap then + if self:isMouseOver() then + if keys.CONTEXT_SCROLL_UP then + self:scrollLeft() + return true + end + if keys.CONTEXT_SCROLL_DOWN then + self:scrollRight() + return true + end + if keys.CONTEXT_SCROLL_PAGEUP then + self:scrollLeft(self:fastStep()) + return true + end + if keys.CONTEXT_SCROLL_PAGEDOWN then + self:scrollRight(self:fastStep()) + return true + end + end + if self.scroll_key and keys[self.scroll_key] then + self:scrollRight() + return true + end + if self.scroll_key_back and keys[self.scroll_key_back] then + self:scrollLeft() + return true + end + end + if self.key and keys[self.key] then + local zero_idx = self.get_cur_page() - 1 + local next_zero_idx = (zero_idx + 1) % #self.labels + self.scrollTabIntoView(self, next_zero_idx + 1) + self.on_select(next_zero_idx + 1) + return true + end + if self.key_back and keys[self.key_back] then + local zero_idx = self.get_cur_page() - 1 + local prev_zero_idx = (zero_idx - 1) % #self.labels + self.scrollTabIntoView(self, prev_zero_idx + 1) + self.on_select(prev_zero_idx + 1) + return true + end +end + +TabBar.Tab = Tab + +return TabBar diff --git a/library/lua/gui/widgets/text_area.lua b/library/lua/gui/widgets/text_area.lua new file mode 100644 index 0000000000..c76a7d03e8 --- /dev/null +++ b/library/lua/gui/widgets/text_area.lua @@ -0,0 +1,183 @@ +-- Multiline text area control + +local Panel = require('gui.widgets.containers.panel') +local Scrollbar = require('gui.widgets.scrollbar') +local TextAreaContent = require('gui.widgets.text_area.text_area_content') +local HistoryStore = require('gui.widgets.text_area.history_store') + +local HISTORY_ENTRY = HistoryStore.HISTORY_ENTRY + +TextArea = defclass(TextArea, Panel) + +TextArea.ATTRS{ + init_text = '', + init_cursor = DEFAULT_NIL, + text_pen = COLOR_LIGHTCYAN, + ignore_keys = {}, + select_pen = COLOR_CYAN, + on_text_change = DEFAULT_NIL, + on_cursor_change = DEFAULT_NIL, + one_line_mode = false, + debug = false +} + +function TextArea:init() + self.render_start_line_y = 1 + + self.text_area = TextAreaContent{ + frame={l=0,r=3,t=0}, + text=self.init_text, + + text_pen=self.text_pen, + ignore_keys=self.ignore_keys, + select_pen=self.select_pen, + debug=self.debug, + one_line_mode=self.one_line_mode, + + on_text_change=function (text, old_text) + self:updateLayout() + if self.on_text_change then + self.on_text_change(text, old_text) + end + end, + on_cursor_change=self:callback('onCursorChange') + } + self.scrollbar = Scrollbar{ + frame={r=0,t=1}, + on_scroll=self:callback('onScrollbar'), + visible=not self.one_line_mode + } + + self:addviews{ + self.text_area, + self.scrollbar, + } +end + +function TextArea:getText() + return self.text_area.text +end + +function TextArea:setText(text) + self.text_area.history:store( + HISTORY_ENTRY.OTHER, + self:getText(), + self:getCursor() + ) + + return self.text_area:setText(text) +end + +function TextArea:getCursor() + return self.text_area.cursor +end + +function TextArea:setCursor(cursor_offset) + return self.text_area:setCursor(cursor_offset) +end + +function TextArea:clearHistory() + return self.text_area.history:clear() +end + +function TextArea:onCursorChange(cursor, old_cursor) + local x, y = self.text_area.wrapped_text:indexToCoords( + self.text_area.cursor + ) + + if y >= self.render_start_line_y + self.text_area.frame_body.height then + self:updateScrollbar( + y - self.text_area.frame_body.height + 1 + ) + elseif (y < self.render_start_line_y) then + self:updateScrollbar(y) + end + + if self.on_cursor_change then + self.on_cursor_change(cursor, old_cursor) + end +end + +function TextArea:scrollToCursor(cursor_offset) + if self.scrollbar.visible then + local _, cursor_line_y = self.text_area.wrapped_text:indexToCoords( + cursor_offset + ) + self:updateScrollbar(cursor_line_y) + end +end + +function TextArea:getPreferredFocusState() + return true +end + +function TextArea:postUpdateLayout() + self:updateScrollbar(self.render_start_line_y) + + if self.text_area.cursor == nil then + local cursor = self.init_cursor or #self.init_text + 1 + self.text_area:setCursor(cursor) + self:scrollToCursor(cursor) + end +end + +function TextArea:onScrollbar(scroll_spec) + local height = self.text_area.frame_body.height + + local render_start_line = self.render_start_line_y + if scroll_spec == 'down_large' then + render_start_line = render_start_line + math.ceil(height / 2) + elseif scroll_spec == 'up_large' then + render_start_line = render_start_line - math.ceil(height / 2) + elseif scroll_spec == 'down_small' then + render_start_line = render_start_line + 1 + elseif scroll_spec == 'up_small' then + render_start_line = render_start_line - 1 + else + render_start_line = tonumber(scroll_spec) + end + + self:updateScrollbar(render_start_line) +end + +function TextArea:updateScrollbar(scrollbar_current_y) + local lines_count = #self.text_area.wrapped_text.lines + + local render_start_line_y = math.min( + #self.text_area.wrapped_text.lines - self.text_area.frame_body.height + 1, + math.max(1, scrollbar_current_y) + ) + + self.scrollbar:update( + render_start_line_y, + self.frame_body.height, + lines_count + ) + + if (self.frame_body.height >= lines_count) then + render_start_line_y = 1 + end + + self.render_start_line_y = render_start_line_y + self.text_area:setRenderStartLineY(self.render_start_line_y) +end + +function TextArea:renderSubviews(dc) + self.text_area.frame_body.y1 = self.frame_body.y1-(self.render_start_line_y - 1) + -- only visible lines of text_area will be rendered + TextArea.super.renderSubviews(self, dc) +end + +function TextArea:onInput(keys) + if (self.scrollbar.is_dragging) then + return self.scrollbar:onInput(keys) + end + + if keys._MOUSE_L and self:getMousePos() then + self:setFocus(true) + end + + return TextArea.super.onInput(self, keys) +end + +return TextArea diff --git a/library/lua/gui/widgets/text_area/history_store.lua b/library/lua/gui/widgets/text_area/history_store.lua new file mode 100644 index 0000000000..abaf2de3ee --- /dev/null +++ b/library/lua/gui/widgets/text_area/history_store.lua @@ -0,0 +1,86 @@ +HistoryStore = defclass(HistoryStore) + +local HISTORY_ENTRY = { + TEXT_BLOCK = 1, + WHITESPACE_BLOCK = 2, + BACKSPACE = 2, + DELETE = 3, + OTHER = 4 +} + +HistoryStore.ATTRS{ + history_size = 25, +} + +function HistoryStore:init() + self.past = {} + self.future = {} +end + +function HistoryStore:store(history_entry_type, text, cursor) + local last_entry = self.past[#self.past] + + if not last_entry or history_entry_type == HISTORY_ENTRY.OTHER or + last_entry.entry_type ~= history_entry_type then + table.insert(self.past, { + entry_type=history_entry_type, + text=text, + cursor=cursor + }) + end + + self.future = {} + + if #self.past > self.history_size then + table.remove(self.past, 1) + end +end + +function HistoryStore:undo(curr_text, curr_cursor) + if #self.past == 0 then + return nil + end + + local history_entry = table.remove(self.past, #self.past) + + table.insert(self.future, { + entry_type=HISTORY_ENTRY.OTHER, + text=curr_text, + cursor=curr_cursor + }) + + if #self.future > self.history_size then + table.remove(self.future, 1) + end + + return history_entry +end + +function HistoryStore:redo(curr_text, curr_cursor) + if #self.future == 0 then + return true + end + + local history_entry = table.remove(self.future, #self.future) + + table.insert(self.past, { + entry_type=HISTORY_ENTRY.OTHER, + text=curr_text, + cursor=curr_cursor + }) + + if #self.past > self.history_size then + table.remove(self.past, 1) + end + + return history_entry +end + +function HistoryStore:clear() + self.past = {} + self.future = {} +end + +HistoryStore.HISTORY_ENTRY = HISTORY_ENTRY + +return HistoryStore diff --git a/library/lua/gui/widgets/text_area/text_area_content.lua b/library/lua/gui/widgets/text_area/text_area_content.lua new file mode 100644 index 0000000000..48877ccaf7 --- /dev/null +++ b/library/lua/gui/widgets/text_area/text_area_content.lua @@ -0,0 +1,673 @@ +local gui = require('gui') +local common = require('gui.widgets.common') +local Widget = require('gui.widgets.widget') +local WrappedText = require('gui.widgets.text_area.wrapped_text') +local HistoryStore = require('gui.widgets.text_area.history_store') + +local CLIPBOARD_MODE = {LOCAL = 1, LINE = 2} +local HISTORY_ENTRY = HistoryStore.HISTORY_ENTRY + +TextAreaContent = defclass(TextAreaContent, Widget) + +TextAreaContent.ATTRS{ + text = '', + text_pen = COLOR_LIGHTCYAN, + ignore_keys = {}, + pen_selection = COLOR_CYAN, + on_text_change = DEFAULT_NIL, + on_cursor_change = DEFAULT_NIL, + enable_cursor_blink = true, + debug = false, + one_line_mode = false, + history_size = 25, +} + +function TextAreaContent:init() + self.sel_end = nil + self.clipboard = nil + self.clipboard_mode = CLIPBOARD_MODE.LOCAL + self.render_start_line_y = 1 + + self.cursor = nil + + self.main_pen = dfhack.pen.parse({ + fg=self.text_pen, + bg=COLOR_RESET, + bold=true + }) + self.sel_pen = dfhack.pen.parse({ + fg=self.text_pen, + bg=self.pen_selection, + bold=true + }) + + self.text = self:normalizeText(self.text) + + self.wrapped_text = WrappedText{ + text=self.text, + wrap_width=256 + } + + self.history = HistoryStore{history_size=self.history_size} +end + +function TextAreaContent:normalizeText(text) + if self.one_line_mode then + return text:gsub("\r?\n", "") + end + + return text +end + +function TextAreaContent:setRenderStartLineY(render_start_line_y) + self.render_start_line_y = render_start_line_y +end + +function TextAreaContent:postComputeFrame() + self:recomputeLines() +end + +function TextAreaContent:recomputeLines() + self.wrapped_text:update( + self.text, + -- something cursor '_' need to be add at the end of a line + self.frame_body.width - 1 + ) +end + +function TextAreaContent:setCursor(cursor_offset) + local old_cursor = self.cursor + + self.cursor = math.max( + 1, + math.min(#self.text + 1, cursor_offset) + ) + + if self.debug then + print('cursor', self.cursor) + end + + self.sel_end = nil + self.last_cursor_x = nil + + if self.on_cursor_change and self.cursor ~= old_cursor then + self.on_cursor_change(self.cursor, old_cursor) + end +end + +function TextAreaContent:setSelection(from_offset, to_offset) + -- text selection is always start on self.cursor and on self.sel_end + self:setCursor(from_offset) + self.sel_end = to_offset + + if self.debug and to_offset then + print('sel_end', to_offset) + end +end + +function TextAreaContent:hasSelection() + return not not self.sel_end +end + +function TextAreaContent:eraseSelection() + if (self:hasSelection()) then + local from, to = self.cursor, self.sel_end + if (from > to) then + from, to = to, from + end + + local new_text = self.text:sub(1, from - 1) .. self.text:sub(to + 1) + self:setText(new_text) + + self:setCursor(from) + self.sel_end = nil + end +end + +function TextAreaContent:setClipboard(text) + dfhack.internal.setClipboardTextCp437Multiline(text) +end + +function TextAreaContent:copy() + if self.sel_end then + self.clipboard_mode = CLIPBOARD_MODE.LOCAL + + local from = self.cursor + local to = self.sel_end + + if from > to then + from, to = to, from + end + + self:setClipboard(self.text:sub(from, to)) + + return from, to + else + self.clipboard_mode = CLIPBOARD_MODE.LINE + + local curr_line = self.text:sub( + self:lineStartOffset(), + self:lineEndOffset() + ) + if curr_line:sub(-1,-1) ~= NEWLINE then + curr_line = curr_line .. NEWLINE + end + + self:setClipboard(curr_line) + + return self:lineStartOffset(), self:lineEndOffset() + end +end + +function TextAreaContent:cut() + local from, to = self:copy() + if not self:hasSelection() then + self:setSelection(from, to) + end + self:eraseSelection() +end + +function TextAreaContent:paste() + local clipboard_lines = dfhack.internal.getClipboardTextCp437Multiline() + local clipboard = table.concat(clipboard_lines, '\n') + if clipboard then + if self.clipboard_mode == CLIPBOARD_MODE.LINE and not self:hasSelection() then + local origin_offset = self.cursor + self:setCursor(self:lineStartOffset()) + self:insert(clipboard) + self:setCursor(#clipboard + origin_offset) + else + self:eraseSelection() + self:insert(clipboard) + end + + end +end + +function TextAreaContent:setText(text) + local old_text = self.text + + self.text = self:normalizeText(text) + + self:recomputeLines() + + if self.on_text_change and text ~= old_text then + self.on_text_change(text, old_text) + end +end + +function TextAreaContent:insert(text) + self:eraseSelection() + local new_text = + self.text:sub(1, self.cursor - 1) .. + text .. + self.text:sub(self.cursor) + + self:setText(new_text) + self:setCursor(self.cursor + #text) +end + +function TextAreaContent:onRenderBody(dc) + dc:pen(self.main_pen) + + local max_width = dc.width + local new_line = self.debug and NEWLINE or '' + + local lines_to_render = math.min( + dc.height, + #self.wrapped_text.lines - self.render_start_line_y + 1 + ) + + dc:seek(0, self.render_start_line_y - 1) + for i = self.render_start_line_y, self.render_start_line_y + lines_to_render - 1 do + -- do not render new lines symbol + local line = self.wrapped_text.lines[i]:gsub(NEWLINE, new_line) + dc:string(line) + dc:newline() + end + + local show_focus = not self.enable_cursor_blink + or ( + not self:hasSelection() + and self.parent_view.focus + and gui.blink_visible(530) + ) + + if show_focus then + local x, y = self.wrapped_text:indexToCoords(self.cursor) + dc:seek(x - 1, y - 1) + :char('_') + end + + if self:hasSelection() then + local sel_new_line = self.debug and PERIOD or '' + local from, to = self.cursor, self.sel_end + if (from > to) then + from, to = to, from + end + + local from_x, from_y = self.wrapped_text:indexToCoords(from) + local to_x, to_y = self.wrapped_text:indexToCoords(to) + + local line = self.wrapped_text.lines[from_y] + :sub(from_x, to_y == from_y and to_x or nil) + :gsub(NEWLINE, sel_new_line) + + dc:pen(self.sel_pen) + :seek(from_x - 1, from_y - 1) + :string(line) + + for y = from_y + 1, to_y - 1 do + line = self.wrapped_text.lines[y]:gsub(NEWLINE, sel_new_line) + dc:seek(0, y - 1) + :string(line) + end + + if (to_y > from_y) then + local line = self.wrapped_text.lines[to_y] + :sub(1, to_x) + :gsub(NEWLINE, sel_new_line) + dc:seek(0, to_y - 1) + :string(line) + end + + dc:pen({fg=self.text_pen, bg=COLOR_RESET}) + end + + if self.debug then + local cursor_char = self:charAtCursor() + local x, y = self.wrapped_text:indexToCoords(self.cursor) + local debug_msg = string.format( + 'x: %s y: %s ind: %s #line: %s char: %s hist-: %s hist+: %s', + x, + y, + self.cursor, + self:lineEndOffset() - self:lineStartOffset(), + (cursor_char == NEWLINE and 'NEWLINE') or + (cursor_char == ' ' and 'SPACE') or + (cursor_char == '' and 'nil') or + cursor_char, + #self.history.past, + #self.history.future + ) + local sel_debug_msg = self.sel_end and string.format( + 'sel_end: %s', + self.sel_end + ) or '' + + dc:pen({fg=COLOR_LIGHTRED, bg=COLOR_RESET}) + :seek(0, self.parent_view.frame_body.height + self.render_start_line_y - 2) + :string(debug_msg) + :seek(0, self.parent_view.frame_body.height + self.render_start_line_y - 3) + :string(sel_debug_msg) + end +end + +function TextAreaContent:charAtCursor() + return self.text:sub(self.cursor, self.cursor) +end + +function TextAreaContent:getMultiLeftClick(x, y) + if self.last_click then + local from_last_click_ms = dfhack.getTickCount() - self.last_click.tick + + if ( + self.last_click.x ~= x or + self.last_click.y ~= y or + from_last_click_ms > common.DOUBLE_CLICK_MS + ) then + self.clicks_count = 0; + end + end + + return self.clicks_count or 0 +end + +function TextAreaContent:triggerMultiLeftClick(x, y) + local clicks_count = self:getMultiLeftClick(x, y) + + self.clicks_count = clicks_count + 1 + if (self.clicks_count >= 4) then + self.clicks_count = 1 + end + + self.last_click = { + tick=dfhack.getTickCount(), + x=x, + y=y, + } + return self.clicks_count +end + +function TextAreaContent:currentSpacesRange() + -- select "word" only from spaces + local prev_word_end, _ = self.text + :sub(1, self.cursor) + :find('[^%s]%s+$') + local _, next_word_start = self.text:find('%s[^%s]', self.cursor) + + return prev_word_end + 1 or 1, next_word_start - 1 or #self.text +end + +function TextAreaContent:currentWordRange() + -- select current word + local _, prev_word_end = self.text + :sub(1, self.cursor - 1) + :find('.*[%s,."\']') + local next_word_start, _ = self.text:find('[%s,."\']', self.cursor) + + return (prev_word_end or 0) + 1, (next_word_start or #self.text + 1) - 1 +end + +function TextAreaContent:lineStartOffset(offset) + local loc_offset = offset or self.cursor + return self.text:sub(1, loc_offset - 1):match(".*\n()") or 1 +end + +function TextAreaContent:lineEndOffset(offset) + local loc_offset = offset or self.cursor + return self.text:find("\n", loc_offset) or #self.text + 1 +end + +function TextAreaContent:wordStartOffset(offset) + return self.text + :sub(1, offset or self.cursor - 1) + :match('.*%s()[^%s]') or 1 +end + +function TextAreaContent:wordEndOffset(offset) + return self.text + :match( + '%s*[^%s]*()', + offset or self.cursor + ) or #self.text + 1 +end + +function TextAreaContent:onInput(keys) + for _,ignore_key in ipairs(self.ignore_keys) do + if keys[ignore_key] then + return false + end + end + + if self:onMouseInput(keys) then + return true + elseif self:onHistoryInput(keys) then + return true + elseif self:onTextManipulationInput(keys) then + return true + elseif self:onCursorInput(keys) then + return true + elseif keys.CUSTOM_CTRL_C then + self:copy() + return true + elseif keys.CUSTOM_CTRL_X then + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + self:cut() + return true + elseif keys.CUSTOM_CTRL_V then + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + self:paste() + return true + else + return TextAreaContent.super.onInput(self, keys) + end +end + +function TextAreaContent:onHistoryInput(keys) + if keys.CUSTOM_CTRL_Z then + local history_entry = self.history:undo(self.text, self.cursor) + + if history_entry then + self:setText(history_entry.text) + self:setCursor(history_entry.cursor) + end + + return true + elseif keys.CUSTOM_CTRL_Y then + local history_entry = self.history:redo(self.text, self.cursor) + + if history_entry then + self:setText(history_entry.text) + self:setCursor(history_entry.cursor) + end + + return true + end +end + +function TextAreaContent:onMouseInput(keys) + if keys._MOUSE_L then + local mouse_x, mouse_y = self:getMousePos() + if mouse_x and mouse_y then + + local clicks_count = self:triggerMultiLeftClick( + mouse_x + 1, + mouse_y + 1 + ) + if clicks_count == 3 then + self:setSelection( + self:lineStartOffset(), + self:lineEndOffset() + ) + elseif clicks_count == 2 then + local cursor_char = self:charAtCursor() + + local is_white_space = ( + cursor_char == ' ' or cursor_char == NEWLINE + ) + + local from, to + if is_white_space then + from, to = self:currentSpacesRange() + else + from, to = self:currentWordRange() + end + + self:setSelection(from, to) + else + self:setCursor(self.wrapped_text:coordsToIndex( + mouse_x + 1, + mouse_y + 1 + )) + end + + return true + end + + elseif keys._MOUSE_L_DOWN then + + local mouse_x, mouse_y = self:getMousePos() + if mouse_x and mouse_y then + if (self:getMultiLeftClick(mouse_x + 1, mouse_y + 1) > 1) then + return true + end + + local offset = self.wrapped_text:coordsToIndex( + mouse_x + 1, + mouse_y + 1 + ) + + if self.cursor ~= offset then + self:setSelection(self.cursor, offset) + else + self.sel_end = nil + end + + return true + end + end +end + +function TextAreaContent:onCursorInput(keys) + if keys.KEYBOARD_CURSOR_LEFT then + self:setCursor(self.cursor - 1) + return true + elseif keys.KEYBOARD_CURSOR_RIGHT then + self:setCursor(self.cursor + 1) + return true + elseif keys.KEYBOARD_CURSOR_UP then + local x, y = self.wrapped_text:indexToCoords(self.cursor) + local last_cursor_x = self.last_cursor_x or x + local offset = y > 1 and + self.wrapped_text:coordsToIndex(last_cursor_x, y - 1) or + 1 + self:setCursor(offset) + self.last_cursor_x = last_cursor_x + return true + elseif keys.KEYBOARD_CURSOR_DOWN then + local x, y = self.wrapped_text:indexToCoords(self.cursor) + local last_cursor_x = self.last_cursor_x or x + local offset = y < #self.wrapped_text.lines and + self.wrapped_text:coordsToIndex(last_cursor_x, y + 1) or + #self.text + 1 + self:setCursor(offset) + self.last_cursor_x = last_cursor_x + return true + elseif keys.CUSTOM_CTRL_HOME then + self:setCursor(1) + return true + elseif keys.CUSTOM_CTRL_END then + -- go to text end + self:setCursor(#self.text + 1) + return true + elseif keys.CUSTOM_CTRL_LEFT then + -- back one word + local word_start = self:wordStartOffset() + self:setCursor(word_start) + return true + elseif keys.CUSTOM_CTRL_RIGHT then + -- forward one word + local word_end = self:wordEndOffset() + self:setCursor(word_end) + return true + elseif keys.CUSTOM_HOME then + -- line start + self:setCursor( + self:lineStartOffset() + ) + return true + elseif keys.CUSTOM_END then + -- line end + self:setCursor( + self:lineEndOffset() + ) + return true + end +end + +function TextAreaContent:onTextManipulationInput(keys) + if keys.SELECT then + -- handle enter + if not self.one_line_mode then + self.history:store( + HISTORY_ENTRY.WHITESPACE_BLOCK, + self.text, + self.cursor + ) + self:insert(NEWLINE) + end + + return true + + elseif keys._STRING then + if keys._STRING == 0 then + -- handle backspace + self.history:store(HISTORY_ENTRY.BACKSPACE, self.text, self.cursor) + + if (self:hasSelection()) then + self:eraseSelection() + else + if (self.cursor == 1) then + return true + end + + self:setSelection( + self.cursor - 1, + self.cursor - 1 + ) + self:eraseSelection() + end + + else + local cv = string.char(keys._STRING) + + if (self:hasSelection()) then + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + self:eraseSelection() + else + local entry_type = cv == ' ' and HISTORY_ENTRY.WHITESPACE_BLOCK + or HISTORY_ENTRY.TEXT_BLOCK + self.history:store(entry_type, self.text, self.cursor) + end + + self:insert(cv) + end + + return true + elseif keys.CUSTOM_CTRL_A then + -- select all + self:setSelection(#self.text + 1, 1) + return true + elseif keys.CUSTOM_CTRL_U then + -- delete current line + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + + if (self:hasSelection()) then + -- delete all lines that has selection + self:setSelection( + self:lineStartOffset(self.cursor), + self:lineEndOffset(self.sel_end) + ) + self:eraseSelection() + else + self:setSelection( + self:lineStartOffset(), + self:lineEndOffset() + ) + self:eraseSelection() + end + + return true + elseif keys.CUSTOM_CTRL_K then + -- delete from cursor to end of current line + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + + local line_end = self:lineEndOffset(self.sel_end or self.cursor) - 1 + self:setSelection( + self.cursor, + math.max(line_end, self.cursor) + ) + self:eraseSelection() + + return true + elseif keys.CUSTOM_DELETE then + self.history:store(HISTORY_ENTRY.DELETE, self.text, self.cursor) + + if (self:hasSelection()) then + self:eraseSelection() + else + self:setText( + self.text:sub(1, self.cursor - 1) .. + self.text:sub(self.cursor + 1) + ) + end + + return true + elseif keys.CUSTOM_CTRL_W then + -- delete one word backward + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + + if not self:hasSelection() and self.cursor ~= 1 then + self:setSelection( + self:wordStartOffset(), + math.max(self.cursor - 1, 1) + ) + end + self:eraseSelection() + + return true + end +end + +return TextAreaContent diff --git a/library/lua/gui/widgets/text_area/wrapped_text.lua b/library/lua/gui/widgets/text_area/wrapped_text.lua new file mode 100644 index 0000000000..b5ff928458 --- /dev/null +++ b/library/lua/gui/widgets/text_area/wrapped_text.lua @@ -0,0 +1,70 @@ +-- This class caches lines of text wrapped to a specified width for performance +-- and readability. It can convert a given text index to (x, y) coordinates in +-- the wrapped text and vice versa. + +-- Usage: +-- This class should only be used in the following scenarios. +-- 1. When text or text features need to be rendered +-- (wrapped {x, y} coordinates are required). +-- 2. When mouse input needs to be converted to the original text position. + +-- Using this class in other scenarios may lead to issues with the component's +-- behavior when the text is wrapped. +WrappedText = defclass(WrappedText) + +WrappedText.ATTRS{ + text = '', + wrap_width = math.huge, +} + +function WrappedText:init() + self:update(self.text, self.wrap_width) +end + +function WrappedText:update(text, wrap_width) + self.lines = text:wrap( + wrap_width, + { + return_as_table=true, + keep_trailing_spaces=true, + keep_original_newlines=true + } + ) +end + +function WrappedText:coordsToIndex(x, y) + local offset = 0 + + local normalized_y = math.max( + 1, + math.min(y, #self.lines) + ) + + local line_bonus_length = normalized_y == #self.lines and 1 or 0 + local normalized_x = math.max( + 1, + math.min(x, #self.lines[normalized_y] + line_bonus_length) + ) + + for i=1, normalized_y - 1 do + offset = offset + #self.lines[i] + end + + return offset + normalized_x +end + +function WrappedText:indexToCoords(index) + local offset = index + + for y, line in ipairs(self.lines) do + local line_bonus_length = y == #self.lines and 1 or 0 + if offset <= #line + line_bonus_length then + return offset, y + end + offset = offset - #line + end + + return #self.lines[#self.lines] + 1, #self.lines +end + +return WrappedText diff --git a/library/lua/gui/widgets/widget.lua b/library/lua/gui/widgets/widget.lua new file mode 100644 index 0000000000..97e3247f2a --- /dev/null +++ b/library/lua/gui/widgets/widget.lua @@ -0,0 +1,57 @@ +local gui = require('gui') + +------------ +-- Widget -- +------------ + +---@class widgets.Widget.frame +---@field l? integer Gap between the left edge of the frame and the parent. +---@field t? integer Gap between the top edge of the frame and the parent. +---@field r? integer Gap between the right edge of the frame and the parent. +---@field b? integer Gap between the bottom edge of the frame and the parent. +---@field w? integer Desired width +---@field h? integer Desired height + +---@class widgets.Widget.inset +---@field l? integer Left margin +---@field t? integer Top margin +---@field r? integer Right margin +---@field b? integer Bottom margin +---@field x? integer Left/right margin (if `l` and/or `r` are ommited) +---@field y? integer Top/bottom margin (if `t` and/or `b` are ommited) + +---@class widgets.Widget.attrs: gui.View.attrs +---@field frame? widgets.Widget.frame +---@field frame_inset? widgets.Widget.inset|integer +---@field frame_background? dfhack.pen + +---@class widgets.Widget.attrs.partial: widgets.Widget.attrs + +---@class widgets.Widget: gui.View +---@field super gui.View +---@field ATTRS widgets.Widget.attrs|fun(attributes: widgets.Widget.attrs.partial) +---@overload fun(init_table: widgets.Widget.attrs.partial): self +Widget = defclass(Widget, gui.View) + +Widget.ATTRS { + frame = DEFAULT_NIL, + frame_inset = DEFAULT_NIL, + frame_background = DEFAULT_NIL, +} + +---@param parent_rect { width: integer, height: integer } +---@return any +function Widget:computeFrame(parent_rect) + local sw, sh = parent_rect.width, parent_rect.height + return gui.compute_frame_body(sw, sh, self.frame, self.frame_inset) +end + +---@param dc gui.Painter +---@param rect { x1: integer, y1: integer, x2: integer, y2: integer } +function Widget:onRenderFrame(dc, rect) + if self.frame_background then + dc:fill(rect, self.frame_background) + end +end + +return Widget diff --git a/library/modules/Constructions.cpp b/library/modules/Constructions.cpp index 04e74fa572..c6b0522b79 100644 --- a/library/modules/Constructions.cpp +++ b/library/modules/Constructions.cpp @@ -33,6 +33,7 @@ using namespace std; #include "Core.h" #include "MemAccess.h" +#include "MiscUtils.h" #include "TileTypes.h" #include "Types.h" #include "VersionInfo.h" diff --git a/library/modules/Gui.cpp b/library/modules/Gui.cpp index 295e779a6b..aeb0bd8721 100644 --- a/library/modules/Gui.cpp +++ b/library/modules/Gui.cpp @@ -154,12 +154,12 @@ static std::map getFocusStringsHandle #define VIEWSCREEN(name) df::viewscreen_##name##st #define DEFINE_GET_FOCUS_STRING_HANDLER(screen_type) \ - static void getFocusStrings_##screen_type(std::string &baseFocus, std::vector &focusStrings, VIEWSCREEN(screen_type) *screen);\ + static void getFocusStrings_##screen_type(const std::string &baseFocus, std::vector &focusStrings, VIEWSCREEN(screen_type) *screen);\ DFHACK_STATIC_ADD_TO_MAP(\ &getFocusStringsHandlers, &VIEWSCREEN(screen_type)::_identity, \ (getFocusStringsHandler)getFocusStrings_##screen_type \ ); \ - static void getFocusStrings_##screen_type(std::string &baseFocus, std::vector &focusStrings, VIEWSCREEN(screen_type) *screen) + static void getFocusStrings_##screen_type(const std::string &baseFocus, std::vector &focusStrings, VIEWSCREEN(screen_type) *screen) DEFINE_GET_FOCUS_STRING_HANDLER(title) { @@ -258,7 +258,7 @@ DEFINE_GET_FOCUS_STRING_HANDLER(world) } static bool widget_is_visible(df::widget * w) { - return w && w->flag.bits.WIDGET_VISIBILITY_VISIBLE; + return w && w->flag.bits.VISIBILITY_VISIBLE; } static size_t get_num_children(df::widget * w) { @@ -327,15 +327,9 @@ static void add_profile_tab_focus_string( focusStrings.push_back(fs); } -DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode) -{ +static void add_main_interface_focus_strings(const string &baseFocus, vector &focusStrings) { std::string newFocusString; - if (df::global::gametype && !World::isFortressMode()) { - newFocusString = baseFocus; - newFocusString += '/' + enum_item_key(*df::global::gametype); - focusStrings.push_back(newFocusString); - } if (game->main_interface.main_designation_selected != -1) { newFocusString = baseFocus; newFocusString += "/Designate/" + enum_item_key(game->main_interface.main_designation_selected); @@ -589,7 +583,7 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode) focusStrings.push_back(newFocusString); } - if(game->main_interface.bottom_mode_selected != -1) { + if (game->main_interface.bottom_mode_selected != -1) { newFocusString = baseFocus; switch(game->main_interface.bottom_mode_selected) { @@ -700,7 +694,7 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode) newFocusString += "/ImageCreator"; focusStrings.push_back(newFocusString); } - if (game->main_interface.unit_selector.flag.bits.WIDGET_VISIBILITY_ACTIVE) { + if (game->main_interface.unit_selector.flag.bits.VISIBILITY_ACTIVE) { newFocusString = baseFocus; newFocusString += "/UnitSelector/"; newFocusString += enum_item_key(game->main_interface.unit_selector.context); @@ -721,11 +715,6 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode) newFocusString += "/PatrolRoutes"; focusStrings.push_back(newFocusString); } - if (game->main_interface.squad_schedule.open) { - newFocusString = baseFocus; - newFocusString += "/SquadSchedule"; - focusStrings.push_back(newFocusString); - } if (game->main_interface.squad_selector.open) { newFocusString = baseFocus; newFocusString += "/SquadSelector"; @@ -772,6 +761,31 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode) newFocusString += "/SquadSupplies"; focusStrings.push_back(newFocusString); } + if (game->main_interface.squads.open) { + newFocusString = baseFocus; + newFocusString += "/Squads"; + if (game->main_interface.squads.editing_squad_schedule_id >= 0) { + newFocusString += "/EditingSchedule"; + } else if (game->main_interface.squad_schedule.open) { + newFocusString += "/Schedule"; + } else if (game->main_interface.squad_equipment.open) { + newFocusString += "/Equipment"; + if (game->main_interface.squad_equipment.customizing_equipment) { + newFocusString += "/Customizing"; + if (game->main_interface.squad_equipment.cs_setting_material) + newFocusString += "/Material"; + else if (game->main_interface.squad_equipment.cs_setting_color_pattern) + newFocusString += "/Color"; + else + newFocusString += "/Default"; + } + else + newFocusString += "/Default"; + } else { + newFocusString += "/Default"; + } + focusStrings.push_back(newFocusString); + } if (game->main_interface.assign_uniform.open) { newFocusString = baseFocus; newFocusString += "/AssignUniform"; @@ -803,33 +817,33 @@ DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode) focusStrings.push_back(newFocusString); } - if (game->main_interface.squad_equipment.open) { +} + +DEFINE_GET_FOCUS_STRING_HANDLER(dwarfmode) +{ + std::string newFocusString; + + if (df::global::gametype && !World::isFortressMode()) { newFocusString = baseFocus; - newFocusString += "/SquadEquipment"; - if (game->main_interface.squad_equipment.customizing_equipment) { - newFocusString += "/Customizing"; - if (game->main_interface.squad_equipment.cs_setting_material) - newFocusString += "/Material"; - else if (game->main_interface.squad_equipment.cs_setting_color_pattern) - newFocusString += "/Color"; + newFocusString += '/' + enum_item_key(*df::global::gametype); + if (*df::global::gametype == df::game_type::DWARF_ARENA) { + if (game->main_interface.bottom_mode_selected != df::main_bottom_mode_type::NONE) + newFocusString += "/Paint/" + enum_item_key(game->main_interface.bottom_mode_selected); + else if (game->main_interface.arena_unit.open) + newFocusString += "/ConfigureUnit"; + else if (game->main_interface.arena_tree.open) + newFocusString += "/ConfigureTree"; else newFocusString += "/Default"; } - else - newFocusString += "/Default"; focusStrings.push_back(newFocusString); } + add_main_interface_focus_strings(baseFocus, focusStrings); - if (!focusStrings.size()) { + static const string squads_default = "dwarfmode/Squads/Default"; + if (!focusStrings.size() || (focusStrings.size() == 1 && focusStrings[0] == squads_default)) { focusStrings.push_back(baseFocus + "/Default"); } - - // squads panel is not exclusive with the others - if (game->main_interface.squads.open) { - newFocusString = baseFocus; - newFocusString += "/Squads"; - focusStrings.push_back(newFocusString); - } } /* TODO: understand how this changes for v50 diff --git a/library/modules/Items.cpp b/library/modules/Items.cpp index e87dcdab1b..fdafcb74ea 100644 --- a/library/modules/Items.cpp +++ b/library/modules/Items.cpp @@ -709,8 +709,8 @@ static string get_base_desc(df::item *item) { if (auto name = Items::getBookTitle(item); !name.empty()) return name; if (auto artifact = get_artifact(item); artifact && artifact->name.has_name) - return Translation::TranslateName(&artifact->name, false) + - ", " + Translation::TranslateName(&artifact->name) + + return Translation::TranslateName(&artifact->name) + + ", " + Translation::TranslateName(&artifact->name, true) + " (" + get_item_type_str(item) + ")"; return Items::getDescription(item, 0, true); } diff --git a/library/modules/Units.cpp b/library/modules/Units.cpp index 6f58a38b2f..2d8d41077a 100644 --- a/library/modules/Units.cpp +++ b/library/modules/Units.cpp @@ -73,6 +73,7 @@ distribution. #include "df/unit_action_type_group.h" #include "df/unit_inventory_item.h" #include "df/unit_misc_trait.h" +#include "df/unit_path_goal.h" #include "df/unit_relationship_type.h" #include "df/unit_skill.h" #include "df/unit_soul.h" @@ -641,13 +642,14 @@ bool Units::isDanger(df::unit *unit) { if (isTame(unit) || isOwnGroup(unit)) return false; + // NOTE: demons from hell have visitor/visitor_unvited set to false + // other demons may visit as a diplomat return isCrazed(unit) || isInvader(unit) || isOpposedToLife(unit) || isAgitated(unit) - || isSemiMegabeast(unit) - || isNightCreature(unit) - || isGreatDanger(unit); + || unit->flags2.bits.visitor_uninvited + || ((isGreatDanger(unit) || isNightCreature(unit)) && !unit->flags2.bits.visitor); } bool Units::isGreatDanger(df::unit *unit) { @@ -933,6 +935,38 @@ void Units::makeown(df::unit *unit) { (*f)(unit); } +void Units::setAutomaticProfessions(df::unit* unit) { + CHECK_NULL_POINTER(unit); + auto fp = df::global::unitst_set_automatic_professions; + CHECK_NULL_POINTER(fp); + + using FT = std::function; + auto f = reinterpret_cast(fp); + (*f)(unit); +} + + +// functionality reverse-engineered from DF's unitst::set_goal +void Units::setPathGoal(df::unit *unit, df::coord pos, df::unit_path_goal goal) +{ + if (unit->path.dest != pos || unit->path.goal != goal) + { + unit->path.dest = pos; + unit->path.goal = goal; + unit->path.path.clear(); + } + + if (unit->flags1.bits.rider && unit->mount_type == df::rider_positions_type::STANDARD) + { + if (auto mount = df::unit::find(unit->relationship_ids[df::unit_relationship_type::RiderMount])) + { + mount->path.dest = pos; + mount->path.goal = goal; + mount->path.path.clear(); + } + } +} + df::unit *Units::create(int16_t race, int16_t caste) { auto fp = df::global::unitst_more_convenient_create; CHECK_NULL_POINTER(fp); @@ -1109,7 +1143,7 @@ string Units::getReadableName(df::historical_figure *hf) { prof_name = "Ghostly " + prof_name; } - string name = Translation::TranslateName(getVisibleName(hf), false); + string name = Translation::TranslateName(getVisibleName(hf)); return name.empty() ? prof_name : name + ", " + prof_name; } @@ -1132,7 +1166,7 @@ string Units::getReadableName(df::unit *unit) { if (isTame(unit)) prof_name += " (" + getTameTag(unit) + ")"; - string name = Translation::TranslateName(getVisibleName(unit), false); + string name = Translation::TranslateName(getVisibleName(unit)); return name.empty() ? prof_name : name + ", " + prof_name; } @@ -1654,7 +1688,7 @@ static string get_land_title(Units::NoblePosition *np) if (site_link->flags.bits.land_for_holding && site_link->position_profile_id == np->assignment->id) { auto site = df::world_site::find(site_link->target); - return site ? " of " + Translation::TranslateName(&site->name) : ""; + return site ? " of " + Translation::TranslateName(&site->name, true) : ""; } return ""; } diff --git a/library/xml b/library/xml index 8db66a097d..42614f426e 160000 --- a/library/xml +++ b/library/xml @@ -1 +1 @@ -Subproject commit 8db66a097d6467e608c9e7c4f378ca0a5b153b0f +Subproject commit 42614f426e77bd23754d382b5bbc742bb014e793 diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 8919dfa02e..b653a32360 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -115,17 +115,16 @@ if(BUILD_SUPPORTED) #add_subdirectory(embark-assistant) dfhack_plugin(eventful eventful.cpp LINK_LIBRARIES lua) dfhack_plugin(fastdwarf fastdwarf.cpp) - dfhack_plugin(faststart faststart.cpp) dfhack_plugin(filltraffic filltraffic.cpp) dfhack_plugin(fix-occupancy fix-occupancy.cpp LINK_LIBRARIES lua) #dfhack_plugin(fixveins fixveins.cpp) dfhack_plugin(flows flows.cpp) #dfhack_plugin(follow follow.cpp) - #dfhack_plugin(forceequip forceequip.cpp) + dfhack_plugin(forceequip forceequip.cpp) #dfhack_plugin(generated-creature-renamer generated-creature-renamer.cpp) dfhack_plugin(getplants getplants.cpp) dfhack_plugin(hotkeys hotkeys.cpp LINK_LIBRARIES lua) - #dfhack_plugin(infiniteSky infiniteSky.cpp) + dfhack_plugin(infinite-sky infinite-sky.cpp LINK_LIBRARIES lua) #dfhack_plugin(isoworldremote isoworldremote.cpp PROTOBUFS isoworldremote) #dfhack_plugin(jobutils jobutils.cpp) dfhack_plugin(lair lair.cpp) @@ -165,6 +164,7 @@ if(BUILD_SUPPORTED) dfhack_plugin(suspendmanager suspendmanager.cpp COMPILE_FLAGS_GCC -fno-gnu-unique LINK_LIBRARIES lua) dfhack_plugin(tailor tailor.cpp LINK_LIBRARIES lua) dfhack_plugin(tiletypes tiletypes.cpp Brushes.h LINK_LIBRARIES lua) + dfhack_plugin(timestream timestream.cpp LINK_LIBRARIES lua) #dfhack_plugin(title-folder title-folder.cpp) dfhack_plugin(tubefill tubefill.cpp) add_subdirectory(tweak) diff --git a/plugins/autobutcher.cpp b/plugins/autobutcher.cpp index aa92709ada..b8765a6372 100644 --- a/plugins/autobutcher.cpp +++ b/plugins/autobutcher.cpp @@ -17,6 +17,7 @@ #include "df/building_civzonest.h" #include "df/creature_raw.h" #include "df/general_ref.h" +#include "df/plotinfost.h" #include "df/unit.h" #include "df/world.h" @@ -31,6 +32,7 @@ using namespace DFHack; DFHACK_PLUGIN("autobutcher"); DFHACK_PLUGIN_IS_ENABLED(is_enabled); +REQUIRE_GLOBAL(plotinfo); REQUIRE_GLOBAL(world); // logging levels can be dynamically controlled with the `debugfilter` command. @@ -89,7 +91,9 @@ DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { DEBUG(control,out).print("%s from the API; persisting\n", is_enabled ? "enabled" : "disabled"); config.set_bool(CONFIG_IS_ENABLED, is_enabled); - if (enable) + // don't autorun cycle on first frame of fortress so we don't mark animals for butchering before + // all initial configuration has been applied + if (enable && plotinfo->fortress_age > 0) autobutcher_cycle(out); } else { DEBUG(control,out).print("%s from the API, but already %s; no action\n", diff --git a/plugins/autoslab.cpp b/plugins/autoslab.cpp index 3de30be7e9..52d2de179a 100644 --- a/plugins/autoslab.cpp +++ b/plugins/autoslab.cpp @@ -174,7 +174,7 @@ static void checkslabs(color_ostream &out) ) { createSlabJob(ghost); - auto fullName = Translation::TranslateName(&ghost->name, false); + auto fullName = Translation::TranslateName(&ghost->name); out.print("Added slab order for ghost %s\n", fullName.c_str()); } } diff --git a/plugins/blueprint.cpp b/plugins/blueprint.cpp index 1542eb4d95..fa0032a902 100644 --- a/plugins/blueprint.cpp +++ b/plugins/blueprint.cpp @@ -41,9 +41,10 @@ #include "df/world.h" using std::endl; +using std::map; using std::ofstream; +using std::ostringstream; using std::pair; -using std::map; using std::string; using std::vector; using namespace DFHack; @@ -242,7 +243,7 @@ static const char * cache(const string &str) { } // Convenience wrapper for std::ostringstream. -static const char * cache(std::ostringstream &str) { +static const char * cache(ostringstream &str) { return cache(str.str()); } @@ -622,7 +623,7 @@ static const char * get_constructed_track_str(df::tiletype *tt, if (!dir.whole) return "~"; - std::ostringstream str; + ostringstream str; str << base; if (dir.north) str << "N"; if (dir.south) str << "S"; @@ -810,7 +811,7 @@ static const char * get_trap_str(df::building *b) { case trap_type::CageTrap: return "Tc"; case trap_type::TrackStop: { - std::ostringstream buf; + ostringstream buf; buf << "CS"; if (trap->track_stop_info.track_flags.bits.use_dump) { if (trap->track_stop_info.dump_x_shift == 0) { @@ -1022,7 +1023,7 @@ static const char * add_expansion_syntax(const df::building *bld, const char *keys) { if (!keys) return "~"; - std::ostringstream s; + ostringstream s; pair size = get_building_size(bld); s << keys << "(" << size.first << "x" << size.second << ")"; return cache(s); @@ -1037,7 +1038,7 @@ static const char * add_label(const tile_context &ctx, const char *keys) { if (!keys) return "~"; auto bld = ctx.b; - std::ostringstream s; + ostringstream s; // use building's id as the unique label s << keys << "/" << "bld_" << bld->id; return cache(s); @@ -1124,7 +1125,7 @@ static const char * get_zone_keys(const df::building_civzonest *zone) { df::building_civzonest::T_gather_flags::mask_gather_fallen; static const df::hospital_supplies DEFAULT_HOSPITAL; - std::ostringstream keys; + ostringstream keys; const df::building_civzonest::T_zone_flags &flags = zone->zone_flags; // inverted logic for Active since it's on by default @@ -1216,12 +1217,12 @@ static const char * get_tile_zone(const df::coord &pos, // surrounds the given string in quotes and replaces internal double quotes (") // with double double quotes ("") (as per the csv spec) static string csv_quote(const string &str) { - std::ostringstream outstr; + ostringstream outstr; outstr << "\""; size_t start = 0; auto end = str.find('"'); - while (end != std::string::npos) { + while (end != string::npos) { outstr << str.substr(start, end - start); outstr << "\"\""; start = end + 1; @@ -1254,7 +1255,7 @@ static const char * get_tile_query(const df::coord &pos, if (!bld_name.size() && !zone_name.size()) return NULL; - std::ostringstream str; + ostringstream str; if (bld_name.size()) str << "{givename name=" + csv_quote(bld_name) + "}"; if (zone_name.size()) @@ -1286,7 +1287,7 @@ static const char * get_tile_rooms(const df::coord &, const tile_context &ctx) { case 4: return "r+&"; } - std::ostringstream str; + ostringstream str; str << "r{+ " << (max_dim - 3) << "}&"; return cache(str); } @@ -1348,11 +1349,11 @@ static void write_minimal(ofstream &ofile, const blueprint_options &opts, const string z_key = opts.depth > 0 ? "#<" : "#>"; int16_t zprev = 0; - for (auto area : mapdata) { + for (auto &area : mapdata) { for ( ; zprev < area.first; ++zprev) ofile << z_key << endl; int16_t yprev = 0; - for (auto row : area.second) { + for (auto &row : area.second) { for ( ; yprev < row.first; ++yprev) ofile << endl; size_t xprev = 0; @@ -1398,7 +1399,7 @@ static void write_pretty(ofstream &ofile, const blueprint_options &opts, static string get_modeline(color_ostream &out, const blueprint_options &opts, const string &mode, const string &phase) { - std::ostringstream modeline; + ostringstream modeline; modeline << "#" << mode << " label(" << phase << ")"; if (opts.playback_start.x > 0) { modeline << " start(" << opts.playback_start.x @@ -1415,7 +1416,7 @@ static string get_modeline(color_ostream &out, const blueprint_options &opts, } static bool write_blueprint(color_ostream &out, - std::map &output_files, + map &output_files, const blueprint_options &opts, const blueprint_processor &processor, bool pretty, int32_t ordinal) { @@ -1437,9 +1438,9 @@ static bool write_blueprint(color_ostream &out, } static void write_meta_blueprint(color_ostream &out, - std::map &output_files, + map &output_files, const blueprint_options &opts, - const std::vector & meta_phases, + const vector & meta_phases, int32_t ordinal) { string fname; get_filename(fname, out, opts, meta_phases.front(), ordinal); @@ -1545,7 +1546,7 @@ static bool do_transform(color_ostream &out, } } - std::vector meta_phases; + vector meta_phases; for (blueprint_processor &processor : processors) { if (processor.mapdata.empty() && !processor.force_create) continue; @@ -1557,7 +1558,7 @@ static bool do_transform(color_ostream &out, bool in_meta = false; int32_t ordinal = 0; - std::map output_files; + map output_files; for (blueprint_processor &processor : processors) { if (processor.mapdata.empty() && !processor.force_create) continue; @@ -1593,7 +1594,7 @@ static command_result do_blueprint(color_ostream &out, CoreSuspender suspend; if (parameters.size() >= 1 && parameters[0] == "gui") { - std::ostringstream command; + ostringstream command; command << "gui/blueprint"; for (size_t i = 1; i < parameters.size(); ++i) { command << " " << parameters[i]; @@ -1619,7 +1620,7 @@ static command_result do_blueprint(color_ostream &out, // start coordinates can come from either the commandline or the map cursor df::coord start(options.start); - if (start.x == -30000) { + if (!start.isValid()) { if (!Gui::getCursorCoords(start)) { out.printerr("Can't get cursor coords! Make sure you specify the" " --cursor parameter or have an active cursor in DF.\n"); @@ -1686,9 +1687,13 @@ command_result blueprint(color_ostream &out, vector ¶meters) { vector files; command_result cr = do_blueprint(out, parameters, files); if (cr == CR_OK) { - out.print("Generated blueprint file(s):\n"); - for (string &fname : files) - out.print(" %s\n", fname.c_str()); + if (files.empty()) // Just natural walls, etc. + out.print("No resulting blueprint.\n"); + else { + out.print("Generated blueprint file(s):\n"); + for (string &fname : files) + out.print(" %s\n", fname.c_str()); + } } return cr; } diff --git a/plugins/buildingplan/itemfilter.cpp b/plugins/buildingplan/itemfilter.cpp index 961ffac674..0e4569f156 100644 --- a/plugins/buildingplan/itemfilter.cpp +++ b/plugins/buildingplan/itemfilter.cpp @@ -1,6 +1,7 @@ #include "itemfilter.h" #include "Debug.h" +#include "MiscUtils.h" #include "df/item.h" diff --git a/plugins/buildingplan/itemfilter.h b/plugins/buildingplan/itemfilter.h index c741504342..8a1f67d3cf 100644 --- a/plugins/buildingplan/itemfilter.h +++ b/plugins/buildingplan/itemfilter.h @@ -1,5 +1,7 @@ #pragma once +#include "ColorText.h" + #include "modules/Materials.h" #include "df/dfhack_material_category.h" diff --git a/plugins/cleanowned.cpp b/plugins/cleanowned.cpp index b8881d46b8..fb62ad9701 100644 --- a/plugins/cleanowned.cpp +++ b/plugins/cleanowned.cpp @@ -168,7 +168,7 @@ command_result df_cleanowned (color_ostream &out, vector & parameters) df::unit *owner = Items::getOwner(item); if (owner) - out.print(", owner %s", DF2CONSOLE(Translation::TranslateName(&owner->name,false)).c_str()); + out.print(", owner %s", DF2CONSOLE(Translation::TranslateName(&owner->name)).c_str()); if (!dry_run) { diff --git a/plugins/faststart.cpp b/plugins/faststart.cpp deleted file mode 100644 index de014801c3..0000000000 --- a/plugins/faststart.cpp +++ /dev/null @@ -1,69 +0,0 @@ -// Fast Startup tweak - -#include "Core.h" -#include -#include -#include -#include -#include - -#include "df/viewscreen_initial_prepst.h" -#include - -using namespace DFHack; -using namespace df::enums; -using std::vector; - -// Uncomment this to make the Loading screen as fast as possible -// This has the side effect of removing the dwarf face animation -// (and briefly making the game become unresponsive) - -//#define REALLY_FAST - -DFHACK_PLUGIN("faststart"); -DFHACK_PLUGIN_IS_ENABLED(is_enabled); - -struct prep_hook : df::viewscreen_initial_prepst -{ - typedef df::viewscreen_initial_prepst interpose_base; - - DEFINE_VMETHOD_INTERPOSE(void, logic, ()) - { -#ifdef REALLY_FAST - while (breakdown_level != interface_breakdown_types::STOPSCREEN) - { - render_count++; - INTERPOSE_NEXT(logic)(); - } -#else - render_count = 4; - INTERPOSE_NEXT(logic)(); -#endif - } -}; - -IMPLEMENT_VMETHOD_INTERPOSE(prep_hook, logic); - -DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) -{ - if (enable != is_enabled) - { - if (!INTERPOSE_HOOK(prep_hook, logic).apply(enable)) - return CR_FAILURE; - - is_enabled = enable; - } - - return CR_OK; -} - -DFhackCExport command_result plugin_init ( color_ostream &out, vector &commands) -{ - return CR_OK; -} - -DFhackCExport command_result plugin_shutdown ( color_ostream &out ) -{ - INTERPOSE_HOOK(prep_hook, logic).remove(); - return CR_OK; -} diff --git a/plugins/fix-occupancy.cpp b/plugins/fix-occupancy.cpp index 1eee73b046..eb153a3e5d 100644 --- a/plugins/fix-occupancy.cpp +++ b/plugins/fix-occupancy.cpp @@ -40,6 +40,7 @@ struct Expected { int32_t dim_x, dim_y, dim_z; size_t size; df::tile_occupancy * occ_buf; + df::building ** bld_buf; std::unordered_map> block_items_buf; public: @@ -47,10 +48,12 @@ struct Expected { Maps::getTileSize(dim_x, dim_y, dim_z); size = dim_x * dim_y * dim_z; occ_buf = (df::tile_occupancy *)calloc(size, sizeof(*occ_buf)); + bld_buf = (df::building **)calloc(size, sizeof(*bld_buf)); } ~Expected() { free(occ_buf); + free(bld_buf); } size_t get_size() const { @@ -58,7 +61,7 @@ struct Expected { } df::tile_occupancy * occ(int32_t x, int32_t y, int32_t z) { - size_t off = (dim_x * dim_y * z) + (dim_x * y) + x; + size_t off = get_offset(x, y, z); if (off < size) return &occ_buf[off]; return nullptr; @@ -67,6 +70,16 @@ struct Expected { return occ(pos.x, pos.y, pos.z); } + df::building ** bld(int32_t x, int32_t y, int32_t z) { + size_t off = get_offset(x, y, z); + if (off < size) + return &bld_buf[off]; + return nullptr; + } + df::building ** bld(const df::coord & pos) { + return bld(pos.x, pos.y, pos.z); + } + std::set * block_items(df::map_block * block) { if (!block) return nullptr; @@ -78,13 +91,27 @@ struct Expected { std::set * block_items(const df::coord & pos) { return block_items(pos.x, pos.y, pos.z); } + +private: + size_t get_offset(int32_t x, int32_t y, int32_t z) { + return (dim_x * dim_y * z) + (dim_x * y) + x; + } }; -static void scan_building(df::building * bld, Expected & expected) { +static void scan_building(color_ostream &out, df::building * bld, Expected & expected) { for (int y = bld->y1; y <= bld->y2; ++y) { for (int x = bld->x1; x <= bld->x2; ++x) { if (!Buildings::containsTile(bld, df::coord2d(x, y))) continue; + auto expected_bld = expected.bld(x, y, bld->z); + if (bld->isSettingOccupancy() && expected_bld) { + if (*expected_bld) { + WARN(log,out).print("Buildings overlap at (%d, %d, %d); please manually remove overlapping building." + " Run ':lua dfhack.gui.revealInDwarfmodeMap(%d, %d, %d, true, true)' to zoom to the tile.\n", + x, y, bld->z, x, y, bld->z); + } + *expected_bld = bld; + } if (auto expected_occ = expected.occ(x, y, bld->z)) { auto bld_occ = df::tile_building_occ::Impassable; if (auto block_occ = Maps::getTileOccupancy(x, y, bld->z)) @@ -142,8 +169,8 @@ static void normalize_item_vector(color_ostream &out, df::map_block *block, bool } } -static void reconcile_map_tile(color_ostream &out, const df::tile_occupancy & expected_occ, df::tile_occupancy & block_occ, - bool dry_run, int x, int y, int z) +static void reconcile_map_tile(color_ostream &out, df::building * bld, const df::tile_occupancy & expected_occ, + df::tile_occupancy & block_occ, bool dry_run, int x, int y, int z) { // clear building occupancy if there is no building there if (expected_occ.bits.building == df::tile_building_occ::None && block_occ.bits.building != df::tile_building_occ::None) { @@ -153,6 +180,21 @@ static void reconcile_map_tile(color_ostream &out, const df::tile_occupancy & ex block_occ.bits.building = df::tile_building_occ::None; } + // recalculate bulding occupancy if there *is* a building there + if (bld) { + auto prev_occ = block_occ.bits.building; + bld->updateOccupancy(x, y); + // if this resets the occupancy to Dynamic, trust the original value + if (block_occ.bits.building == df::tile_building_occ::Dynamic) + block_occ.bits.building = prev_occ; + else if (prev_occ != block_occ.bits.building) { + INFO(log,out).print("%s building occupancy at (%d, %d, %d)\n", + dry_run ? "would fix" : "fixing", x, y, z); + if (dry_run) + block_occ.bits.building = prev_occ; + } + } + // clear unit occupancy if there are no units there if (!expected_occ.bits.unit && block_occ.bits.unit) { INFO(log,out).print("%s standing unit occupancy at (%d, %d, %d)\n", @@ -186,11 +228,13 @@ static void fix_tile(color_ostream &out, df::coord pos, bool dry_run) { Expected expected; - // building occupancy + // building occupancy (scan all buildings since we can't depend on Buildings::findAtTile) size_t num_buildings = 0; - if (auto bld = Buildings::findAtTile(pos)) { - ++num_buildings; - scan_building(bld, expected); + for (auto bld : world->buildings.all) { + if (Buildings::containsTile(bld, pos)) { + ++num_buildings; + scan_building(out, bld, expected); + } } // unit occupancy (make sure we pick up wagons that might be overlapping the tile) @@ -214,8 +258,10 @@ static void fix_tile(color_ostream &out, df::coord pos, bool dry_run) { } // check/fix occupancy - if (auto expected_occ = expected.occ(pos)) - reconcile_map_tile(out, *expected_occ, block->occupancy[pos.x&15][pos.y&15], dry_run, pos.x, pos.y, pos.z); + auto expected_occ = expected.occ(pos); + auto expected_bld = expected.bld(pos); + if (expected_bld && expected_occ) + reconcile_map_tile(out, *expected_bld, *expected_occ, block->occupancy[pos.x&15][pos.y&15], dry_run, pos.x, pos.y, pos.z); INFO(log,out).print("verified %zd building(s), %zd unit(s), %zd item(s), 1 map block(s), and 1 map tile(s)\n", num_buildings, num_units, num_items); @@ -252,7 +298,7 @@ static void fix_map(color_ostream &out, bool dry_run) { // set expected building occupancy for (auto bld : world->buildings.all) - scan_building(bld, expected); + scan_building(out, bld, expected); // set expected unit occupancy for (auto unit : world->units.active) @@ -275,15 +321,16 @@ static void fix_map(color_ostream &out, bool dry_run) { for (int xoff = 0; xoff < 16; ++xoff) { int x = block->map_pos.x + xoff; auto expected_occ = expected.occ(x, y, z); - if (!expected_occ) { + auto expected_bld = expected.bld(x, y, z); + if (!expected_occ || !expected_bld) { TRACE(log,out).print("pos out of bounds (%d, %d, %d)\n", x, y, z); continue; } df::tile_occupancy &block_occ = block->occupancy[xoff][yoff]; - if ((expected_occ->whole & occ_mask) != (block_occ.whole & occ_mask)) { - DEBUG(log,out).print("reconciling occupancy at (%d, %d, %d) (0x%x != 0x%x)\n", - x, y, z, expected_occ->whole & occ_mask, block_occ.whole & occ_mask); - reconcile_map_tile(out, *expected_occ, block_occ, dry_run, x, y, z); + if (*expected_bld || (expected_occ->whole & occ_mask) != (block_occ.whole & occ_mask)) { + DEBUG(log,out).print("reconciling occupancy at (%d, %d, %d) (bld=%p, 0x%x ?= 0x%x)\n", + x, y, z, *expected_bld, expected_occ->whole & occ_mask, block_occ.whole & occ_mask); + reconcile_map_tile(out, *expected_bld, *expected_occ, block_occ, dry_run, x, y, z); } } } diff --git a/plugins/forceequip.cpp b/plugins/forceequip.cpp index 4c09fef701..9a0086ae11 100644 --- a/plugins/forceequip.cpp +++ b/plugins/forceequip.cpp @@ -8,9 +8,9 @@ #include #include #include -using namespace std; #include "Core.h" +#include "Debug.h" #include "Console.h" #include "Export.h" #include "PluginManager.h" @@ -18,7 +18,6 @@ using namespace std; #include "modules/Gui.h" #include "modules/Items.h" #include "modules/Materials.h" -#include "modules/MapCache.h" #include "DataDefs.h" #include "df/item.h" #include "df/itemdef.h" @@ -42,12 +41,17 @@ using namespace std; using namespace DFHack; using namespace df::enums; -using MapExtras::Block; -using MapExtras::MapCache; +using std::string; +using std::vector; +using std::endl; DFHACK_PLUGIN("forceequip"); REQUIRE_GLOBAL(world); +namespace DFHack { + DBG_DECLARE(forceequip, log, DebugCategory::LINFO); +} + const int const_GloveRightHandedness = 1; const int const_GloveLeftHandedness = 2; @@ -68,18 +72,18 @@ DFhackCExport command_result plugin_shutdown ( color_ostream &out ) return CR_OK; } -static bool moveToInventory(MapExtras::MapCache &mc, df::item *item, df::unit *unit, df::body_part_raw * targetBodyPart, bool ignoreRestrictions, int multiEquipLimit, bool verbose) +static bool moveToInventory(df::item *item, df::unit *unit, df::body_part_raw * targetBodyPart, bool ignoreRestrictions, int multiEquipLimit, bool verbose) { // Step 1: Check for anti-requisite conditions df::unit * itemOwner = Items::getOwner(item); if (ignoreRestrictions) { // If the ignoreRestrictions cmdline switch was specified, then skip all of the normal preventative rules - if (verbose) { Core::print("Skipping integrity checks...\n"); } + if (verbose) { DEBUG(log).print("Skipping integrity checks...\n"); } } else if(!item->isClothing() && !item->isArmorNotClothing()) { - if (verbose) { Core::printerr("Item %d is not clothing or armor; it cannot be equipped. Please choose a different item (or use the Ignore option if you really want to equip an inappropriate item).\n", item->id); } + if (verbose) { WARN(log).print("Item %d is not clothing or armor; it cannot be equipped. Please choose a different item (or use the Ignore option if you really want to equip an inappropriate item).\n", item->id); } return false; } else if (item->getType() != df::enums::item_type::GLOVES && @@ -89,22 +93,22 @@ static bool moveToInventory(MapExtras::MapCache &mc, df::item *item, df::unit *u item->getType() != df::enums::item_type::SHOES && !targetBodyPart) { - if (verbose) { Core::printerr("Item %d is of an unrecognized type; it cannot be equipped (because the module wouldn't know where to put it).\n", item->id); } + if (verbose) { WARN(log).print("Item %d is of an unrecognized type; it cannot be equipped (because the module wouldn't know where to put it).\n", item->id); } return false; } else if (itemOwner && itemOwner->id != unit->id) { - if (verbose) { Core::printerr("Item %d is owned by someone else. Equipping it on this unit is not recommended. Please use DFHack's Confiscate plugin, choose a different item, or use the Ignore option to proceed in spite of this warning.\n", item->id); } + if (verbose) { WARN(log).print("Item %d is owned by someone else. Equipping it on this unit is not recommended. Please use DFHack's Confiscate plugin, choose a different item, or use the Ignore option to proceed in spite of this warning.\n", item->id); } return false; } else if (item->flags.bits.in_inventory) { - if (verbose) { Core::printerr("Item %d is already in a unit's inventory. Direct inventory transfers are not recommended; please move the item to the ground first (or use the Ignore option).\n", item->id); } + if (verbose) { WARN(log).print("Item %d is already in a unit's inventory. Direct inventory transfers are not recommended; please move the item to the ground first (or use the Ignore option).\n", item->id); } return false; } else if (item->flags.bits.in_job) { - if (verbose) { Core::printerr("Item %d is reserved for use in a queued job. Equipping it is not recommended, as this might interfere with the completion of vital jobs. Use the Ignore option to ignore this warning.\n", item->id); } + if (verbose) { WARN(log).print("Item %d is reserved for use in a queued job. Equipping it is not recommended, as this might interfere with the completion of vital jobs. Use the Ignore option to ignore this warning.\n", item->id); } return false; } @@ -131,55 +135,55 @@ static bool moveToInventory(MapExtras::MapCache &mc, df::item *item, df::unit *u else if (bpIndex < unit->body.body_plan->body_parts.size()) { // The current body part is not the one that was specified in the function call, but we can keep searching - if (verbose) { Core::printerr("Found bodypart %s; not a match; continuing search.\n", currPart->token.c_str()); } + if (verbose) { WARN(log).print("Found bodypart %s; not a match; continuing search.\n", currPart->token.c_str()); } continue; } else { // The specified body part has not been found, and we've reached the end of the list. Report failure. - if (verbose) { Core::printerr("The specified body part (%s) does not belong to the chosen unit. Please double-check to ensure that your spelling is correct, and that you have not chosen a dismembered bodypart.\n",targetBodyPart->token.c_str()); } + if (verbose) { WARN(log).print("The specified body part (%s) does not belong to the chosen unit. Please double-check to ensure that your spelling is correct, and that you have not chosen a dismembered bodypart.\n",targetBodyPart->token.c_str()); } return false; } - if (verbose) { Core::print("Inspecting bodypart %s.\n", currPart->token.c_str()); } + if (verbose) { DEBUG(log).print("Inspecting bodypart %s.\n", currPart->token.c_str()); } // Inspect the current bodypart if (item->getType() == df::enums::item_type::GLOVES && currPart->flags.is_set(df::body_part_raw_flags::GRASP) && ((item->getGloveHandedness() == const_GloveLeftHandedness && currPart->flags.is_set(df::body_part_raw_flags::LEFT)) || (item->getGloveHandedness() == const_GloveRightHandedness && currPart->flags.is_set(df::body_part_raw_flags::RIGHT)))) { - if (verbose) { Core::print("Hand found (%s)...", currPart->token.c_str()); } + if (verbose) { DEBUG(log).print("Hand found (%s)...", currPart->token.c_str()); } } else if (item->getType() == df::enums::item_type::HELM && currPart->flags.is_set(df::body_part_raw_flags::HEAD)) { - if (verbose) { Core::print("Head found (%s)...", currPart->token.c_str()); } + if (verbose) { DEBUG(log).print("Head found (%s)...", currPart->token.c_str()); } } else if (item->getType() == df::enums::item_type::ARMOR && currPart->flags.is_set(df::body_part_raw_flags::UPPERBODY)) { - if (verbose) { Core::print("Upper body found (%s)...", currPart->token.c_str()); } + if (verbose) { DEBUG(log).print("Upper body found (%s)...", currPart->token.c_str()); } } else if (item->getType() == df::enums::item_type::PANTS && currPart->flags.is_set(df::body_part_raw_flags::LOWERBODY)) { - if (verbose) { Core::print("Lower body found (%s)...", currPart->token.c_str()); } + if (verbose) { DEBUG(log).print("Lower body found (%s)...", currPart->token.c_str()); } } else if (item->getType() == df::enums::item_type::SHOES && currPart->flags.is_set(df::body_part_raw_flags::STANCE)) { - if (verbose) { Core::print("Foot found (%s)...", currPart->token.c_str()); } + if (verbose) { DEBUG(log).print("Foot found (%s)...", currPart->token.c_str()); } } else if (targetBodyPart && ignoreRestrictions) { // The BP in question would normally be considered ineligible for equipment. But since it was deliberately specified by the user, we'll proceed anyways. - if (verbose) { Core::print("Non-standard bodypart found (%s)...", targetBodyPart->token.c_str()); } + if (verbose) { DEBUG(log).print("Non-standard bodypart found (%s)...", targetBodyPart->token.c_str()); } } else if (targetBodyPart) { // The BP in question is not eligible for equipment and the ignore flag was not specified. Report failure. - if (verbose) { Core::printerr("Non-standard bodypart found, but it is ineligible for standard equipment. Use the Ignore flag to override this warning.\n"); } + if (verbose) { WARN(log).print("Non-standard bodypart found, but it is ineligible for standard equipment. Use the Ignore flag to override this warning.\n"); } return false; } else { - if (verbose) { Core::print("Skipping ineligible bodypart.\n"); } + if (verbose) { DEBUG(log).print("Skipping ineligible bodypart.\n"); } // This body part is not eligible for the equipment in question; skip it continue; } @@ -189,7 +193,7 @@ static bool moveToInventory(MapExtras::MapCache &mc, df::item *item, df::unit *u if (multiEquipLimit == INT_MAX) { // Note: this loop/check is skipped if the MultiEquip option is specified; we'll simply add the item to the bodyPart even if it's already holding a dozen gloves, shoes, and millstones (or whatever) - if (verbose) { Core::print(" inventory checking skipped..."); } + if (verbose) { DEBUG(log).print(" inventory checking skipped..."); } confirmedBodyPart = currPart; break; } @@ -204,7 +208,7 @@ static bool moveToInventory(MapExtras::MapCache &mc, df::item *item, df::unit *u // Collision detected; have we reached the limit? if (++collisions >= multiEquipLimit) { - if (verbose) { Core::printerr(" but it already carries %d piece(s) of equipment. Either remove the existing equipment or use the Multi option.\n", multiEquipLimit); } + if (verbose) { WARN(log).print(" but it already carries %d piece(s) of equipment. Either remove the existing equipment or use the Multi option.\n", multiEquipLimit); } confirmedBodyPart = NULL; break; } @@ -214,7 +218,7 @@ static bool moveToInventory(MapExtras::MapCache &mc, df::item *item, df::unit *u if (confirmedBodyPart) { // Match found; no need to examine any other BPs - if (verbose) { Core::print(" eligibility confirmed..."); } + if (verbose) { DEBUG(log).print(" eligibility confirmed..."); } break; } else if (!targetBodyPart) @@ -233,17 +237,17 @@ static bool moveToInventory(MapExtras::MapCache &mc, df::item *item, df::unit *u if (!confirmedBodyPart) { // No matching body parts found; report failure - if (verbose) { Core::printerr("\nThe item could not be equipped because the relevant body part(s) of the unit are missing or already occupied. Try again with the Multi option if you're like to over-equip a body part, or choose a different unit-item combination (e.g. stop trying to put shoes on a trout).\n" ); } + if (verbose) { WARN(log).print("\nThe item could not be equipped because the relevant body part(s) of the unit are missing or already occupied. Try again with the Multi option if you're like to over-equip a body part, or choose a different unit-item combination (e.g. stop trying to put shoes on a trout).\n" ); } return false; } - if (!Items::moveToInventory(mc, item, unit, df::unit_inventory_item::Worn, bpIndex)) + if (!Items::moveToInventory(item, unit, df::unit_inventory_item::Worn, bpIndex)) { - if (verbose) { Core::printerr("\nEquipping failed - failed to retrieve item from its current location/container/inventory. Please move it to the ground and try again.\n"); } + if (verbose) { WARN(log).print("\nEquipping failed - failed to retrieve item from its current location/container/inventory. Please move it to the ground and try again.\n"); } return false; } - if (verbose) { Core::print(" Success!\n"); } + if (verbose) { DEBUG(log).print(" Success!\n"); } return true; } @@ -317,7 +321,7 @@ command_result df_forceequip(color_ostream &out, vector & parameters) // must be followed by bodypart code (e.g. NECK) if(i == parameters.size()-1 || parameters[i+1].size() == 0) { - out.printerr("The bp switch must be followed by a bodypart code!\n"); + WARN(log).print("The bp switch must be followed by a bodypart code!\n"); return CR_FAILURE; } targetBodyPartCode = parameters[i+1]; @@ -333,7 +337,7 @@ command_result df_forceequip(color_ostream &out, vector & parameters) // Ensure that the map information is available (e.g. a game is actually in-progress) if (!Maps::IsValid()) { - out.printerr("Map is not available!\n"); + WARN(log).print("Map is not available!\n"); return CR_FAILURE; } @@ -344,7 +348,7 @@ command_result df_forceequip(color_ostream &out, vector & parameters) // needs a cursor if (!Gui::getCursorCoords(cx,cy,cz)) { - out.printerr("Cursor position not found. Please enable the cursor.\n"); + WARN(log).print("Cursor position not found. Please enable the cursor.\n"); return CR_FAILURE; } pos_cursor = DFCoord(cx,cy,cz); @@ -365,7 +369,7 @@ command_result df_forceequip(color_ostream &out, vector & parameters) if (!targetUnit) { - out.printerr("No unit found at cursor!\n"); + WARN(log).print("No unit found at cursor!\n"); return CR_FAILURE; } @@ -381,13 +385,13 @@ command_result df_forceequip(color_ostream &out, vector & parameters) if (targetBodyPart->token.compare(targetBodyPartCode) == 0) { // It is indeed a match; exit the loop (while leaving the variable populated) - if (verbose) { out.print("Matching bodypart (%s) found.\n", targetBodyPart->token.c_str()); } + if (verbose) { INFO(log).print("Matching bodypart (%s) found.\n", targetBodyPart->token.c_str()); } break; } else { // Not a match; nullify the variable (it will get re-populated on the next pass through the loop) - if (verbose) { out.printerr("Bodypart \"%s\" does not match \"%s\".\n", targetBodyPart->token.c_str(), targetBodyPartCode.c_str()); } + if (verbose) { WARN(log).print("Bodypart \"%s\" does not match \"%s\".\n", targetBodyPart->token.c_str(), targetBodyPartCode.c_str()); } targetBodyPart = NULL; } } @@ -395,14 +399,12 @@ command_result df_forceequip(color_ostream &out, vector & parameters) if (!targetBodyPart) { // Loop iteration is complete but no match was found. - out.printerr("The unit does not possess a bodypart of type \"%s\". Please check the spelling or choose a different unit.\n", targetBodyPartCode.c_str()); + WARN(log).print("The unit does not possess a bodypart of type \"%s\". Please check the spelling or choose a different unit.\n", targetBodyPartCode.c_str()); return CR_FAILURE; } } // Search for item(s) - MapCache mc; - // iterate over all items, process those where pos == pos_cursor int itemsEquipped = 0; int itemsFound = 0; @@ -435,7 +437,7 @@ command_result df_forceequip(color_ostream &out, vector & parameters) else if (currentItem->flags.bits.forbid == 1) { // The item is forbidden; skip it - if (verbose) { out.printerr("Forbidden item encountered; skipping to next item.\n"); } + if (verbose) { WARN(log).print("Forbidden item encountered; skipping to next item.\n"); } } } @@ -444,16 +446,16 @@ command_result df_forceequip(color_ostream &out, vector & parameters) if (currentItem->flags.bits.in_inventory == 1) { // The item is in a unit's inventory; skip it - if (verbose) { out.printerr("Inventory item encountered; skipping to next item.\n"); } + if (verbose) { WARN(log).print("Inventory item encountered; skipping to next item.\n"); } } else { itemsFound ++; // Track the number of items found under the cursor (for feedback purposes) - if (moveToInventory(mc, currentItem, targetUnit, targetBodyPart, ignore, multiEquipLimit, verbose)) + if (moveToInventory(currentItem, targetUnit, targetBodyPart, ignore, multiEquipLimit, verbose)) { // // TODO TEMP EXPERIMENTAL - try to alter the item size in order to conform to its wearer // currentItem->getRace(); -// out.print("Critter size: %d| %d | Armor size: %d", world->raws.creatures.all[targetUnit->race]->caste[targetUnit->caste]->body_size_1, world->raws.creatures.all[targetUnit->race]->caste[targetUnit->caste]->body_size_2, currentItem->getTotalDimension()); +// INFO(log).print("Critter size: %d| %d | Armor size: %d", world->raws.creatures.all[targetUnit->race]->caste[targetUnit->caste]->body_size_1, world->raws.creatures.all[targetUnit->race]->caste[targetUnit->caste]->body_size_2, currentItem->getTotalDimension()); itemsEquipped++; // Track the number of items successfully processed (for feedback purposes) } @@ -461,16 +463,13 @@ command_result df_forceequip(color_ostream &out, vector & parameters) } if (itemsFound == 0) { - out.printerr("No usable items found at the cursor position. Please choose a different location and try again.\n"); + WARN(log).print("No usable items found at the cursor position. Please choose a different location and try again.\n"); return CR_OK; } - if (itemsEquipped == 0 && !verbose) { out.printerr("Some items were found but no equipment changes could be made. Use the /verbose switch to display the reasons for failure.\n"); } - if (itemsEquipped > 0) { out.print("%d items equipped.\n", itemsEquipped); } - // At this point, some changes may have been made (such as detaching items from their original position), regardless of whether any equipment changes succeeded. - // Therefore, we must update the map. - mc.WriteAll(); + if (itemsEquipped == 0 && !verbose) { WARN(log).print("Some items were found but no equipment changes could be made. Use the /verbose switch to display the reasons for failure.\n"); } + if (itemsEquipped > 0) { INFO(log).print("%d items equipped.\n", itemsEquipped); } // Note: we might expect to recalculate the unit's weight at this point, in order to account for the // added items. In fact, this recalculation occurs automatically during each dwarf's "turn". diff --git a/plugins/infinite-sky.cpp b/plugins/infinite-sky.cpp new file mode 100644 index 0000000000..950b7bdff4 --- /dev/null +++ b/plugins/infinite-sky.cpp @@ -0,0 +1,265 @@ + +#include "Core.h" +#include "Debug.h" +#include "LuaTools.h" +#include "PluginManager.h" + +#include "modules/EventManager.h" +#include "modules/Maps.h" +#include "modules/World.h" + +#include "df/construction.h" +#include "df/map_block.h" +#include "df/map_block_column.h" +#include "df/world.h" +#include "df/z_level_flags.h" + +#include +#include +#include + +using std::string; +using std::vector; + +using namespace DFHack; +using namespace df::enums; + +DFHACK_PLUGIN("infinite-sky"); +DFHACK_PLUGIN_IS_ENABLED(is_enabled); + +REQUIRE_GLOBAL(world); + +namespace DFHack { + // for configuration-related logging + DBG_DECLARE(infiniteSky, control, DebugCategory::LINFO); + // for logging during creation of z-levels + DBG_DECLARE(infiniteSky, cycle, DebugCategory::LINFO); +} + +static const string CONFIG_KEY = string(plugin_name) + "/config"; +static PersistentDataItem config; +enum ConfigValues { + CONFIG_IS_ENABLED = 0, +}; + +command_result infiniteSky (color_ostream &out, std::vector & parameters); + +static void constructionEventHandler(color_ostream& out, void* ptr); +EventManager::EventHandler handler(plugin_self, constructionEventHandler,11); + +DFhackCExport command_result plugin_init(color_ostream &out, + std::vector &commands) { + commands.push_back(PluginCommand( + "infinite-sky", "Automatically allocate new z-levels of sky.", + infiniteSky)); + return CR_OK; +} + +void cleanup() { + EventManager::unregister(EventManager::EventType::CONSTRUCTION, handler); +} + +DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { + if (!Core::getInstance().isMapLoaded() || !World::isFortressMode()) { + out.printerr("Cannot enable %s without a loaded fort.\n", plugin_name); + return CR_FAILURE; + } + if (enable != is_enabled) { + is_enabled = enable; + DEBUG(control, out) + .print("%s from the API; persisting\n", + is_enabled ? "enabled" : "disabled"); + config.set_bool(CONFIG_IS_ENABLED, is_enabled); + + if (enable) { + EventManager::registerListener( + EventManager::EventType::CONSTRUCTION, handler); + } else { + cleanup(); + } + } else { + DEBUG(control, out) + .print("%s from the API, but already %s; no action\n", + is_enabled ? "enabled" : "disabled", + is_enabled ? "enabled" : "disabled"); + } + + return CR_OK; +} + +DFhackCExport command_result plugin_load_site_data(color_ostream &out) { + config = World::GetPersistentSiteData(CONFIG_KEY); + + if (!config.isValid()) { + DEBUG(control, out) + .print("no config found in this save; initializing\n"); + config = World::AddPersistentSiteData(CONFIG_KEY); + config.set_bool(CONFIG_IS_ENABLED, is_enabled); + } + + // Call plugin_enable to set value to ensure the event handler is properly registered + plugin_enable(out, config.get_bool(CONFIG_IS_ENABLED)); + DEBUG(control, out) + .print("loading persisted enabled state: %s\n", + is_enabled ? "true" : "false"); + return CR_OK; +} + +DFhackCExport command_result plugin_onstatechange(color_ostream &out, + state_change_event event) { + if (event == DFHack::SC_WORLD_UNLOADED) { + if (is_enabled) { + DEBUG(control, out) + .print("world unloaded; disabling %s\n", plugin_name); + is_enabled = false; + cleanup(); + } + } + return CR_OK; +} + +void doInfiniteSky(color_ostream& out, int32_t howMany); + +static void constructionEventHandler(color_ostream &out, void *ptr) { + df::construction *constr = (df::construction *)ptr; + + if (constr->pos.z >= world->map.z_count_block - 2) + doInfiniteSky(out, 1); +} + +void doInfiniteSky(color_ostream& out, int32_t howMany) { + CoreSuspender suspend; + int32_t z_count_block = world->map.z_count_block; + df::map_block ****block_index = world->map.block_index; + + cuboid last_air_layer( + 0, 0, world->map.z_count_block - 1, + world->map.x_count_block - 1, world->map.y_count_block - 1, world->map.z_count_block - 1); + + last_air_layer.forCoord([&](df::coord bpos) { + // Allocate a new block column and copy over data from the old + df::map_block **blockColumn = + new df::map_block *[z_count_block + howMany]; + memcpy(blockColumn, block_index[bpos.x][bpos.y], + z_count_block * sizeof(df::map_block *)); + delete[] block_index[bpos.x][bpos.y]; + block_index[bpos.x][bpos.y] = blockColumn; + + df::map_block *last_air_block = blockColumn[bpos.z]; + for (int32_t count = 0; count < howMany; count++) { + df::map_block *air_block = new df::map_block(); + std::fill(&air_block->tiletype[0][0], + &air_block->tiletype[0][0] + (16 * 16), + df::tiletype::OpenSpace); + + // Set block positions properly (based on prior air layer) + air_block->map_pos = last_air_block->map_pos; + air_block->map_pos.z += count + 1; + air_block->region_pos = last_air_block->region_pos; + + // Copy other potentially important metadata from prior air + // layer + std::memcpy(air_block->lighting, last_air_block->lighting, + sizeof(air_block->lighting)); + std::memcpy(air_block->temperature_1, last_air_block->temperature_1, + sizeof(air_block->temperature_1)); + std::memcpy(air_block->temperature_2, last_air_block->temperature_2, + sizeof(air_block->temperature_2)); + std::memcpy(air_block->region_offset, last_air_block->region_offset, + sizeof(air_block->region_offset)); + + // Create tile designations to inform lighting and + // outside markers + df::tile_designation designation{}; + designation.bits.light = true; + designation.bits.outside = true; + std::fill(&air_block->designation[0][0], + &air_block->designation[0][0] + (16 * 16), designation); + + blockColumn[z_count_block + count] = air_block; + world->map.map_blocks.push_back(air_block); + + // deal with map_block_column stuff even though it'd probably be + // fine + df::map_block_column *column = + world->map.column_index[bpos.x][bpos.y]; + if (!column) { + DEBUG(cycle, out) + .print("%s, line %d: column is null (%d, %d).\n", __FILE__, + __LINE__, bpos.x, bpos.y); + continue; + } + df::map_block_column::T_unmined_glyphs *glyphs = + new df::map_block_column::T_unmined_glyphs; + glyphs->x[0] = 0; + glyphs->x[1] = 1; + glyphs->x[2] = 2; + glyphs->x[3] = 3; + glyphs->y[0] = 0; + glyphs->y[1] = 0; + glyphs->y[2] = 0; + glyphs->y[3] = 0; + glyphs->tile[0] = 'e'; + glyphs->tile[1] = 'x'; + glyphs->tile[2] = 'p'; + glyphs->tile[3] = '^'; + column->unmined_glyphs.push_back(glyphs); + } + return true; + }); + + // Update global z level flags + df::z_level_flags *flags = new df::z_level_flags[z_count_block + howMany]; + memcpy(flags, world->map_extras.z_level_flags, + z_count_block * sizeof(df::z_level_flags)); + for (int32_t count = 0; count < howMany; count++) { + flags[z_count_block + count].whole = 0; + flags[z_count_block + count].bits.update = 1; + } + world->map.z_count_block += howMany; + world->map.z_count += howMany; + delete[] world->map_extras.z_level_flags; + world->map_extras.z_level_flags = flags; +} + +struct infinitesky_options { + // whether to display help + bool help = false; + + // how many z levels to generate immediately (0 for none) + int32_t n = 0; + + static struct_identity _identity; +}; +static const struct_field_info infinitesky_options_fields[] = { + {struct_field_info::PRIMITIVE, "help", offsetof(infinitesky_options, help), &df::identity_traits::identity, 0, 0}, + {struct_field_info::PRIMITIVE, "n", offsetof(infinitesky_options, n), &df::identity_traits::identity, 0, 0}, + {struct_field_info::END} +}; +struct_identity infinitesky_options::_identity{sizeof(infinitesky_options), &df::allocator_fn, NULL, "infinitesky_options", NULL, infinitesky_options_fields}; + +command_result infiniteSky(color_ostream &out, + std::vector ¶meters) { + CoreSuspender suspend; + if (!Core::getInstance().isMapLoaded() || !World::isFortressMode()) { + out.printerr("Cannot run %s without a loaded fort.\n", plugin_name); + return CR_FAILURE; + } + + infinitesky_options opts; + if (!Lua::CallLuaModuleFunction(out, "plugins.infinite-sky", + "parse_commandline", + std::make_tuple(&opts, parameters)) || + opts.help) + return CR_WRONG_USAGE; + + if (opts.n > 0) { + out.print("Infinite-sky: creating %d new z-level%s of sky.\n", opts.n, + opts.n == 1 ? "" : "s"); + doInfiniteSky(out, opts.n); + } else { + out.print("Construction monitoring is %s.\n", + is_enabled ? "enabled" : "disabled"); + } + return CR_OK; +} diff --git a/plugins/infiniteSky.cpp b/plugins/infiniteSky.cpp deleted file mode 100644 index 55fd4be229..0000000000 --- a/plugins/infiniteSky.cpp +++ /dev/null @@ -1,174 +0,0 @@ - -#include "Core.h" -#include "Console.h" -#include "DataDefs.h" -#include "Export.h" -#include "PluginManager.h" - -#include "modules/World.h" - -#include "df/construction.h" -#include "df/game_mode.h" -#include "df/map_block.h" -#include "df/map_block_column.h" -#include "df/world.h" -#include "df/z_level_flags.h" - -#include -#include -#include - -using namespace std; - -using namespace DFHack; -using namespace df::enums; - -DFHACK_PLUGIN("infiniteSky"); -REQUIRE_GLOBAL(world); - -command_result infiniteSky (color_ostream &out, std::vector & parameters); - -DFhackCExport command_result plugin_init ( color_ostream &out, std::vector &commands) -{ - commands.push_back(PluginCommand( - "infiniteSky", - "Create new sky levels on request, or as needed.", - infiniteSky)); - return CR_OK; -} - -DFhackCExport command_result plugin_shutdown ( color_ostream &out ) -{ - return CR_OK; -} - -/* -DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_change_event event) -{ - switch (event) { - case SC_GAME_LOADED: - // initialize from the world just loaded - break; - case SC_GAME_UNLOADED: - // cleanup - break; - default: - break; - } - return CR_OK; -} -*/ - -static size_t constructionSize = 0; -DFHACK_PLUGIN_IS_ENABLED(enabled); -void doInfiniteSky(color_ostream& out, int32_t howMany); - -DFhackCExport command_result plugin_onupdate ( color_ostream &out ) -{ - if ( !Core::getInstance().isMapLoaded() ) - return CR_OK; - { - t_gamemodes mode; - if ( !World::ReadGameMode(mode) ) - return CR_FAILURE; - if ( mode.g_mode != df::enums::game_mode::DWARF ) - return CR_OK; - } - - if ( world->event.constructions.size() == constructionSize ) - return CR_OK; - int32_t zNow = world->map.z_count_block; - for ( size_t a = constructionSize; a < world->event.constructions.size(); a++ ) { - df::construction* construct = world->event.constructions[a]; - if ( construct->pos.z+2 < zNow ) - continue; - doInfiniteSky(out, 1); - zNow = world->map.z_count_block; - ///break; - } - constructionSize = world->event.constructions.size(); - - return CR_OK; -} - -void doInfiniteSky(color_ostream& out, int32_t howMany) { - CoreSuspender suspend; - int32_t x_count_block = world->map.x_count_block; - int32_t y_count_block = world->map.y_count_block; - for ( int32_t count = 0; count < howMany; count++ ) { - //change the size of the pointer stuff - int32_t z_count_block = world->map.z_count_block; - df::map_block**** block_index = world->map.block_index; - for ( int32_t a = 0; a < x_count_block; a++ ) { - for ( int32_t b = 0; b < y_count_block; b++ ) { - df::map_block** blockColumn = new df::map_block*[z_count_block+1]; - memcpy(blockColumn, block_index[a][b], z_count_block*sizeof(df::map_block*)); - blockColumn[z_count_block] = NULL; - delete[] block_index[a][b]; - block_index[a][b] = blockColumn; - - //deal with map_block_column stuff even though it'd probably be fine - df::map_block_column* column = world->map.column_index[a][b]; - if ( !column ) { - out.print("%s, line %d: column is null (%d, %d).\n", __FILE__, __LINE__, a, b); - continue; - } - df::map_block_column::T_unmined_glyphs* glyphs = new df::map_block_column::T_unmined_glyphs; - glyphs->x[0] = 0; - glyphs->x[1] = 1; - glyphs->x[2] = 2; - glyphs->x[3] = 3; - glyphs->y[0] = 0; - glyphs->y[1] = 0; - glyphs->y[2] = 0; - glyphs->y[3] = 0; - glyphs->tile[0] = 'e'; - glyphs->tile[1] = 'x'; - glyphs->tile[2] = 'p'; - glyphs->tile[3] = '^'; - column->unmined_glyphs.push_back(glyphs); - } - } - df::z_level_flags* flags = new df::z_level_flags[z_count_block+1]; - memcpy(flags, world->map_extras.z_level_flags, z_count_block*sizeof(df::z_level_flags)); - flags[z_count_block].whole = 0; - flags[z_count_block].bits.update = 1; - world->map.z_count_block++; - world->map.z_count++; - delete[] world->map_extras.z_level_flags; - world->map_extras.z_level_flags = flags; - } - -} - -DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) -{ - enabled = enable; - return CR_OK; -} - -command_result infiniteSky (color_ostream &out, std::vector & parameters) -{ - if ( parameters.size() > 1 ) - return CR_WRONG_USAGE; - if ( parameters.size() == 0 ) { - out.print("Construction monitoring is %s.\n", enabled ? "enabled" : "disabled"); - return CR_OK; - } - if (parameters[0] == "enable") { - enabled = true; - out.print("Construction monitoring enabled.\n"); - return CR_OK; - } - if (parameters[0] == "disable") { - enabled = false; - out.print("Construction monitoring disabled.\n"); - constructionSize = 0; - return CR_OK; - } - int32_t howMany = 0; - howMany = atoi(parameters[0].c_str()); - out.print("InfiniteSky: creating %d new z-level%s of sky.\n", howMany, howMany == 1 ? "" : "s" ); - doInfiniteSky(out, howMany); - return CR_OK; -} diff --git a/plugins/logistics.cpp b/plugins/logistics.cpp index 5c9f1fdac8..d58a583d6d 100644 --- a/plugins/logistics.cpp +++ b/plugins/logistics.cpp @@ -431,7 +431,7 @@ static const struct BadFlags { df::item_flags flags; #define F(x) flags.bits.x = true; F(garbage_collect); F(hostile); F(on_fire); - F(rotten); F(trader); F(in_building); F(construction); + F(trader); F(in_building); F(construction); F(artifact); F(spider_web); F(owned); F(in_job); #undef F return flags.whole; diff --git a/plugins/lua/buildingplan/itemselection.lua b/plugins/lua/buildingplan/itemselection.lua index a7fa5835ac..59a4773a57 100644 --- a/plugins/lua/buildingplan/itemselection.lua +++ b/plugins/lua/buildingplan/itemselection.lua @@ -4,6 +4,7 @@ local gui = require('gui') local pens = require('plugins.buildingplan.pens') local utils = require('utils') local widgets = require('gui.widgets') +local caravan = reqscript('internal/caravan/common') local uibs = df.global.buildreq local to_pen = dfhack.pen.parse @@ -60,6 +61,12 @@ local function sort_by_quantity(a, b) (ad.quantity == bd.quantity and sort_by_type(a, b)) end +local function sort_by_value(a, b) + local ad, bd = a.data, b.data + return ad.value > bd.value or + (ad.value == bd.value and sort_by_type(a, b)) +end + ItemSelection = defclass(ItemSelection, widgets.Window) ItemSelection.ATTRS{ frame_title='Choose items', @@ -151,8 +158,9 @@ function ItemSelection:init() label='Sort by:', options={ {label='Recently used', value=sort_by_recency}, - {label='Name', value=sort_by_name}, {label='Amount', value=sort_by_quantity}, + {label='Value', value=sort_by_value}, + {label='Name', value=sort_by_name}, }, on_change=self:callback('on_sort'), }, @@ -256,8 +264,10 @@ function ItemSelection:get_choices(sort_fn) local item = df.item.find(item_id) if not item then goto continue end local desc = get_item_description(item_id, item) - if buckets[desc] then - local bucket = buckets[desc] + local value = dfhack.items.getValue(item) + local key = desc .. "_" .. tostring(value) + if buckets[key] then + local bucket = buckets[key] table.insert(bucket.data.item_ids, item_id) bucket.data.quantity = bucket.data.quantity + 1 else @@ -265,24 +275,28 @@ function ItemSelection:get_choices(sort_fn) search_key=make_search_key(desc), icon=self:callback('get_entry_icon', item_id), data={ + desc=desc, item_ids={item_id}, item_type=item:getType(), item_subtype=item:getSubtype(), quantity=1, quality=item:getQuality(), + value=value, selected=0, }, } - buckets[desc] = entry + buckets[key] = entry end ::continue:: end local choices = {} - for desc,choice in pairs(buckets) do + for key,choice in pairs(buckets) do local data = choice.data + local obfuscated_value = caravan.obfuscate_value(data.value) choice.text = { - {width=10, text=function() return ('%d/%d'):format(data.selected, data.quantity) end}, - {gap=2, text=desc}, + {width=8, text=function() return ('%d/%d'):format(data.selected, data.quantity) end}, + {width=9, gap=2, text=function() return ('%s%s'):format(obfuscated_value, caravan.CH_MONEY) end}, + {gap=2, text=data.desc}, } table.insert(choices, choice) end diff --git a/plugins/lua/burrow.lua b/plugins/lua/burrow.lua index ffea7eb032..9d32fe50bd 100644 --- a/plugins/lua/burrow.lua +++ b/plugins/lua/burrow.lua @@ -105,7 +105,7 @@ function BurrowDesignationOverlay:onInput(keys) self.last_click_ms = now_ms self.saved_pos = pos elseif fill ~= 'off' then - if now_ms - self.last_click_ms <= widgets.DOUBLE_CLICK_MS then + if now_ms - self.last_click_ms <= widgets.getDoubleClickMs() then self.last_click_ms = 0 local do_3d = fill == '3d' self.pending_fn = curry(flood_fill, pos, if_burrow.erasing, do_3d) diff --git a/plugins/lua/dwarfvet.lua b/plugins/lua/dwarfvet.lua index f2967a43e1..a1f837a66a 100644 --- a/plugins/lua/dwarfvet.lua +++ b/plugins/lua/dwarfvet.lua @@ -59,8 +59,8 @@ local function get_new_patients(cur_patients) local new_patients = {} for _,unit in ipairs(df.global.world.units.active) do if unit.job.current_job then goto continue end - if cur_patients[unit.id] or not is_valid_animal(unit) then goto continue end if not unit.health or not unit.health.flags.needs_healthcare then goto continue end + if cur_patients[unit.id] or not is_valid_animal(unit) then goto continue end new_patients[unit.id] = unit ::continue:: end diff --git a/plugins/lua/infinite-sky.lua b/plugins/lua/infinite-sky.lua new file mode 100644 index 0000000000..ae7ee9531c --- /dev/null +++ b/plugins/lua/infinite-sky.lua @@ -0,0 +1,18 @@ +local _ENV = mkmodule('plugins.infinite-sky') + +local argparse = require('argparse') + +function parse_commandline(opts, args) + local positionals = argparse.processArgsGetopt(args, { + {'h', 'help', handler=function() opts.help = true end}, + }) + if opts.help or positionals[1] == 'help' then + opts.help = true + return + end + if positionals[1] then + opts.n = argparse.positiveInt(positionals[1]) + end +end + +return _ENV diff --git a/plugins/lua/preserve-rooms.lua b/plugins/lua/preserve-rooms.lua index 7d0e86adab..8dd4e507af 100644 --- a/plugins/lua/preserve-rooms.lua +++ b/plugins/lua/preserve-rooms.lua @@ -114,10 +114,10 @@ ReservedWidget.ATTRS{ new_world_loaded = true local CONFLICTING_TOOLTIPS = utils.invert{ - df.main_hover_instruction.ZoneRepaint, - df.main_hover_instruction.ZoneSuspend, - df.main_hover_instruction.ZoneRemove, - df.main_hover_instruction.ZoneAssignLocation, + df.main_hover_instruction.ZONE_REPAINT, + df.main_hover_instruction.ZONE_SUSPEND, + df.main_hover_instruction.ZONE_REMOVE_EXISTING, + df.main_hover_instruction.ZONE_ASSIGN_LOCATION, } function ReservedWidget:init() diff --git a/plugins/lua/timestream.lua b/plugins/lua/timestream.lua new file mode 100644 index 0000000000..6f5b68ddf6 --- /dev/null +++ b/plugins/lua/timestream.lua @@ -0,0 +1,50 @@ +local _ENV = mkmodule('plugins.timestream') + +function migrate_old_config() + local GLOBAL_KEY = 'timestream' + local old_config = dfhack.persistent.getSiteData(GLOBAL_KEY) + if not old_config then return end + if old_config.enabled then dfhack.run_command('enable', GLOBAL_KEY) end + if old_config.settings and type(old_config.settings) == 'table' and tonumber(old_config.settings.fps) then + timestream_setFps(tonumber(old_config.settings.fps)) + end +end + +local function do_set(setting_name, arg) + local numarg = tonumber(arg) + if setting_name ~= 'fps' or not numarg then + qerror('must specify setting and value') + end + timestream_setFps(arg) + print(('set %s to %s'):format(setting_name, timestream_getFps())) +end + +local function do_reset() + timestream_resetSettings() +end + +local function print_status() + print('timestream is ' .. (isEnabled() and 'enabled' or 'not enabled')) + print() + print('target FPS is set to: ' .. tostring(timestream_getFps())) +end + +function parse_commandline(args) + local command = table.remove(args, 1) + + if command == 'help' then + return false + elseif command == 'set' then + do_set(args[1], args[2]) + elseif command == 'reset' then + do_reset() + elseif not command or command == 'status' then + print_status() + else + return false + end + + return true +end + +return _ENV diff --git a/plugins/luasocket.cpp b/plugins/luasocket.cpp index 2903616e78..d5cdb369f4 100644 --- a/plugins/luasocket.cpp +++ b/plugins/luasocket.cpp @@ -103,7 +103,7 @@ static int lua_server_accept(int id,bool fail_on_timeout) CActiveSocket* sock=cur_server.socket->Accept(); if(!sock) { - handle_error(sock->GetSocketError(),!fail_on_timeout); + handle_error(cur_server.socket->GetSocketError(),!fail_on_timeout); return 0; } else diff --git a/plugins/nestboxes.cpp b/plugins/nestboxes.cpp index 6f21628170..64cfcc301b 100644 --- a/plugins/nestboxes.cpp +++ b/plugins/nestboxes.cpp @@ -118,15 +118,13 @@ static void do_cycle(color_ostream &out) { cycle_timestamp = world->frame_counter; for (df::building_nest_boxst *nb : world->buildings.other.NEST_BOX) { - bool fertile = false; - if (nb->claimed_by != -1) { - df::unit *u = df::unit::find(nb->claimed_by); - if (u && u->pregnancy_timer > 0) - fertile = true; - } for (auto &contained_item : nb->contained_items) { - auto *item = virtual_cast(contained_item->item); - if (item && item->flags.bits.forbid != fertile) { + if (contained_item->use_mode == df::building_item_role_type::PERM) + continue; + if (auto *item = virtual_cast(contained_item->item)) { + bool fertile = item->egg_flags.bits.fertile; + if (item->flags.bits.forbid == fertile) + continue; item->flags.bits.forbid = fertile; if (fertile && item->flags.bits.in_job) { // cancel any job involving the egg @@ -135,7 +133,7 @@ static void do_cycle(color_ostream &out) { if (sref && sref->data.job) Job::removeJob(sref->data.job); } - out.print("%d eggs %s.\n", item->getStackSize(), fertile ? "forbidden" : "unforbidden"); + out.print("nestboxes: %d eggs %s\n", item->getStackSize(), fertile ? "forbidden" : "unforbidden"); } } } diff --git a/plugins/orders.cpp b/plugins/orders.cpp index 0e28f1ec11..3a98e16bf7 100644 --- a/plugins/orders.cpp +++ b/plugins/orders.cpp @@ -1011,8 +1011,12 @@ static command_result orders_clear_command(color_ostream & out) return CR_OK; } -static bool compare_freq(df::manager_order *a, df::manager_order *b) +static bool orders_compare(df::manager_order *a, df::manager_order *b) { + if (a->workshop_id != b->workshop_id) { + return a->workshop_id >= 0; + } + if (a->frequency == df::manager_order::T_frequency::OneTime || b->frequency == df::manager_order::T_frequency::OneTime) return a->frequency < b->frequency; @@ -1025,11 +1029,11 @@ static command_result orders_sort_command(color_ostream & out) if (!std::is_sorted(world->manager_orders.all.begin(), world->manager_orders.all.end(), - compare_freq)) + orders_compare)) { std::stable_sort(world->manager_orders.all.begin(), world->manager_orders.all.end(), - compare_freq); + orders_compare); out << "Fixed priority of manager orders." << std::endl; } diff --git a/plugins/pathable.cpp b/plugins/pathable.cpp index 579ffb17f1..832a817313 100644 --- a/plugins/pathable.cpp +++ b/plugins/pathable.cpp @@ -1,4 +1,5 @@ #include "Debug.h" +#include "Error.h" #include "PluginManager.h" #include "TileTypes.h" diff --git a/plugins/plant.cpp b/plugins/plant.cpp index 035b911d6a..c11e2600a1 100644 --- a/plugins/plant.cpp +++ b/plugins/plant.cpp @@ -1,6 +1,7 @@ // Grow and remove shrubs or trees. #include "Debug.h" +#include "Error.h" #include "LuaTools.h" #include "PluginManager.h" #include "TileTypes.h" diff --git a/plugins/preserve-rooms.cpp b/plugins/preserve-rooms.cpp index e170bd87a1..d4519402bb 100644 --- a/plugins/preserve-rooms.cpp +++ b/plugins/preserve-rooms.cpp @@ -389,13 +389,15 @@ static void clear_reservation(color_ostream &out, int32_t zone_id, df::building_ zone->spec_sub_flag.bits.active = true; } -// stop reserving zones for dead units +// stop reserving zones for dead units or units that are no longer in an army static void scrub_reservations(color_ostream &out) { vector hfids_to_scrub; for (auto &[hfid, zone_ids] : pending_reassignment) { - if (auto hf = df::historical_figure::find(hfid); hf && hf->died_year == -1) + auto hf = df::historical_figure::find(hfid); + if (hf && hf->died_year == -1 && hf->info && hf->info->whereabouts && hf->info->whereabouts->army_id > -1) continue; - DEBUG(cycle,out).print("removed reservation for dead or culled hfid %d\n", hfid); + DEBUG(cycle,out).print("removed reservation for dead, culled, or non-army hfid %d: %s\n", hfid, + hf ? DF2CONSOLE(Units::getReadableName(hf)).c_str() : "culled"); hfids_to_scrub.push_back(hfid); for (int32_t zone_id : zone_ids) { if (scrub_id_from_entries(hfid, zone_id, reserved_zones)) { @@ -505,6 +507,11 @@ static void process_rooms(color_ostream &out, auto it = last_known.begin(); auto it_end = last_known.end(); for (auto zone : vec) { + auto idx = linear_index(df::global::world->buildings.all, (df::building*)(zone)); + if (idx == -1) { + WARN(cycle, out).print("invalid building pointer %p in building vector\n", zone); + continue; + } if (!zone->assigned_unit) { handle_missing_assignments(out, active_unit_ids, &it, it_end, share_with_spouse, zone->id); continue; diff --git a/plugins/regrass.cpp b/plugins/regrass.cpp index d06e63f5eb..40b4e0448e 100644 --- a/plugins/regrass.cpp +++ b/plugins/regrass.cpp @@ -2,6 +2,7 @@ #include "DataDefs.h" #include "Debug.h" +#include "Error.h" #include "LuaTools.h" #include "PluginManager.h" #include "TileTypes.h" diff --git a/plugins/remotefortressreader/remotefortressreader.cpp b/plugins/remotefortressreader/remotefortressreader.cpp index aa68c9e8d9..701e18d9b4 100644 --- a/plugins/remotefortressreader/remotefortressreader.cpp +++ b/plugins/remotefortressreader/remotefortressreader.cpp @@ -1697,7 +1697,7 @@ static command_result GetUnitListInside(color_ostream &stream, const BlockReques size_info->set_length_base(unit->body.size_info.length_base); if (unit->name.has_name) { - send_unit->set_name(DF2UTF(Translation::TranslateName(Units::getVisibleName(unit)))); + send_unit->set_name(DF2UTF(Translation::TranslateName(Units::getVisibleName(unit), true))); } auto appearance = send_unit->mutable_appearance(); diff --git a/plugins/showmood.cpp b/plugins/showmood.cpp index 51578a8226..06ee9ace37 100644 --- a/plugins/showmood.cpp +++ b/plugins/showmood.cpp @@ -69,7 +69,7 @@ command_result df_showmood (color_ostream &out, vector & parameters) out.printerr("Dwarf with strange mood does not have a mood type!\n"); continue; } - out.print("%s is currently ", DF2CONSOLE(out, Translation::TranslateName(&unit->name, false)).c_str()); + out.print("%s is currently ", DF2CONSOLE(out, Translation::TranslateName(&unit->name)).c_str()); switch (unit->mood) { case mood_type::Macabre: diff --git a/plugins/stockpiles/OrganicMatLookup.h b/plugins/stockpiles/OrganicMatLookup.h index 37e019b588..f585ef2de4 100644 --- a/plugins/stockpiles/OrganicMatLookup.h +++ b/plugins/stockpiles/OrganicMatLookup.h @@ -1,5 +1,7 @@ #pragma once +#include "ColorText.h" + #include "modules/Materials.h" #include "df/organic_mat_category.h" diff --git a/plugins/stonesense b/plugins/stonesense index 81c380f2fb..e4bf6dcd27 160000 --- a/plugins/stonesense +++ b/plugins/stonesense @@ -1 +1 @@ -Subproject commit 81c380f2fb88630ecd5293bcb2ee0ad9e70ac025 +Subproject commit e4bf6dcd27b77a85a6b9a0fbc7a55d346ebc1620 diff --git a/plugins/strangemood.cpp b/plugins/strangemood.cpp index 4ed37944ce..2dd5f52bb3 100644 --- a/plugins/strangemood.cpp +++ b/plugins/strangemood.cpp @@ -548,7 +548,7 @@ command_result df_strangemood (color_ostream &out, vector & parameters) if (unit->job.current_job) { // TODO: cancel job - out.printerr("Chosen unit '%s' has active job, cannot start mood!\n", Translation::TranslateName(&unit->name, false).c_str()); + out.printerr("Chosen unit '%s' has active job, cannot start mood!\n", Translation::TranslateName(&unit->name).c_str()); return CR_FAILURE; } diff --git a/plugins/tailor.cpp b/plugins/tailor.cpp index 5b746333ce..57b1f58cbd 100644 --- a/plugins/tailor.cpp +++ b/plugins/tailor.cpp @@ -313,7 +313,7 @@ class Tailor { { DEBUG(cycle).print ("tailor: %s (size %d) worn by %s (size %d) needs replacement\n", DF2CONSOLE(description).c_str(), isize, - DF2CONSOLE(Translation::TranslateName(&u->name, false)).c_str(), usize); + DF2CONSOLE(Translation::TranslateName(&u->name)).c_str(), usize); needed[std::make_pair(ty, usize)] += 1; ordered.insert(ty); } @@ -329,7 +329,7 @@ class Tailor { "tailor: %s %s from %s.\n", (confiscated ? "confiscated" : "could not confiscate"), DF2CONSOLE(description).c_str(), - DF2CONSOLE(Translation::TranslateName(&u->name, false)).c_str() + DF2CONSOLE(Translation::TranslateName(&u->name)).c_str() ); } @@ -346,7 +346,7 @@ class Tailor { TRACE(cycle).print("tailor: one %s of size %d needed to cover %s\n", ENUM_KEY_STR(item_type, ty).c_str(), usize, - DF2CONSOLE(Translation::TranslateName(&u->name, false)).c_str()); + DF2CONSOLE(Translation::TranslateName(&u->name)).c_str()); needed[std::make_pair(ty, usize)] += 1; } } diff --git a/plugins/timestream.cpp b/plugins/timestream.cpp new file mode 100644 index 0000000000..63f23ad520 --- /dev/null +++ b/plugins/timestream.cpp @@ -0,0 +1,696 @@ +#include "Debug.h" +#include "LuaTools.h" +#include "PluginManager.h" + +#include "modules/EventManager.h" +#include "modules/Items.h" +#include "modules/Job.h" +#include "modules/Persistence.h" +#include "modules/Units.h" +#include "modules/World.h" + +#include "df/activity_entry.h" +#include "df/activity_event.h" +#include "df/activity_event_conflictst.h" +#include "df/activity_event_conversationst.h" +#include "df/activity_event_copy_written_contentst.h" +#include "df/activity_event_discuss_topicst.h" +#include "df/activity_event_make_believest.h" +#include "df/activity_event_performancest.h" +#include "df/activity_event_playst.h" +#include "df/activity_event_play_with_toyst.h" +#include "df/activity_event_ponder_topicst.h" +#include "df/activity_event_prayerst.h" +#include "df/activity_event_ranged_practicest.h" +#include "df/activity_event_readst.h" +#include "df/activity_event_skill_demonstrationst.h" +#include "df/activity_event_socializest.h" +#include "df/activity_event_sparringst.h" +#include "df/activity_event_teach_topicst.h" +#include "df/activity_event_writest.h" +#include "df/activity_event_worshipst.h" +#include "df/building_nest_boxst.h" +#include "df/building_trapst.h" +#include "df/init.h" +#include "df/item_eggst.h" +#include "df/unit.h" +#include "df/world.h" + +#include + +using std::string; +using std::vector; +using namespace DFHack; +using namespace df::enums; + +DFHACK_PLUGIN("timestream"); +DFHACK_PLUGIN_IS_ENABLED(is_enabled); + +REQUIRE_GLOBAL(cur_year_tick); +REQUIRE_GLOBAL(cur_year_tick_advmode); +REQUIRE_GLOBAL(init); +REQUIRE_GLOBAL(world); + +namespace DFHack { + // for configuration-related logging + DBG_DECLARE(timestream, control, DebugCategory::LINFO); + // for logging during the periodic scan + DBG_DECLARE(timestream, cycle, DebugCategory::LINFO); + // for logging during event callbacks + DBG_DECLARE(timestream, event, DebugCategory::LINFO); +} + +static const string CONFIG_KEY = string(plugin_name) + "/config"; +static PersistentDataItem config; + +enum ConfigValues { + CONFIG_IS_ENABLED = 0, + CONFIG_TARGET_FPS = 1, +}; + +static const int32_t CYCLE_TICKS = 1; +static int32_t cycle_timestamp = 0; // world->frame_counter at last cycle + +static command_result do_command(color_ostream &out, vector ¶meters); +static void on_new_active_unit(color_ostream& out, void* data); +static void do_cycle(color_ostream &out); + +DFhackCExport command_result plugin_init(color_ostream &out, std::vector &commands) { + DEBUG(control,out).print("initializing %s\n", plugin_name); + + commands.push_back(PluginCommand( + plugin_name, + "Fix FPS death.", + do_command)); + + return CR_OK; +} + + +// ensure we never skip over cur_year_tick values that match this list +// first element of the pair is the modulus +// second element of the pair is a vector of remainders to match +static vector>> TICK_TRIGGERS = { + {10, {0}}, // 0: season ticks (and lots of other stuff) + // 0 mod 100: crop growth, strange mood, minimap update, rot + // 20 mod 100: building updates + // 40 mod 100: assign tombs to newly tomb-eligible corpses + // 80 mod 100: incarceration updates + // 40 mod 1000: remove excess seeds + {50, {25, 35, 45}}, // 25: stockpile updates + // 35: check bags + // 35 mod 100: job auction + // 45: stockpile updates + {100, {99}} // 99: new job creation +}; + +// "owed" ticks we would like to skip at the next opportunity +static float timeskip_deficit = 0.0F; + +// birthday_triggers is a dense sequence of cur_year_tick values -> next unit birthday +// the sequence covers 0 .. greatest unit birthday value +// this cache is augmented when new units appear (as per the new unit event) and is cleared and +// refreshed from scratch once a year to evict data for units that are no longer active. +static vector birthday_triggers; + +// coverage record for cur_year_tick % 50 so we can be sure that all items are being scanned +// (DF scans 1/50th of items every tick based on cur_year_tick % 50) +// we want every section hit at least once every 1000 ticks +static const uint32_t NUM_COVERAGE_TICKS = 50; +static std::array tick_coverage; + +// only throttle due to tick_coverage at most once per season tick to avoid clustering +static bool season_tick_throttled = false; + +static const int32_t TICKS_PER_YEAR = 403200; + +static void register_birthday(df::unit * unit) { + int32_t btick = unit->birth_time; + if (btick < 0 || btick > TICKS_PER_YEAR) + return; + if (birthday_triggers.size() <= (size_t)btick) + birthday_triggers.resize(btick+1, INT32_MAX); + for (int32_t tick=btick; tick >= 0; --tick) { + if (birthday_triggers[tick] > btick) + birthday_triggers[tick] = btick; + else + break; + } +} + +static void refresh_birthday_triggers() { + birthday_triggers.clear(); + for (auto unit : world->units.active) { + if (Units::isActive(unit) && !Units::isDead(unit)) + register_birthday(unit); + } +} + +static void reset_ephemeral_state() { + timeskip_deficit = 0.0F; + refresh_birthday_triggers(); + tick_coverage = {}; + season_tick_throttled = false; +} + +static void do_disable() { + EventManager::unregisterAll(plugin_self); +} + +DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { + if (!Core::getInstance().isMapLoaded() || !World::isFortressMode()) { + out.printerr("Cannot enable %s without a loaded fort.\n", plugin_name); + return CR_FAILURE; + } + + static EventManager::EventHandler new_unit_handler(plugin_self, on_new_active_unit, CYCLE_TICKS); + + if (enable != is_enabled) { + is_enabled = enable; + DEBUG(control,out).print("%s from the API; persisting\n", + is_enabled ? "enabled" : "disabled"); + config.set_bool(CONFIG_IS_ENABLED, is_enabled); + if (enable) { + reset_ephemeral_state(); + EventManager::registerListener(EventManager::EventType::UNIT_NEW_ACTIVE, new_unit_handler); + do_cycle(out); + } else { + do_disable(); + } + } else { + DEBUG(control,out).print("%s from the API, but already %s; no action\n", + is_enabled ? "enabled" : "disabled", + is_enabled ? "enabled" : "disabled"); + } + return CR_OK; +} + +DFhackCExport command_result plugin_shutdown(color_ostream &out) { + DEBUG(control,out).print("shutting down %s\n", plugin_name); + + return CR_OK; +} + +static int clamp_fps_to_valid(int32_t fps) { + return std::max(10, fps); +} + +static void set_default_settings() { + config.set_int(CONFIG_TARGET_FPS, clamp_fps_to_valid(init->fps_cap)); +} + +static void migrate_old_config(color_ostream &out) { + Lua::CallLuaModuleFunction(out, "plugins.timestream", "migrate_old_config"); +} + +DFhackCExport command_result plugin_load_site_data(color_ostream &out) { + cycle_timestamp = 0; + config = World::GetPersistentSiteData(CONFIG_KEY); + + if (!config.isValid()) { + DEBUG(control,out).print("no config found in this save; initializing\n"); + config = World::AddPersistentSiteData(CONFIG_KEY); + config.set_bool(CONFIG_IS_ENABLED, is_enabled); + set_default_settings(); + migrate_old_config(out); + } + + plugin_enable(out, config.get_bool(CONFIG_IS_ENABLED)); + DEBUG(control,out).print("loading persisted enabled state: %s\n", + is_enabled ? "true" : "false"); + + return CR_OK; +} + +DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_change_event event) { + if (event == DFHack::SC_WORLD_UNLOADED) { + if (is_enabled) { + DEBUG(control,out).print("world unloaded; disabling %s\n", + plugin_name); + do_disable(); + } + } + return CR_OK; +} + +DFhackCExport command_result plugin_onupdate(color_ostream &out) { + if (world->frame_counter - cycle_timestamp >= CYCLE_TICKS) + do_cycle(out); + return CR_OK; +} + +static command_result do_command(color_ostream &out, vector ¶meters) { + CoreSuspender suspend; + + if (!Core::getInstance().isMapLoaded() || !World::isFortressMode()) { + out.printerr("Cannot run %s without a loaded fort.\n", plugin_name); + return CR_FAILURE; + } + + bool show_help = false; + if (!Lua::CallLuaModuleFunction(out, "plugins.timestream", "parse_commandline", std::make_tuple(parameters), + 1, [&](lua_State *L) { + show_help = !lua_toboolean(L, -1); + })) { + return CR_FAILURE; + } + + return show_help ? CR_WRONG_USAGE : CR_OK; +} + +///////////////////////////////////////////////////// +// cycle logic +// + +static void record_coverage(color_ostream &out) { + uint32_t coverage_slot = *cur_year_tick % NUM_COVERAGE_TICKS; + if (coverage_slot >= NUM_COVERAGE_TICKS) + return; + if (!tick_coverage[coverage_slot]) { + DEBUG(cycle,out).print("recording coverage for slot: %u", coverage_slot); + } + tick_coverage[coverage_slot] = true; +} + +static float get_desired_timeskip(int32_t real_fps, int32_t target_fps) { + // minus 1 to account for the current frame + return (float(target_fps) / float(real_fps)) - 1; +} + +static int32_t get_next_trigger_year_tick(int32_t next_tick) { + int32_t next_trigger_tick = INT32_MAX; + for (auto trigger : TICK_TRIGGERS) { + int32_t cur_rem = next_tick % trigger.first; + for (auto rem : trigger.second) { + if (cur_rem <= rem) { + next_trigger_tick = std::min(next_trigger_tick, next_tick + (rem - cur_rem)); + continue; + } + } + next_trigger_tick = std::min(next_trigger_tick, next_tick + trigger.first - cur_rem + trigger.second.back()); + } + return next_trigger_tick; +} + +static int32_t get_next_birthday(int32_t next_tick) { + if (next_tick < 0 || next_tick >= (int32_t)birthday_triggers.size()) + return INT32_MAX; + return birthday_triggers[next_tick]; +} + +static int32_t clamp_coverage(int32_t timeskip) { + if (season_tick_throttled) + return timeskip; + for (int32_t val = 1; val <= timeskip; ++val) { + uint32_t coverage_slot = (*cur_year_tick + val) % NUM_COVERAGE_TICKS; + if (coverage_slot < NUM_COVERAGE_TICKS && !tick_coverage[coverage_slot]) { + season_tick_throttled = true; + return val - 1; + } + } + return timeskip; +} + +static int32_t clamp_timeskip(int32_t timeskip) { + if (timeskip <= 0) + return 0; + int32_t next_tick = *cur_year_tick + 1; + timeskip = std::min(timeskip, get_next_trigger_year_tick(next_tick) - next_tick); + timeskip = std::min(timeskip, get_next_birthday(next_tick) - next_tick); + return clamp_coverage(timeskip); +} + +template +static void increment_counter(T *obj, FT T::*field, int32_t timeskip) { + if (obj->*field <= 0) + return; + obj->*field = obj->*field + timeskip; +} + +template +static void decrement_counter(T *obj, FT T::*field, int32_t timeskip) { + if (obj->*field <= 0) + return; + // TODO: check for overflow/underflow + int32_t cur_val = static_cast(obj->*field); + obj->*field = static_cast(std::max(1, cur_val - timeskip)); +} + +static void adjust_unit_counters(df::unit * unit, int32_t timeskip) { + auto * c1 = &unit->counters; + decrement_counter(c1, &df::unit::T_counters::think_counter, timeskip); + decrement_counter(c1, &df::unit::T_counters::job_counter, timeskip); + decrement_counter(c1, &df::unit::T_counters::swap_counter, timeskip); + decrement_counter(c1, &df::unit::T_counters::winded, timeskip); + decrement_counter(c1, &df::unit::T_counters::stunned, timeskip); + decrement_counter(c1, &df::unit::T_counters::unconscious, timeskip); + decrement_counter(c1, &df::unit::T_counters::suffocation, timeskip); + decrement_counter(c1, &df::unit::T_counters::webbed, timeskip); + decrement_counter(c1, &df::unit::T_counters::soldier_mood_countdown, timeskip); + decrement_counter(c1, &df::unit::T_counters::pain, timeskip); + decrement_counter(c1, &df::unit::T_counters::nausea, timeskip); + decrement_counter(c1, &df::unit::T_counters::dizziness, timeskip); + auto * c2 = &unit->counters2; + decrement_counter(c2, &df::unit::T_counters2::paralysis, timeskip); + decrement_counter(c2, &df::unit::T_counters2::numbness, timeskip); + decrement_counter(c2, &df::unit::T_counters2::fever, timeskip); + decrement_counter(c2, &df::unit::T_counters2::exhaustion, timeskip * 3); + increment_counter(c2, &df::unit::T_counters2::hunger_timer, timeskip); + increment_counter(c2, &df::unit::T_counters2::thirst_timer, timeskip); + auto job = unit->job.current_job; + if (job && job->job_type == df::job_type::Rest) + decrement_counter(c2, &df::unit::T_counters2::sleepiness_timer, timeskip * 200); + else if (job && job->job_type == df::job_type::Sleep) + decrement_counter(c2, &df::unit::T_counters2::sleepiness_timer, timeskip * 19); + else + increment_counter(c2, &df::unit::T_counters2::sleepiness_timer, timeskip); + decrement_counter(c2, &df::unit::T_counters2::stomach_content, timeskip * 5); + decrement_counter(c2, &df::unit::T_counters2::stomach_food, timeskip * 5); + decrement_counter(c2, &df::unit::T_counters2::vomit_timeout, timeskip); + // stored_fat wanders about based on other state; we can likely leave it alone and + // not materially affect gameplay +} + +// need to manually adjust job completion_timer values for jobs that are controlled by unit actions +// with a timer of 1, which are destroyed immediately after they are created. longer-lived unit +// actions are already sufficiently handled by dfhack.units.subtractGroupActionTimers(). +// this will also decrement timers for jobs with actions that have just expired, but on average, this +// should balance out to be correct, since we're losing time when we subtract from the action timers +// and cap the value so it never drops below 1. +static void adjust_job_counter(df::unit * unit, int32_t timeskip) { + auto job = unit->job.current_job; + if (!job) + return; + for (auto action : unit->actions) { + if (action->type == df::unit_action_type::Job || action->type == df::unit_action_type::JobRecover) + return; + } + decrement_counter(job, &df::job::completion_timer, timeskip); +} + +// unit needs appear to be incremented on season ticks, so we don't need to worry about those +// since the TICK_TRIGGERS check makes sure that we never skip season ticks +static void adjust_units(color_ostream &out, int32_t timeskip) { + for (auto unit : world->units.active) { + if (!Units::isActive(unit)) + continue; + decrement_counter(unit, &df::unit::pregnancy_timer, timeskip); + Units::subtractGroupActionTimers(out, unit, timeskip, df::unit_action_type_group::All); + if (!Units::isOwnGroup(unit)) + continue; + adjust_unit_counters(unit, timeskip); + adjust_job_counter(unit, timeskip); + } +} + +// behavior ascertained from in-game observation +static void adjust_activities(color_ostream &out, int32_t timeskip) { + for (auto act : world->activities.all) { + for (auto ev : act->events) { + switch (ev->getType()) { + using namespace df::enums::activity_event_type; + + case TrainingSession: + // no counters + break; + + case CombatTraining: + // has organize_counter at a non-zero value, but it doesn't seem to move + break; + + case SkillDemonstration: + if (auto sd_ev = virtual_cast(ev)) { + // can be negative or positive, but always counts towards 0 + if (sd_ev->organize_counter < 0) + sd_ev->organize_counter = std::min(-1, sd_ev->organize_counter + timeskip); + else + decrement_counter(sd_ev, &df::activity_event_skill_demonstrationst::organize_counter, timeskip); + decrement_counter(sd_ev, &df::activity_event_skill_demonstrationst::train_countdown, timeskip); + break; + } + + case IndividualSkillDrill: + // only counts down on season ticks, nothing to do here + break; + + case Sparring: + if (auto s_ev = virtual_cast(ev)) { + decrement_counter(s_ev, &df::activity_event_sparringst::countdown, timeskip * 2); + break; + } + + case RangedPractice: + // countdown appears to never move from 0 + if (auto rp_ev = virtual_cast(ev)) { + decrement_counter(rp_ev, &df::activity_event_ranged_practicest::countdown, timeskip); + break; + } + + case Harassment: + { + DEBUG(cycle,out).print("activity_event_harassmentst ready for analysis\n"); + break; + } + + case Conversation: + if (auto c_ev = virtual_cast(ev)) { + increment_counter(c_ev, &df::activity_event_conversationst::pause, timeskip); + break; + } + + case Conflict: + if (auto c_ev = virtual_cast(ev)) { + increment_counter(c_ev, &df::activity_event_conflictst::inactivity_timer, timeskip); + increment_counter(c_ev, &df::activity_event_conflictst::attack_inactivity_timer, timeskip); + increment_counter(c_ev, &df::activity_event_conflictst::stop_fort_fights_timer, timeskip); + break; + } + + case Guard: + // no counters + break; + + case Reunion: + { + DEBUG(cycle,out).print("activity_event_reunionst ready for analysis\n"); + break; + } + + case Prayer: + if (auto rp_ev = virtual_cast(ev)) { + decrement_counter(rp_ev, &df::activity_event_prayerst::timer, timeskip); + break; + } + + case Socialize: + if (auto p_ev = virtual_cast(ev)) { + increment_counter(p_ev, &df::activity_event_socializest::down_time_counter, timeskip); + break; + } + + case Worship: + if (auto p_ev = virtual_cast(ev)) { + increment_counter(p_ev, &df::activity_event_worshipst::down_time_counter, timeskip); + break; + } + + case Performance: + if (auto p_ev = virtual_cast(ev)) { + increment_counter(p_ev, &df::activity_event_performancest::current_position, timeskip); + break; + } + + case Research: + // no counters + break; + + case PonderTopic: + if (auto pt_ev = virtual_cast(ev)) { + decrement_counter(pt_ev, &df::activity_event_ponder_topicst::timer, timeskip); + break; + } + + case DiscussTopic: + if (auto pt_ev = virtual_cast(ev)) { + decrement_counter(pt_ev, &df::activity_event_discuss_topicst::timer, timeskip); + break; + } + + case Read: + if (auto pt_ev = virtual_cast(ev)) { + decrement_counter(pt_ev, &df::activity_event_readst::timer, timeskip); + break; + } + + case FillServiceOrder: + // no counters + break; + + case Write: + if (auto pt_ev = virtual_cast(ev)) { + decrement_counter(pt_ev, &df::activity_event_writest::timer, timeskip); + break; + } + + case CopyWrittenContent: + if (auto pt_ev = virtual_cast(ev)) { + decrement_counter(pt_ev, &df::activity_event_copy_written_contentst::timer, timeskip); + break; + } + + case TeachTopic: + if (auto pt_ev = virtual_cast(ev)) { + decrement_counter(pt_ev, &df::activity_event_teach_topicst::time_left, timeskip); + break; + } + + case Play: + if (auto p_ev = virtual_cast(ev)) { + increment_counter(p_ev, &df::activity_event_playst::down_time_counter, timeskip); + break; + } + + case MakeBelieve: + if (auto pt_ev = virtual_cast(ev)) { + decrement_counter(pt_ev, &df::activity_event_make_believest::time_left, timeskip); + break; + } + + case PlayWithToy: + if (auto pt_ev = virtual_cast(ev)) { + decrement_counter(pt_ev, &df::activity_event_play_with_toyst::time_left, timeskip); + break; + } + + case Encounter: + { + DEBUG(cycle,out).print("activity_event_encounterst ready for analysis\n"); + break; + } + + case StoreObject: + { + DEBUG(cycle,out).print("activity_event_store_objectst ready for analysis\n"); + break; + } + + // no default case so compiler complains if we miss something + } + } + } +} + +static void adjust_buildings(color_ostream &out, int32_t timeskip) { + // decrement trap timers + for (df::building_trapst *tr : world->buildings.other.TRAP) { + decrement_counter(tr, &df::building_trapst::ready_timeout, timeskip); + // used by pressure plates to delay until the plate is triggerable again + // other trap types never set this to a value higher than 1 so it is safe to decrement here + decrement_counter(tr, &df::building_trapst::state, timeskip); + } + + for (df::building *bld : world->buildings.all) { + // assumes age > 0, but that will become true very quickly for all new buildings + increment_counter(bld, &df::building::age, timeskip); + } +} + +static void adjust_items(color_ostream &out, int32_t timeskip) { + // increment incubation counters for fertile eggs in non-forbidden nestboxes + for (df::building_nest_boxst *nb : world->buildings.other.NEST_BOX) { + for (auto & contained_item : nb->contained_items) { + if (contained_item->use_mode == df::building_item_role_type::PERM) { + if (contained_item->item->flags.bits.forbid) + break; + else + continue; + } + if (auto *egg = virtual_cast(contained_item->item); egg && egg->egg_flags.bits.fertile) + increment_counter(egg, &df::item_eggst::incubation_counter, timeskip); + } + } +} + +static void do_cycle(color_ostream &out) { + DEBUG(cycle,out).print("running %s cycle\n", plugin_name); + + // mark that we have recently run + cycle_timestamp = world->frame_counter; + + record_coverage(out); + + if (*cur_year_tick % 10 == 0) { + season_tick_throttled = false; + if (*cur_year_tick % 1000 == 0) { + DEBUG(cycle,out).print("resetting coverage tracking\n"); + tick_coverage = {}; + } + if (*cur_year_tick == 0) + refresh_birthday_triggers(); + } + + uint32_t unpaused_fps = Core::getInstance().perf_counters.getUnpausedFps(); + int32_t real_fps = std::max(1, static_cast(std::min(static_cast(INT32_MAX), unpaused_fps))); + int32_t target_fps = config.get_int(CONFIG_TARGET_FPS); + if (real_fps >= target_fps) { + timeskip_deficit = 0.0F; + return; + } + + float desired_timeskip = get_desired_timeskip(real_fps, target_fps) + timeskip_deficit; + int32_t timeskip = std::max(0, clamp_timeskip(int32_t(desired_timeskip))); + + // don't let our deficit grow unbounded if we can never catch up + timeskip_deficit = std::min(desired_timeskip - float(timeskip), 100.0F); + + DEBUG(cycle,out).print("cur_year_tick: %d, real_fps: %d, timeskip: (%d, +%.2f)\n", *cur_year_tick, real_fps, timeskip, timeskip_deficit); + if (timeskip <= 0) + return; + + *cur_year_tick += timeskip; + *cur_year_tick_advmode += timeskip * 144; + + adjust_units(out, timeskip); + adjust_activities(out, timeskip); + adjust_buildings(out, timeskip); + adjust_items(out, timeskip); +} + +///////////////////////////////////////////////////// +// event logic +// + +static void on_new_active_unit(color_ostream& out, void* data) { + int32_t unit_id = reinterpret_cast(data); + auto unit = df::unit::find(unit_id); + if (!unit) + return; + DEBUG(event,out).print("registering new unit %d (%s)\n", unit->id, Units::getReadableName(unit).c_str()); + register_birthday(unit); +} + +///////////////////////////////////////////////////// +// Lua API +// + +static void timestream_setFps(color_ostream &out, int fps) { + DEBUG(cycle,out).print("timestream_setFps: %d\n", fps); + config.set_int(CONFIG_TARGET_FPS, clamp_fps_to_valid(fps)); +} + +static int timestream_getFps(color_ostream &out) { + DEBUG(cycle,out).print("timestream_getFps\n"); + return config.get_int(CONFIG_TARGET_FPS); +} + +static void timestream_resetSettings(color_ostream &out) { + DEBUG(cycle,out).print("timestream_resetSettings\n"); + set_default_settings(); +} + +DFHACK_PLUGIN_LUA_FUNCTIONS { + DFHACK_LUA_FUNCTION(timestream_setFps), + DFHACK_LUA_FUNCTION(timestream_getFps), + DFHACK_LUA_FUNCTION(timestream_resetSettings), + DFHACK_LUA_END +}; diff --git a/plugins/tweak/tweak.cpp b/plugins/tweak/tweak.cpp index e10a9535a3..e621a14d5e 100644 --- a/plugins/tweak/tweak.cpp +++ b/plugins/tweak/tweak.cpp @@ -17,6 +17,7 @@ using namespace DFHack; #include "tweaks/eggs-fertile.h" #include "tweaks/fast-heat.h" #include "tweaks/flask-contents.h" +#include "tweaks/material-size-for-melting.h" #include "tweaks/named-codices.h" #include "tweaks/partial-items.h" #include "tweaks/reaction-gloves.h" @@ -74,6 +75,15 @@ DFhackCExport command_result plugin_init(color_ostream &out, vector + +#include "modules/Materials.h" +#include "modules/Random.h" + +#include "df/inorganic_raw.h" +#include "df/item_armorst.h" +#include "df/item_constructed.h" +#include "df/item_glovesst.h" +#include "df/item_helmst.h" +#include "df/item_pantsst.h" +#include "df/item_shieldst.h" +#include "df/item_shoesst.h" +#include "df/item_toolst.h" +#include "df/item_trapcompst.h" +#include "df/item_weaponst.h" + +struct Mrng { + Random::MersenneRNG rng; + Mrng() { rng.init(); } +}; + +static float get_random() { + static Mrng mrng; + return static_cast (mrng.rng.drandom1()); +} + +static int32_t get_material_size_for_melting(df::item_constructed *item, int32_t base_material_size, float production_stack_size) { + const float melt_return_per_material_size = 0.3f, base_melt_recovery = 0.95f, loss_per_wear_level = 0.1f; + + if (item->mat_type != 0) // bail if not INORGANIC + return base_material_size; + + float forging_cost_per_item; + if (auto inorganic = df::inorganic_raw::find(item->mat_index); + inorganic && inorganic->flags.is_set(df::inorganic_flags::DEEP_SPECIAL)) + { + // adamantine items + forging_cost_per_item = static_cast(base_material_size) / production_stack_size; + } else { + // non adamantine items + forging_cost_per_item = std::max(std::floor(static_cast(base_material_size) / 3.0f), 1.0f); + forging_cost_per_item /= production_stack_size; + } + + float calculated_size = forging_cost_per_item / melt_return_per_material_size; + float melt_recovery = base_melt_recovery - static_cast(item->wear) * loss_per_wear_level; + calculated_size *= melt_recovery; + int32_t random_part = ((modff(calculated_size, &calculated_size) > get_random()) ? 1 : 0); + return static_cast(calculated_size) + random_part; +} + +#define DEFINE_MATERIAL_SIZE_FOR_MELTING_TWEAK(TYPE, PRODUCTION_STACK_SIZE) \ +struct material_size_for_melting_##TYPE##_hook : df::item_##TYPE##st {\ + typedef df::item_##TYPE##st interpose_base;\ + DEFINE_VMETHOD_INTERPOSE(int32_t, getMaterialSizeForMelting, ()) {\ + return get_material_size_for_melting(this, INTERPOSE_NEXT(getMaterialSizeForMelting)(), PRODUCTION_STACK_SIZE);\ + }\ +};\ +IMPLEMENT_VMETHOD_INTERPOSE(material_size_for_melting_##TYPE##_hook, getMaterialSizeForMelting); + +DEFINE_MATERIAL_SIZE_FOR_MELTING_TWEAK(armor, 1.0f) +DEFINE_MATERIAL_SIZE_FOR_MELTING_TWEAK(gloves, 2.0f) +DEFINE_MATERIAL_SIZE_FOR_MELTING_TWEAK(shoes, 2.0f) +DEFINE_MATERIAL_SIZE_FOR_MELTING_TWEAK(helm, 1.0f) +DEFINE_MATERIAL_SIZE_FOR_MELTING_TWEAK(pants, 1.0f) +DEFINE_MATERIAL_SIZE_FOR_MELTING_TWEAK(weapon, 1.0f) +DEFINE_MATERIAL_SIZE_FOR_MELTING_TWEAK(trapcomp, 1.0f) +DEFINE_MATERIAL_SIZE_FOR_MELTING_TWEAK(tool, 1.0f) diff --git a/plugins/xlsxreader.cpp b/plugins/xlsxreader.cpp index 014fa117aa..8e8c11229b 100644 --- a/plugins/xlsxreader.cpp +++ b/plugins/xlsxreader.cpp @@ -6,6 +6,7 @@ #include #include "DataFuncs.h" +#include "Error.h" #include "LuaTools.h" #include "PluginManager.h" #include "PluginStatics.h" diff --git a/scripts b/scripts index 7b8adddf4b..35a5e01113 160000 --- a/scripts +++ b/scripts @@ -1 +1 @@ -Subproject commit 7b8adddf4b0380ef4503c2f4d810307acb1c5f59 +Subproject commit 35a5e011130885973e8a5cfa656fc29d9145707c diff --git a/test/core.lua b/test/core.lua index cc102984eb..58f139a8cb 100644 --- a/test/core.lua +++ b/test/core.lua @@ -38,3 +38,15 @@ function test.getHackPath_chdir() expect.eq(clean_path(dfhack.getHackPath()), clean_path(fs.getcwd())) end) end + +-- validates that the size in df.globals.xml is correct +function test.global_table_size() + local elem_size = df.global_table_entry:sizeof() + local actual_arr_size = 0 + while df._displace(df.global.global_table[0], actual_arr_size, elem_size).address do + actual_arr_size = actual_arr_size + 1 + end + local declared_arr_size = df.global.global_table:sizeof() // elem_size + expect.eq(declared_arr_size, actual_arr_size, + ('global_table size mismatch: expected: %d, actual: %d'):format(declared_arr_size, actual_arr_size)) +end diff --git a/test/library/gui/widgets.TabBar.lua b/test/library/gui/widgets.TabBar.lua new file mode 100644 index 0000000000..e8c4af3307 --- /dev/null +++ b/test/library/gui/widgets.TabBar.lua @@ -0,0 +1,555 @@ +config.target = 'core' + +local gui = require('gui') +local widgets = require('gui.widgets') +local mock = require('test_util.mock') + +local fs = defclass(nil, gui.FramedScreen) +fs.ATTRS = { + frame_style = gui.GREY_LINE_FRAME, + frame_title = 'TestFramedScreen', + frame_inset = 0, + focus_path = 'test-framed-screen', +} + +---@class SetupArgs +---@field frame_width number | nil +---@field frame_height number | nil +---@field wrap boolean | nil +---@field selected number | nil +---@field labels string[] | nil + +local DEFAULT_FRAME_WIDTH = 20 +local DEFAULT_FRAME_HEIGHT = 20 +local DEFAULT_WRAP = false +local DEFAULT_LABELS = {'Foo', 'Bar', 'Baz', 'Qux'} +local DEFAULT_SELECTED = 1 + +---@param args SetupArgs | nil +local function setup(args) + args = args or { + frame_width = DEFAULT_FRAME_WIDTH, + frame_height = DEFAULT_FRAME_HEIGHT, + wrap = DEFAULT_WRAP, + labels = DEFAULT_LABELS, + selected = DEFAULT_SELECTED, + } + + local wrap = args.wrap or DEFAULT_WRAP + local frame_width = args.frame_width or DEFAULT_FRAME_WIDTH + local frame_height = args.frame_height or DEFAULT_FRAME_HEIGHT + local labels = args.labels or DEFAULT_LABELS + local selected = args.selected or DEFAULT_SELECTED + + local panel = Panel{ + frame = {t=0}, + subviews={ + widgets.TabBar { + frame = {t=0}, + labels = labels, + wrap = wrap, + key = 'CUSTOM_ALT_S', + key_back = 'CUSTOM_ALT_A', + on_select=function(idx) selected = idx end, + get_cur_page=function() return selected end, + }, + } + } + + function fs:init() + self:addviews{ + panel + } + end + + local framed_screen = fs{ + frame_width = frame_width, + frame_height = frame_height, + } + local tab_bar = framed_screen.subviews[1].subviews[1] + return tab_bar, framed_screen +end + +function test.tabsElement() + local tb = setup() + local tabsElement = tb:tabsElement() + + local tabsElementSubviewCount = #tabsElement.subviews + + expect.eq(tabsElementSubviewCount, 4, 'tabsElement should have 4 subviews (one for each Tab)') + + for i = 1, tabsElementSubviewCount do + expect.eq(tabsElement.subviews[i].label, tb.labels[i], 'tabsElement subview text should match label') + end +end + +function test.scroll_elements_do_not_exist_when_wrap_is_true() + local tb = setup({wrap = true}) + + expect.eq(tb:scrollLeftElement(), nil, 'scroll elements should exist when wrap is true') + expect.eq(tb:scrollRightElement(), nil, 'scroll elements should exist when wrap is true') +end + +function test.tabs_element_frame_height_increases_with_number_of_tab_rows() + local tb = setup({wrap = true}) + + expect.eq(tb:tabsElement().frame.h, 4, 'tabsElement height should be equal to the number of rows to fit all tabs * 2') + + tb = setup({ + wrap = true, + labels = {'Foo', 'Bar', 'Baz', 'Qux', 'Foo2', 'Bar2', 'Baz2', 'Qux2', 'Foo3', 'Bar3', 'Baz3', 'Qux3'}, + }) + + expect.eq(tb:tabsElement().frame.h, 12, 'tabsElement height should be equal to the number of rows to fit all tabs * 2') +end + +function test.tab_on_select_called_when_tab_is_clicked() + local tb = setup() + local secondTab = tb:tabsElement().subviews[2] + + secondTab.on_select = mock.observe_func(secondTab.on_select) + secondTab.getMousePos = function() return {x=0, y=0} end + + secondTab:onInput({_MOUSE_L = true, _MOUSE_L_DOWN = true}) + + expect.eq(secondTab.on_select.call_count, 1, 'on_select should be called when tab is clicked') +end + +function test.tab_on_select_not_called_without_mouse_pos() + local tb = setup() + local secondTab = tb:tabsElement().subviews[2] + + secondTab.on_select = mock.observe_func(secondTab.on_select) + secondTab.getMousePos = function() return nil end + + secondTab:onInput({_MOUSE_L = true, _MOUSE_L_DOWN = true}) + + expect.eq(secondTab.on_select.call_count, 0, 'on_select should not be called when getMousePos returns nil') +end + +local TO_THE_RIGHT = string.char(16) +local TO_THE_LEFT = string.char(17) + +function test.scrollLeftElement() + local tb = setup() + local scrollLeftElement = tb:scrollLeftElement() + + expect.eq(scrollLeftElement.text, TO_THE_LEFT, 'scrollLeftElement should have text "' .. TO_THE_LEFT .. '"') +end + +function test.scrollRightElement() + local tb = setup() + local scrollRightElement = tb:scrollRightElement() + + expect.eq(scrollRightElement.text, TO_THE_RIGHT, 'scrollRightElement should have text "' .. TO_THE_RIGHT .. '"') +end + +function test.leftScrollVisible() + local tb = setup() + + tb.scroll_offset = 0 + + expect.eq(tb:leftScrollVisible(), false, 'leftScrollVisible should return false when scroll_offset is 0') + + tb.scroll_offset = -1 + expect.eq(tb:leftScrollVisible(), true, 'leftScrollVisible should return true when scroll_offset is less than 0') +end + +function test.rightScrollVisible() + local tb = setup() + + tb.scroll_offset = 0 + tb.offset_to_show_last_tab = 50 + + expect.eq(tb:rightScrollVisible(), false, 'rightScrollVisible should return false when scroll_offset is 0') + + tb.scroll_offset = 51 + expect.eq(tb:rightScrollVisible(), true, 'rightScrollVisible should return true when scroll_offset is greater than offset_to_show_last_tab') +end + +function test.showScrollRight() + local tb = setup() + + tb.scroll_offset = 0 + tb.offset_to_show_last_tab = 50 + tb:showScrollRight() + + expect.eq(tb:scrollRightElement().visible, false, 'scroll right element should not be visible') + + tb.scroll_offset = 51 + tb:showScrollRight() + + expect.eq(tb:scrollRightElement().visible, true, 'scroll right element should be visible') +end + +function test.showScrollLeft() + local tb = setup() + + tb.scroll_offset = 0 + tb:showScrollLeft() + + expect.eq(tb:scrollLeftElement().visible, false, 'scroll left element should not be visible') + + tb.scroll_offset = -1 + tb:showScrollLeft() + + expect.eq(tb:scrollLeftElement().visible, true, 'scroll left element should be visible') +end + +function test.updateTabPanelPosition() + local tb = setup() + + tb.scroll_offset = 0 + tb:tabsElement().updateLayout = mock.observe_func(tb:tabsElement().updateLayout) + tb:updateTabPanelPosition() + + expect.eq(tb:tabsElement().frame_inset.l, tb.scroll_offset, 'frame_inset.l should be equal to scroll_offset after updateTabPanelPosition') + expect.eq(tb:tabsElement().updateLayout.call_count, 1, 'updateTabPanelPosition should call updateLayout') + + tb.scroll_offset = -50 + tb:tabsElement().updateLayout = mock.observe_func(tb:tabsElement().updateLayout) + tb:updateTabPanelPosition() + + expect.eq(tb:tabsElement().frame_inset.l, tb.scroll_offset, 'frame_inset.l should be equal to scroll_offset after updateTabPanelPosition') + expect.eq(tb:tabsElement().updateLayout.call_count, 1, 'updateTabPanelPosition should call updateLayout') +end + +function test.updateScrollElements() + local tb = setup() + + tb.showScrollLeft = mock.observe_func(tb.showScrollLeft) + tb.showScrollRight = mock.observe_func(tb.showScrollRight) + tb.updateTabPanelPosition = mock.observe_func(tb.updateTabPanelPosition) + + tb:updateScrollElements() + + expect.eq(tb.showScrollLeft.call_count, 1, 'updateScrollElements should call showScrollLeft') + expect.eq(tb.showScrollRight.call_count, 1, 'updateScrollElements should call showScrollRight') + expect.eq(tb.updateTabPanelPosition.call_count, 1, 'updateScrollElements should call updateTabPanelPosition') +end + +function test.scrollLeft() + local tb = setup() + tb.offset_to_show_last_tab = -100 + tb.scroll_step = 25 + + tb.scroll_offset = -50 + tb:showScrollLeft() + + tb.updateScrollElements = mock.observe_func(tb.updateScrollElements) + tb:scrollLeft() + + expect.eq(tb.scroll_offset, -25, 'scroll left should increase scroll offset by scroll step') + expect.eq(tb.updateScrollElements.call_count, 1, 'scroll left should call updateScrollElements') +end + +function test.scrollRight() + local tb = setup() + tb.offset_to_show_last_tab = -100 + tb.scroll_step = 25 + + tb.scroll_offset = -50 + tb:updateScrollElements() + + tb.updateScrollElements = mock.observe_func(tb.updateScrollElements) + tb.updateTabPanelPosition = mock.observe_func(tb.updateTabPanelPosition) + tb:scrollRight() + + expect.eq(tb.scroll_offset, -75, 'scroll right should decrease scroll offset by scroll step') + expect.eq(tb.updateScrollElements.call_count, 1, 'scroll right should call updateScrollElements') +end + +function test.capScrollOffset() + local tb = setup() + tb.offset_to_show_last_tab = -100 + + tb.scroll_offset = -50 + tb:capScrollOffset() + + expect.eq(tb.scroll_offset, -50, 'capScrollOffset should not change scroll offset when it is within bounds') + + tb.scroll_offset = -101 + tb:capScrollOffset() + + expect.eq(tb.scroll_offset, -100, 'capScrollOffset should set scroll offset to -100 when it is less than -100') + + tb.scroll_offset = 0 + tb:capScrollOffset() + + expect.eq(tb.scroll_offset, 0, 'capScrollOffset should not change scroll offset when it is 0') + + tb.scroll_offset = 1 + tb:capScrollOffset() + + expect.eq(tb.scroll_offset, 0, 'capScrollOffset should set scroll offset to 0 when it is greater than 0') +end + +function test.scrollTabIntoView() + local tb = setup() + + tb.scroll_step = 10 + + tb:scrollTabIntoView(1) + expect.eq(tb.scroll_offset, 0, 'scrollTabIntoView should not change scroll offset when tab is already in view') + + tb:scrollTabIntoView(2) + expect.eq(tb.scroll_offset, 0, 'scrollTabIntoView should not change scroll offset when tab is already in view') + + tb:scrollTabIntoView(4) + expect.eq(tb.scroll_offset, tb.offset_to_show_last_tab, 'scrollTabIntoView should scroll to the right when tab is to the right of the view') + + tb:scrollTabIntoView(3) + expect.eq(tb.scroll_offset, tb.offset_to_show_last_tab, 'scrollTabIntoView should not change scroll offset when tab is already in view') + + tb:scrollTabIntoView(1) + expect.eq(tb.scroll_offset, 0, 'scrollTabIntoView should scroll to the left when tab is to the left of the view') +end + +function test.selected_tab_scrolled_into_view_on_first_render() + local tb = setup({selected=4}) + + expect.eq(tb.scroll_offset, tb.offset_to_show_last_tab, + 'scroll offset should be set to offset_to_show_last_tab on first render to ensure current tab is visible' + ) + + tb = setup({selected=1}) + + expect.eq(tb.scroll_offset, 0, + 'scroll offset should be set to 0 on first render to ensure current tab is visible' + ) + + tb = setup({selected=3}) + + expect.gt(tb.scroll_offset, tb.offset_to_show_last_tab, + 'scroll offset should be greather than offset_to_show_last_tab' + ) + expect.lt(tb.scroll_offset, 0, + 'scroll offset should be less than 0' + ) +end + +function test.fastStep() + local tb = setup() + + tb.scroll_step = 10 + tb.fast_scroll_modifier = 2 + expect.eq(tb:fastStep(), 20, 'fastStep should return scroll_step * fast_scroll_modifier') + + tb.scroll_step = 5 + tb.fast_scroll_modifier = 3 + expect.eq(tb:fastStep(), 15, 'fastStep should return scroll_step * fast_scroll_modifier') +end + +function test.scroll_controls_do_nothing_when_wrap_true() + local tb = setup({wrap = true}) + + tb.scroll_offset = tb.offset_to_show_last_tab + local current_scroll_offset = tb.scroll_offset + + tb:onInput({ + CONTEXT_SCROLL_UP = true, + CONTEXT_SCROLL_DOWN = true, + CONTEXT_SCROLL_PAGEUP= true, + CONTEXT_SCROLL_PAGEDOWN = true, + [tb.scroll_key] = true, + [tb.scroll_key_back] = true, + }) + + expect.eq(tb.scroll_offset, current_scroll_offset, 'scroll offset should not change when wrap is true') +end + +function test.mouse_scroll_up_requires_mouse_focus() + local tb = setup() + + tb.scroll_offset = tb.offset_to_show_last_tab + tb:updateScrollElements() + local current_scroll_offset = tb.scroll_offset + + tb.isMouseOver = mock.func(false) + tb:onInput({CONTEXT_SCROLL_UP = true}) + + expect.eq(tb.scroll_offset, current_scroll_offset, 'scroll offset should not change if the mouse is not over the tab bar') + + tb.isMouseOver = mock.func(true) + tb:onInput({CONTEXT_SCROLL_UP = true}) + + expect.gt(tb.scroll_offset, current_scroll_offset, 'scroll offset should increase if the mouse is over the tab bar') +end + +function test.shift_mouse_scroll_up_requires_mouse_focus() + local tb = setup() + + tb.scroll_offset = tb.offset_to_show_last_tab + tb:updateScrollElements() + local current_scroll_offset = tb.scroll_offset + + tb.isMouseOver = mock.func(false) + tb:onInput({CONTEXT_SCROLL_PAGEUP = true}) + + expect.eq(tb.scroll_offset, current_scroll_offset, 'scroll offset should not change if the mouse is not over the tab bar') + + tb.isMouseOver = mock.func(true) + tb:onInput({CONTEXT_SCROLL_PAGEUP = true}) + + expect.gt(tb.scroll_offset, current_scroll_offset, 'scroll offset should increase if the mouse is over the tab bar') +end + +function test.mouse_scroll_down_requires_mouse_focus() + local tb = setup() + + tb.scroll_offset = 0 + tb:updateScrollElements() + local current_scroll_offset = tb.scroll_offset + + tb.isMouseOver = mock.func(false) + tb:onInput({CONTEXT_SCROLL_DOWN = true}) + + expect.eq(tb.scroll_offset, current_scroll_offset, 'scroll offset should not change if the mouse is not over the tab bar') + + tb.isMouseOver = mock.func(true) + tb:onInput({CONTEXT_SCROLL_DOWN = true}) + + expect.lt(tb.scroll_offset, current_scroll_offset, 'scroll offset should decrease if the mouse is over the tab bar') +end + +function test.shift_mouse_scroll_down_requires_mouse_focus() + local tb = setup() + + tb.scroll_offset = 0 + tb:updateScrollElements() + local current_scroll_offset = tb.scroll_offset + + tb.isMouseOver = mock.func(false) + tb:onInput({CONTEXT_SCROLL_PAGEDOWN = true}) + + expect.eq(tb.scroll_offset, current_scroll_offset, 'scroll offset should not change if the mouse is not over the tab bar') + + tb.isMouseOver = mock.func(true) + tb:onInput({CONTEXT_SCROLL_PAGEDOWN = true}) + + expect.lt(tb.scroll_offset, current_scroll_offset, 'scroll offset should decrease if the mouse is over the tab bar') +end + +function test.scroll_key_should_scroll_right() + local tb = setup() + + tb.scroll_offset = 0 + tb:updateScrollElements() + local current_scroll_offset = tb.scroll_offset + + tb:onInput({[tb.scroll_key] = true}) + + expect.lt(tb.scroll_offset, current_scroll_offset, 'scroll offset should decrease when scroll key is pressed') +end + +function test.scroll_key_back_should_scroll_left() + local tb = setup() + + tb.scroll_offset = tb.offset_to_show_last_tab + tb:updateScrollElements() + local current_scroll_offset = tb.scroll_offset + + tb:onInput({[tb.scroll_key_back] = true}) + + expect.gt(tb.scroll_offset, current_scroll_offset, 'scroll offset should increase when scroll key back is pressed') +end + +function test.scrollable_is_false_when_wrap_is_true() + local tb = setup({wrap = true}) + + expect.eq(tb.scrollable, false, 'scrollable should return false when wrap is true') +end + +function test.scrollable_is_false_when_all_tabs_fit_and_wrap_is_false() + local tb = setup({ + frame_width = 100, + frame_height = 100, + }) + + expect.eq(tb.scrollable, false, 'scrollable should return false when all tabs fit in the frame') +end + +function test.scrollable_is_true_when_tabs_do_not_fit_and_wrap_is_false() + local tb = setup() + + expect.eq(tb.scrollable, true, 'scrollable should return true when tabs do not fit in the frame') +end + +function test.key_should_select_next_tab() + local tb = setup({wrap=false}) + + tb:onInput({[tb.key] = true}) + + expect.eq(tb:get_cur_page(), 2, 'key should select the next tab') +end + +function test.key_back_should_select_previous_tab() + local tb = setup({wrap=false}) + + tb:onInput({[tb.key_back] = true}) + + expect.eq(tb:get_cur_page(), 4, 'key back should select the previous tab') +end + +function test.key_should_wrap_to_first_tab_when_on_last_tab() + local tb = setup({wrap=false, selected=4}) + + tb:onInput({[tb.key] = true}) + + expect.eq(tb:get_cur_page(), 1, 'key should wrap to the first tab when on the last tab') +end + +function test.key_back_should_wrap_to_last_tab_when_on_first_tab() + local tb = setup({wrap=false, selected=1}) + + tb:onInput({[tb.key_back] = true}) + + expect.eq(tb:get_cur_page(), 4, 'key back should wrap to the last tab when on the first tab') +end + +function test.key_should_scroll_next_tab_into_view_if_necessary_when_wrap_is_false() + local tb = setup({ + wrap=false, + frame_width=10, + }) + + local scroll_offset_before_input = tb.scroll_offset + tb:onInput({[tb.key] = true}) + + expect.eq(tb:get_cur_page(), 2, 'key should select the next tab') + expect.lt(tb.scroll_offset, scroll_offset_before_input, 'key should scroll the next tab into view') +end + +function test.key_back_should_scroll_previous_tab_into_view_if_necessary_when_wrap_is_false() + local tb = setup({ + selected=4, + wrap=false, + frame_width=10, + }) + + local scroll_offset_before_input = tb.scroll_offset + tb:onInput({[tb.key_back] = true}) + + expect.eq(tb:get_cur_page(), 3, 'key back should select the previous tab') + expect.gt(tb.scroll_offset, scroll_offset_before_input, 'key back should scroll the previous tab into view') +end + +function test.scroll_offset_is_reset_if_width_changes() + local tb = setup({frame_width=10}) + + tb:postComputeFrame({t=0,l=0,width=100}) + + expect.eq(tb.scroll_offset, 0, 'scroll offset should be reset when parent frame width changes') + + tb.scroll_offset = -50 + + tb:postComputeFrame({t=0,l=0,width=100}) + + expect.eq(tb.scroll_offset, -50, 'scroll offset should not be reset when parent frame width does not change') +end + +function test.offset_to_show_last_tab_is_calculated_on_first_render() + local tb = setup({frame_width=10}) + + expect.lt(tb.offset_to_show_last_tab, 0, 'offset_to_show_last_tab should be calculated on first render') +end diff --git a/test/library/gui/widgets.TextArea.lua b/test/library/gui/widgets.TextArea.lua new file mode 100644 index 0000000000..0223b05282 --- /dev/null +++ b/test/library/gui/widgets.TextArea.lua @@ -0,0 +1,3317 @@ +local gui = require('gui') +local widgets = require('gui.widgets') + +config.target = 'core' + +local function simulate_input_keys(...) + local keys = {...} + for _,key in ipairs(keys) do + gui.simulateInput(dfhack.gui.getCurViewscreen(true), key) + end +end + +local function simulate_input_text(text) + local screen = dfhack.gui.getCurViewscreen(true) + + for i = 1, #text do + local charcode = string.byte(text:sub(i,i)) + local code_key = string.format('STRING_A%03d', charcode) + + gui.simulateInput(screen, { [code_key]=true }) + end +end + +local function simulate_mouse_click(element, x, y) + local screen = dfhack.gui.getCurViewscreen(true) + + local g_x, g_y = element.frame_body:globalXY(x, y) + df.global.gps.mouse_x = g_x + df.global.gps.mouse_y = g_y + + if not element.frame_body:inClipGlobalXY(g_x, g_y) then + print('--- Click outside provided element area, re-check the test') + return + end + + gui.simulateInput(screen, { + _MOUSE_L=true, + _MOUSE_L_DOWN=true, + }) + gui.simulateInput(screen, '_MOUSE_L_DOWN') +end + +local function simulate_mouse_drag(element, x_from, y_from, x_to, y_to) + local g_x_from, g_y_from = element.frame_body:globalXY(x_from, y_from) + local g_x_to, g_y_to = element.frame_body:globalXY(x_to, y_to) + + df.global.gps.mouse_x = g_x_from + df.global.gps.mouse_y = g_y_from + + gui.simulateInput(dfhack.gui.getCurViewscreen(true), { + _MOUSE_L=true, + _MOUSE_L_DOWN=true, + }) + gui.simulateInput(dfhack.gui.getCurViewscreen(true), '_MOUSE_L_DOWN') + + df.global.gps.mouse_x = g_x_to + df.global.gps.mouse_y = g_y_to + gui.simulateInput(dfhack.gui.getCurViewscreen(true), '_MOUSE_L_DOWN') +end + +local function arrange_textarea(options) + options = options or {} + + local window_width = 50 + local window_height = 50 + + if options.w then + local border_width = 2 + local scrollbar_width = 3 + local cursor_buffor = 1 + window_width = options.w + border_width + scrollbar_width + cursor_buffor + end + + if options.h then + local border_width = 2 + window_height = options.h + border_width + end + + local screen = gui.ZScreen{} + + screen:addviews({ + widgets.Window{ + view_id='window', + resizable=true, + frame={w=window_width, h=window_height}, + frame_inset=0, + subviews={ + widgets.TextArea{ + view_id='text_area_widget', + init_text=options.text or '', + init_cursor=options.cursor or 1, + frame={l=0,r=0,t=0,b=0}, + on_cursor_change=options.on_cursor_change, + on_text_change=options.on_text_change, + } + } + } + }) + + local window = screen.subviews.window + local text_area = screen.subviews.text_area_widget.text_area + text_area.enable_cursor_blink = false + + screen:show() + screen:onRender() + + return text_area, screen, window, screen.subviews.text_area_widget +end + +local function read_rendered_text(text_area) + text_area.parent_view.parent_view.parent_view:onRender() + + local pen = nil + local text = '' + + local frame_body = text_area.frame_body + + for y=frame_body.clip_y1,frame_body.clip_y2 do + + for x=frame_body.clip_x1,frame_body.clip_x2 do + pen = dfhack.screen.readTile(x, y) + + if pen == nil or pen.ch == nil or pen.ch == 0 or pen.fg == 0 then + break + else + text = text .. string.char(pen.ch) + end + end + + text = text .. '\n' + end + + return text:gsub("\n+$", "") +end + +local function read_selected_text(text_area) + text_area.parent_view.parent_view.parent_view:onRender() + + local pen = nil + local text = '' + + for y=0,text_area.frame_body.height do + local has_sel = false + + for x=0,text_area.frame_body.width do + local g_x, g_y = text_area.frame_body:globalXY(x, y) + pen = dfhack.screen.readTile(g_x, g_y) + + local pen_char = string.char(pen.ch) + if pen == nil or pen.ch == nil or pen.ch == 0 then + break + elseif pen.bg == COLOR_CYAN then + has_sel = true + text = text .. pen_char + end + end + if has_sel then + text = text .. '\n' + end + end + + return text:gsub("\n+$", "") +end + +function test.load() + local text_area, screen = arrange_textarea() + + expect.eq(read_rendered_text(text_area), '_') + + screen:dismiss() +end + +function test.load_input_multiline_text() + local text_area, screen, window = arrange_textarea({w=80}) + + local text = table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Pellentesque dignissim volutpat orci, sed molestie metus elementum vel.', + 'Donec sit amet mattis ligula, ac vestibulum lorem.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), text .. '_') + + screen:dismiss() +end + +function test.handle_numpad_numbers_as_text() + local text_area, screen, window = arrange_textarea({w=80}) + + local text = table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + simulate_input_text(text) + + simulate_input_keys({ + STANDARDSCROLL_LEFT = true, + KEYBOARD_CURSOR_LEFT = true, + _STRING = 52, + STRING_A052 = true, + }) + + expect.eq(read_rendered_text(text_area), text .. '4_') + + simulate_input_keys({ + STRING_A054 = true, + STANDARDSCROLL_RIGHT = true, + KEYBOARD_CURSOR_RIGHT = true, + _STRING = 54, + }) + + expect.eq(read_rendered_text(text_area), text .. '46_') + + simulate_input_keys({ + KEYBOARD_CURSOR_DOWN = true, + STRING_A050 = true, + _STRING = 50, + STANDARDSCROLL_DOWN = true, + }) + + expect.eq(read_rendered_text(text_area), text .. '462_') + + simulate_input_keys({ + KEYBOARD_CURSOR_UP = true, + STRING_A056 = true, + STANDARDSCROLL_UP = true, + _STRING = 56, + }) + + expect.eq(read_rendered_text(text_area), text .. '4628_') + screen:dismiss() +end + +function test.wrap_text_to_available_width() + local text_area, screen, window = arrange_textarea({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor est pellentesque ac.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac._', + }, '\n')); + + screen:dismiss() +end + +function test.submit_new_line() + local text_area, screen, window = arrange_textarea({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('SELECT') + + simulate_input_keys('SELECT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '', + '_', + }, '\n')); + + text_area:setCursor(58) + + simulate_input_keys('SELECT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'el', + '_t.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + -- empty end lines are not rendered + }, '\n')); + + text_area:setCursor(84) + + simulate_input_keys('SELECT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'el', + 'it.', + '112: Sed consectetur,', + -- wrapping changed + '_urna sit amet aliquet egestas, ante nibh porttitor ', + 'mi, vitae rutrum eros metus nec libero.', + -- empty end lines are not rendered + }, '\n')); + + screen:dismiss() +end + +function test.submit_new_line_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=55, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + expect.table_eq(cursor_change, {new=#text + 1, old=#text}) + + simulate_input_keys('SELECT') + expect.table_eq(cursor_change, {new=#text + 2, old=#text + 1}) + + expect.table_eq(text_change, { + new=table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '' + }, '\n'), + old=text + }) + + screen:dismiss() +end + +function test.keyboard_arrow_up_navigation() + local text_area, screen, window = arrange_textarea({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor est pellentesque ac.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim _uismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim li_ero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP') + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor _i, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP') + simulate_input_keys('KEYBOARD_CURSOR_UP') + simulate_input_keys('KEYBOARD_CURSOR_UP') + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur_ urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + screen:dismiss() +end + +function test.keyboard_arrow_up_navigation_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=55, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor est pellentesque ac.', + }, '\n') + + simulate_input_text(text) + expect.table_eq(cursor_change, {new=#text + 1, old=#text}) + + text_change = {old=nil, new=nil} + simulate_input_keys('KEYBOARD_CURSOR_UP') + expect.table_eq(text_change, {new=nil, old=nil}) + + expect.table_eq(cursor_change, {new=284, old=#text + 1}) + + screen:dismiss() +end + +function test.keyboard_arrow_down_navigation() + local text_area, screen, window = arrange_textarea({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor est pellentesque ac.', + }, '\n') + + simulate_input_text(text) + text_area:setCursor(11) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem _psum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit._', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed c_nsectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellen_esque ac.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac._', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin _ignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + screen:dismiss() +end + +function test.keyboard_arrow_down_navigation_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=55, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor est pellentesque ac.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(11) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem _psum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + '41: Etiam id congue urna, vel aliquet mi.', + '45: Nam dignissim libero a interdum porttitor.', + '73: Proin dignissim euismod augue, laoreet porttitor ', + 'est pellentesque ac.', + }, '\n')); + + text_change = {old=nil, new=nil} + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + + expect.table_eq(cursor_change, {new=61, old=11}) + expect.table_eq(text_change, {new=nil, old=nil}) + + screen:dismiss() +end + +function test.keyboard_arrow_left_navigation() + local text_area, screen, window = arrange_textarea({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero_', + }, '\n')); + + for i=1,6 do + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + '_ibero.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec_', + 'libero.', + }, '\n')); + + for i=1,105 do + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit._', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,60 do + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + screen:dismiss() +end + +function test.keyboard_arrow_left_navigation_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=55, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + expect.table_eq(cursor_change, {new=#text + 1, old=#text}) + + text_change = {old=nil, new=nil} + + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + expect.table_eq(cursor_change, {new=#text, old=#text + 1}) + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + expect.table_eq(cursor_change, {new=#text - 1, old=#text}) + + expect.table_eq(text_change, {new=nil, old=nil}) + + screen:dismiss() +end + +function test.keyboard_arrow_right_navigation() + local text_area, screen, window = arrange_textarea({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(1) + + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '6_: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,53 do + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing_', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + '_lit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,5 do + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit._', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,113 do + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + }, '\n')); + + screen:dismiss() +end + +function test.keyboard_arrow_right_navigation_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=55, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + expect.table_eq(cursor_change, {new=#text + 1, old=#text}) + + text_area:setCursor(1) + expect.table_eq(cursor_change, {new=1, old=#text + 1}) + + text_change = {old=nil, new=nil} + + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + expect.table_eq(cursor_change, {new=2, old=1}) + + expect.table_eq(text_change, {new=nil, old=nil}) + + screen:dismiss() +end + +function test.handle_backspace() + local text_area, screen, window = arrange_textarea({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('STRING_A000') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero_', + }, '\n')); + + for i=1,3 do + simulate_input_keys('STRING_A000') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec lib_', + }, '\n')); + + text_area:setCursor(62) + + simulate_input_keys('STRING_A000') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit._12: Sed consectetur, urna sit amet aliquet ', + 'egestas, ante nibh porttitor mi, vitae rutrum eros ', + 'metus nec lib', + }, '\n')); + + text_area:setCursor(2) + + simulate_input_keys('STRING_A000') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.112: Sed consectetur, urna sit amet aliquet ', + 'egestas, ante nibh porttitor mi, vitae rutrum eros ', + 'metus nec lib', + }, '\n')); + + simulate_input_keys('STRING_A000') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.112: Sed consectetur, urna sit amet aliquet ', + 'egestas, ante nibh porttitor mi, vitae rutrum eros ', + 'metus nec lib', + }, '\n')); + + screen:dismiss() +end + +function test.handle_backspace_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=55, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + expect.table_eq(cursor_change, {new=#text + 1, old=#text}) + + simulate_input_keys('STRING_A000') + expect.table_eq(cursor_change, {new=#text, old=#text + 1}) + + expect.table_eq(text_change, {new=text:sub(1, -2), old=text}) + + screen:dismiss() +end + +function test.handle_delete() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(1) + + simulate_input_keys('CUSTOM_DELETE') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(124) + simulate_input_keys('CUSTOM_DELETE') + + expect.eq(read_rendered_text(text_area), table.concat({ + '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + '_rttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(123) + simulate_input_keys('CUSTOM_DELETE') + + expect.eq(read_rendered_text(text_area), table.concat({ + '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante ', + 'nibh_rttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(171) + simulate_input_keys('CUSTOM_DELETE') + + expect.eq(read_rendered_text(text_area), table.concat({ + '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante ', + 'nibhorttitor mi, vitae rutrum eros metus nec libero._0: Lorem ', + 'ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + for i=1,59 do + simulate_input_keys('CUSTOM_DELETE') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante ', + 'nibhorttitor mi, vitae rutrum eros metus nec libero._', + }, '\n')); + + simulate_input_keys('CUSTOM_DELETE') + + expect.eq(read_rendered_text(text_area), table.concat({ + '0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante ', + 'nibhorttitor mi, vitae rutrum eros metus nec libero._', + }, '\n')); + + screen:dismiss() +end + +function test.handle_delete_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=65, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(1) + + cursor_change = {old=nil, new=nil} + + simulate_input_keys('CUSTOM_DELETE') + + expect.table_eq(cursor_change, {new=nil, old=nil}) + expect.table_eq(text_change, {new=text:sub(2), old=text}) + + screen:dismiss() +end + +function test.line_end() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + text_area:setCursor(1) + + simulate_input_keys('CUSTOM_END') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(70) + + simulate_input_keys('CUSTOM_END') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero._', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(200) + + simulate_input_keys('CUSTOM_END') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_input_keys('CUSTOM_END') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + screen:dismiss() +end + +function test.line_end_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=65, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + text_area:setCursor(1) + expect.table_eq(cursor_change, {new=1, old=#text + 1}) + + text_change = {old=nil, new=nil} + + simulate_input_keys('CUSTOM_END') + expect.table_eq(cursor_change, {new=61, old=1}) + expect.table_eq(text_change, {new=nil, old=nil}) + + screen:dismiss() +end + +function test.line_beging() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_HOME') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(173) + + simulate_input_keys('CUSTOM_HOME') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '_12: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + text_area:setCursor(1) + + simulate_input_keys('CUSTOM_HOME') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + screen:dismiss() +end + +function test.line_beging_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=65, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + expect.table_eq(cursor_change, {new=#text + 1, old=#text}) + + text_change = {old=nil, new=nil} + + simulate_input_keys('CUSTOM_HOME') + expect.table_eq(cursor_change, {new=#text + 1 - 60, old=#text + 1}) + expect.table_eq(text_change, {new=nil, old=nil}) + + screen:dismiss() +end + +function test.line_delete() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(65) + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '_' + }, '\n')); + + text_area:setCursor(1) + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_' + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_' + }, '\n')); + + screen:dismiss() +end + +function test.line_delete_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=65, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(65) + expect.table_eq(cursor_change, {new=65, old=#text + 1}) + + simulate_input_keys('CUSTOM_CTRL_U') + expect.table_eq(cursor_change, {new=62, old=65}) + + expect.table_eq(text_change, { + new=table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n'), + old=text + }) + + screen:dismiss() +end + +function test.line_delete_to_end() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(70) + + simulate_input_keys('CUSTOM_CTRL_K') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed_', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_K') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + }, '\n')); + + screen:dismiss() +end + +function test.line_delete_to_end_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=65, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(70) + + cursor_change = {old=nil, new=nil} + + simulate_input_keys('CUSTOM_CTRL_K') + printall(cursor_change) + expect.table_eq(cursor_change, {new=nil, old=nil}) + + expect.table_eq(text_change, { + new=table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n'), + old=text + }) + + screen:dismiss() +end + +function test.delete_last_word() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing _', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur _', + }, '\n')); + + text_area:setCursor(82) + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed _ urna sit amet aliquet egestas, ante nibh porttitor ', + 'mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur ', + }, '\n')); + + text_area:setCursor(37) + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, _ctetur adipiscing elit.', + '112: Sed , urna sit amet aliquet egestas, ante nibh porttitor ', + 'mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur ', + }, '\n')); + + for i=1,6 do + simulate_input_keys('CUSTOM_CTRL_W') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '_ctetur adipiscing elit.', + '112: Sed , urna sit amet aliquet egestas, ante nibh porttitor ', + 'mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur ', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_ctetur adipiscing elit.', + '112: Sed , urna sit amet aliquet egestas, ante nibh porttitor ', + 'mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur ', + }, '\n')); + + screen:dismiss() +end + +function test.delete_last_word_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=65, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + expect.table_eq(cursor_change, {new=#text + 1, old=#text}) + + simulate_input_keys('CUSTOM_CTRL_W') + expect.table_eq(cursor_change, {new=#text + 1 - 5, old=#text + 1}) + + expect.table_eq(text_change, { + new=text:sub(1, -6), + old=text + }) + + screen:dismiss() +end + +function test.jump_to_text_end() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(1) + + simulate_input_keys('CUSTOM_CTRL_END') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_END') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + screen:dismiss() +end + +function test.jump_to_text_end_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=65, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + text_area:setCursor(1) + expect.table_eq(cursor_change, {new=1, old=#text + 1}) + + text_change = {old=nil, new=nil} + + simulate_input_keys('CUSTOM_CTRL_END') + expect.table_eq(cursor_change, {new=#text +1, old=1}) + expect.table_eq(text_change, {new=nil, old=nil}) + + screen:dismiss() +end + +function test.jump_to_text_begin() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_HOME') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_HOME') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + screen:dismiss() +end + +function test.jump_to_text_begin_callbacks() + local cursor_change = {old=nil, new=nil} + local text_change = {old=nil, new=nil} + local text_area, screen, window = arrange_textarea({ + w=65, + on_cursor_change=function (_cursor, _old_cursor) + cursor_change = {old=_old_cursor, new=_cursor} + end, + on_text_change=function (_text, _old_text) + text_change = {old=_old_text, new=_text} + end, + }) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + expect.table_eq(cursor_change, {new=#text + 1, old=#text}) + + text_change = {old=nil, new=nil} + + simulate_input_keys('CUSTOM_CTRL_HOME') + expect.table_eq(cursor_change, {new=1, old=#text + 1}) + expect.table_eq(text_change, {new=nil, old=nil}) + + screen:dismiss() +end + +function test.select_all() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + screen:dismiss() +end + +function test.text_key_replace_selection() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 9, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem '); + + simulate_input_text('+') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: +_psum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 6, 1, 6, 2) + + simulate_input_text('!') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: +ipsum dolor sit amet, consectetur adipiscing elit.', + '112: S!_r mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 3, 1, 6, 2) + + simulate_input_text('@') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: +ipsum dolor sit amet, consectetur adipiscing elit.', + '112@_m ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + screen:dismiss() +end + +function test.arrows_reset_selection() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero._', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_A') + + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_A') + + simulate_input_keys('KEYBOARD_CURSOR_UP') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_A') + + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + expect.eq(read_selected_text(text_area), '') + + screen:dismiss() +end + +function test.click_reset_selection() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero._', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_mouse_click(text_area, 4, 0) + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_A') + + simulate_mouse_click(text_area, 4, 8) + expect.eq(read_selected_text(text_area), '') + + screen:dismiss() +end + +function test.line_navigation_reset_selection() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero._', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_input_keys('CUSTOM_HOME') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_END') + expect.eq(read_selected_text(text_area), '') + + screen:dismiss() +end + +function test.jump_begin_or_end_reset_selection() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero._', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_HOME') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_END') + expect.eq(read_selected_text(text_area), '') + + screen:dismiss() +end + +function test.new_line_override_selection() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 29, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum ero', + }, '\n')); + + simulate_input_keys('SELECT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ', + '_ metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + screen:dismiss() +end + +function test.backspace_delete_selection() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 29, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum ero', + }, '\n')); + + simulate_input_keys('STRING_A000') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: _ metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + screen:dismiss() +end + +function test.delete_char_delete_selection() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 29, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum ero', + }, '\n')); + + simulate_input_keys('CUSTOM_DELETE') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: _ metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + screen:dismiss() +end + +function test.delete_line_delete_selection_lines() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 9, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem '); + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_12: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 4, 1, 29, 2) + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_1: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + screen:dismiss() +end + +function test.delete_line_rest_delete_selection_lines() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 9, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem '); + + simulate_input_keys('CUSTOM_CTRL_K') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: _', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 6, 1, 6, 2) + + simulate_input_keys('CUSTOM_CTRL_K') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ', + '112: S_', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 3, 1, 6, 2) + + simulate_input_keys('CUSTOM_CTRL_K') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ', + '112_', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + screen:dismiss() +end + +function test.delete_last_word_delete_selection() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 9, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem '); + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: _psum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 6, 1, 6, 2) + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ipsum dolor sit amet, consectetur adipiscing elit.', + '112: S_r mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + simulate_mouse_drag(text_area, 3, 1, 6, 2) + + simulate_input_keys('CUSTOM_CTRL_W') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ipsum dolor sit amet, consectetur adipiscing elit.', + '112_m ipsum dolor sit amet, consectetur adipiscing elit.', + '51: Sed consectetur, urna sit amet aliquet egestas.', + }, '\n')); + + screen:dismiss() +end + +function test.single_mouse_click_set_cursor() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_click(text_area, 4, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: _orem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 40, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus ne_ libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 49, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero._', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 60, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero._', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 0, 10) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 21, 10) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor_sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 63, 10) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + screen:dismiss() +end + +function test.double_mouse_click_select_word() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_mouse_click(text_area, 0, 0) + simulate_mouse_click(text_area, 0, 0) + + expect.eq(read_selected_text(text_area), '60:') + + simulate_mouse_click(text_area, 4, 0) + simulate_mouse_click(text_area, 4, 0) + + expect.eq(read_selected_text(text_area), 'Lorem') + + simulate_mouse_click(text_area, 40, 2) + simulate_mouse_click(text_area, 40, 2) + + expect.eq(read_selected_text(text_area), 'nec') + + simulate_mouse_click(text_area, 58, 3) + simulate_mouse_click(text_area, 58, 3) + expect.eq(read_selected_text(text_area), 'elit') + + simulate_mouse_click(text_area, 60, 3) + simulate_mouse_click(text_area, 60, 3) + expect.eq(read_selected_text(text_area), '.') + + screen:dismiss() +end + +function test.double_mouse_click_select_white_spaces() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = 'Lorem ipsum dolor sit amet, consectetur elit.' + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), text .. '_') + + simulate_mouse_click(text_area, 29, 0) + simulate_mouse_click(text_area, 29, 0) + + expect.eq(read_selected_text(text_area), ' ') + + screen:dismiss() +end + +function test.triple_mouse_click_select_line() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_mouse_click(text_area, 0, 0) + simulate_mouse_click(text_area, 0, 0) + simulate_mouse_click(text_area, 0, 0) + + expect.eq( + read_selected_text(text_area), + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + ) + + simulate_mouse_click(text_area, 4, 0) + simulate_mouse_click(text_area, 4, 0) + simulate_mouse_click(text_area, 4, 0) + + expect.eq( + read_selected_text(text_area), + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + ) + + simulate_mouse_click(text_area, 40, 2) + simulate_mouse_click(text_area, 40, 2) + simulate_mouse_click(text_area, 40, 2) + + expect.eq(read_selected_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_mouse_click(text_area, 58, 3) + simulate_mouse_click(text_area, 58, 3) + simulate_mouse_click(text_area, 58, 3) + + expect.eq( + read_selected_text(text_area), + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + ) + + simulate_mouse_click(text_area, 60, 3) + simulate_mouse_click(text_area, 60, 3) + simulate_mouse_click(text_area, 60, 3) + + expect.eq( + read_selected_text(text_area), + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + ) + + screen:dismiss() +end + +function test.mouse_selection_control() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 29, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem ipsum dolor sit amet') + + simulate_mouse_drag(text_area, 0, 0, 29, 0) + + expect.eq(read_selected_text(text_area), '60: Lorem ipsum dolor sit amet') + + simulate_mouse_drag(text_area, 32, 0, 32, 1) + + expect.eq(read_selected_text(text_area), table.concat({ + 'consectetur adipiscing elit.', + '112: Sed consectetur, urna sit am' + }, '\n')); + + simulate_mouse_drag(text_area, 32, 1, 48, 2) + + expect.eq(read_selected_text(text_area), table.concat({ + 'met aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_mouse_drag(text_area, 42, 2, 59, 3) + + expect.eq(read_selected_text(text_area), table.concat({ + 'libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + }, '\n')); + + simulate_mouse_drag(text_area, 42, 2, 65, 3) + + expect.eq(read_selected_text(text_area), table.concat({ + 'libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + }, '\n')); + + simulate_mouse_drag(text_area, 42, 2, 65, 6) + + expect.eq(read_selected_text(text_area), table.concat({ + 'libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.' + }, '\n')); + + simulate_mouse_drag(text_area, 42, 2, 42, 6) + + expect.eq(read_selected_text(text_area), table.concat({ + 'libero.', + '60: Lorem ipsum dolor sit amet, consectetur' + }, '\n')); + + screen:dismiss() +end + +function test.copy_and_paste_text_line() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_C') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_mouse_click(text_area, 15, 3) + simulate_input_keys('CUSTOM_CTRL_C') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum_dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 5, 0) + simulate_input_keys('CUSTOM_CTRL_C') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '112: _ed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 6, 0) + simulate_input_keys('CUSTOM_CTRL_C') + simulate_mouse_click(text_area, 5, 6) + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: L_rem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + screen:dismiss() +end + +function test.copy_and_paste_selected_text() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 8, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem') + + simulate_input_keys('CUSTOM_CTRL_C') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem_ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 4, 2) + + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLorem_itor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 0, 0) + + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Lorem_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLoremtitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 60, 4) + + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Lorem60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLoremtitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.Lorem_', + }, '\n')); + + screen:dismiss() +end + +function test.cut_and_paste_text_line() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit._', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_X') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '_', + }, '\n')); + + simulate_mouse_click(text_area, 0, 0) + simulate_input_keys('CUSTOM_CTRL_X') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_click(text_area, 60, 2) + simulate_input_keys('CUSTOM_CTRL_X') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '_', + }, '\n')); + + screen:dismiss() +end + +function test.cut_and_paste_selected_text() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + + simulate_mouse_drag(text_area, 4, 0, 8, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + expect.eq(read_selected_text(text_area), 'Lorem') + + simulate_input_keys('CUSTOM_CTRL_X') + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem_ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_drag(text_area, 4, 0, 8, 0) + simulate_input_keys('CUSTOM_CTRL_X') + + simulate_mouse_click(text_area, 4, 2) + + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLorem_itor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_drag(text_area, 5, 2, 8, 2) + simulate_input_keys('CUSTOM_CTRL_X') + + simulate_mouse_click(text_area, 0, 0) + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'orem_0: ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLtitor mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + simulate_mouse_drag(text_area, 5, 2, 8, 2) + simulate_input_keys('CUSTOM_CTRL_X') + + simulate_mouse_click(text_area, 60, 4) + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'orem60: ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'portLr mi, vitae rutrum eros metus nec libero.', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.tito_', + }, '\n')); + + screen:dismiss() +end + +function test.scroll_long_text() + local text_area, screen, window, widget = arrange_textarea({w=100, h=10}) + local scrollbar = widget.scrollbar + + local text = table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + 'Nam scelerisque ligula vitae magna varius, vel porttitor tellus egestas.', + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + '18: Vestibulum at ante ut dui hendrerit pellentesque ut eu ex.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + '18: Vestibulum at ante ut dui hendrerit pellentesque ut eu ex._', + }, '\n')) + + simulate_mouse_click(scrollbar, 0, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + }, '\n')) + + simulate_mouse_click(scrollbar, 0, 0) + simulate_mouse_click(scrollbar, 0, 0) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + }, '\n')) + + simulate_mouse_click(scrollbar, 0, scrollbar.frame_body.height - 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + '18: Vestibulum at ante ut dui hendrerit pellentesque ut eu ex._', + }, '\n')) + + simulate_mouse_click(scrollbar, 0, 2) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n')) + + screen:dismiss() +end + +function test.scroll_follows_cursor() + local text_area, screen, window = arrange_textarea({w=100, h=10}) + local scrollbar = window.subviews.text_area_scrollbar + + local text = table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + 'Nam scelerisque ligula vitae magna varius, vel porttitor tellus egestas.', + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + '18: Vestibulum at ante ut dui hendrerit pellentesque ut eu ex.', + }, '\n') + + simulate_input_text(text) + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + '18: Vestibulum at ante ut dui hendrerit pellentesque ut eu ex._', + }, '\n')) + + simulate_mouse_click(text_area, 0, 8) + simulate_input_keys('KEYBOARD_CURSOR_UP') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_nteger tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + 'Morbi id mauris dignissim, suscipit metus nec, auctor odio.', + 'Sed in libero eget velit condimentum lacinia ut quis dui.', + 'Praesent sollicitudin dui ac mollis lacinia.', + 'Ut gravida tortor ac accumsan suscipit.', + }, '\n')) + + simulate_input_keys('CUSTOM_CTRL_HOME') + + simulate_mouse_click(text_area, 0, 9) + simulate_input_keys('KEYBOARD_CURSOR_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Nulla ut lacus ut tortor semper consectetur.', + 'Nam scelerisque ligula vitae magna varius, vel porttitor tellus egestas.', + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '_onec quis lectus ac erat placerat eleifend.', + }, '\n')) + + simulate_mouse_click(text_area, 44, 10) + simulate_input_keys('KEYBOARD_CURSOR_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Nam scelerisque ligula vitae magna varius, vel porttitor tellus egestas.', + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + '_enean non orci id erat malesuada pharetra.', + }, '\n')) + + simulate_mouse_click(text_area, 0, 2) + simulate_input_keys('KEYBOARD_CURSOR_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Nulla ut lacus ut tortor semper consectetur._', + 'Nam scelerisque ligula vitae magna varius, vel porttitor tellus egestas.', + 'Suspendisse aliquet dolor ac velit maximus, ut tempor lorem tincidunt.', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + 'Donec quis lectus ac erat placerat eleifend.', + }, '\n')) + + screen:dismiss() +end + +function test.fast_rewind_words_right() + local text_area, screen, window = arrange_textarea({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + text_area:setCursor(1) + + simulate_input_keys('CUSTOM_CTRL_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60:_Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem_ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,6 do + simulate_input_keys('CUSTOM_CTRL_RIGHT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing_', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit._', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112:_Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,17 do + simulate_input_keys('CUSTOM_CTRL_RIGHT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_RIGHT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + }, '\n')); + + screen:dismiss() +end + +function test.fast_rewind_words_left() + local text_area, screen, window = arrange_textarea({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + '_ibero.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus _ec ', + 'libero.', + }, '\n')); + + for i=1,8 do + simulate_input_keys('CUSTOM_CTRL_LEFT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + '_nte nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet _gestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,16 do + simulate_input_keys('CUSTOM_CTRL_LEFT') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_LEFT') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + screen:dismiss() +end + +function test.fast_rewind_reset_selection() + local text_area, screen, window = arrange_textarea({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero._', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_input_keys('CUSTOM_CTRL_LEFT') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_A') + + simulate_input_keys('CUSTOM_CTRL_RIGHT') + expect.eq(read_selected_text(text_area), '') + + screen:dismiss() +end + +function test.render_text_set_by_api() + local text_area, screen, window, widget = arrange_textarea({w=80}) + + local text = table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Pellentesque dignissim volutpat orci, sed molestie metus elementum vel.', + 'Donec sit amet mattis ligula, ac vestibulum lorem.', + }, '\n') + + widget:setText(text) + widget:setCursor(#text + 1) + + expect.eq(read_rendered_text(text_area), text .. '_') + + screen:dismiss() +end + +function test.undo_redo_keyboard_changes() + local text_area, screen, window, widget = arrange_textarea({w=80}) + + local text = table.concat({ + 'Lorem ipsum dolor sit amet. ', + }, '\n') + + function reset_text() + text_area:setText(text) + text_area:setCursor(#text + 1) + end + + reset_text() + + -- undo single char + simulate_input_text('A') + + expect.eq(read_rendered_text(text_area), text .. 'A_') + + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), text .. '_') + + simulate_input_keys('CUSTOM_CTRL_Y') + + expect.eq(read_rendered_text(text_area), text .. 'A_') + + -- undo fast written text as group + reset_text() + simulate_input_text('123') + + expect.eq(read_rendered_text(text_area), text .. '123_') + + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), text .. '_') + + simulate_input_keys('CUSTOM_CTRL_Y') + + expect.eq(read_rendered_text(text_area), text .. '123_') + + -- undo cut feature + reset_text() + simulate_input_text('123') + + expect.eq(read_rendered_text(text_area), text .. '123_') + + simulate_input_keys('CUSTOM_CTRL_A') + simulate_input_keys('CUSTOM_CTRL_X') + + expect.eq(read_rendered_text(text_area), '_') + + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), text .. '123_') + + simulate_input_keys('CUSTOM_CTRL_Y') + + expect.eq(read_rendered_text(text_area), '_') + + -- undo paste feature + reset_text() + + simulate_input_keys('CUSTOM_CTRL_V') + + expect.eq(read_rendered_text(text_area), text .. text .. '123_') + + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), text .. '_') + + simulate_input_keys('CUSTOM_CTRL_Y') + + expect.eq(read_rendered_text(text_area), text .. text .. '123_') + + -- undo enter + reset_text() + + simulate_input_keys('SELECT') + + expect.eq(read_rendered_text(text_area), text .. '\n_') + + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), text .. '_') + + simulate_input_keys('CUSTOM_CTRL_Y') + + expect.eq(read_rendered_text(text_area), text .. '\n_') + + -- undo backspace + reset_text() + + simulate_input_keys('STRING_A000') + + expect.eq(read_rendered_text(text_area), text:sub(1, #text - 1) .. '_') + + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), text .. '_') + + simulate_input_keys('CUSTOM_CTRL_Y') + + expect.eq(read_rendered_text(text_area), text:sub(1, #text - 1) .. '_') + + -- undo line delete + reset_text() + + simulate_input_keys('CUSTOM_CTRL_U') + + expect.eq(read_rendered_text(text_area), '_') + + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), text .. '_') + + simulate_input_keys('CUSTOM_CTRL_Y') + + expect.eq(read_rendered_text(text_area), '_') + + -- undo delete rest of line + reset_text() + + text_area:setCursor(5) + local expected_text = text:sub(1, 4) .. '_' .. text:sub(6, #text) + expect.eq(read_rendered_text(text_area), expected_text) + + simulate_input_keys('CUSTOM_CTRL_K') + + expect.eq(read_rendered_text(text_area), text:sub(1, 4) .. '_') + + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), expected_text) + + simulate_input_keys('CUSTOM_CTRL_Y') + + expect.eq(read_rendered_text(text_area), text:sub(1, 4) .. '_') + + -- undo delete char + reset_text() + + text_area:setCursor(5) + expect.eq(read_rendered_text(text_area), expected_text) + + simulate_input_keys('CUSTOM_DELETE') + + expect.eq( + read_rendered_text(text_area), + text:sub(1, 4) .. '_' .. text:sub(7, #text) + ) + + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), expected_text) + + simulate_input_keys('CUSTOM_CTRL_Y') + + expect.eq( + read_rendered_text(text_area), + text:sub(1, 4) .. '_' .. text:sub(7, #text) + ) + + -- undo delete last word + reset_text() + + simulate_input_keys('CUSTOM_CTRL_W') + expect.eq(read_rendered_text(text_area), 'Lorem ipsum dolor sit _') + + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), text .. '_') + + simulate_input_keys('CUSTOM_CTRL_Y') + + expect.eq(read_rendered_text(text_area), 'Lorem ipsum dolor sit _') + + -- undo API setText + reset_text() + + widget:clearHistory() + widget:setText('Random new text') + widget:setCursor(1) + expect.eq(read_rendered_text(text_area), '_andom new text') + + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), text .. '_') + + simulate_input_keys('CUSTOM_CTRL_Y') + + expect.eq(read_rendered_text(text_area), '_andom new text') + + screen:dismiss() +end + +function test.clear_undo_redo_history() + local text_area, screen, window, widget = arrange_textarea({w=80}) + + local text = table.concat({ + 'Lorem ipsum dolor sit amet. ', + }, '\n') + + text_area:setText(text) + text_area:setCursor(#text + 1) + + simulate_input_text('A') + simulate_input_text(' ') + simulate_input_text('longer text') + + expect.eq(read_rendered_text(text_area), text .. 'A longer text_') + + widget:clearHistory() + simulate_input_keys('CUSTOM_CTRL_Z') + + expect.eq(read_rendered_text(text_area), text .. 'A longer text_') + + screen:dismiss() +end diff --git a/test/modules/job.lua b/test/modules/job.lua index 518cb26a3a..6f7090e731 100644 --- a/test/modules/job.lua +++ b/test/modules/job.lua @@ -1,3 +1,5 @@ +local utils = require('utils') + config.target = 'core' config.mode = 'title' -- alters world state, not safe when a world is loaded @@ -17,3 +19,28 @@ function test.removeJob() expect.true_(dfhack.job.removeJob(job)) expect.nil_(df.global.world.jobs.list.next, 'job list is not empty after removeJob()') end + +-- EventManager job completion handling expects sorted order +function test.jobIDsAreSortedAfterAdd() + local job1 = df.job:new() + dfhack.job.linkIntoWorld(job1) + + local job2 = df.job:new() + dfhack.job.linkIntoWorld(job2) + + local is_sorted = true + local prev_id = nil + + for _, job in utils.listpairs(df.global.world.jobs.list) do + if prev_id and job.id < prev_id then + is_sorted = false + break + end + prev_id = job.id + end + + dfhack.job.removeJob(job1) + dfhack.job.removeJob(job2) + + expect.true_(is_sorted) +end diff --git a/test/modules/job_fortress.lua b/test/modules/job_fortress.lua new file mode 100644 index 0000000000..6f3047c8bf --- /dev/null +++ b/test/modules/job_fortress.lua @@ -0,0 +1,22 @@ +local utils = require('utils') + +config.target = 'core' +config.mode = 'fortress' + +-- EventManager job completion handling expects sorted order +function test.jobIDsAreSorted() + local is_sorted = true + local prev_id = nil + + -- assumes there are at least some "naturally added" jobs currently in the list + -- but this should always be true for CI test saves + for _, job in utils.listpairs(df.global.world.jobs.list) do + if prev_id and job.id < prev_id then + is_sorted = false + break + end + prev_id = job.id + end + + expect.true_(is_sorted) +end