first commit

This commit is contained in:
Mossfet 2023-03-17 23:15:56 +00:00
commit 7efc5b5bfa
39 changed files with 1948 additions and 0 deletions

28
Cargo.toml Normal file
View File

@ -0,0 +1,28 @@
[package]
name = "mossfets-life"
version = "0.9.1"
authors = ["Mossfet"]
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://git.solarpunk.moe/mossfet/game-of-life-gui"
description = "A GTK4 Conway's Game of Life emulator"
[dependencies]
gettext-rs = { version = "0.7", features = ["gettext-system"] }
relm4-components = "0.5.0-rc.2"
mossfets-game-of-life = "0.7.0"
tokio = "^0"
tracker = "0.1"
async-std = "1"
[dependencies.relm4]
package = "relm4"
version = "0.5.0-rc.2"
features = ["macros", "libadwaita", "gnome_43"]
[dependencies.adw]
package = "libadwaita"
version = "0.2.1"
features = ["v1_2"]
[dependencies.gtk]
package = "gtk4"
version = "^0"
features = ["v4_4"]

View File

@ -0,0 +1,51 @@
{
"id": "com.example.Life.Devel",
"runtime": "org.gnome.Platform",
"runtime-version": "43",
"sdk": "org.gnome.Sdk",
"sdk-extensions": [
"org.freedesktop.Sdk.Extension.rust-nightly",
"org.freedesktop.Sdk.Extension.llvm14"
],
"command": "mossfets-life",
"finish-args": [
"--socket=fallback-x11",
"--socket=wayland",
"--device=dri",
"--env=RUST_LOG=gtk_rust_template=debug",
"--env=G_MESSAGES_DEBUG=none",
"--env=RUST_BACKTRACE=1"
],
"build-options": {
"append-path": "/usr/lib/sdk/rust-nightly/bin:/usr/lib/sdk/llvm14/bin",
"build-args": [
"--share=network"
],
"env": {
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "clang",
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold",
"CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER": "clang",
"CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold"
},
"test-args": [
"--socket=x11",
"--share=network"
]
},
"modules": [
{
"name": "mossfets-life",
"buildsystem": "meson",
"run-tests": true,
"config-opts": [
"-Dprofile=development"
],
"sources": [
{
"type": "dir",
"path": "../"
}
]
}
]
}

View File

@ -0,0 +1,50 @@
{
"id": "com.example.Life",
"runtime": "org.gnome.Platform",
"runtime-version": "43",
"sdk": "org.gnome.Sdk",
"sdk-extensions": [
"org.freedesktop.Sdk.Extension.rust-nightly",
"org.freedesktop.Sdk.Extension.llvm14"
],
"command": "mossfets-life",
"finish-args": [
"--socket=fallback-x11",
"--socket=wayland",
"--device=dri",
"--env=G_MESSAGES_DEBUG=none",
"--env=RUST_BACKTRACE=1"
],
"build-options": {
"append-path": "/usr/lib/sdk/rust-nightly/bin:/usr/lib/sdk/llvm14/bin",
"build-args": [
"--share=network"
],
"env": {
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "clang",
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold",
"CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER": "clang",
"CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold"
},
"test-args": [
"--socket=x11",
"--share=network"
]
},
"modules": [
{
"name": "mossfets-life",
"buildsystem": "meson",
"run-tests": true,
"config-opts": [
"-Dprofile=default"
],
"sources": [
{
"type": "dir",
"path": "../"
}
]
}
]
}

10
build-aux/dist-vendor.sh Normal file
View File

@ -0,0 +1,10 @@
#!/bin/bash
export DIST="$1"
export SOURCE_ROOT="$2"
cd "$SOURCE_ROOT"
mkdir "$DIST"/.cargo
cargo vendor | sed 's/^directory = ".*"/directory = "vendor"/g' > $DIST/.cargo/config
# Move vendor into dist tarball directory
mv vendor "$DIST"

View File

@ -0,0 +1,12 @@
[Desktop Entry]
Name=Mossfet's Life
Comment=A GTK4 Game of Life emulator
Type=Application
Exec=mossfets-life
Terminal=false
Categories=GNOME;GTK;
# Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon!
Keywords=Gnome;GTK;
# Translators: Do NOT translate or transliterate this text (this is an icon file name)!
Icon=@icon@
StartupNotify=true

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<schemalist>
<schema path="/com/example/Life/" id="@app-id@" gettext-domain="@gettext-package@">
<key name="window-width" type="i">
<default>600</default>
<summary>Window width</summary>
</key>
<key name="window-height" type="i">
<default>400</default>
<summary>Window height</summary>
</key>
<key name="is-maximized" type="b">
<default>false</default>
<summary>Window maximized state</summary>
</key>
<key name="theme-selected" type="u">
<default>2</default>
<summary>Dark Mode</summary>
</key>
</schema>
</schemalist>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Mossfet 2023 -->
<component type="desktop-application">
<id>@app-id@</id>
<metadata_license>CC0</metadata_license>
<!-- Insert your license of choice here -->
<project_license>GPL-3.0+</project_license>
<name>Mossfet's Life</name>
<summary>A GTK4 Game of Life emulator</summary>
<description>
<p>A Conway's Game of Life emulator written in Rust and GTK4. </p>
</description>
<url type="homepage">https://git.solarpunk.moe/mossfet/game-of-life-gui</url>
<url type="bugtracker">https://git.solarpunk.moe/mossfet/game-of-life-gui/issues</url>
<content_rating type="oars-1.0" />
<releases>
<release version="0.8.0" date="2023-01-26" />
</releases>
<kudos>
<!--
GNOME Software kudos:
https://gitlab.gnome.org/GNOME/gnome-software/-/blob/main/doc/kudos.md
-->
</kudos>
<developer_name>Mossfet</developer_name>
<translation type="gettext">@gettext-package@</translation>
<launchable type="desktop-id">@app-id@.desktop</launchable>
</component>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1">
<defs>
<filter id="alpha" filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">
<feColorMatrix type="matrix" in="SourceGraphic" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
</filter>
<mask id="mask0">
<g filter="url(#alpha)">
<rect x="0" y="0" width="16" height="16" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip1">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10818" clip-path="url(#clip1)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 123.503906 236 C 123.503906 268.863281 96.863281 295.503906 64 295.503906 C 31.136719 295.503906 4.496094 268.863281 4.496094 236 C 4.496094 203.136719 31.136719 176.496094 64 176.496094 C 96.863281 176.496094 123.503906 203.136719 123.503906 236 Z M 123.503906 236 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask1">
<g filter="url(#alpha)">
<rect x="0" y="0" width="16" height="16" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip2">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10821" clip-path="url(#clip2)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 29.195312 180.496094 L 98.804688 180.496094 C 103.609375 180.496094 107.503906 184.046875 107.503906 188.425781 L 107.503906 283.574219 C 107.503906 287.953125 103.609375 291.503906 98.804688 291.503906 L 29.195312 291.503906 C 24.390625 291.503906 20.496094 287.953125 20.496094 283.574219 L 20.496094 188.425781 C 20.496094 184.046875 24.390625 180.496094 29.195312 180.496094 Z M 29.195312 180.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask2">
<g filter="url(#alpha)">
<rect x="0" y="0" width="16" height="16" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip3">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10824" clip-path="url(#clip3)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 20.417969 184.496094 L 107.582031 184.496094 C 111.957031 184.496094 115.503906 188.042969 115.503906 192.417969 L 115.503906 279.582031 C 115.503906 283.957031 111.957031 287.503906 107.582031 287.503906 L 20.417969 287.503906 C 16.042969 287.503906 12.496094 283.957031 12.496094 279.582031 L 12.496094 192.417969 C 12.496094 188.042969 16.042969 184.496094 20.417969 184.496094 Z M 20.417969 184.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask3">
<g filter="url(#alpha)">
<rect x="0" y="0" width="16" height="16" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip4">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10827" clip-path="url(#clip4)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 16.425781 200.496094 L 111.574219 200.496094 C 115.953125 200.496094 119.503906 204.390625 119.503906 209.195312 L 119.503906 278.804688 C 119.503906 283.609375 115.953125 287.503906 111.574219 287.503906 L 16.425781 287.503906 C 12.046875 287.503906 8.496094 283.609375 8.496094 278.804688 L 8.496094 209.195312 C 8.496094 204.390625 12.046875 200.496094 16.425781 200.496094 Z M 16.425781 200.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
</defs>
<g id="surface10764">
<rect x="0" y="0" width="16" height="16" style="fill:rgb(94.117647%,94.117647%,94.117647%);fill-opacity:1;stroke:none;"/>
<use xlink:href="#surface10818" transform="matrix(1,0,0,1,-168,-16)" mask="url(#mask0)"/>
<use xlink:href="#surface10821" transform="matrix(1,0,0,1,-168,-16)" mask="url(#mask1)"/>
<use xlink:href="#surface10824" transform="matrix(1,0,0,1,-168,-16)" mask="url(#mask2)"/>
<use xlink:href="#surface10827" transform="matrix(1,0,0,1,-168,-16)" mask="url(#mask3)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,147 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="128px" height="128px" viewBox="0 0 128 128" version="1.1">
<defs>
<filter id="alpha" filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">
<feColorMatrix type="matrix" in="SourceGraphic" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
</filter>
<mask id="mask0">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip1">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10726" clip-path="url(#clip1)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 123.503906 236 C 123.503906 268.863281 96.863281 295.503906 64 295.503906 C 31.136719 295.503906 4.496094 268.863281 4.496094 236 C 4.496094 203.136719 31.136719 176.496094 64 176.496094 C 96.863281 176.496094 123.503906 203.136719 123.503906 236 Z M 123.503906 236 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask1">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip2">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10729" clip-path="url(#clip2)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 29.195312 180.496094 L 98.804688 180.496094 C 103.609375 180.496094 107.503906 184.046875 107.503906 188.425781 L 107.503906 283.574219 C 107.503906 287.953125 103.609375 291.503906 98.804688 291.503906 L 29.195312 291.503906 C 24.390625 291.503906 20.496094 287.953125 20.496094 283.574219 L 20.496094 188.425781 C 20.496094 184.046875 24.390625 180.496094 29.195312 180.496094 Z M 29.195312 180.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask2">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip3">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10732" clip-path="url(#clip3)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 20.417969 184.496094 L 107.582031 184.496094 C 111.957031 184.496094 115.503906 188.042969 115.503906 192.417969 L 115.503906 279.582031 C 115.503906 283.957031 111.957031 287.503906 107.582031 287.503906 L 20.417969 287.503906 C 16.042969 287.503906 12.496094 283.957031 12.496094 279.582031 L 12.496094 192.417969 C 12.496094 188.042969 16.042969 184.496094 20.417969 184.496094 Z M 20.417969 184.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask3">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip4">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10735" clip-path="url(#clip4)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 16.425781 200.496094 L 111.574219 200.496094 C 115.953125 200.496094 119.503906 204.390625 119.503906 209.195312 L 119.503906 278.804688 C 119.503906 283.609375 115.953125 287.503906 111.574219 287.503906 L 16.425781 287.503906 C 12.046875 287.503906 8.496094 283.609375 8.496094 278.804688 L 8.496094 209.195312 C 8.496094 204.390625 12.046875 200.496094 16.425781 200.496094 Z M 16.425781 200.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask5">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip7">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10726" clip-path="url(#clip7)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 123.503906 236 C 123.503906 268.863281 96.863281 295.503906 64 295.503906 C 31.136719 295.503906 4.496094 268.863281 4.496094 236 C 4.496094 203.136719 31.136719 176.496094 64 176.496094 C 96.863281 176.496094 123.503906 203.136719 123.503906 236 Z M 123.503906 236 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask6">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip8">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10729" clip-path="url(#clip8)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 29.195312 180.496094 L 98.804688 180.496094 C 103.609375 180.496094 107.503906 184.046875 107.503906 188.425781 L 107.503906 283.574219 C 107.503906 287.953125 103.609375 291.503906 98.804688 291.503906 L 29.195312 291.503906 C 24.390625 291.503906 20.496094 287.953125 20.496094 283.574219 L 20.496094 188.425781 C 20.496094 184.046875 24.390625 180.496094 29.195312 180.496094 Z M 29.195312 180.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask7">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip9">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10732" clip-path="url(#clip9)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 20.417969 184.496094 L 107.582031 184.496094 C 111.957031 184.496094 115.503906 188.042969 115.503906 192.417969 L 115.503906 279.582031 C 115.503906 283.957031 111.957031 287.503906 107.582031 287.503906 L 20.417969 287.503906 C 16.042969 287.503906 12.496094 283.957031 12.496094 279.582031 L 12.496094 192.417969 C 12.496094 188.042969 16.042969 184.496094 20.417969 184.496094 Z M 20.417969 184.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask8">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip10">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10735" clip-path="url(#clip10)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 16.425781 200.496094 L 111.574219 200.496094 C 115.953125 200.496094 119.503906 204.390625 119.503906 209.195312 L 119.503906 278.804688 C 119.503906 283.609375 115.953125 287.503906 111.574219 287.503906 L 16.425781 287.503906 C 12.046875 287.503906 8.496094 283.609375 8.496094 278.804688 L 8.496094 209.195312 C 8.496094 204.390625 12.046875 200.496094 16.425781 200.496094 Z M 16.425781 200.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<clipPath id="clip6">
<rect x="0" y="0" width="128" height="128"/>
</clipPath>
<g id="surface10750" clip-path="url(#clip6)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(94.117647%,94.117647%,94.117647%);fill-opacity:1;stroke:none;"/>
<use xlink:href="#surface10726" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask5)"/>
<use xlink:href="#surface10729" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask6)"/>
<use xlink:href="#surface10732" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask7)"/>
<use xlink:href="#surface10735" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask8)"/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(38.431373%,62.7451%,91.764706%);stroke-opacity:1;stroke-miterlimit:4;" d="M 0 289 L 128 289 " transform="matrix(1,0,0,1,0,-172)"/>
</g>
<clipPath id="clip5">
<rect x="0" y="0" width="128" height="128"/>
</clipPath>
<g id="surface10753" clip-path="url(#clip5)" filter="url(#alpha)">
<use xlink:href="#surface10750"/>
</g>
<mask id="mask4">
<use xlink:href="#surface10753"/>
</mask>
<mask id="mask9">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.8;stroke:none;"/>
</g>
</mask>
<linearGradient id="linear0" gradientUnits="userSpaceOnUse" x1="300" y1="235" x2="428" y2="235" gradientTransform="matrix(0.000000000000000023,0.37,-0.98462,0.00000000000000006,295.38501,-30.360001)">
<stop offset="0" style="stop-color:rgb(97.647059%,94.117647%,41.960785%);stop-opacity:1;"/>
<stop offset="1" style="stop-color:rgb(96.078432%,76.078433%,6.666667%);stop-opacity:1;"/>
</linearGradient>
<clipPath id="clip12">
<rect x="0" y="0" width="128" height="128"/>
</clipPath>
<g id="surface10747" clip-path="url(#clip12)">
<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear0);" d="M 128 80.640625 L 128 128 L 0 128 L 0 80.640625 Z M 128 80.640625 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,0%);fill-opacity:1;" d="M 13.308594 80.640625 L 60.664062 128 L 81.878906 128 L 34.519531 80.640625 Z M 55.730469 80.640625 L 103.09375 128 L 124.308594 128 L 76.945312 80.640625 Z M 98.160156 80.640625 L 128 110.480469 L 128 89.269531 L 119.371094 80.640625 Z M 0 88.546875 L 0 109.761719 L 18.238281 128 L 39.453125 128 Z M 0 88.546875 "/>
</g>
<clipPath id="clip11">
<rect x="0" y="0" width="128" height="128"/>
</clipPath>
<g id="surface10752" clip-path="url(#clip11)">
<use xlink:href="#surface10747" mask="url(#mask9)"/>
</g>
</defs>
<g id="surface10672">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(94.117647%,94.117647%,94.117647%);fill-opacity:1;stroke:none;"/>
<use xlink:href="#surface10726" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask0)"/>
<use xlink:href="#surface10729" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask1)"/>
<use xlink:href="#surface10732" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask2)"/>
<use xlink:href="#surface10735" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask3)"/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(38.431373%,62.7451%,91.764706%);stroke-opacity:1;stroke-miterlimit:4;" d="M 0 289 L 128 289 " transform="matrix(1,0,0,1,0,-172)"/>
<use xlink:href="#surface10752" mask="url(#mask4)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="128px" height="128px" viewBox="0 0 128 128" version="1.1">
<defs>
<filter id="alpha" filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">
<feColorMatrix type="matrix" in="SourceGraphic" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
</filter>
<mask id="mask0">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip1">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10632" clip-path="url(#clip1)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 123.503906 236 C 123.503906 268.863281 96.863281 295.503906 64 295.503906 C 31.136719 295.503906 4.496094 268.863281 4.496094 236 C 4.496094 203.136719 31.136719 176.496094 64 176.496094 C 96.863281 176.496094 123.503906 203.136719 123.503906 236 Z M 123.503906 236 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask1">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip2">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10635" clip-path="url(#clip2)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 29.195312 180.496094 L 98.804688 180.496094 C 103.609375 180.496094 107.503906 184.046875 107.503906 188.425781 L 107.503906 283.574219 C 107.503906 287.953125 103.609375 291.503906 98.804688 291.503906 L 29.195312 291.503906 C 24.390625 291.503906 20.496094 287.953125 20.496094 283.574219 L 20.496094 188.425781 C 20.496094 184.046875 24.390625 180.496094 29.195312 180.496094 Z M 29.195312 180.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask2">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip3">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10638" clip-path="url(#clip3)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 20.417969 184.496094 L 107.582031 184.496094 C 111.957031 184.496094 115.503906 188.042969 115.503906 192.417969 L 115.503906 279.582031 C 115.503906 283.957031 111.957031 287.503906 107.582031 287.503906 L 20.417969 287.503906 C 16.042969 287.503906 12.496094 283.957031 12.496094 279.582031 L 12.496094 192.417969 C 12.496094 188.042969 16.042969 184.496094 20.417969 184.496094 Z M 20.417969 184.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask3">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip4">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10641" clip-path="url(#clip4)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 16.425781 200.496094 L 111.574219 200.496094 C 115.953125 200.496094 119.503906 204.390625 119.503906 209.195312 L 119.503906 278.804688 C 119.503906 283.609375 115.953125 287.503906 111.574219 287.503906 L 16.425781 287.503906 C 12.046875 287.503906 8.496094 283.609375 8.496094 278.804688 L 8.496094 209.195312 C 8.496094 204.390625 12.046875 200.496094 16.425781 200.496094 Z M 16.425781 200.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
</defs>
<g id="surface10578">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(94.117647%,94.117647%,94.117647%);fill-opacity:1;stroke:none;"/>
<use xlink:href="#surface10632" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask0)"/>
<use xlink:href="#surface10635" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask1)"/>
<use xlink:href="#surface10638" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask2)"/>
<use xlink:href="#surface10641" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask3)"/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(38.431373%,62.7451%,91.764706%);stroke-opacity:1;stroke-miterlimit:4;" d="M 0 289 L 128 289 " transform="matrix(1,0,0,1,0,-172)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

10
data/icons/meson.build Normal file
View File

@ -0,0 +1,10 @@
install_data(
'@0@.svg'.format(application_id),
install_dir: iconsdir / 'hicolor' / 'scalable' / 'apps'
)
install_data(
'@0@-symbolic.svg'.format(base_id),
install_dir: iconsdir / 'hicolor' / 'symbolic' / 'apps',
rename: '@0@-symbolic.svg'.format(application_id)
)

76
data/meson.build Normal file
View File

@ -0,0 +1,76 @@
subdir('icons')
subdir('resources')
# Desktop file
desktop_conf = configuration_data()
desktop_conf.set('icon', application_id)
desktop_file = i18n.merge_file(
type: 'desktop',
input: configure_file(
input: '@0@.desktop.in.in'.format(base_id),
output: '@BASENAME@',
configuration: desktop_conf
),
output: '@0@.desktop'.format(application_id),
po_dir: podir,
install: true,
install_dir: datadir / 'applications'
)
# Validate Desktop file
if desktop_file_validate.found()
test(
'validate-desktop',
desktop_file_validate,
args: [
desktop_file.full_path()
],
depends: desktop_file,
)
endif
# Appdata
appdata_conf = configuration_data()
appdata_conf.set('app-id', application_id)
appdata_conf.set('gettext-package', gettext_package)
appdata_file = i18n.merge_file(
input: configure_file(
input: '@0@.metainfo.xml.in.in'.format(base_id),
output: '@BASENAME@',
configuration: appdata_conf
),
output: '@0@.metainfo.xml'.format(application_id),
po_dir: podir,
install: true,
install_dir: datadir / 'metainfo'
)
# Validate Appdata
if appstream_util.found()
test(
'validate-appdata', appstream_util,
args: [
'validate', '--nonet', appdata_file.full_path()
],
depends: appdata_file,
)
endif
# GSchema
gschema_conf = configuration_data()
gschema_conf.set('app-id', application_id)
gschema_conf.set('gettext-package', gettext_package)
configure_file(
input: '@0@.gschema.xml.in'.format(base_id),
output: '@0@.gschema.xml'.format(application_id),
configuration: gschema_conf,
install: true,
install_dir: datadir / 'glib-2.0' / 'schemas'
)
# Validata GSchema
if glib_compile_schemas.found()
test(
'validate-gschema', glib_compile_schemas,
args: [
'--strict', '--dry-run', meson.current_build_dir()
],
)
endif

View File

@ -0,0 +1,9 @@
# Resources
resources = gnome.compile_resources(
'resources',
'resources.gresource.xml',
gresource_bundle: true,
source_dir: meson.current_build_dir(),
install: true,
install_dir: pkgdatadir,
)

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/com/example/Life/">
<!-- see https://gtk-rs.org/gtk4-rs/git/docs/gtk4/struct.Application.html#automatic-resources -->
<file compressed="true" preprocess="xml-stripblanks" alias="gtk/help-overlay.ui">ui/shortcuts.ui</file>
<file compressed="true">style.css</file>
</gresource>
</gresources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

4
data/resources/style.css Normal file
View File

@ -0,0 +1,4 @@
.title-header{
font-size: 36px;
font-weight: bold;
}

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<object class="GtkShortcutsWindow" id="help_overlay">
<property name="modal">True</property>
<child>
<object class="GtkShortcutsSection">
<property name="section-name">shortcuts</property>
<property name="max-height">10</property>
<child>
<object class="GtkShortcutsGroup">
<property name="title" translatable="yes" context="shortcut window">General</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes" context="shortcut window">Show Shortcuts</property>
<property name="action-name">win.show-help-overlay</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes" context="shortcut window">Quit</property>
<property name="action-name">app.quit</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

154
devlog/log.md Normal file
View File

@ -0,0 +1,154 @@
## Overview
I will be implementing Conways Game of Life, a cellular automaton and zero-player game with the following rules:
The game takes place on a two-dimensional orthogonal grid, extending infinitely in all four directions. On a given grid cell, there are two possible states - alive and dead. The grid has a starting condition, the only interaction a human has with the system. The state of the grid on a given turn informs the state on the next turn like so:
* A live cell with fewer than two live neighbours (orthogonally or diagonally) becomes dead (starvation).
* A live cell with greater than three live neighbours becomes dead (overpopulation).
* A dead cell with exactly three live neighbours becomes alive (reproduction).
* Otherwise, the state of the cell remains unchanged.
* From these rules, more complex systems can be constructed. Game of Life is Turing-complete, meaning it is possible to construct a computer that can solve any computable problem (including simulating Game of Life) given enough time and memory.
While it is possible to carry out the rules by hand, this problem lends itself well to being calculated computationally due to its repetitive nature. It becomes very tedious and time-consuming for a human to simulate the game for more complex systems, and computers can calculate Life rules quickly and accurately.
I intend to write the program in Rust, due to its speed, advanced standard library, and object-oriented capabilities.
## Features
The application should have the following features
* The program should be able to display cells on a grid, providing UI buttons to step forwards, backwards, and run the game on a timestep. This is a necessary feature of interacting with Life.
* The program should allow the user to change cells at a given point, and clear the grid, which are necessary features to create custom patterns.
* The program should be able to open files from the internet. This allows users to open complex patterns without editing them in themself.
* The program should be able to save patterns into a standard file format compatible with other Life programs. This allows users to retain patterns without leaving the program open.
* The program should provide a set of example presets, allowing an easy onboarding process for users unfamiliar with Life.
* The program should be reasonably performant, which is necessary for a comfortable user experience.
I have chosen not to provide some features provided by more complex Life apps, such as variable timesteps, stepping multiple frames at once, and advanced board editing features, like those found in programs like Golly and LifeViewer. This not only saves program complexity but also allows for a simple UI consistent with other GNOME applications, which tend to sacrifice on advanced features in favour of a consistent and uncomplicated UI. Overall, this places the program inbetween the complexity of simple emulators like playgameoflife.com, and the aformentioned advanced emulators.
## Stakeholders
My first stakeholder is my uncle, who I will be asking for feedback with both the functionality and UX of the program. He is a competent programmer, and as a result will be able to compile it rather than be provided a package, and is able to use Linux, meaning I don't need to support other platforms, cutting down on complexity. He is not part of the Life hobbyist community and does not need advanced features used for very complex patterns.
My second stakeholder is a person seeking to implement their own application that implements the Life logic through my logic code, but does not need my UI. They will be able to compile the program, require good documentation for the logic code's functions, and require the logic code to be shipped seperately from the UI code. For example, the developer may want to develop an app with a different UI library, such as Qt, or may want to develop an alternative to my UI for Windows or macOS. For this reason, the logic code needs to be completely agnostic to both UI toolkit and operating system, including for interactions with the OS such as threading and file access. They may require more complex features, so the logic code should not be opinionated on things like timestep or editing features. Ideally I would ensure that it is ISA-independent and works on 32-bit machines, but I lack the hardware with which to test this.
My third stakeholder is a Linux user who uses the GNOME desktop environment and has limited technical skills. They will likely be uncomfortable installing the Rust toolchain and compiling from source, and will expect an easy-to-use package such as a Flatpak. They will expect the application to be consistent with the rest of their (GNOME) desktop, and as such I will have to use GNOME technologies and patterns (GTK4, libadwaita, Flatpak, HIG, etc). I should reference other GNOME applications and mimic their UI for consistency.
## Limitations
One of the main concerns with how this project will be implemented is the stepping backwards feature. It is not possible to determine the previous state of a grid from the current state - multiple states may result in the same state upon the next turn, so information is lost. I can see three possible solutions:
1. Store an entire history of the board (potentially as a linked list from end to beginning), which expands with each step. This would allow for fast retrieval of previous states (stepping back one turn having a time complexity of O(1)), but the programs memory usage would grow with each step, until the program would eventually run out of memory.
2. Only store the state of the two most recent turns. This allows a user to step between the current and last state for comparison, but not further than that. This is very lightweight and as I already intended to store the state with a double-buffer, is very easy to implement, but is limited in use, as it can only go back one.
3. The program keeps track of the initial state of the game, and to return to a previous state, simply steps through the program again. This is space-efficient (only storing the first and current state), but will take longer to execute depending on how many turns the user has taken, a time complexity of O(n) where the state to be reverted to is the nth state since the initial state.
I ultimately opted for the third option. Not only is it simply to implement, but was not ultimately very slow except for very high iteration counts. A major limitation of this is that it is very wasteful for stepping back multiple turns - if the developer wanted to add a "step back by n turns" function to their own application, they may need to fork my library.
A second limitation is that my program does not optimise for stepping multiple turns at once. This makes sense for my UI, and a developer seeking a library that optimises for that should turn to significantly more complex libraries implementing algorithms such as HashLife, which is fast but considerably too complex to implement for an NEA.
Rust's safety features also made it difficult to implement some features that improve quality of life for developers interacting with the library. For example, it was functionally impossible to implement the `Index` trait for a game or board, due to Rust's strict ownership rules, meaning a developer cannot use easy indexing syntax (`board[2][1]`) to interact with a board. This is not a huge problem, but is inconvenient.
## Success Criteria
* The user should be able to open the program to an empty grid.
* The user should be able to edit the grid by clicking on cells to change state.
* The user should be able to clear the board through a UI button.
* The user should be able to step forwards, backwards, and "run", stepping the board forwards repeatedly on a fixed timestep. The step should not noticeably slow down as the program runs, and should match Life's rules.
* The program should be performant and run on low-spec computers, as well as not having any limit to grid size likely to be reached in a normal usecase.
* The program should be written in GTK4, run at least on Linux, and follow conventions for GNOME apps such as adding less frequently used actions to the menu in the top-right of the screen, have an about popup, and use client-side decorations (see the GNOME HIG).
* The program should be stable and not crash or leak memory. If an action fails, the user should be notified through the toasts standard on GNOME apps, rather than exit or fail silently.
* The program should be able to open files with at least one commonly used Life file format.
* The program should be able to save files with at least one commonly used Life file format that it can open.
* The UI and logic code should be cleanly separated, and the logic code should be downloadable as a separate package for use in Rust applications in the same way as it is used in my own application
* The logic code should be cross-platform
* The logic code should be well-documented and handle errors gracefully in a way idiomatic to Rust
* The application should be packaged as a Flatpak.
## Implementation
The program will have a `Game` class to represent the grids state and handle methods to change it. The grid will be implemented as a hashset using a tuple of two integers as keys. This means that the only information stored is the indices of live cells. For other cells, there is no value. This allows for two things - a sparse matrix - with no space taken up for dead cells - and an infinite matrix, allowing the game to stretch on in any dimension.
To find out the next value of a cell, the neighbouring indices are checked for whether they exist in the hashset, and the entry for the cells indices is created, removed, or left accordingly. To avoid checking over large areas of empty space, only hashset entries and their neighbours are checked. The program will step through one at a time, which leaves out some more optimised algorithms but is appropriate for the program's UI, which will not have multiple-step functionality.
The `Game` class will hold a second grid, advancing the game with a double buffer - the second grids next state is based on the state of the current grid, and then they are swapped. This allows going to the previous state by loading the second grid, but another strategy is needed to go further back than that (see limitations). The class will implement methods for advancing, reverting, and indexing. This is separated from UI logic, which I will implement in GTK. The UI could retrieve all of the elements it needs through requesting a slice of the overall matrix, from a top-left coordinate to a bottom-right coordinate.
## Beginning
My initial problem was the issue of storing a grid of infinite size in a program. While the size is infinite, the game will only ever have a finite number of live cells, making a hashset an ideal data structure for representing this. A hashset is a hashmap that does not store any value associated with each key, only whether an entry for a given key exists. I can use a tuple of integers as the hashset, giving it a grid size with 2^128 cells. My user will not be reaching the end of this, I think. (Rust has a bigint library, but I wasn't convinced that coordinates of this size were necessary, as no user would realistically reach the edge).
The `Game` struct contains a few fields. `primary_board` contains a `HashSet` representing the current state of the board. `secondary_board` contains a `HashSet` representing the prior state. They are swapped out for one another in a double buffer. `initial_state` is another `HashSet` representing the earliest tracked change to the board. This is done to implement reverting the board state.
When advancing to the next turn, each eligable cell has its neighbours counted up and applied to the game's rules. It doesn't make sense to check every cell (which is impossible on an infinite board), but thankfully only live cells and their neighbours have the capacity to change state. A "check set" is created, containing each live cell on the board and its neighbours. The set is then collected into a Vector and is split into chunks, with one chunk per core on the machine. A thread is then spawned for each chunk, which is tasked with determining the next state of each cell in its chunk. Once all threads have been completed, the secondary board is cleared and is extended with the values of each thread's results. The primary and secondary boards are then swapped.
The board implements Rust's `Iterator` trait (interface), allowing for lazily evaluated iteration over the game at each turn. While this is not used in the UI code, it can be useful for anyone seeking to use the library in their own project.
## Reverting the board
For reverting the board, the game keeps track of its `initial_state` and a `count` - the nuber of turns since the initial state. To revert a turn, the board reverts to the initial state and advances `count - 1` times. This is not performant at high `count`s, and cannot track before the last manual change (like the player clicking on a tile to flip its state manually), but is relatively simple to implement.
## GUI
The game logic is implemented as a Rust library to be included in other projects. For this reason, it makes sense to implement the GUI in Rust as well. Writing the UI in GTK is important for the third stakeholder, and is make easier here as my application only has to work on Linux. Unfortunately, Rust's default GTK bindings are quite difficult for me to use, as Rust's OOP system is quite different from GObjects, the cross-langauge OOP system that GTK uses. For example, GObjects has both inheritence and polymorphism, whereas Rust does not, opting for extensive use of "traits" (which are analogous to Java interfaces). This makes the GTK bindings cumbersome and full of unintuitive macros. This had stumped me for a while before I discovered Relm4, a Rust library for creating UIs that used gtk-rs as a backend but provided a much more intuitive API.
## Relm4
Relm4 is based on "models", representations of application state, their representations in GTK, and the messages passed between them. The main model for the Game of Life GUI has several messages that it can receive from UI elements - advance the board, revert the board, flip at a coordinate, etc.
Although GTK is not web-based, it uses CSS to style itself. The GNOME project provides the libadwaita library and stylesheets to enforce a consistent and clean look across their apps, which I opted to use. The program opens onto a single window, containing a headerbar with controls for the grid and a grid in the center of the screen.
The grid uses another useful concept that Relm4 has - factories. A factory is a collection of elements that each contain the data for a UI element. Adding an item to the collection (in my case, an item being a struct containing a coordinate and whether a cell is live) is sufficient to add it to the grid.
Two buttons on the left and right of the top bar send advance and revert messages to the app model, while the middle "play" button is a component with its own model, which, upon being clicked, toggles the "running" boolean through the toggle message. When the play button model receives a message, it checks if it is running, and if it is, spawns a thread that sends the advance message to the main model, waits 1 second, then sends a "pass" message to its own model (think of it as a form of recursion). The pass message contains the id of the thread it is called from. The model also contains the ID of the currently running thread, which is compared and does not spawn a thread if the ID does not match. This is to avoid a scenario where the user presses the play button twice in half a second, which ends up spawning two instances of the threads recursing at once. Now, only the most recently spawned will be allowed to continue.
To add cells to the factory, the app model requests a "view" from the game of life library - a list of all live cells between a top-left and bottom-right coordinate. The model then iterates through all coordinates between these two, adds it with the live value set to false if it is not in the view, and to true if it is. The model performs this synchronisation every time it receives a message.
## File access
There are several file formats that board states in game of life can be encoded in. The most popular are "plaintext" and "RLE". RLE uses a form of run-length encoding, which I opted not to use to avoid unnecessary complexity. The other, plaintext, sees each line in the file represent a row on the board, with "." representing a dead cell and "O" representing a live cell. An older format, Life 1.05, uses "\*" as a live cell. My game of life library supports both, and can generate a new game from them. This allows me to load pretty much whatever pattern I want from the internet and test it, which is helpful.
Relm4 provides a widget to allow file selection using the OS's native UI, giving me a path to feed into my reader function. This file selection also works on Flatpaks' sandboxing. Rather than giving the program universal file access, it has to open a dialog controlled by the OS for the user to select a file that they consent to the program accessing.
## Performance and optimisation
Implementing file loading allowed me to conduct more thorough benchmarks. Particularly, I was interested in how the performance of my system would manage with patterns whose live cell counts dramatically increase over time. Space fillers are a type of pattern that expand to fill the grid as quickly as possible. I downloaded a plaintext file for a spacefiller and tried to benchmark how quickly it would slow down. Sadly, the slowdowns were quite quick, with performance dropping off after 60 turns or so. It didn't reach a speed lower than the UI advanced it either way, but I still found it unsatisfactory. Reaching 150 turns took about 5.8 seconds, and I wanted to lower this.
Looking at the `advance_board` function, there were a few places I could see room for improvement. Rather than use a double buffer, with secondary board being drained and filled with the new values, I simplified it by removing the secondary board and simply initialising a new board each update, which lowered memory requirements. I experimented with changing the form of each thread's results from a `HashSet` to a vector, but this had no major effect. The system finds out how many threads it should split itself into by calling the `available_parallelism` function, which queries the system for a core count. This was being done every advance in my current implementation, which the documentation for the function warns against. I moved the call into the board's new function and stored it in the game struct itself, which improved it by about 0.2 seconds. Removing unnecessary prints in my code vastly improved performance. Still, only around 5.2 seconds for 150 iterations on the spacefiller.
I looked for other, more efficient algorithms for life and one that stood out to me was HashLife. HashLife is notable firstly because it's much faster, but also because it does not calculate the board turn-by-turn. It is able to look ahead and calculate several turns at once, which allows for a variety of optimisations. Sadly, the ability to traverse multiple turns does not make sense for my program, and the algorithm is also notoriously complex to implement, and would require me to implement several data structures I'm unfamiliar with, as well as my own garbage collector.
I had also been reccomended using SIMD for further improvements, but I was very unfamiliar with SIMD concepts and wasn't confident in implementing it. I explored simplifying the boolean logic for the neighbour counts, but I found it to have no benefit for performance and decrease readability. I also tried a parallel iteration library called rayon, which allowed me to abstract away the process of chunking and mapping each hashset, which was faster, but I wanted to try to do the game logic with as few libraries as possible.
The Rust Performance Book's section of hashing details that the default hasher used in `HashMap`s and `HashSet`s is relatively safe, but slow. However, it is possible to provide a custom hasher. The book details the rustc-hash crate (a crate is a Rust library), which provides a custom hasher that is considerably faster at the cost of a higher collision rate. Applying this to my board gave a moderate speedup (about 0.6s) with no apparent logic errors. It also mentions the nohash_hasher crate, which is able to use integers as keys verbatim, without hashing. I wanted to see if I could do something similar with my key, a tuple of `isize`s (an isize is a signed integer with size dependent on the architecture). Sadly, no implementation I could provide was faster than rustc-hash, so I didn't pursue it further, but it did give me some useful knowledge of Rust's `Hash` and `Hasher` traits (interfaces).
## Testing
The library has a number of integration tests. These ensure that the rules of Life are followed in the program's simulation. These tests typically involve taking established patterns like the R-Pentomino, advancing them a couple turns, and checking them against a result I took from another simulator. The Linux UI automated testing scene is not well-established, and the application has no tests for ITs UI. Rust's extensive use of static analysis allows many things that would otherwise have been tested for to be caught as compile-time, such as type errors, memory unsafety, and race conditions (Rust is famously impossible to get memory unsafety without an explicit unsafe keyword).
## Packaging
So far, if a user wanted to install the application, they would have to install the Rust toolchain (compiler, package manager, etc), install the necessary GTK development libraries, and compile my program from source, a long and performance-intensive process. This may be acceptable for my first stakeholder, and is considerably easier for the Rust developer (they would already have the toolchain, do not require GTK dependencies, and can compile the library quicker than the full application), but it is not realistic for a casual Linux user with limited technical skills like my third stakeholder.
Fortunately, Relm4 provides a template for building applications into Flatpaks. After integrating the necessary build files and installing runtimes, it becomes possible to build a full Flatpak for my application. Not only does this make my application very easy to install (Flatpak installation is well-documented on almost all Linux systems), but it also provides users a degree of security assurance - for example, Flatpaks do not have file access by default outside of a file picker that the user controls and the application cannot tamper with. This was not a design goal, but is nice to have.
## Documentation
Rust has a built-in documentation system, in which code and appropriate comments are compiled into an HTML page containing documentation for any given type, module, or library. This allows me to document any given function by writing a comment directly above it. My code is well-commented and documentation extends across all types and functions that are exposed in the library's API.
## Software/hardware requirements
As this is a GNOME app, it is only runnable on a Linux system, or through WSL2, which needs some technical knowledge to set up. This is acceptable, as my stakeholder is confident with using Linux. Installing from source requires Rust and several GTK development libraries, which are readily available through most Linux package managers, while Flatpaks make installation significantly easier. Flatpaks can also be installed through Crostini, making the application available on ChromeOS (with some elbow grease). The application is not available for macOS, Android, and iOS. It is untested for Linux phones but should run (though presently the user would not be able to scroll).
It is a lightweight app, with low hardware requirements. The speed at which the board advances is slow enough that the cost of computing the next term is negligable. It will struggle on very low-spec systems due to the requirements of GTK, but works well on my budget Chromebook.
## Success analysis
Overall, almost all of the success criteria have been met, with the exception of providing presets. This is relatively low-impact, as a user can easily find examples of patterns online and open them in the app.
The program's interface is idiomatic for GNOME apps, and any GNOME user should be able to navigate it without surprises. The application is available as a Flatpak, and the logic code is easy to compile and cross-platform. The logic library is both well-documented and idiomatic to a Rust library.
I have a few further goals with which I intend to extend the program after the NEA is complete:
* I will replace some of my own code with libraries, such as having rayon implement multithreading for me
* I will ensure that my program works well with standard Linux screen readers.
* I will ensure that the program works on mobile Linux devices
* I will modify the logic library to allow for custom rulesets.

154
devlog/log.md~ Normal file
View File

@ -0,0 +1,154 @@
## Overview
I will be implementing Conways Game of Life, a cellular automaton and zero-player game with the following rules:
The game takes place on a two-dimensional orthogonal grid, extending infinitely in all four directions. On a given grid cell, there are two possible states - alive and dead. The grid has a starting condition, the only interaction a human has with the system. The state of the grid on a given turn informs the state on the next turn like so:
* A live cell with fewer than two live neighbours (orthogonally or diagonally) becomes dead (starvation).
* A live cell with greater than three live neighbours becomes dead (overpopulation).
* A dead cell with exactly three live neighbours becomes alive (reproduction).
* Otherwise, the state of the cell remains unchanged.
* From these rules, more complex systems can be constructed. Game of Life is Turing-complete, meaning it is possible to construct a computer that can solve any computable problem (including simulating Game of Life) given enough time and memory.
While it is possible to carry out the rules by hand, this problem lends itself well to being calculated computationally due to its repetitive nature. It becomes very tedious and time-consuming for a human to simulate the game for more complex systems, and computers can calculate Life rules quickly and accurately.
I intend to write the program in Rust, due to its speed, advanced standard library, and object-oriented capabilities.
## Features
The application should have the following features
* The program should be able to display cells on a grid, providing UI buttons to step forwards, backwards, and run the game on a timestep. This is a necessary feature of interacting with Life.
* The program should allow the user to change cells at a given point, and clear the grid, which are necessary features to create custom patterns.
* The program should be able to open files from the internet. This allows users to open complex patterns without editing them in themself.
* The program should be able to save patterns into a standard file format compatible with other Life programs. This allows users to retain patterns without leaving the program open.
* The program should provide a set of example presets, allowing an easy onboarding process for users unfamiliar with Life.
* The program should be reasonably performant, which is necessary for a comfortable user experience.
I have chosen not to provide some features provided by more complex Life apps, such as variable timesteps, stepping multiple frames at once, and advanced board editing features, like those found in programs like Golly and LifeViewer. This not only saves program complexity but also allows for a simple UI consistent with other GNOME applications, which tend to sacrifice on advanced features in favour of a consistent and uncomplicated UI. Overall, this places the program inbetween the complexity of simple emulators like playgameoflife.com, and the aformentioned advanced emulators.
## Stakeholders
My first stakeholder is my uncle, who I will be asking for feedback with both the functionality and UX of the program. He is a competent programmer, and as a result will be able to compile it rather than be provided a package, and is able to use Linux, meaning I don't need to support other platforms, cutting down on complexity. He is not part of the Life hobbyist community and does not need advanced features used for very complex patterns.
My second stakeholder is a person seeking to implement their own application that implements the Life logic through my logic code, but does not need my UI. They will be able to compile the program, require good documentation for the logic code's functions, and require the logic code to be shipped seperately from the UI code. For example, the developer may want to develop an app with a different UI library, such as Qt, or may want to develop an alternative to my UI for Windows or macOS. For this reason, the logic code needs to be completely agnostic to both UI toolkit and operating system, including for interactions with the OS such as threading and file access. They may require more complex features, so the logic code should not be opinionated on things like timestep or editing features. Ideally I would ensure that it is ISA-independent and works on 32-bit machines, but I lack the hardware with which to test this.
My third stakeholder is a Linux user who uses the GNOME desktop environment and has limited technical skills. They will likely be uncomfortable installing the Rust toolchain and compiling from source, and will expect an easy-to-use package such as a Flatpak. They will expect the application to be consistent with the rest of their (GNOME) desktop, and as such I will have to use GNOME technologies and patterns (GTK4, libadwaita, Flatpak, HIG, etc). I should reference other GNOME applications and mimic their UI for consistency.
## Limitations
One of the main concerns with how this project will be implemented is the stepping backwards feature. It is not possible to determine the previous state of a grid from the current state - multiple states may result in the same state upon the next turn, so information is lost. I can see three possible solutions:
1. Store an entire history of the board (potentially as a linked list from end to beginning), which expands with each step. This would allow for fast retrieval of previous states (stepping back one turn having a time complexity of O(1)), but the programs memory usage would grow with each step, until the program would eventually run out of memory.
2. Only store the state of the two most recent turns. This allows a user to step between the current and last state for comparison, but not further than that. This is very lightweight and as I already intended to store the state with a double-buffer, is very easy to implement, but is limited in use, as it can only go back one.
3. The program keeps track of the initial state of the game, and to return to a previous state, simply steps through the program again. This is space-efficient (only storing the first and current state), but will take longer to execute depending on how many turns the user has taken, a time complexity of O(n) where the state to be reverted to is the nth state since the initial state.
I ultimately opted for the third option. Not only is it simply to implement, but was not ultimately very slow except for very high iteration counts. A major limitation of this is that it is very wasteful for stepping back multiple turns - if the developer wanted to add a "step back by n turns" function to their own application, they may need to fork my library.
A second limitation is that my program does not optimise for stepping multiple turns at once. This makes sense for my UI, and a developer seeking a library that optimises for that should turn to significantly more complex libraries implementing algorithms such as HashLife, which is fast but considerably too complex to implement for an NEA.
Rust's safety features also made it difficult to implement some features that improve quality of life for developers interacting with the library. For example, it was functionally impossible to implement the `Index` trait for a game or board, due to Rust's strict ownership rules, meaning a developer cannot use easy indexing syntax (`board[2][1]`) to interact with a board. This is not a huge problem, but is inconvenient.
## Success Criteria
* The user should be able to open the program to an empty grid.
* The user should be able to edit the grid by clicking on cells to change state.
* The user should be able to clear the board through a UI button.
* The user should be able to step forwards, backwards, and "run", stepping the board forwards repeatedly on a fixed timestep. The step should not noticeably slow down as the program runs, and should match Life's rules.
* The program should be performant and run on low-spec computers, as well as not having any limit to grid size likely to be reached in a normal usecase.
* The program should be written in GTK4, run at least on Linux, and follow conventions for GNOME apps such as adding less frequently used actions to the menu in the top-right of the screen, have an about popup, and use client-side decorations (see the GNOME HIG).
* The program should be stable and not crash or leak memory. If an action fails, the user should be notified through the toasts standard on GNOME apps, rather than exit or fail silently.
* The program should be able to open files with at least one commonly used Life file format.
* The program should be able to save files with at least one commonly used Life file format that it can open.
* The UI and logic code should be cleanly separated, and the logic code should be downloadable as a separate package for use in Rust applications in the same way as it is used in my own application
* The logic code should be cross-platform
* The logic code should be well-documented and handle errors gracefully in a way idiomatic to Rust
* The application should be packaged as a Flatpak.
## Implementation
The program will have a `Game` class to represent the grids state and handle methods to change it. The grid will be implemented as a hashset using a tuple of two integers as keys. This means that the only information stored is the indices of live cells. For other cells, there is no value. This allows for two things - a sparse matrix - with no space taken up for dead cells - and an infinite matrix, allowing the game to stretch on in any dimension.
To find out the next value of a cell, the neighbouring indices are checked for whether they exist in the hashset, and the entry for the cells indices is created, removed, or left accordingly. To avoid checking over large areas of empty space, only hashset entries and their neighbours are checked. The program will step through one at a time, which leaves out some more optimised algorithms but is appropriate for the program's UI, which will not have multiple-step functionality.
The `Game` class will hold a second grid, advancing the game with a double buffer - the second grids next state is based on the state of the current grid, and then they are swapped. This allows going to the previous state by loading the second grid, but another strategy is needed to go further back than that (see limitations). The class will implement methods for advancing, reverting, and indexing. This is separated from UI logic, which I will implement in GTK. The UI could retrieve all of the elements it needs through requesting a slice of the overall matrix, from a top-left coordinate to a bottom-right coordinate.
## Beginning
My initial problem was the issue of storing a grid of infinite size in a program. While the size is infinite, the game will only ever have a finite number of live cells, making a hashset an ideal data structure for representing this. A hashset is a hashmap that does not store any value associated with each key, only whether an entry for a given key exists. I can use a tuple of integers as the hashset, giving it a grid size with 2^128 cells. My user will not be reaching the end of this, I think. (Rust has a bigint library, but I wasn't convinced that coordinates of this size were necessary, as no user would realistically reach the edge).
The `Game` struct contains a few fields. `primary_board` contains a `HashSet` representing the current state of the board. `secondary_board` contains a `HashSet` representing the prior state. They are swapped out for one another in a double buffer. `initial_state` is another `HashSet` representing the earliest tracked change to the board. This is done to implement reverting the board state.
When advancing to the next turn, each eligable cell has its neighbours counted up and applied to the game's rules. It doesn't make sense to check every cell (which is impossible on an infinite board), but thankfully only live cells and their neighbours have the capacity to change state. A "check set" is created, containing each live cell on the board and its neighbours. The set is then collected into a Vector and is split into chunks, with one chunk per core on the machine. A thread is then spawned for each chunk, which is tasked with determining the next state of each cell in its chunk. Once all threads have been completed, the secondary board is cleared and is extended with the values of each thread's results. The primary and secondary boards are then swapped.
The board implements Rust's `Iterator` trait (interface), allowing for lazily evaluated iteration over the game at each turn. While this is not used in the UI code, it can be useful for anyone seeking to use the library in their own project.
## Reverting the board
For reverting the board, the game keeps track of its `initial_state` and a `count` - the nuber of turns since the initial state. To revert a turn, the board reverts to the initial state and advances `count - 1` times. This is not performant at high `count`s, and cannot track before the last manual change (like the player clicking on a tile to flip its state manually), but is relatively simple to implement.
## GUI
The game logic is implemented as a Rust library to be included in other projects. For this reason, it makes sense to implement the GUI in Rust as well. Writing the UI in GTK is important for the third stakeholder, and is make easier here as my application only has to work on Linux. Unfortunately, Rust's default GTK bindings are quite difficult for me to use, as Rust's OOP system is quite different from GObjects, the cross-langauge OOP system that GTK uses. For example, GObjects has both inheritence and polymorphism, whereas Rust does not, opting for extensive use of "traits" (which are analogous to Java interfaces). This makes the GTK bindings cumbersome and full of unintuitive macros. This had stumped me for a while before I discovered Relm4, a Rust library for creating UIs that used gtk-rs as a backend but provided a much more intuitive API.
## Relm4
Relm4 is based on "models", representations of application state, their representations in GTK, and the messages passed between them. The main model for the Game of Life GUI has several messages that it can receive from UI elements - advance the board, revert the board, flip at a coordinate, etc.
Although GTK is not web-based, it uses CSS to style itself. The GNOME project provides the libadwaita library and stylesheets to enforce a consistent and clean look across their apps, which I opted to use. The program opens onto a single window, containing a headerbar with controls for the grid and a grid in the center of the screen.
The grid uses another useful concept that Relm4 has - factories. A factory is a collection of elements that each contain the data for a UI element. Adding an item to the collection (in my case, an item being a struct containing a coordinate and whether a cell is live) is sufficient to add it to the grid.
Two buttons on the left and right of the top bar send advance and revert messages to the app model, while the middle "play" button is a component with its own model, which, upon being clicked, toggles the "running" boolean through the toggle message. When the play button model receives a message, it checks if it is running, and if it is, spawns a thread that sends the advance message to the main model, waits 1 second, then sends a "pass" message to its own model (think of it as a form of recursion). The pass message contains the id of the thread it is called from. The model also contains the ID of the currently running thread, which is compared and does not spawn a thread if the ID does not match. This is to avoid a scenario where the user presses the play button twice in half a second, which ends up spawning two instances of the threads recursing at once. Now, only the most recently spawned will be allowed to continue.
To add cells to the factory, the app model requests a "view" from the game of life library - a list of all live cells between a top-left and bottom-right coordinate. The model then iterates through all coordinates between these two, adds it with the live value set to false if it is not in the view, and to true if it is. The model performs this synchronisation every time it receives a message.
## File access
There are several file formats that board states in game of life can be encoded in. The most popular are "plaintext" and "RLE". RLE uses a form of run-length encoding, which I opted not to use to avoid unnecessary complexity. The other, plaintext, sees each line in the file represent a row on the board, with "." representing a dead cell and "O" representing a live cell. An older format, Life 1.05, uses "\*" as a live cell. My game of life library supports both, and can generate a new game from them. This allows me to load pretty much whatever pattern I want from the internet and test it, which is helpful.
Relm4 provides a widget to allow file selection using the OS's native UI, giving me a path to feed into my reader function. This file selection also works on Flatpaks' sandboxing. Rather than giving the program universal file access, it has to open a dialog controlled by the OS for the user to select a file that they consent to the program accessing.
## Performance and optimisation
Implementing file loading allowed me to conduct more thorough benchmarks. Particularly, I was interested in how the performance of my system would manage with patterns whose live cell counts dramatically increase over time. Space fillers are a type of pattern that expand to fill the grid as quickly as possible. I downloaded a plaintext file for a spacefiller and tried to benchmark how quickly it would slow down. Sadly, the slowdowns were quite quick, with performance dropping off after 60 turns or so. It didn't reach a speed lower than the UI advanced it either way, but I still found it unsatisfactory. Reaching 150 turns took about 5.8 seconds, and I wanted to lower this.
Looking at the `advance_board` function, there were a few places I could see room for improvement. Rather than use a double buffer, with secondary board being drained and filled with the new values, I simplified it by removing the secondary board and simply initialising a new board each update, which lowered memory requirements. I experimented with changing the form of each thread's results from a `HashSet` to a vector, but this had no major effect. The system finds out how many threads it should split itself into by calling the `available_parallelism` function, which queries the system for a core count. This was being done every advance in my current implementation, which the documentation for the function warns against. I moved the call into the board's new function and stored it in the game struct itself, which improved it by about 0.2 seconds. Removing unnecessary prints in my code vastly improved performance. Still, only around 5.2 seconds for 150 iterations on the spacefiller.
I looked for other, more efficient algorithms for life and one that stood out to me was HashLife. HashLife is notable firstly because it's much faster, but also because it does not calculate the board turn-by-turn. It is able to look ahead and calculate several turns at once, which allows for a variety of optimisations. Sadly, the ability to traverse multiple turns does not make sense for my program, and the algorithm is also notoriously complex to implement, and would require me to implement several data structures I'm unfamiliar with, as well as my own garbage collector.
I had also been reccomended using SIMD for further improvements, but I was very unfamiliar with SIMD concepts and wasn't confident in implementing it. I explored simplifying the boolean logic for the neighbour counts, but I found it to have no benefit for performance and decrease readability. I also tried a parallel iteration library called rayon, which allowed me to abstract away the process of chunking and mapping each hashset, which was faster, but I wanted to try to do the game logic with as few libraries as possible.
The Rust Performance Book's section of hashing details that the default hasher used in `HashMap`s and `HashSet`s is relatively safe, but slow. However, it is possible to provide a custom hasher. The book details the rustc-hash crate (a crate is a Rust library), which provides a custom hasher that is considerably faster at the cost of a higher collision rate. Applying this to my board gave a moderate speedup (about 0.6s) with no apparent logic errors. It also mentions the nohash_hasher crate, which is able to use integers as keys verbatim, without hashing. I wanted to see if I could do something similar with my key, a tuple of `isize`s (an isize is a signed integer with size dependent on the architecture). Sadly, no implementation I could provide was faster than rustc-hash, so I didn't pursue it further, but it did give me some useful knowledge of Rust's `Hash` and `Hasher` traits (interfaces).
## Testing
The library has a number of integration tests. These ensure that the rules of Life are followed in the program's simulation. These tests typically involve taking established patterns like the R-Pentomino, advancing them a couple turns, and checking them against a result I took from another simulator. The Linux UI automated testing scene is not well-established, and the application has no tests for ITs UI. Rust's extensive use of static analysis allows many things that would otherwise have been tested for to be caught as compile-time, such as type errors, memory unsafety, and race conditions (Rust is famously impossible to get memory unsafety without an explicit unsafe keyword).
## Packaging
So far, if a user wanted to install the application, they would have to install the Rust toolchain (compiler, package manager, etc), install the necessary GTK development libraries, and compile my program from source, a long and performance-intensive process. This may be acceptable for my first stakeholder, and is considerably easier for the Rust developer (they would already have the toolchain, do not require GTK dependencies, and can compile the library quicker than the full application), but it is not realistic for a casual Linux user with limited technical skills like my third stakeholder.
Fortunately, Relm4 provides a template for building applications into Flatpaks. After integrating the necessary build files and installing runtimes, it becomes possible to build a full Flatpak for my application. Not only does this make my application very easy to install (Flatpak installation is well-documented on almost all Linux systems), but it also provides users a degree of security assurance - for example, Flatpaks do not have file access by default outside of a file picker that the user controls and the application cannot tamper with. This was not a design goal, but is nice to have.
## Documentation
Rust has a built-in documentation system, in which code and appropriate comments are compiled into an HTML page containing documentation for any given type, module, or library. This allows me to document any given function by writing a comment directly above it. My code is well-commented and documentation extends across all types and functions that are exposed in the library's API.
## Software/hardware requirements
As this is a GTK app, it is only runnable on a Linux system, or through WSL2, which needs some technical knowledge to set up. This is acceptable, as my stakeholder is confident with using Linux. Installing from source requires Rust and several GTK development libraries, which are readily available through most Linux package managers, while Flatpaks make installation significantly easier. Flatpaks can also be installed through Crostini, making the application available on ChromeOS (with some elbow grease). The application is not available for macOS, Android, and iOS. It is untested for Linux phones but should run (though presently the user would not be able to scroll).
It is a lightweight app, with low hardware requirements. The speed at which the board advances is slow enough that the cost of computing the next term is negligable. It will struggle on very low-spec systems due to the requirements of GTK, but works well on my budget Chromebook.
## Success analysis
Overall, almost all of the success criteria have been met, with the exception of providing presets. This is relatively low-impact, as a user can easily find examples of patterns online and open them in the app.
The program's interface is idiomatic for GNOME apps, and any GNOME user should be able to navigate it without surprises. The application is available as a Flatpak, and the logic code is easy to compile and cross-platform. The logic library is both well-documented and idiomatic to a Rust library.
I have a few further goals with which I intend to extend the program after the NEA is complete:
* I will replace some of my own code with libraries, such as having rayon implement multithreading for me
* I will ensure that my program works well with standard Linux screen readers.
* I will ensure that the program works on mobile Linux devices
* I will modify the logic library to allow for custom rulesets.

3
examples/e Normal file
View File

@ -0,0 +1,3 @@
.**
**.
.*.

7
examples/glider.lif Normal file
View File

@ -0,0 +1,7 @@
#Life 1.05
#D This is a glider.
#N
#P -1 -1
.*.
..*
***

3
examples/pentomino.lif Normal file
View File

@ -0,0 +1,3 @@
.**
**.
.*.

83
examples/sirrobin.cells Normal file
View File

@ -0,0 +1,83 @@
! Sir Robin
! Adam P. Goucher, Tom Rokicki; 2018
! The first elementary knightship to be found in Conway's Game of Life.
! https://conwaylife.com/wiki/Sir_Robin
....OO.........................
....O..O.......................
....O...O......................
......OOO......................
..OO......OOOO.................
..O.OO....OOOO.................
.O....O......OOO...............
..OOOO....OO...O...............
O.........OO...................
.O...O.........................
......OOO..OO..O...............
..OO.......O....O..............
.............O.OO..............
..........OO......O............
...........OO.OOO.O............
..........OO...O..O............
..........O.O..OO..............
..........O..O.O.O.............
..........OOO......O...........
...........O.O.O...O...........
..............OO.O.O...........
...........O......OOO..........
...............................
...........O.........O.........
...........O...O......O........
............O.....OOOOO........
............OOO................
................OO.............
.............OOO..O............
...........O.OOO.O.............
..........O...O..O.............
...........O....OO.OOO.........
.............OOOO.O....OO......
.............O.OOOO....OO......
...................O...........
....................O..OO......
....................OO.........
.....................OOOOO.....
.........................OO....
...................OOO......O..
....................O.O...O.O..
...................O...O...O...
...................O...OO......
..................O......O.OOO.
...................OO...O...OO.
....................OOOO..O..O.
......................OO...O...
.....................O.........
.....................OO.O......
....................O..........
...................OOOOO.......
...................O....O......
..................OOO.OOO......
..................O.OOOOO......
..................O............
....................O..........
................O....OOOO......
....................OOOO.OO....
.................OOO....O......
........................O.O....
............................O..
........................O..OO..
.........................OOO...
......................OO.......
.....................OOO.....O.
........................OO..O.O
.....................O..OOO.O.O
......................OO.O..O..
........................O.O..OO
..........................OO...
......................OOO....O.
......................OOO....O.
.......................OO...OOO
........................OO.OO..
.........................OO....
.........................O.....
...............................
........................OO.....
..........................O....

71
meson.build Normal file
View File

@ -0,0 +1,71 @@
project(
'mossfets-life',
'rust',
version: '0.1.0',
meson_version: '>= 0.59',
# license: 'MIT',
)
i18n = import('i18n')
gnome = import('gnome')
base_id = 'com.example.Life'
dependency('glib-2.0', version: '>= 2.66')
dependency('gio-2.0', version: '>= 2.66')
dependency('gtk4', version: '>= 4.0.0')
glib_compile_resources = find_program('glib-compile-resources', required: true)
glib_compile_schemas = find_program('glib-compile-schemas', required: true)
desktop_file_validate = find_program('desktop-file-validate', required: false)
appstream_util = find_program('appstream-util', required: false)
cargo = find_program('cargo', required: true)
version = meson.project_version()
prefix = get_option('prefix')
bindir = prefix / get_option('bindir')
localedir = prefix / get_option('localedir')
datadir = prefix / get_option('datadir')
pkgdatadir = datadir / meson.project_name()
iconsdir = datadir / 'icons'
podir = meson.project_source_root() / 'po'
gettext_package = meson.project_name()
if get_option('profile') == 'development'
profile = 'Devel'
vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD', check: false).stdout().strip()
if vcs_tag == ''
version_suffix = '-devel'
else
version_suffix = '-@0@'.format(vcs_tag)
endif
application_id = '@0@.@1@'.format(base_id, profile)
else
profile = ''
version_suffix = ''
application_id = base_id
endif
meson.add_dist_script(
'build-aux/dist-vendor.sh',
meson.project_build_root() / 'meson-dist' / meson.project_name() + '-' + version,
meson.project_source_root()
)
if get_option('profile') == 'development'
# Setup pre-commit hook for ensuring coding style is always consistent
message('Setting up git pre-commit hook..')
run_command('cp', '-f', 'hooks/pre-commit.hook', '.git/hooks/pre-commit', check: false)
endif
subdir('data')
subdir('po')
subdir('src')
gnome.post_install(
gtk_update_icon_cache: true,
glib_compile_schemas: true,
update_desktop_database: true,
)

10
meson_options.txt Normal file
View File

@ -0,0 +1,10 @@
option(
'profile',
type: 'combo',
choices: [
'default',
'development'
],
value: 'default',
description: 'The build profile for GTK Rust Template. One of "default" or "development".'
)

0
po/LINGUAS Normal file
View File

6
po/POTFILES.in Normal file
View File

@ -0,0 +1,6 @@
data/com.example.Life.desktop.in.in
data/com.example.Life.gschema.xml.in
data/com.example.Life.metainfo.xml.in.in
data/resources/ui/shortcuts.ui
data/resources/ui/window.ui
src/application.rs

1
po/meson.build Normal file
View File

@ -0,0 +1 @@
i18n.gettext(gettext_package, preset: 'glib')

465
src/app.rs Normal file
View File

@ -0,0 +1,465 @@
use std::path::PathBuf;
use std::time::Duration;
use adw::prelude::{WidgetExt, AdwApplicationWindowExt, Cast};
use gtk::prelude::{BoxExt, ButtonExt, GtkWindowExt, OrientableExt};
use relm4::actions::{AccelsPlus, RelmActionGroup, RelmAction};
use relm4::*;
use relm4::factory::FactoryVecDeque;
use relm4_components::{save_dialog::*, open_dialog::*};
use mossfets_game_of_life::game::{Game, Coord};
use crate::factory::cell::Cell;
use crate::widgets::runner::{RunModel, RunMsg, RunOutput};
use crate::widgets::preferences::{PreferencesModel, PreferencesMsg};
/// The model representing the application's state
#[tracker::track]
pub struct AppModel {
#[do_not_track]
pub game: Game,
#[do_not_track]
pub size: (Coord, Coord),
#[do_not_track]
running: bool,
#[do_not_track]
offset: Coord,
toast: Option<adw::Toast>,
#[do_not_track]
pub cells: FactoryVecDeque<Cell>,
#[do_not_track]
runner: Controller<RunModel>,
#[do_not_track]
preferences: Controller<PreferencesModel>,
#[do_not_track]
save_dialog: Controller<SaveDialog>,
#[do_not_track]
open_dialog: Controller<OpenDialog>,
}
/// The messages given the the model to update its state
#[derive(Debug)]
pub enum AppMsg {
Advance,
Revert,
ChangeAtPoint(Coord),
ChangeAtGridSpacePoint(Coord),
Move(Direction),
Sync,
Toggle,
Size(Coord),
Clear,
SaveRequest,
OpenRequest,
SaveResponse(PathBuf),
OpenResponse(PathBuf),
ShowPreferences,
}
/// A direction to move the view of the grid
#[derive(Debug)]
pub enum Direction {
Up,
Down,
Left,
Right,
}
impl AppModel {
/// Convert from a space on the visible grid (starting from 0,0 in top-left) to the board's
/// coordinate system
pub fn to_game_space(&self, coord: Coord) -> Coord {
let c1 = self.size.0;
(c1.0 + coord.0 + self.offset.0, c1.1 + coord.1 + self.offset.1)
}
/// Modify the factory vector to fit only cells in the appropriate range
pub fn size(&mut self, c1: Coord, c2: Coord) {
let mut cells_guard = self.cells.guard();
cells_guard.clear();
for i in 0..c2.0-c1.0 + 1 {
for j in 0..c2.1-c1.1 + 1 {
cells_guard.push_back(((i, j), false));
}
}
self.size = (c1, c2);
}
/// Modify the cells of the factory to match the game's state
pub fn sync(&mut self) {
let c1 = (self.size.0.0 + self.offset.0, self.size.0.1 + self.offset.1);
let c2 = (self.size.1.0 + self.offset.0, self.size.1.1 + self.offset.1);
let view = self.game.get_view(c1, c2);
let len = self.cells.len();
let mut cells_guard = self.cells.guard();
for index in 0..len {
let cell = cells_guard.get_mut(index).unwrap();
let coord = cell.get_coordinate();
let coord = (coord.0 + c1.0, coord.1 + c1.1);
if view.contains(&&coord) {
cell.value = true;
} else {
cell.value = false;
}
}
}
fn observe_window(sender: &ComponentSender<Self>, window: &adw::ApplicationWindow, exit_fullscreen: bool) {
let window = window.clone();
let sender = sender.clone();
spawn_local(async move {
async_std::task::sleep(Duration::from_millis(25)).await;
if exit_fullscreen && (window.is_maximized() || window.is_fullscreened()) { return };
let (x, y) = (window.width(), window.height());
if x == 0 || y == 0 { return };
sender.input(AppMsg::Size((x as isize, y as isize)));
});
}
}
#[relm4::component(pub)]
impl SimpleComponent for AppModel {
type Widgets = AppWidgets;
type Init = Game;
type Input = AppMsg;
type Output = ();
view! {
main_window = adw::ApplicationWindow {
set_title: Some("Game of Life"),
set_default_width: 600,
set_default_height: 500,
#[wrap(Some)]
set_content: toast_overlay = &adw::ToastOverlay {
#[wrap(Some)]
set_child = &gtk::Box {
set_orientation: gtk::Orientation::Vertical,
append = &adw::HeaderBar {
set_show_start_title_buttons: true,
// Main menu
pack_end = &gtk::MenuButton {
set_tooltip_text: Some("Main Menu"),
set_icon_name: "open-menu-symbolic",
set_menu_model: Some(&main_menu),
set_primary: true,
},
// Window controls
#[wrap(Some)]
set_title_widget = &gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
append: prev = &gtk::Button {
set_tooltip_text: Some("Step Backward"),
set_icon_name: "go-previous-symbolic",
connect_clicked[sender] => move |_| {
sender.input(AppMsg::Revert)
}
},
append = &gtk::Button {
set_tooltip_text: Some("Step Forward"),
set_icon_name: "go-next-symbolic",
connect_clicked[sender] => move |_| {
sender.input(AppMsg::Advance);
},
},
// Runner component has to be inserted after everything else
insert_child_after[Some(&prev)]: model.runner.widget(),
},
set_show_end_title_buttons: true,
},
gtk::Overlay {
add_overlay = &gtk::AspectFrame {
#[local_ref]
#[wrap(Some)]
set_child = cell_grid -> gtk::Grid {
set_valign: gtk::Align::Center,
set_halign: gtk::Align::Center,
}
},
#[wrap(Some)]
set_child = &gtk::Box {
set_height_request: 200,
set_width_request: 300,
}
}
}
}
}
}
// The application's main menu
menu! {
main_menu: {
"_Clear" => ClearAction,
"_Open File" => OpenAction,
"_Save File" => SaveAction,
"_Preferences" => PreferencesAction,
"_About" => AboutAction,
}
}
fn init(
init: Self::Init,
root: &Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let game = init;
let runner = RunModel::builder()
.launch(())
.forward(sender.input_sender(),
|msg| match msg {
RunOutput::Advance => AppMsg::Advance,
});
let open_dialog = OpenDialog::builder()
.transient_for_native(root)
.launch(OpenDialogSettings::default())
.forward(sender.input_sender(), |response| match response {
OpenDialogResponse::Accept(path) => AppMsg::OpenResponse(path),
OpenDialogResponse::Cancel => AppMsg::Sync,
});
let save_dialog = SaveDialog::builder()
.transient_for_native(root)
.launch(SaveDialogSettings::default())
.forward(sender.input_sender(), |response| match response {
SaveDialogResponse::Accept(path) => AppMsg::SaveResponse(path),
SaveDialogResponse::Cancel => AppMsg::Sync,
});
let preferences = PreferencesModel::builder()
.launch(root.clone().upcast())
.forward(sender.input_sender(), |_| AppMsg::Sync);
let model = AppModel {
cells: FactoryVecDeque::new(gtk::Grid::default(), sender.input_sender()),
game,
size: ((-1, -1), (1, 1)),
running: false,
toast: None,
tracker: 0,
offset: (0,0),
runner,
open_dialog,
save_dialog,
preferences,
};
let cell_grid = model.cells.widget();
let widgets = view_output!();
let app = relm4::main_application();
app.set_accelerators_for_action::<UpAction>(&["k", "w", "Up"]);
app.set_accelerators_for_action::<DownAction>(&["j", "s", "Down"]);
app.set_accelerators_for_action::<RightAction>(&["l", "d", "Right"]);
app.set_accelerators_for_action::<LeftAction>(&["h", "a", "Left"]);
let group = RelmActionGroup::<WindowActionGroup>::new();
let sender_clone = sender.clone();
let action: RelmAction<UpAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::Move(Direction::Up));
});
let sender_clone = sender.clone();
let action2: RelmAction<DownAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::Move(Direction::Down));
});
let sender_clone = sender.clone();
let action3: RelmAction<LeftAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::Move(Direction::Left));
});
let sender_clone = sender.clone();
let action4: RelmAction<RightAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::Move(Direction::Right));
});
let sender_clone = sender.clone();
let action5: RelmAction<ClearAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::Clear);
});
let sender_clone = sender.clone();
let action6: RelmAction<OpenAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::OpenRequest);
});
let sender_clone = sender.clone();
let action7: RelmAction<SaveAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::SaveRequest);
});
// Open the about menu
let action8: RelmAction<AboutAction> = RelmAction::new_stateless(move |_| {
adw::builders::AboutWindowBuilder::new()
.copyright("Copyright. Mossfet")
.license_type(gtk::License::Gpl30)
.application_name("Life")
.comments("A GTK4 Conway's Game of Life emulator")
.title("About Life")
.developer_name("Mossfet")
.application_icon("view-app-grid-symbolic")
.height_request(600)
.width_request(360)
.build()
.show();
});
let sender_clone = sender.clone();
let action9: RelmAction<PreferencesAction> = RelmAction::new_stateless(move |_| {
sender_clone.input(AppMsg::ShowPreferences);
});
group.add_action(&action);
group.add_action(&action2);
group.add_action(&action3);
group.add_action(&action4);
group.add_action(&action5);
group.add_action(&action6);
group.add_action(&action7);
group.add_action(&action8);
group.add_action(&action9);
let actions = group.into_action_group();
widgets.main_window.insert_action_group("win", Some(&actions));
let sender_clone = sender.clone();
widgets.main_window.connect_default_height_notify(move |window| {
AppModel::observe_window(&sender_clone, &window, true);
});
let sender_clone = sender.clone();
widgets.main_window.connect_default_width_notify(move |window| {
AppModel::observe_window(&sender_clone, &window, true);
});
let sender_clone = sender.clone();
widgets.main_window.connect_maximized_notify(move |window| {
AppModel::observe_window(&sender_clone, &window, false);
});
let sender_clone = sender.clone();
AppModel::observe_window(&sender_clone, &widgets.main_window, false);
sender.input(AppMsg::Sync);
ComponentParts { model, widgets }
}
fn update(
&mut self,
msg: Self::Input,
_sender: ComponentSender<Self>,
) {
self.reset();
match msg {
AppMsg::Size(c) => {
let (width, height) = c;
let mut horizontal_count = width/56;
// If there are less than 15 pixels of free horizontal space, shrink the grid
if width%56 < 2 {
horizontal_count -= 1;
}
let mut vertical_count = height/56;
// If there are less than 15 pixels of free vertical space (offset to include
// headerbar), shrink the grid
if (height%56 - 47) < 1 {
vertical_count -= 1;
}
// When there is an even number of rows/columns, the origin should be left/above
// the center.
//println!("{}, {}", horizontal_count, vertical_count);
let c1 = (-(horizontal_count.div_ceil(2)-1), -(vertical_count.div_ceil(2)-1));
let c2 = (horizontal_count/2, vertical_count/2);
if (c1, c2) != self.size {
self.size(c1, c2);
}
}
AppMsg::Advance => {
self.game.advance_board();
}
AppMsg::Revert => {
self.game.revert_board();
}
AppMsg::ChangeAtPoint(coord) => {
self.game.flip_state(coord);
}
AppMsg::ChangeAtGridSpacePoint(coord) => {
self.game.flip_state(self.to_game_space(coord));
}
AppMsg::Move(direction) => {
let mut offset = self.offset;
match direction {
Direction::Left => {
offset = (offset.0 - 1, offset.1);
}
Direction::Right => {
offset = (offset.0 + 1, offset.1);
}
Direction::Up => {
offset = (offset.0, offset.1 - 1);
}
Direction::Down => {
offset = (offset.0, offset.1 + 1);
}
}
self.offset = offset;
}
AppMsg::Sync => {}
AppMsg::Toggle => {
self.runner.emit(RunMsg::Toggle);
self.running = !self.running;
}
AppMsg::Clear => {
self.game = Game::new();
}
AppMsg::SaveRequest => {
self.save_dialog.emit(SaveDialogMsg::SaveAs(format!("pattern.lif")));
}
AppMsg::OpenRequest => {
self.open_dialog.emit(OpenDialogMsg::Open);
}
AppMsg::SaveResponse(path) => {
match mossfets_game_of_life::writer::write_game_at_path(path, &self.game) {
Ok(_) => {},
Err(_) => self.set_toast(Some(adw::Toast::builder().title("Failed to write file!").priority(adw::ToastPriority::High).build())),
}
},
AppMsg::OpenResponse(path) => {
let path = path.to_str().unwrap();
match mossfets_game_of_life::reader::read_game_at_path(path) {
Ok(game) => self.game = game,
// TODO: Make toast respond to error
Err(_) => self.set_toast(Some(adw::Toast::builder().title("Failed to read file!").priority(adw::ToastPriority::High).build())),
}
}
AppMsg::ShowPreferences => {
self.preferences.emit(PreferencesMsg::Show);
}
/*AppMsg::SetDark(theme_enabled) => {
let style = adw::StyleManager::default();
match theme_enabled {
ThemeVariant::Dark => {
style.set_color_scheme(adw::ColorScheme::PreferDark);
}
ThemeVariant::Light => {
style.set_color_scheme(adw::ColorScheme::PreferLight);
}
ThemeVariant::Default => {
style.set_color_scheme(adw::ColorScheme::Default);
}
}
}*/
}
self.sync();
}
fn pre_view() {
if model.changed(AppModel::toast()) {
toast_overlay.add_toast(model.toast.as_ref().unwrap());
sender.input(AppMsg::Sync);
}
}
}
/*fn setup_action<T, F, U> (sender: Sender<T>, f: F, group: RelmActionGroup<U>)
where F: Fn(&gio::SimpleAction) + 'static,
U: ActionGroupName
{
let sender = sender.clone();
let action: RelmAction<U> = RelmAction::new_stateless(f);
group.add_action(action);
}*/
relm4::new_action_group!(WindowActionGroup, "win");
relm4::new_stateless_action!(UpAction, WindowActionGroup, "up");
relm4::new_stateless_action!(DownAction, WindowActionGroup, "down");
relm4::new_stateless_action!(LeftAction, WindowActionGroup, "left");
relm4::new_stateless_action!(RightAction, WindowActionGroup, "right");
relm4::new_stateless_action!(ClearAction, WindowActionGroup, "clear");
relm4::new_stateless_action!(OpenAction, WindowActionGroup, "open-file");
relm4::new_stateless_action!(SaveAction, WindowActionGroup, "save");
relm4::new_stateless_action!(AboutAction, WindowActionGroup, "about");
relm4::new_stateless_action!(PreferencesAction, WindowActionGroup, "preferences");

8
src/config.rs.in Normal file
View File

@ -0,0 +1,8 @@
pub const APP_ID: &str = @APP_ID@;
pub const GETTEXT_PACKAGE: &str = @GETTEXT_PACKAGE@;
pub const LOCALEDIR: &str = @LOCALEDIR@;
#[allow(unused)]
pub const PKGDATADIR: &str = @PKGDATADIR@;
pub const PROFILE: &str = @PROFILE@;
pub const RESOURCES_FILE: &str = concat!(@PKGDATADIR@, "/resources.gresource");
pub const VERSION: &str = @VERSION@;

109
src/factory/cell.rs Normal file
View File

@ -0,0 +1,109 @@
use mossfets_game_of_life::game::Coord;
use relm4::factory::Position;
use relm4::factory::positions::GridPosition;
use relm4::prelude::{FactoryComponent, DynamicIndex};
use relm4::{gtk, RelmWidgetExt};
use adw::prelude::{AccessibleExtManual, WidgetExt};
use gtk::prelude::ButtonExt;
use crate::app::AppMsg;
/// A cell being rendered to the grid. The coordinates are in grid space.
#[derive(Debug)]
pub struct Cell {
coordinate: (isize, isize),
pub value: bool,
}
impl Cell {
pub fn get_coordinate(&self) -> &(isize, isize) {
&(self.coordinate)
}
pub fn new(coordinate: (isize, isize), value: bool) -> Self {
Self {
coordinate,
value
}
}
}
#[derive(Debug)]
pub enum CellMsg {}
#[derive(Debug)]
pub enum CellOutput {
ChangeAtGridSpacePoint(Coord),
}
#[relm4::factory(pub)]
impl FactoryComponent for Cell {
type Init = (Coord, bool);
type Input = CellMsg;
type Output = CellOutput;
type CommandOutput = ();
type Widgets = FactoryWidgets;
type ParentInput = AppMsg;
//type Factory = FactoryVec<Self>;
//type Root = gtk::AspectFrame;
type ParentWidget = gtk::Grid;
// A grid cell
view! {
root = gtk::AspectFrame {
set_ratio: 10.0,
#[wrap(Some)]
#[name = "button"]
set_child = &gtk::Button {
connect_clicked[sender, coord = self.coordinate] => move |_| {
sender.output(CellOutput::ChangeAtGridSpacePoint(coord));
},
set_hexpand: true,
set_vexpand: true,
set_class_active: ("cell", true),
#[watch]
update_property: &[gtk::accessible::Property::Description(
&format!(
"Cell {} {} {}",
self.coordinate.0,
self.coordinate.1,
if self.value { "live" } else { "dead" }
)
)],
// Set the CSS "live" class to whether the cell is live, and update appropriately
#[watch]
set_class_active: ("live", self.value),
},
}
}
fn init_model(init: Self::Init, _index: &DynamicIndex, _sender: relm4::FactorySender<Self>) -> Self {
let (coordinate, value) = init;
Self { coordinate, value }
}
fn output_to_parent_input(output: Self::Output) -> Option<Self::ParentInput> {
Some(match output {
CellOutput::ChangeAtGridSpacePoint(coordinate) => AppMsg::ChangeAtGridSpacePoint(coordinate),
})
}
/*fn view (
&self,
_key: &<Self::Factory as relm4::factory::Factory<Self, Self::View>>::Key,
widgets: &Self::Widgets,
) {
// The "live" CSS class makes the cell appear white.
widgets.button.child().unwrap().set_class_active("live", self.value)
}*/
}
impl Position<GridPosition> for Cell {
fn position(&self, _index: usize) -> GridPosition {
GridPosition {
column: self.coordinate.0 as i32,
row: self.coordinate.1 as i32,
width: 1,
height: 1,
}
}
}

1
src/factory/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod cell;

5
src/lib.rs Normal file
View File

@ -0,0 +1,5 @@
#![feature(int_roundings)]
pub mod app;
pub mod factory;
pub mod widgets;
pub mod config;

13
src/main.rs Normal file
View File

@ -0,0 +1,13 @@
use std::str::from_utf8;
use relm4::set_global_css;
use relm4::RelmApp;
use mossfets_game_of_life::game::Game;
use mossfets_life::app::AppModel;
use mossfets_life::config::APP_ID;
fn main() {
let game = Game::new();
let app = RelmApp::new(APP_ID);
set_global_css(from_utf8(include_bytes!("styles/style.scss")).unwrap());
app.run::<AppModel>(game);
}

52
src/meson.build Normal file
View File

@ -0,0 +1,52 @@
global_conf = configuration_data()
global_conf.set_quoted('APP_ID', application_id)
global_conf.set_quoted('PKGDATADIR', pkgdatadir)
global_conf.set_quoted('PROFILE', profile)
global_conf.set_quoted('VERSION', version + version_suffix)
global_conf.set_quoted('GETTEXT_PACKAGE', gettext_package)
global_conf.set_quoted('LOCALEDIR', localedir)
config = configure_file(
input: 'config.rs.in',
output: 'config.rs',
configuration: global_conf
)
# Copy the config.rs output to the source directory.
run_command(
'cp',
meson.project_build_root() / 'src' / 'config.rs',
meson.project_source_root() / 'src' / 'config.rs',
check: true
)
cargo_options = [ '--manifest-path', meson.project_source_root() / 'Cargo.toml' ]
cargo_options += [ '--target-dir', meson.project_build_root() / 'src' ]
if get_option('profile') == 'default'
cargo_options += [ '--release' ]
rust_target = 'release'
message('Building in release mode')
else
rust_target = 'debug'
message('Building in debug mode')
endif
cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home' ]
cargo_build = custom_target(
'cargo-build',
build_by_default: true,
build_always_stale: true,
output: meson.project_name(),
console: true,
install: true,
install_dir: bindir,
depends: resources,
command: [
'env',
cargo_env,
cargo, 'build',
cargo_options,
'&&',
'cp', 'src' / rust_target / meson.project_name(), '@OUTPUT@',
]
)

11
src/styles/style.scss Normal file
View File

@ -0,0 +1,11 @@
button.cell {
/*border-radius: 10px;
border-width: 2px;
padding: 5px;*/
margin: 10px;
}
button.live {
color: @accent_fg_color;
background-color: @accent_bg_color;
}

2
src/widgets/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod runner;
pub mod preferences;

View File

@ -0,0 +1,98 @@
use adw::prelude::{WidgetExt, PreferencesRowExt, PreferencesPageExt, PreferencesGroupExt, PreferencesWindowExt, GtkWindowExt, ComboRowExt, SettingsExt};
use gtk::gio::Settings;
use relm4::*;
use crate::config;
#[derive(Debug)]
pub enum PreferencesMsg {
Show,
Close,
SetTheme(u32),
}
#[derive(Debug)]
pub enum PreferencesOutput {
}
pub struct PreferencesModel {
prefwindow: adw::PreferencesWindow,
settings: Settings,
style: adw::StyleManager,
}
#[relm4::component(pub)]
impl SimpleComponent for PreferencesModel {
type Widgets = PreferencesWidgets;
type Init = gtk::Window;
type Input = PreferencesMsg;
type Output = ();
view! {
adw::PreferencesWindow {
set_transient_for: Some(&parent_window),
set_modal: true,
set_search_enabled: false,
set_width_request: 400,
set_height_request: 600,
connect_close_request[sender] => move |_| {
sender.input(PreferencesMsg::Close);
gtk::Inhibit(true)
},
add = &adw::PreferencesPage {
add = &adw::PreferencesGroup {
/*add_suffix = &gtk::Switch {
set_valign: gtk::Align::Center,
connect_state_set[sender] => move |_, b| {
sender.output(PreferencesOutput::SetDark(b));
gtk::Inhibit(true)
},
},*/
add = &adw::ComboRow {
set_title: "Theme",
set_valign: gtk::Align::Center,
set_model: Some(&gtk::StringList::new(&["Light", "Dark", "Default"])),
set_selected: model.settings.uint("theme-selected"),
connect_selected_notify[sender] => move |item| {
sender.input(PreferencesMsg::SetTheme(item.selected()));
},
}
}
}
}
}
fn init(
parent_window: Self::Init,
root: &Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let settings = Settings::new(config::APP_ID);
let style = adw::StyleManager::default();
let model = PreferencesModel { prefwindow: root.clone(), settings, style};
let widgets = view_output!();
ComponentParts { model, widgets }
}
fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) {
match msg {
PreferencesMsg::Show => {
self.prefwindow.show();
}
PreferencesMsg::Close => {
self.prefwindow.hide();
}
PreferencesMsg::SetTheme(theme) => {
self.settings.set_uint("theme-selected", theme).unwrap();
let theme = match theme {
0 => adw::ColorScheme::PreferLight,
1 => adw::ColorScheme::PreferDark,
2 => adw::ColorScheme::Default,
_ => panic!(),
};
self.style.set_color_scheme(theme);
}
}
}
}

90
src/widgets/runner.rs Normal file
View File

@ -0,0 +1,90 @@
use std::{time::Duration, thread::ThreadId};
use adw::prelude::ButtonExt;
use gtk::prelude::WidgetExt;
use relm4::*;
/// A message that can be provided to the runner
#[derive(Debug)]
pub enum RunMsg {
Toggle,
Pass(ThreadId),
}
/// A message that the runner can forward to the app
#[derive(Debug)]
pub enum RunOutput {
Advance,
}
/// The runner's internal state
pub struct RunModel {
running: bool,
id: Option<ThreadId>,
}
#[relm4::component(pub)]
impl SimpleComponent for RunModel {
type Widgets = RunWidgets;
type Init = ();
type Input = RunMsg;
type Output = RunOutput;
view! {
#[name = "button"]
&gtk::Button {
#[watch]
set_tooltip_text: Some(if model.running { "Pause" } else { "Play" }),
#[watch]
set_icon_name: if model.running { "media-playback-stop-symbolic" } else { "media-playback-start-symbolic" },
connect_clicked[sender] => move |_| {
sender.input(RunMsg::Toggle);
},
}
}
fn init(
_init: Self::Init,
root: &Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let model = RunModel {
running: false,
id: None,
};
let widgets = view_output!();
ComponentParts { model, widgets }
}
fn update(
&mut self,
input: RunMsg,
sender: ComponentSender<Self>,
) {
match input {
// Toggle the play-pause state of the runner
RunMsg::Toggle => {
self.running = !self.running;
}
// Called every 500ms when running
RunMsg::Pass(id) => {
if self.id != Some(id) {return}
}
}
/* Here, to prevent the possibility of a user pressing the play button twice in quick
* succession, spawning two threads that are both advancing the game, the runner keeps
* track of which thread id is the most recently spawned. If a thread detects that it is
* not the most recent, it will close.
*/
if self.running {
sender.output(RunOutput::Advance).unwrap();
self.id = Some(std::thread::spawn(move || {
// Note that timing is handled by the application, not the library. Another
// developer could change it
std::thread::sleep(Duration::from_millis(500));
sender.input(RunMsg::Pass(std::thread::current().id()));
}).thread().id());
}
}
}