Files
switchnote/source/main.cpp
T
2026-10-29 03:55:40 +00:00

7797 lines
377 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <SDL.h>
#include <SDL_image.h>
#include <SDL_mixer.h>
#include <SDL_ttf.h>
#ifdef __SWITCH__
#include <switch.h>
#endif
#include "sppm.hpp"
#include "export.hpp"
#include "audio_upload_server.hpp"
#include "branding_config_server.hpp"
#include "branding_bundle.hpp"
#include <algorithm>
#include <array>
#include <atomic>
#include <cerrno>
#include <cmath>
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <dirent.h>
#include <fstream>
#include <map>
#include <limits>
#include <random>
#include <sstream>
#include <string>
#include <sys/stat.h>
#ifdef __SWITCH__
#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#endif
#ifdef _WIN32
#include <direct.h>
#endif
#include <unordered_map>
#include <utility>
#include <vector>
namespace {
constexpr int W = 1280;
constexpr int H = 720;
const SDL_Color ORANGE{255, 117, 30, 255};
const SDL_Color UPDATE_ORANGE{255, 100, 0, 255};
const SDL_Color CREAM{255, 211, 166, 255};
const SDL_Color GREEN{86, 166, 41, 255};
const SDL_Color PALE_GREEN{169, 235, 129, 255};
const SDL_Color INK{28, 30, 35, 255};
const SDL_Color DRAW_BLACK{28, 30, 35, 255};
const SDL_Color LIGHT{226, 230, 242, 255};
const SDL_Color WHITE{255, 255, 255, 255};
const SDL_Color GRAY{140, 145, 150, 255};
constexpr std::uint32_t kScreenFadeMs = 250;
constexpr float kCanvasChromeMs = 125.0f;
constexpr int kCanvasChromeTravel = 150;
constexpr std::array<int, 10> kInkPresetIndexes{{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}};
constexpr const char* kGalleryServerHost = "reximemo.net";
constexpr const char* kGalleryServerBasePath = "/nswitch";
constexpr std::uint16_t kGalleryServerPort = 443;
constexpr const char* kInstalledAppVersion = "0.1.51";
std::atomic<std::uint64_t> galleryRequestCounter{0};
std::string jsonStringField(const std::string& json, const std::string& key) {
const std::string marker = "\"" + key + "\"";
std::size_t position = json.find(marker);
if (position == std::string::npos) return {};
position = json.find(':', position + marker.size());
if (position == std::string::npos) return {};
++position;
while (position < json.size() && std::isspace(static_cast<unsigned char>(json[position]))) ++position;
if (position >= json.size() || json[position++] != '"') return {};
std::string value;
while (position < json.size()) {
const char c = json[position++];
if (c == '"') return value;
if (c == '\\') {
if (position >= json.size()) return {};
const char escaped = json[position++];
switch (escaped) {
case '"': case '\\': case '/': value.push_back(escaped); break;
case 'n': value.push_back('\n'); break;
case 'r': value.push_back('\r'); break;
case 't': value.push_back('\t'); break;
default: return {};
}
} else value.push_back(c);
}
return {};
}
bool versionParts(const std::string& input, std::vector<unsigned long>& parts) {
std::string version = input;
if (version.rfind("Ver ", 0) == 0) version.erase(0, 4);
else if (!version.empty() && (version[0] == 'v' || version[0] == 'V')) version.erase(0, 1);
if (version.empty()) return false;
std::size_t begin = 0;
while (begin < version.size()) {
const std::size_t end = version.find('.', begin);
const std::size_t stop = end == std::string::npos ? version.size() : end;
if (stop == begin) return false;
unsigned long value = 0;
for (std::size_t i = begin; i < stop; ++i) {
if (version[i] < '0' || version[i] > '9') return false;
const unsigned digit = static_cast<unsigned>(version[i] - '0');
if (value > (std::numeric_limits<unsigned long>::max() - digit) / 10) return false;
value = value * 10 + digit;
}
parts.push_back(value);
if (end == std::string::npos) break;
begin = end + 1;
}
return !parts.empty() && version.back() != '.';
}
int compareVersions(const std::string& left, const std::string& right) {
std::vector<unsigned long> a, b;
if (!versionParts(left, a) || !versionParts(right, b)) return 0;
const std::size_t count = std::max(a.size(), b.size());
for (std::size_t i = 0; i < count; ++i) {
const unsigned long av = i < a.size() ? a[i] : 0;
const unsigned long bv = i < b.size() ? b[i] : 0;
if (av != bv) return av < bv ? -1 : 1;
}
return 0;
}
bool trustedUpdatePath(const std::string& url, std::string& path) {
constexpr const char* scheme = "https://";
if (url.rfind(scheme, 0) != 0) return false;
const std::size_t authorityStart = std::strlen(scheme);
const std::size_t slash = url.find('/', authorityStart);
if (slash == std::string::npos) return false;
const std::string authority = url.substr(authorityStart, slash - authorityStart);
if (authority != kGalleryServerHost && authority != std::string(kGalleryServerHost) + ":443") return false;
const std::string fullPath = url.substr(slash);
if (fullPath.rfind(std::string(kGalleryServerBasePath) + "/", 0) != 0) return false;
path = fullPath.substr(std::strlen(kGalleryServerBasePath));
return (path == "/api/download" || path.rfind("/api/download?", 0) == 0) &&
path.find('#') == std::string::npos;
}
bool validSha256Hex(const std::string& value) {
return value.size() == 64 && std::all_of(value.begin(), value.end(), [](unsigned char c) {
return std::isxdigit(c) != 0;
});
}
bool validNroFile(const std::string& path) {
std::ifstream input(path, std::ios::binary);
if (!input) return false;
input.seekg(0, std::ios::end);
const std::streamoff size = input.tellg();
if (size < 20 || size > 72LL * 1024LL * 1024LL) return false;
char magic[4]{};
input.seekg(16, std::ios::beg);
input.read(magic, sizeof(magic));
return input.gcount() == static_cast<std::streamsize>(sizeof(magic)) &&
std::memcmp(magic, "NRO0", sizeof(magic)) == 0;
}
std::string canonicalSdPath(std::string path) {
while (!path.empty() && std::isspace(static_cast<unsigned char>(path.front()))) path.erase(path.begin());
while (!path.empty() && std::isspace(static_cast<unsigned char>(path.back()))) path.pop_back();
if (path.size() >= 2 && path.front() == '"' && path.back() == '"')
path = path.substr(1, path.size() - 2);
std::replace(path.begin(), path.end(), '\\', '/');
std::transform(path.begin(), path.end(), path.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
if (path.rfind("sdmc:", 0) == 0) {
path.erase(0, 5);
while (!path.empty() && path.front() == '/') path.erase(path.begin());
return "sdmc:/" + path;
}
while (!path.empty() && path.front() == '/') path.erase(path.begin());
if (path.rfind("switch/", 0) == 0) return "sdmc:/" + path;
return path;
}
bool sameSdPath(const std::string& left, const std::string& right) {
return canonicalSdPath(left) == canonicalSdPath(right);
}
int retryAfterSeconds(const std::string& headers) {
std::istringstream lines(headers);
std::string line;
while (std::getline(lines, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
const std::size_t colon = line.find(':');
if (colon == std::string::npos) continue;
std::string name = line.substr(0, colon);
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (name != "retry-after") continue;
std::string value = line.substr(colon + 1);
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.front()))) value.erase(value.begin());
if (value.empty()) return -1;
int seconds = 0;
for (const char c : value) {
if (c < '0' || c > '9') return -1;
if (seconds > 18000) return -1;
seconds = seconds * 10 + (c - '0');
}
return std::clamp(seconds, 0, 1800);
}
return -1;
}
enum class Screen { Title, Library, Details, Sequence, Canvas, Viewer, Settings, Gallery, GalleryDetails, GalleryProfile, GalleryComments, CommentCanvas, AppletWarning };
enum class GalleryLoadKind { Feed, Profile };
enum class UpdateTask { None, Check, Download };
enum class Tool { Pen, Brush, Eraser };
enum class Modal { None, Folder, Audio, Branding, Export, ConfirmDelete, ConfirmGalleryDelete, GalleryPostChoice, GalleryNotice, GalleryOffline, GalleryTerms, Credits, Help, TextInput, Options, PhotoProbe, PhotoChoice, PhotoAlbum, PhotoUpload, Photo, UpdateAvailable };
enum class ServerStartTask { None, Audio, Photo, Branding };
enum class Field { None, Title, Description, FolderName, CommentText, GalleryBio };
struct ScreenState {
enum class Phase { Idle, ToWhite, FromWhite };
Screen active = Screen::Title;
Screen target = Screen::Title;
Phase phase = Phase::Idle;
std::uint32_t phaseStart = 0;
operator Screen() const { return active; }
ScreenState& operator=(Screen next) {
if (phase == Phase::Idle && active == next) return *this;
if (phase != Phase::Idle && target == next) return *this;
target = next;
phase = Phase::ToWhite;
phaseStart = SDL_GetTicks();
return *this;
}
void beginTitleIntro() {
active = target = Screen::Title;
phase = Phase::FromWhite;
phaseStart = SDL_GetTicks();
}
bool transitioning() const { return phase != Phase::Idle; }
void advance(std::uint32_t now) {
if (!transitioning() || now - phaseStart < kScreenFadeMs) return;
if (phase == Phase::ToWhite) {
active = target;
phase = Phase::FromWhite;
phaseStart = now;
} else {
phase = Phase::Idle;
}
}
Uint8 whiteAlpha(std::uint32_t now) const {
if (phase == Phase::Idle) return 0;
const float progress = std::clamp((now - phaseStart) / static_cast<float>(kScreenFadeMs), 0.0f, 1.0f);
return static_cast<Uint8>(std::lround((phase == Phase::ToWhite ? progress : 1.0f - progress) * 255.0f));
}
};
struct Item {
std::string title;
std::string path;
std::string artwork;
std::string author;
bool sample = false;
std::string description;
std::string originalAuthor;
std::string authorFsid;
std::string originalAuthorFsid;
std::string currentId;
std::string rootId;
std::uint64_t createdUnixSeconds = 0;
std::uint64_t modifiedUnixSeconds = 0;
bool allowSpinoffs = true;
bool locked = false;
std::string galleryId;
std::string fsid;
std::string ownerIdentity;
int coins = 0;
int views = 0;
std::vector<std::uint8_t> previewBytes;
};
struct GalleryComment {
std::string author;
std::string text;
std::string created;
std::vector<std::uint8_t> drawing;
};
struct GalleryProfileData {
std::string identity;
std::string username;
std::string bio;
int switchnotes = 0;
int views = 0;
int coins = 0;
int followers = 0;
int following = 0;
bool followed = false;
bool blocked = false;
std::vector<std::uint8_t> avatar;
std::vector<Item> uploads;
};
struct UndoState {
std::size_t frame = 0;
int layer = 0;
std::vector<std::uint8_t> pixels;
};
struct FrameTexture {
SDL_Texture* texture = nullptr;
std::uint64_t revision = 0;
};
const std::array<int, 8> kSpeedFps{{1, 1, 2, 4, 6, 12, 20, 30}};
const std::array<int, 8> kLegacySpeedFps{{3, 5, 8, 12, 15, 18, 24, 30}};
const std::array<double, 8> kSpeedRates{{0.4985, 1.0, 2.0, 4.0, 5.9972, 11.986, 19.9459, 29.9275}};
double selectedFrameRate(int speed, int legacyFps) {
return speed >= 1 && speed <= 8 ? kSpeedRates[static_cast<std::size_t>(speed - 1)] : std::max(1, legacyFps);
}
int speedFromFps(int fps) {
int closest = 0;
for (int i = 1; i < static_cast<int>(kLegacySpeedFps.size()); ++i) {
if (std::abs(kLegacySpeedFps[i] - fps) < std::abs(kLegacySpeedFps[closest] - fps)) closest = i;
}
return closest + 1;
}
int documentSpeed(const sppm::Metadata& metadata) {
return metadata.speed >= 1 && metadata.speed <= 8 ? metadata.speed : speedFromFps(metadata.fps);
}
std::string join(const std::string& a, const std::string& b) {
return a + "/" + b;
}
std::string percentEncode(const std::string& input) {
constexpr char hex[] = "0123456789ABCDEF";
std::string out;
out.reserve(input.size() * 3);
for (unsigned char ch : input) {
if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') ||
(ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~') {
out.push_back(static_cast<char>(ch));
} else {
out.push_back('%');
out.push_back(hex[ch >> 4]);
out.push_back(hex[ch & 15]);
}
}
return out;
}
int hexDigit(char ch) {
if (ch >= '0' && ch <= '9') return ch - '0';
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
return -1;
}
std::string hexEncode(const std::uint8_t* bytes, std::size_t size) {
constexpr char hex[] = "0123456789ABCDEF";
std::string encoded;
encoded.reserve(size * 2);
for (std::size_t i = 0; i < size; ++i) {
encoded.push_back(hex[bytes[i] >> 4]);
encoded.push_back(hex[bytes[i] & 15]);
}
return encoded;
}
bool hexDecode(const std::string& encoded, std::uint8_t* bytes, std::size_t size) {
if (encoded.size() != size * 2 || (size && !bytes)) return false;
for (std::size_t i = 0; i < size; ++i) {
const int high = hexDigit(encoded[i * 2]);
const int low = hexDigit(encoded[i * 2 + 1]);
if (high < 0 || low < 0) return false;
bytes[i] = static_cast<std::uint8_t>((high << 4) | low);
}
return true;
}
std::string percentDecode(const std::string& input) {
std::string out;
out.reserve(input.size());
for (std::size_t i = 0; i < input.size(); ++i) {
if (input[i] == '%' && i + 2 < input.size()) {
const int hi = hexDigit(input[i + 1]), lo = hexDigit(input[i + 2]);
if (hi >= 0 && lo >= 0) { out.push_back(static_cast<char>((hi << 4) | lo)); i += 2; continue; }
}
if (input[i] == '+') out.push_back(' ');
else out.push_back(input[i]);
}
return out;
}
std::vector<std::string> splitTabs(const std::string& line) {
std::vector<std::string> fields;
std::size_t start = 0;
while (true) {
const std::size_t end = line.find('\t', start);
fields.emplace_back(line.substr(start, end == std::string::npos ? std::string::npos : end - start));
if (end == std::string::npos) break;
start = end + 1;
}
return fields;
}
std::string base64Encode(const std::vector<std::uint8_t>& input) {
constexpr char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve(((input.size() + 2) / 3) * 4);
for (std::size_t i = 0; i < input.size(); i += 3) {
const std::uint32_t a = input[i];
const std::uint32_t b = i + 1 < input.size() ? input[i + 1] : 0;
const std::uint32_t c = i + 2 < input.size() ? input[i + 2] : 0;
const std::uint32_t n = (a << 16) | (b << 8) | c;
out.push_back(alphabet[(n >> 18) & 63]);
out.push_back(alphabet[(n >> 12) & 63]);
out.push_back(i + 1 < input.size() ? alphabet[(n >> 6) & 63] : '=');
out.push_back(i + 2 < input.size() ? alphabet[n & 63] : '=');
}
return out;
}
std::vector<std::uint8_t> base64Decode(const std::string& input) {
std::array<int, 256> values{};
values.fill(-1);
const std::string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
for (int i = 0; i < static_cast<int>(alphabet.size()); ++i) values[static_cast<unsigned char>(alphabet[static_cast<std::size_t>(i)])] = i;
std::vector<std::uint8_t> out;
out.reserve((input.size() / 4) * 3);
std::uint32_t accumulator = 0;
int bits = 0;
for (unsigned char ch : input) {
if (ch == '=') break;
const int value = values[ch];
if (value < 0) continue;
accumulator = (accumulator << 6) | static_cast<std::uint32_t>(value);
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back(static_cast<std::uint8_t>((accumulator >> bits) & 0xFF));
}
}
return out;
}
int blackInkIndex(const sppm::Palette& palette) {
int bestIndex = 2;
int bestDistance = std::numeric_limits<int>::max();
for (int i = 1; i < static_cast<int>(palette.size()); ++i) {
const auto& color = palette[static_cast<std::size_t>(i)];
if (!color.a) continue;
const int distance = static_cast<int>(color.r) * color.r +
static_cast<int>(color.g) * color.g +
static_cast<int>(color.b) * color.b;
if (distance < bestDistance) { bestDistance = distance; bestIndex = i; }
}
return bestIndex;
}
void logStartup(const std::string& message, bool reset = false) {
#ifdef __SWITCH__
const char* path = "sdmc:/switch/Switchnote/startup.log";
#else
const char* path = "data/startup.log";
#endif
std::ofstream log(path, std::ios::binary | (reset ? std::ios::trunc : std::ios::app));
if (log) log << message << '\n';
std::fprintf(stderr, "%s\n", message.c_str());
}
bool endsWith(const std::string& text, const std::string& suffix) {
return text.size() >= suffix.size() && text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0;
}
std::string utf8Prefix(const std::string& value, std::size_t maxCharacters) {
std::size_t offset = 0;
std::size_t characters = 0;
while (offset < value.size() && characters < maxCharacters) {
const auto lead = static_cast<unsigned char>(value[offset]);
const std::size_t bytes = lead < 0x80 ? 1 : (lead & 0xe0) == 0xc0 ? 2 : (lead & 0xf0) == 0xe0 ? 3 : (lead & 0xf8) == 0xf0 ? 4 : 1;
offset += std::min(bytes, value.size() - offset);
++characters;
}
return value.substr(0, offset);
}
void ensureDir(const std::string& path) {
std::string current;
current.reserve(path.size());
auto make = [](const std::string& directory) {
#ifdef _WIN32
_mkdir(directory.c_str());
#else
mkdir(directory.c_str(), 0777);
#endif
};
for (char ch : path) {
current.push_back(ch);
if (ch == '/' || ch == '\\') make(current);
}
make(current);
}
bool copyMissingTree(const std::string& source, const std::string& destination, bool renameLegacyNotes = false) {
struct stat sourceInfo{};
if (stat(source.c_str(), &sourceInfo) != 0) return true;
if (S_ISDIR(sourceInfo.st_mode)) {
ensureDir(destination);
DIR* directory = opendir(source.c_str());
if (!directory) return false;
bool ok = true;
while (dirent* entry = readdir(directory)) {
const std::string name = entry->d_name;
if (name == "." || name == "..") continue;
const std::string mappedName = renameLegacyNotes && name == "Flipnotes" ? "Switchnotes" : name;
if (!copyMissingTree(join(source, name), join(destination, mappedName), false)) ok = false;
}
closedir(directory);
return ok;
}
if (!S_ISREG(sourceInfo.st_mode)) return true;
struct stat destinationInfo{};
if (stat(destination.c_str(), &destinationInfo) == 0) return true;
std::ifstream input(source, std::ios::binary);
if (!input) return false;
const std::size_t separator = destination.find_last_of("/\\");
if (separator != std::string::npos) ensureDir(destination.substr(0, separator));
const std::string temporary = destination + ".migrating";
std::remove(temporary.c_str());
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) return false;
std::array<char, 16384> buffer{};
while (input) {
input.read(buffer.data(), static_cast<std::streamsize>(buffer.size()));
const std::streamsize count = input.gcount();
if (count > 0) output.write(buffer.data(), count);
}
output.flush();
output.close();
if (!input.eof() || !output) { std::remove(temporary.c_str()); return false; }
if (std::rename(temporary.c_str(), destination.c_str()) != 0) {
std::remove(temporary.c_str());
return false;
}
return true;
}
bool migrateLegacyStorage(const std::string& destination) {
#ifdef __SWITCH__
const std::string marker = join(destination, "storage-migrated-v1");
struct stat markerInfo{};
if (stat(marker.c_str(), &markerInfo) == 0) return true;
const std::string legacy = "sdmc:/switch/FlipnoteNX";
struct stat legacyInfo{};
if (stat(legacy.c_str(), &legacyInfo) != 0) return true;
DIR* directory = opendir(legacy.c_str());
if (!directory) return false;
bool ok = true;
while (dirent* entry = readdir(directory)) {
const std::string name = entry->d_name;
if (name == "." || name == "..") continue;
if (endsWith(name, ".nro")) continue;
const bool notes = name == "flipnotes";
if (!copyMissingTree(join(legacy, name), join(destination, notes ? "switchnotes" : name), notes)) ok = false;
}
closedir(directory);
if (!ok) return false;
std::ofstream complete(marker, std::ios::binary | std::ios::trunc);
if (!complete) return false;
complete << "1\n";
complete.flush();
return static_cast<bool>(complete);
#else
(void)destination;
return true;
#endif
}
std::uint64_t nowSeconds() { return static_cast<std::uint64_t>(std::time(nullptr)); }
std::string dateText(std::uint64_t seconds) {
if (!seconds) return "Unknown";
std::time_t value = static_cast<std::time_t>(seconds);
std::tm* tm = std::localtime(&value);
if (!tm) return "Unknown";
char buffer[64]{};
std::strftime(buffer, sizeof(buffer), "%B %d %Y %H:%M:%S", tm);
return buffer;
}
std::string defaultSwitchnoteTitle(const std::string& nickname, const std::string& noteName) {
return noteName + " by " + (nickname.empty() ? noteName + " User" : nickname);
}
bool randomBytes(void* out, std::size_t size) {
#ifdef __SWITCH__
return R_SUCCEEDED(csrngGetRandomBytes(out, size));
#else
try {
std::random_device rd;
auto* bytes = static_cast<std::uint8_t*>(out);
for (std::size_t i = 0; i < size; ++i) bytes[i] = static_cast<std::uint8_t>(rd());
return true;
} catch (...) {
return false;
}
#endif
}
void appendIdentityMaterial(std::vector<std::uint8_t>& out, std::uint8_t field,
const std::uint8_t* bytes, std::size_t length) {
out.push_back(field);
out.push_back(static_cast<std::uint8_t>(length));
out.insert(out.end(), bytes, bytes + length);
}
bool getDeviceMaterial(std::vector<std::uint8_t>& material) {
constexpr char domain[] = "FlipnoteStudio-console-binding-v2";
material.assign(domain, domain + sizeof(domain));
#ifdef __SWITCH__
bool found = false;
Uuid pseudoId{};
if (R_SUCCEEDED(appletGetPseudoDeviceId(&pseudoId))) {
appendIdentityMaterial(material, 1, pseudoId.uuid, sizeof(pseudoId.uuid));
found = true;
}
if (R_SUCCEEDED(setcalInitialize())) {
SetCalMacAddress mac{};
if (R_SUCCEEDED(setcalGetWirelessLanMacAddress(&mac))) {
appendIdentityMaterial(material, 2, mac.addr, sizeof(mac.addr));
found = true;
}
u64 calibratedDeviceId = 0;
if (R_SUCCEEDED(setcalGetDeviceId(&calibratedDeviceId)) && calibratedDeviceId != 0) {
const auto* bytes = reinterpret_cast<const std::uint8_t*>(&calibratedDeviceId);
appendIdentityMaterial(material, 3, bytes, sizeof(calibratedDeviceId));
found = true;
}
setcalExit();
}
if (R_SUCCEEDED(splInitialize())) {
u64 securityDeviceId = 0;
if (R_SUCCEEDED(splGetConfig(SplConfigItem_DeviceId, &securityDeviceId)) && securityDeviceId != 0) {
const auto* bytes = reinterpret_cast<const std::uint8_t*>(&securityDeviceId);
appendIdentityMaterial(material, 5, bytes, sizeof(securityDeviceId));
found = true;
}
splExit();
}
if (!found) {
material.clear();
return false;
}
return true;
#else
const char* machine = std::getenv("COMPUTERNAME");
if (!machine || !*machine) machine = std::getenv("HOSTNAME");
if (machine && *machine) {
appendIdentityMaterial(material, 4, reinterpret_cast<const std::uint8_t*>(machine), std::strlen(machine));
} else {
const std::string fallback = "local-development-profile";
appendIdentityMaterial(material, 4, reinterpret_cast<const std::uint8_t*>(fallback.data()), fallback.size());
}
return true;
#endif
}
bool readBinaryFile(const std::string& path, std::vector<std::uint8_t>& bytes) {
std::ifstream input(path, std::ios::binary | std::ios::ate);
if (!input) return false;
const std::streamoff size = input.tellg();
if (size < 0 || size > 4096) return false;
bytes.resize(static_cast<std::size_t>(size));
input.seekg(0, std::ios::beg);
if (!bytes.empty()) input.read(reinterpret_cast<char*>(bytes.data()), size);
return input.good() || (input.eof() && input.gcount() == size);
}
bool loadEncryptedIdentity(const std::string& path,
const std::vector<std::uint8_t>& deviceMaterial,
sppm::SigningIdentity& identity) {
constexpr std::size_t fileBytes = 4 + 16 + 24 + 32 + 16 + 128;
std::vector<std::uint8_t> bytes;
if (!readBinaryFile(path, bytes) || bytes.size() != fileBytes ||
std::memcmp(bytes.data(), "FNI2", 4) != 0) return false;
std::size_t at = 4;
std::array<std::uint8_t, 16> salt{};
std::array<std::uint8_t, 24> nonce{};
std::array<std::uint8_t, 128> sealed{};
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(at), salt.size(), salt.begin()); at += salt.size();
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(at), nonce.size(), nonce.begin()); at += nonce.size();
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(at), identity.publicIdentity.key.size(), identity.publicIdentity.key.begin()); at += identity.publicIdentity.key.size();
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(at), identity.publicIdentity.deviceTag.size(), identity.publicIdentity.deviceTag.begin()); at += identity.publicIdentity.deviceTag.size();
std::copy_n(bytes.begin() + static_cast<std::ptrdiff_t>(at), sealed.size(), sealed.begin());
std::vector<std::uint8_t> tagMaterial = deviceMaterial;
tagMaterial.insert(tagMaterial.end(), salt.begin(), salt.end());
if (sppm::deriveDeviceTag(tagMaterial) != identity.publicIdentity.deviceTag) return false;
const auto wrapKey = sppm::deriveDeviceWrapKey(deviceMaterial, salt);
std::string error;
if (!sppm::unsealSigningSecret(sealed, wrapKey, nonce, identity, error) ||
!std::equal(identity.publicIdentity.key.begin(), identity.publicIdentity.key.end(), identity.secretKey.begin() + 32)) return false;
return sppm::hasPublicIdentity(identity.publicIdentity);
}
bool createEncryptedIdentity(const std::string& dataRoot,
const std::vector<std::uint8_t>& deviceMaterial,
const std::array<std::uint8_t, 32>& legacyKey,
sppm::SigningIdentity& identity,
std::string& pathOut) {
std::array<std::uint8_t, 16> salt{};
std::array<std::uint8_t, 24> nonce{};
if (!randomBytes(salt.data(), salt.size()) || !randomBytes(nonce.data(), nonce.size())) return false;
std::vector<std::uint8_t> tagMaterial = deviceMaterial;
tagMaterial.insert(tagMaterial.end(), salt.begin(), salt.end());
std::string error;
if (!sppm::createSigningIdentity(sppm::deriveDeviceTag(tagMaterial), identity, error)) return false;
identity.legacyHmacKey = legacyKey;
const auto wrapKey = sppm::deriveDeviceWrapKey(deviceMaterial, salt);
std::array<std::uint8_t, 128> sealed{};
if (!sppm::sealSigningSecret(identity, wrapKey, nonce, sealed, error)) return false;
constexpr char hex[] = "0123456789ABCDEF";
std::string saltName;
saltName.reserve(32);
for (std::uint8_t byte : salt) { saltName.push_back(hex[byte >> 4]); saltName.push_back(hex[byte & 15]); }
pathOut = join(dataRoot, "identity-v2-" + saltName + ".bin");
std::vector<std::uint8_t> bytes{'F', 'N', 'I', '2'};
bytes.insert(bytes.end(), salt.begin(), salt.end());
bytes.insert(bytes.end(), nonce.begin(), nonce.end());
bytes.insert(bytes.end(), identity.publicIdentity.key.begin(), identity.publicIdentity.key.end());
bytes.insert(bytes.end(), identity.publicIdentity.deviceTag.begin(), identity.publicIdentity.deviceTag.end());
bytes.insert(bytes.end(), sealed.begin(), sealed.end());
std::ofstream output(pathOut, std::ios::binary | std::ios::out);
if (!output) return false;
output.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
return static_cast<bool>(output);
}
struct App {
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
SDL_GameController* controller = nullptr;
bool romfsReady = false;
bool csrngReady = false;
bool accountReady = false;
bool gallerySocketsReady = false;
bool gallerySslReady = false;
bool nifmReady = false;
bool sdlReady = false;
bool imageReady = false;
bool ttfReady = false;
bool audioReady = false;
bool appletMode = false;
#ifdef __SWITCH__
bool irsReady = false;
bool irCameraRunning = false;
IrsIrCameraHandle irCamera{};
Thread photoProbeThread{};
bool photoProbeThreadCreated = false;
bool photoProbeRunning = false;
std::atomic<bool> photoProbeFinished{false};
IrsImageTransferProcessorState irImageState{};
std::vector<std::uint8_t> irImage = std::vector<std::uint8_t>(320 * 240, 0);
std::vector<std::uint8_t> irPhoto = std::vector<std::uint8_t>(320 * 240, 0);
std::vector<std::uint8_t> irPreviewRgba = std::vector<std::uint8_t>(320 * 240 * 4, 255);
SDL_Texture* irPreviewTexture = nullptr;
bool irImageReady = false;
bool irPhotoTaken = false;
bool irRotatePhoto = false;
#endif
AudioUploadServer audioUploadServer;
BrandingConfigServer brandingConfigServer;
std::string audioUploadUrl;
std::string audioUploadCode;
std::string audioUploadStatus;
bool photoTransferActive = false;
bool photoImporting = false;
bool photoProbeHasCamera = false;
std::vector<std::uint8_t> photoImportRgba;
int photoImportRow = 0;
int photoImportLayer = 0;
int photoAlbumPage = 0;
bool galleryTermsAccepted = false;
#ifdef __SWITCH__
std::vector<CapsAlbumEntry> photoAlbumEntries;
std::array<SDL_Texture*, 6> photoAlbumThumbs{};
std::array<std::size_t, 6> photoAlbumThumbIndexes{};
#endif
std::string brandingUrl;
std::string brandingCode;
std::string brandingStatus;
std::string appDisplayName = "Switchnote Studio";
std::string noteSingular = "Switchnote";
std::string notePlural = "Switchnotes";
std::string brandingLogoPath;
SDL_Texture* brandingLogoTexture = nullptr;
bool renderingModal = false;
int loadingDepth = 0;
std::uint32_t loadingStartedAt = 0;
std::uint32_t loadingFinishedAt = 0;
std::uint32_t completedLoadingDuration = 0;
bool loadingCompletionVisible = false;
SDL_Thread* updateThread = nullptr;
std::atomic<bool> updateWorkerDone{false};
bool updateInProgress = false;
bool updateManualCheck = false;
bool updateOfferPending = false;
bool updateAvailable = false;
bool updateNoChange = false;
std::uint32_t updateStartedAt = 0;
UpdateTask updateTask = UpdateTask::None;
std::string updateServerVersion;
std::string updateDownloadUrl;
std::string updateSha256;
std::string updateError;
std::string updateDownloadedPath;
int updateHttpStatus = 0;
std::string launchNroPath;
std::string updateInstallSourcePath;
bool updateInstallPending = false;
std::uint32_t updateInstallReadyAt = 0;
bool photoModalTransitionDim = false;
std::uint32_t photoModalTransitionDimAt = 0;
std::array<SDL_Texture*, 8> loadingFrogFrames{};
bool running = true;
std::string assetsRoot;
std::string dataRoot;
std::string notesRoot;
std::string exportRoot;
std::string importRoot;
std::string folder = "Switchnotes";
std::string userName = "Switchnote User";
std::string myFsid;
std::vector<std::uint8_t> profileImageBytes;
SDL_Texture* profileTexture = nullptr;
SDL_Texture* profileCornerTexture = nullptr;
SDL_Texture* profileDetailTexture = nullptr;
sppm::SigningIdentity signingIdentity{};
std::map<std::string, SDL_Texture*> images;
std::map<std::string, SDL_Texture*> whiteImages;
std::map<std::pair<int, bool>, TTF_Font*> fonts;
std::unordered_map<std::string, SDL_Texture*> textCache;
std::unordered_map<std::uint64_t, float> hoverMotion;
std::map<std::string, Mix_Chunk*> cues;
std::array<Mix_Chunk*, 4> trackChunks{};
Mix_Chunk* bgmSeekChunk = nullptr;
std::array<std::vector<Uint8>, 4> trackAudioBuffers{};
std::map<std::size_t, FrameTexture> frameTextures;
std::uint64_t docRevision = 1;
ScreenState screen;
Modal modal = Modal::None;
Field editingField = Field::None;
std::string inputText;
std::string toast;
std::uint32_t toastUntil = 0;
std::vector<Item> library;
std::vector<Item> gallery;
std::vector<GalleryComment> galleryComments;
GalleryProfileData galleryProfile;
SDL_Texture* galleryProfileAvatarTexture = nullptr;
bool galleryProfileIsOwn = false;
Screen galleryProfileReturnScreen = Screen::Gallery;
int galleryProfilePage = 0;
bool galleryProfileActive = false;
bool galleryProfileDetailMode = false;
std::vector<Item> galleryBeforeProfile;
std::size_t gallerySelectedBeforeProfile = 0;
int galleryPageBeforeProfile = 0;
int galleryTotalBeforeProfile = 0;
std::size_t selected = 0;
std::size_t gallerySelected = 0;
int page = 0;
int galleryPage = 0;
int galleryTotal = 0;
bool galleryPopular = false;
bool galleryCommentsLoaded = false;
int galleryCommentScroll = 0;
bool galleryCommentTouchScroll = false;
int galleryCommentTouchStartY = 0;
int galleryCommentTouchStartOffset = 0;
std::uint32_t lastGalleryCommentScrollTick = 0;
std::string galleryStatus;
std::size_t pendingGalleryPublishIndex = 0;
bool galleryLoadRunning = false;
std::atomic<bool> galleryLoadFinished{false};
std::string galleryLoadPath;
GalleryLoadKind galleryLoadKind = GalleryLoadKind::Feed;
std::vector<std::uint8_t> galleryLoadResponse;
int galleryLoadHttpStatus = 0;
bool galleryLoadSucceeded = false;
std::string galleryLoadError;
bool serverStartRunning = false;
std::atomic<bool> serverStartFinished{false};
bool serverStartSucceeded = false;
ServerStartTask serverStartTask = ServerStartTask::None;
#ifdef __SWITCH__
Thread galleryLoadThread{};
bool galleryLoadThreadCreated = false;
Thread serverStartThread{};
bool serverStartThreadCreated = false;
#endif
std::string commentDraft;
std::unordered_map<std::string, SDL_Texture*> galleryCommentTextures;
sppm::Document doc;
bool docLoaded = false;
bool galleryViewing = false;
bool dirty = false;
std::uint32_t dirtyTick = 0;
std::string docPath;
std::size_t frame = 0;
int layer = 0;
int color = 2;
int defaultFps = 12;
int defaultSpeed = 6;
int modalSelection = 0;
Screen settingsReturnScreen = Screen::Library;
Modal lastRenderedModal = Modal::None;
Modal modalVisual = Modal::None;
bool modalClosing = false;
std::uint32_t modalTransitionStart = 0;
std::uint32_t lastScreenCueStart = 0;
int brush = 4;
int pattern = 0;
int eraserSlot = 1;
int penType = 0;
std::uint16_t penRngState = 0x1F01;
int squiggleAnchorX = -1;
int squiggleAnchorY = -1;
int ringPreviousX = -1;
int ringPreviousY = -1;
bool square = false;
bool advancedBrush = false;
Tool activeTool = Tool::Pen;
bool canvasYChordUsed = false;
bool penStabilisationEnabled = false;
bool penStabilisationAfter = false;
int penStabilisationStrength = 50;
bool stabilisedAfterReplay = false;
bool realtimeStrokeInitialized = false;
float realtimeStrokeX = 0.0f;
float realtimeStrokeY = 0.0f;
std::vector<std::pair<int, int>> afterStrokePoints;
bool onion = true;
bool canvasGrid = false;
bool copyToNewPage = false;
bool expandMetadata = false;
bool paintMenu = false;
bool colorMenu = false;
bool toolMenu = false;
bool canvasMenuOpen = false;
float canvasMenuProgress = 0.0f;
std::uint32_t lastCanvasMenuTick = 0;
bool playing = false;
float zoom = 1.0f;
int panX = 0;
int panY = 0;
std::uint32_t lastPlaybackTick = 0;
double playbackTickRemainder = 0.0;
sppm::Frame frameClipboard;
bool clipboardReady = false;
std::vector<UndoState> undoStack;
std::vector<UndoState> redoStack;
int audioTrack = 0;
int audioEraseArmedTrack = -1;
std::vector<std::string> importFiles;
std::size_t importSelection = 0;
std::vector<std::uint8_t> commentPixels = std::vector<std::uint8_t>(sppm::kPixelsPerLayer, 0);
bool commentDirty = true;
SDL_Texture* commentTexture = nullptr;
int pointerX = 0;
int pointerY = 0;
bool pointerDown = false;
bool pointerPressed = false;
bool pointerReleased = false;
bool clickConsumed = false;
bool touchHoverExpiryPending = false;
// A canvas press that began on visible chrome remains a chrome gesture for
// its entire lifetime. Once a drawing gesture starts, the chrome can move
// away without creating invisible "no draw" rectangles underneath it.
bool canvasControlGesture = false;
bool drawing = false;
bool hasLastStroke = false;
int lastStrokeX = 0;
int lastStrokeY = 0;
bool canvasTapPending = false;
bool canvasFillCommitted = false;
int canvasPressScreenX = 0;
int canvasPressScreenY = 0;
int canvasPressPixelX = 0;
int canvasPressPixelY = 0;
std::uint32_t canvasPressStartedAt = 0;
bool fillWaveActive = false;
int fillWaveOriginX = 0;
int fillWaveOriginY = 0;
float fillWaveRadius = 0.0f;
std::uint32_t fillWaveTick = 0;
std::size_t fillWaveFrame = 0;
int fillWaveLayer = -1;
std::uint8_t fillWaveColor = 0;
std::vector<std::pair<std::uint32_t, std::uint16_t>> fillWavePixels;
std::size_t fillWaveCursor = 0;
int wheel = 0;
std::uint32_t buttonsDown = 0;
std::uint32_t buttonsUp = 0;
std::uint32_t buttonsHeld = 0;
std::uint32_t rightDpadNextRepeatAt = 0;
bool rightDpadRepeating = false;
float pulse = 0;
float canvasChromeVisible = 1.0f;
std::uint32_t lastChromeTick = 0;
float leftStickX = 0.0f;
float leftStickY = 0.0f;
std::uint32_t lastPanTick = 0;
bool init(int argc, char** argv);
void shutdown();
void run();
void poll();
void handleKeys();
void update();
void startUpdateCheck(bool manual);
void startUpdateDownload();
bool requestUpdateHandoff();
void completeUpdateInstall();
void runUpdateTask();
void consumeUpdateTask();
static int updateThreadEntry(void* context);
void render();
void renderTitle();
void renderAppletWarning();
void renderLibrary();
void renderSettings();
void renderDetails(bool galleryMode);
void renderSequence();
void renderCanvas();
void renderViewer();
void renderGallery();
void renderGalleryProfile();
void renderComments();
void renderCommentCanvas();
void renderModal();
void drawPlayTriangle(int x, int y, int size, SDL_Color color = ORANGE);
void drawLockIcon(int x, int y);
void renderToast();
void renderLoadingOverlay();
void beginLoading();
void endLoading();
void beginFillWave(int x, int y);
void updateFillWave(std::uint32_t now);
void background();
SDL_Texture* image(const std::string& relative);
SDL_Texture* whiteImage(const std::string& relative);
SDL_Texture* loadBrandingLogo(const std::string& path);
void drawImage(const std::string& relative, int x, int y, int w, int h, bool nearest = false);
void drawTexture(SDL_Texture* texture, int x, int y, int w, int h);
TTF_Font* font(int px, bool bold);
std::string brandString(const std::string& label) const;
void rawText(const std::string& label, int x, int y, int px, SDL_Color color, bool bold);
void text(const std::string& label, int x, int y, int px, SDL_Color color, bool bold = false);
void centered(const std::string& label, int x, int y, int w, int h, int px, SDL_Color color, bool bold = false);
void fill(int x, int y, int w, int h, SDL_Color c);
void rounded(int x, int y, int w, int h, int radius, SDL_Color c);
void roundedOutline(int x, int y, int w, int h, int radius, int thickness, SDL_Color c);
bool hit(int x, int y, int w, int h);
int hoverLift(int x, int y, bool hovered);
bool pill(const std::string& label, int x, int y, int w, int h, bool green = false, int px = 22, bool enabled = true, const std::string& cue = "button_on");
bool actionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, bool green = false, bool enabled = true, const std::string& cue = "button_on");
bool dangerPill(const std::string& label, int x, int y, int w, int h, int px = 18, bool enabled = true);
bool dangerActionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, int px = 19, bool enabled = true);
bool imagePill(const std::string& icon, int x, int y, int w, int h, bool green = false, bool enabled = true, const std::string& cue = "button_on");
bool imageButton(const std::string& path, int x, int y, int w, int h, const std::string& cue = "button_on");
void showToast(const std::string& message);
void playCue(const std::string& name);
void playEvent(const std::string& event, const std::string& defaultCue);
void loadPreferences();
void savePreferences();
bool loadIdentity();
std::string newId(const char* domain);
void scanLibrary();
void scanImports();
void enterGallery();
void showGalleryNotice();
void confirmGalleryLeave();
void refreshGallery();
void loadGalleryProfile(const std::string& identity, Screen returnScreen, int pageIndex = 0);
void leaveGalleryProfile();
void finishGalleryRefresh(const std::vector<std::uint8_t>& response, int status, const std::string& error);
void finishGalleryProfile(const std::vector<std::uint8_t>& response, int status, const std::string& error);
void saveGalleryProfile(bool quiet = false);
void updateGalleryRelationship(const char* action);
void consumeGalleryLoad();
bool internetConnected();
#ifdef __SWITCH__
static void galleryLoadThreadEntry(void* argument);
void runGalleryLoadThread();
#endif
void refreshGalleryComments();
void openGalleryComments();
void openItem(std::size_t index, bool edit);
void openGalleryItem(std::size_t index);
void saveGalleryItem(std::size_t index);
void publishGalleryItem(std::size_t index);
void performGalleryUpload(std::size_t index, bool locked);
void addGalleryCoin();
void deleteGalleryItem();
void postGalleryComment();
SDL_Texture* galleryPreviewTexture(const Item& item);
SDL_Texture* galleryCommentTexture(const GalleryComment& comment, std::size_t index);
bool galleryRequest(const std::string& method, const std::string& path,
const std::vector<std::uint8_t>& requestBody,
const std::string& contentType,
std::vector<std::uint8_t>& responseBody, int& status,
std::string& error, std::string* responseHeaders = nullptr);
bool loadSample(const Item& item, sppm::Document& out);
bool startAudioUploadServer(bool photoTransfer = false);
void requestAudioUploadServer(bool photoTransfer = false);
void stopAudioUploadServer();
void closeAudioMenu();
void consumeAudioUpload();
bool startBrandingConfigServer();
void requestServerStart(ServerStartTask task);
void requestBrandingConfigServer();
void consumeServerStart();
void consumePhotoProbe();
#ifdef __SWITCH__
static void serverStartThreadEntry(void* argument);
void runServerStartThread();
static void photoProbeThreadEntry(void* argument);
void runPhotoProbeThread();
#endif
void stopBrandingConfigServer();
void closeBrandingMenu();
void consumeBrandingConfig();
void importBrandingConfig();
void exportBrandingConfig();
void newDocument();
bool saveDocument(bool force = false);
void closeDocument();
void deleteDocument();
void forkDocument();
void markDirty();
void invalidateFrames();
SDL_Texture* frameTexture(std::size_t index);
SDL_Texture* thumbTexture(const Item& item);
void drawCurrentFrame(int x, int y, int w, int h, bool withOnion = false);
void drawFrameImage(std::size_t index, int x, int y, int w, int h);
void drawDocumentControls(bool galleryColor = false);
void playControl(int x, int y, int w, int h);
void setFrame(std::size_t index, bool playNavigationCue = true, const char* event = nullptr);
void startPlayback();
void stopPlayback(bool playStopCue = true);
void updatePlayback(std::uint32_t now);
void playFrameSound();
void startBgmAtCurrentFrame();
void rebuildAudio();
void insertFrame(bool paste);
void eraseFrame();
void copyFrame();
void undo();
void redo();
void pushUndo();
bool paintPixel(int sx, int sy, std::vector<std::uint8_t>& pixels);
void strokeAt(int sx, int sy, bool comment = false);
void finishAfterStroke();
void beginText(Field field, const std::string& current);
void applyText();
void importAudio();
bool importAudioFile(const std::string& path, int track, const std::string& displayName);
void exportDocument(int destination);
void openPhotoCapture();
void closePhotoCapture();
void updatePhotoCapture();
void importPhoto();
void openPhotoUpload();
void openPhotoAlbum();
void importPhotoAlbumEntry(std::size_t index);
bool importUploadedPhoto();
void advancePhotoImport();
};
struct LoadingScope {
App& app;
explicit LoadingScope(App& target) : app(target) { app.beginLoading(); }
~LoadingScope() { app.endLoading(); }
};
enum ButtonBits : std::uint32_t {
BA = 1u << 0, BB = 1u << 1, BX = 1u << 2, BY = 1u << 3,
BL = 1u << 4, BR = 1u << 5, BZL = 1u << 6, BZR = 1u << 7,
BLEFT = 1u << 8, BRIGHT = 1u << 9, BUP = 1u << 10, BDOWN = 1u << 11,
BPLUS = 1u << 12, BMINUS = 1u << 13, BLS = 1u << 14, BRS = 1u << 15, BMISC = 1u << 16
};
std::uint32_t controllerBit(std::uint8_t b) {
switch (b) {
// SDL names the standardized Xbox-position buttons; Nintendo labels
// put A on the right, B on the bottom, X on top and Y on the left.
case SDL_CONTROLLER_BUTTON_B: return BA;
case SDL_CONTROLLER_BUTTON_A: return BB;
case SDL_CONTROLLER_BUTTON_Y: return BX;
case SDL_CONTROLLER_BUTTON_X: return BY;
case SDL_CONTROLLER_BUTTON_LEFTSHOULDER: return BL;
case SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: return BR;
case SDL_CONTROLLER_BUTTON_DPAD_LEFT: return BLEFT;
case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: return BRIGHT;
case SDL_CONTROLLER_BUTTON_DPAD_UP: return BUP;
case SDL_CONTROLLER_BUTTON_DPAD_DOWN: return BDOWN;
case SDL_CONTROLLER_BUTTON_START: return BPLUS;
case SDL_CONTROLLER_BUTTON_BACK: return BMINUS;
case SDL_CONTROLLER_BUTTON_LEFTSTICK: return BLS;
case SDL_CONTROLLER_BUTTON_RIGHTSTICK: return BRS;
case SDL_CONTROLLER_BUTTON_MISC1: return BMISC;
default: return 0;
}
}
} // namespace
int main(int argc, char** argv) {
App app;
if (!app.init(argc, argv)) { app.shutdown(); return 1; }
app.run();
app.shutdown();
return 0;
}
namespace {
bool App::init(int argc, char** argv) {
logStartup("startup: entering init", true);
#ifdef __SWITCH__
if (argc > 0 && argv && argv[0]) launchNroPath = argv[0];
logStartup("startup: loader NRO path=" + (launchNroPath.empty() ? std::string("<empty>") : launchNroPath));
assetsRoot = "romfs:";
dataRoot = "sdmc:/switch/Switchnote";
const Result romfsResult = romfsInit();
if (R_FAILED(romfsResult)) {
logStartup("startup: romfsInit failed: " + std::to_string(static_cast<unsigned int>(romfsResult)));
return false;
}
romfsReady = true;
logStartup("startup: romfs ready");
appletMode = appletGetAppletType() == AppletType_LibraryApplet;
if (appletMode) logStartup("startup: LibraryApplet mode detected; showing compatibility warning");
else {
// libnx mounts sdmc before main() as part of its default runtime startup.
const Result csrngResult = csrngInitialize();
if (R_FAILED(csrngResult)) {
logStartup("startup: csrngInitialize failed: " + std::to_string(static_cast<unsigned int>(csrngResult)));
return false;
}
csrngReady = true;
const Result accountResult = accountInitialize(AccountServiceType_Application);
logStartup("profile: accountInitialize=" + std::to_string(static_cast<unsigned int>(accountResult)));
if (R_SUCCEEDED(accountResult)) {
accountReady = true;
std::vector<AccountUid> candidates;
const auto addCandidate = [&candidates](const AccountUid& uid) {
if (!accountUidIsValid(&uid)) return;
const auto duplicate = std::find_if(candidates.begin(), candidates.end(), [&uid](const AccountUid& item) {
return item.uid[0] == uid.uid[0] && item.uid[1] == uid.uid[1];
});
if (duplicate == candidates.end()) candidates.push_back(uid);
};
AccountUid uid{};
const Result preselectedResult = accountGetPreselectedUser(&uid);
if (R_SUCCEEDED(preselectedResult)) addCandidate(uid);
logStartup("profile: preselected-user=" + std::to_string(static_cast<unsigned int>(preselectedResult)));
uid = {};
const Result lastOpenedResult = accountGetLastOpenedUser(&uid);
if (R_SUCCEEDED(lastOpenedResult)) addCandidate(uid);
logStartup("profile: last-opened-user=" + std::to_string(static_cast<unsigned int>(lastOpenedResult)));
uid = {};
const Result implicitResult = accountTrySelectUserWithoutInteraction(&uid, false);
if (R_SUCCEEDED(implicitResult)) addCandidate(uid);
logStartup("profile: implicit-user=" + std::to_string(static_cast<unsigned int>(implicitResult)));
std::array<AccountUid, ACC_USER_LIST_SIZE> users{};
s32 actualUserCount = 0;
const Result listResult = accountListAllUsers(users.data(), ACC_USER_LIST_SIZE, &actualUserCount);
if (R_SUCCEEDED(listResult)) {
for (s32 i = 0; i < std::clamp(actualUserCount, 0, ACC_USER_LIST_SIZE); ++i) addCandidate(users[static_cast<std::size_t>(i)]);
}
logStartup("profile: user-list=" + std::to_string(static_cast<unsigned int>(listResult)) +
" count=" + std::to_string(actualUserCount));
for (std::size_t candidateIndex = 0; candidateIndex < candidates.size(); ++candidateIndex) {
AccountProfile profile{};
const Result profileResult = accountGetProfile(&profile, candidates[candidateIndex]);
if (R_FAILED(profileResult)) {
logStartup("profile: get-profile failed candidate=" + std::to_string(candidateIndex) +
" result=" + std::to_string(static_cast<unsigned int>(profileResult)));
continue;
}
AccountProfileBase profileBase{};
const Result profileDataResult = accountProfileGet(&profile, nullptr, &profileBase);
if (R_FAILED(profileDataResult)) {
logStartup("profile: profile-data failed candidate=" + std::to_string(candidateIndex) +
" result=" + std::to_string(static_cast<unsigned int>(profileDataResult)));
accountProfileClose(&profile);
continue;
}
profileBase.nickname[sizeof(profileBase.nickname) - 1] = '\0';
const bool nicknameFound = profileBase.nickname[0] != '\0';
if (nicknameFound) userName = profileBase.nickname;
u32 imageSize = 0;
const Result imageSizeResult = accountProfileGetImageSize(&profile, &imageSize);
if (R_SUCCEEDED(imageSizeResult) && imageSize > 0 && imageSize <= 4U * 1024U * 1024U) {
std::vector<std::uint8_t> jpeg(imageSize);
u32 loadedSize = 0;
const Result imageLoadResult = accountProfileLoadImage(&profile, jpeg.data(), jpeg.size(), &loadedSize);
if (R_SUCCEEDED(imageLoadResult) && loadedSize > 0 && loadedSize <= jpeg.size()) {
jpeg.resize(loadedSize);
profileImageBytes = std::move(jpeg);
} else {
logStartup("profile: image-load failed candidate=" + std::to_string(candidateIndex) +
" result=" + std::to_string(static_cast<unsigned int>(imageLoadResult)));
}
} else {
logStartup("profile: image-size unavailable candidate=" + std::to_string(candidateIndex) +
" result=" + std::to_string(static_cast<unsigned int>(imageSizeResult)) +
" size=" + std::to_string(imageSize));
}
accountProfileClose(&profile);
if (nicknameFound || !profileImageBytes.empty()) {
logStartup("profile: loaded system profile candidate=" + std::to_string(candidateIndex) +
(profileImageBytes.empty() ? " (nickname only)" : " (nickname and image)"));
break;
}
}
if (userName == "Switchnote User" && profileImageBytes.empty()) logStartup("profile: no usable system profile; using fallback");
}
}
#else
assetsRoot = "romfs";
dataRoot = "data";
#endif
if (!appletMode) {
notesRoot = join(dataRoot, "switchnotes");
exportRoot = join(dataRoot, "exports");
importRoot = join(dataRoot, "import");
ensureDir(dataRoot);
if (!migrateLegacyStorage(dataRoot)) {
logStartup("startup: could not migrate existing Switchnote data");
return false;
}
ensureDir(notesRoot);
ensureDir(exportRoot);
ensureDir(importRoot);
ensureDir(join(notesRoot, folder));
const std::string updatePrefix = join(join(dataRoot, "updates"), "Switchnote-");
const std::string updateSuffix = ".nro";
const std::string canonicalLaunchPath = canonicalSdPath(launchNroPath);
const std::string canonicalUpdatePrefix = canonicalSdPath(updatePrefix);
if (argc > 1 && argv && argv[1] && std::string(argv[1]) == "--switchnote-update-handoff" &&
canonicalLaunchPath.rfind(canonicalUpdatePrefix, 0) == 0 &&
canonicalLaunchPath.size() > canonicalUpdatePrefix.size() + updateSuffix.size() &&
canonicalLaunchPath.compare(canonicalLaunchPath.size() - updateSuffix.size(), updateSuffix.size(), updateSuffix) == 0 &&
validNroFile(launchNroPath)) {
updateInstallPending = true;
updateInstallSourcePath = launchNroPath;
logStartup("update: launched verified NRO from update folder; install handoff armed");
}
if (!loadIdentity()) {
logStartup("startup: identity creation failed");
return false;
}
loadPreferences();
logStartup("startup: identity ready");
}
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear");
const Uint32 sdlFlags = SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK |
(appletMode ? 0U : SDL_INIT_AUDIO);
if (SDL_Init(sdlFlags) != 0) {
logStartup("startup: SDL_Init failed: " + std::string(SDL_GetError()));
return false;
}
sdlReady = true;
const int imageFlags = IMG_Init(IMG_INIT_PNG | IMG_INIT_JPG);
imageReady = true;
if ((imageFlags & IMG_INIT_PNG) == 0) {
logStartup("startup: SDL_image PNG initialization failed: " + std::string(IMG_GetError()));
return false;
}
if (TTF_Init() != 0) {
logStartup("startup: TTF_Init failed: " + std::string(TTF_GetError()));
return false;
}
ttfReady = true;
window = SDL_CreateWindow("Switchnote Studio", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
W, H, SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE);
if (!window) { logStartup("startup: SDL_CreateWindow failed: " + std::string(SDL_GetError())); return false; }
SDL_SetWindowTitle(window, appDisplayName.c_str());
renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
if (!renderer) renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_SOFTWARE);
if (!renderer) { logStartup("startup: SDL_CreateRenderer failed: " + std::string(SDL_GetError())); return false; }
SDL_RenderSetLogicalSize(renderer, W, H);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
if (appletMode) screen.active = screen.target = Screen::AppletWarning;
// Decode the complete frog loop before the first real loading event. Lazy
// PNG decoding here caused a hitch precisely when the overlay was needed.
if (!appletMode) {
for (std::size_t i = 0; i < loadingFrogFrames.size(); ++i) {
const std::string customFrame = join(dataRoot, "branding-loading" + std::to_string(i) + ".png");
std::ifstream custom(customFrame, std::ios::binary);
loadingFrogFrames[i] = custom ? loadBrandingLogo(customFrame)
: image("ui/loading/frog" + std::to_string(i) + ".png");
}
const std::string titleLogoPath = brandingLogoPath.empty()
? join(assetsRoot, "art/switchnotedefault.png") : brandingLogoPath;
if (!titleLogoPath.empty()) {
brandingLogoTexture = loadBrandingLogo(titleLogoPath);
if (!brandingLogoTexture) logStartup("title logo could not be decoded: " + titleLogoPath + " :: " + IMG_GetError());
}
}
if (!profileImageBytes.empty() && profileImageBytes.size() <= static_cast<std::size_t>(std::numeric_limits<int>::max())) {
SDL_RWops* stream = SDL_RWFromConstMem(profileImageBytes.data(), static_cast<int>(profileImageBytes.size()));
SDL_Surface* source = stream ? IMG_Load_RW(stream, 1) : nullptr;
if (!source) logStartup("profile: JPEG decode failed: " + std::string(IMG_GetError()));
auto createProfileTexture = [&](int edge) -> SDL_Texture* {
SDL_Surface* avatar = source ? SDL_CreateRGBSurfaceWithFormat(0, edge, edge, 32, SDL_PIXELFORMAT_RGBA32) : nullptr;
if (!avatar) return nullptr;
if (SDL_BlitScaled(source, nullptr, avatar, nullptr) != 0) { SDL_FreeSurface(avatar); return nullptr; }
if (SDL_MUSTLOCK(avatar)) SDL_LockSurface(avatar);
constexpr float radius = 3.0f;
for (int y = 0; y < avatar->h; ++y) {
auto* row = reinterpret_cast<std::uint32_t*>(static_cast<std::uint8_t*>(avatar->pixels) + y * avatar->pitch);
for (int x = 0; x < avatar->w; ++x) {
const bool left = x < static_cast<int>(radius);
const bool right = x >= avatar->w - static_cast<int>(radius);
const bool top = y < static_cast<int>(radius);
const bool bottom = y >= avatar->h - static_cast<int>(radius);
if (!((left || right) && (top || bottom))) continue;
const float cx = left ? radius : static_cast<float>(avatar->w) - radius;
const float cy = top ? radius : static_cast<float>(avatar->h) - radius;
const float dx = static_cast<float>(x) + 0.5f - cx;
const float dy = static_cast<float>(y) + 0.5f - cy;
const float edgeCoverage = std::clamp(radius + 0.5f - std::sqrt(dx * dx + dy * dy), 0.0f, 1.0f);
std::uint8_t r, g, b, a;
SDL_GetRGBA(row[x], avatar->format, &r, &g, &b, &a);
a = static_cast<std::uint8_t>(static_cast<float>(a) * edgeCoverage + 0.5f);
row[x] = SDL_MapRGBA(avatar->format, r, g, b, a);
}
}
if (SDL_MUSTLOCK(avatar)) SDL_UnlockSurface(avatar);
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, avatar);
SDL_FreeSurface(avatar);
if (texture) {
SDL_SetTextureScaleMode(texture, SDL_ScaleModeLinear);
SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND);
}
return texture;
};
profileTexture = createProfileTexture(84);
profileCornerTexture = createProfileTexture(58);
profileDetailTexture = createProfileTexture(32);
if (source && !profileTexture) logStartup("profile: avatar texture creation failed: " + std::string(SDL_GetError()));
if (source) SDL_FreeSurface(source);
}
if (!appletMode && Mix_OpenAudio(48000, AUDIO_S16SYS, 2, 4096) == 0) {
audioReady = true;
Mix_AllocateChannels(52);
const std::array<std::pair<const char*, const char*>, 24> soundMap{{
{"button_on", "BUTTON_ON"}, {"button_off", "BUTTON_OFF"},
{"cursor_cancel", "CURSOR_CANCEL"}, {"undo", "UNDO"}, {"redo", "REDO"},
{"page_new", "EDIT_PAGE_INSERT"}, {"movie_play", "MOVIE_PLAY"}, {"movie_stop", "MOVIE_STOP"},
{"draw_tool_btn_on", "DRAW_TOOL_BTN_ON"}, {"folder_open", "FOLDER_OPEN"},
{"pic_stamp", "PIC_STAMP"}, {"paint", "PAINT"},
{"page_backward", "PAGE_BACKWARD"}, {"page_forward", "PAGE_FORWARD"},
{"page_first", "PAGE_FIRST"}, {"page_end", "PAGE_END"},
{"edit_page_copy", "EDIT_PAGE_COPY"}, {"edit_page_paste", "EDIT_PAGE_PASTE"},
{"edit_page_remove", "EDIT_PAGE_REMOVE"}, {"screen_swap", "SCREEN_SWAP"},
{"popup", "POPUP"}, {"popup_select", "POPUP_SELECT"}, {"save_ok", "SAVE_OK"},
{"delete_complete", "DELETE_COMPLETE"}
}};
int loadedCues = 0;
for (const auto& cue : soundMap) {
const std::string path = join(assetsRoot, std::string("sfx/") + cue.second + ".wav");
std::ifstream optionalCue(path, std::ios::binary);
if (!optionalCue) continue;
optionalCue.close();
cues[cue.first] = Mix_LoadWAV(path.c_str());
if (cues[cue.first]) ++loadedCues;
else logStartup("audio cue load failed: " + path + " :: " + Mix_GetError());
}
logStartup("audio ready: " + std::to_string(loadedCues) + "/" + std::to_string(soundMap.size()) + " cues loaded");
} else if (!appletMode) logStartup("audio open failed: " + std::string(Mix_GetError()));
for (int i = 0; i < SDL_NumJoysticks(); ++i) {
if (SDL_IsGameController(i)) {
controller = SDL_GameControllerOpen(i);
if (controller) break;
}
}
if (!appletMode) {
gallery.clear();
scanLibrary();
scanImports();
screen.beginTitleIntro();
if (updateInstallPending) updateInstallReadyAt = SDL_GetTicks() + 500;
else startUpdateCheck(false);
} else {
screen.active = screen.target = Screen::AppletWarning;
screen.phase = ScreenState::Phase::Idle;
}
logStartup("startup: initialization complete");
return true;
}
void App::shutdown() {
if (updateThread) {
SDL_WaitThread(updateThread, nullptr);
updateThread = nullptr;
}
#ifdef __SWITCH__
if (serverStartThreadCreated) {
threadWaitForExit(&serverStartThread);
threadClose(&serverStartThread);
serverStartThreadCreated = false;
}
if (galleryLoadThreadCreated) {
threadWaitForExit(&galleryLoadThread);
threadClose(&galleryLoadThread);
galleryLoadThreadCreated = false;
}
if (photoProbeThreadCreated) {
threadWaitForExit(&photoProbeThread);
threadClose(&photoProbeThread);
photoProbeThreadCreated = false;
}
#endif
if (dirty && docLoaded) saveDocument();
stopAudioUploadServer();
stopBrandingConfigServer();
stopPlayback();
closePhotoCapture();
#ifdef __SWITCH__
if (irPreviewTexture) SDL_DestroyTexture(irPreviewTexture);
for (SDL_Texture* texture : photoAlbumThumbs) if (texture) SDL_DestroyTexture(texture);
#endif
for (SDL_Texture* texture : loadingFrogFrames) if (texture) SDL_DestroyTexture(texture);
for (auto& it : frameTextures) SDL_DestroyTexture(it.second.texture);
frameTextures.clear();
if (commentTexture) SDL_DestroyTexture(commentTexture);
if (profileTexture) SDL_DestroyTexture(profileTexture);
if (profileCornerTexture) SDL_DestroyTexture(profileCornerTexture);
if (profileDetailTexture) SDL_DestroyTexture(profileDetailTexture);
if (galleryProfileAvatarTexture) SDL_DestroyTexture(galleryProfileAvatarTexture);
if (brandingLogoTexture) SDL_DestroyTexture(brandingLogoTexture);
for (auto& it : galleryCommentTextures) if (it.second) SDL_DestroyTexture(it.second);
galleryCommentTextures.clear();
for (auto& it : images) SDL_DestroyTexture(it.second);
for (auto& it : whiteImages) if (it.second) SDL_DestroyTexture(it.second);
for (auto& it : textCache) SDL_DestroyTexture(it.second);
for (auto& it : fonts) TTF_CloseFont(it.second);
for (auto& it : cues) if (it.second) Mix_FreeChunk(it.second);
for (auto* chunk : trackChunks) if (chunk) Mix_FreeChunk(chunk);
if (controller) SDL_GameControllerClose(controller);
if (audioReady) Mix_CloseAudio();
if (renderer) SDL_DestroyRenderer(renderer);
if (window) SDL_DestroyWindow(window);
if (ttfReady) TTF_Quit();
if (imageReady) IMG_Quit();
if (sdlReady) SDL_Quit();
#ifdef __SWITCH__
if (gallerySslReady) sslExit();
if (gallerySocketsReady) socketExit();
if (nifmReady) nifmExit();
if (accountReady) accountExit();
if (csrngReady) csrngExit();
if (romfsReady) romfsExit();
#endif
}
void App::beginLoading() {
if (loadingDepth++ == 0) {
loadingStartedAt = SDL_GetTicks();
loadingCompletionVisible = false;
}
}
void App::endLoading() {
if (loadingDepth <= 0) return;
if (--loadingDepth == 0) {
completedLoadingDuration = SDL_GetTicks() - loadingStartedAt;
if (completedLoadingDuration >= 1000) {
loadingFinishedAt = SDL_GetTicks();
loadingCompletionVisible = true;
}
}
}
void App::renderLoadingOverlay() {
const std::uint32_t now = SDL_GetTicks();
if (photoModalTransitionDim) {
const std::uint32_t transitionElapsed = now - photoModalTransitionDimAt;
if (transitionElapsed < 125) {
const Uint8 alpha = static_cast<Uint8>(std::lround(72.0f * (1.0f - transitionElapsed / 125.0f)));
fill(0, 0, W, H, SDL_Color{18, 20, 28, alpha});
} else photoModalTransitionDim = false;
}
if (loadingDepth <= 0 && !loadingCompletionVisible) return;
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
const bool completed = loadingDepth <= 0;
const std::uint32_t completionElapsed = completed ? now - loadingFinishedAt : 0;
const std::uint32_t elapsed = completed ? completedLoadingDuration : now - loadingStartedAt;
if (!updateInProgress && elapsed < 1000) return;
if (completed && completionElapsed >= 800) { loadingCompletionVisible = false; return; }
// Keep the scene undimmed while a task is merely pending. Once the frog
// appears, dim behind it so loading feedback is visible without obscuring
// ordinary modal interactions.
fill(0, 0, W, H, SDL_Color{18, 20, 28, 72});
constexpr std::uint32_t frameMs = 90;
const std::size_t frameIndex = static_cast<std::size_t>((now / frameMs) % loadingFrogFrames.size());
float progress = updateInProgress ? 1.0f : std::clamp((elapsed - 1000) / 220.0f, 0.0f, 1.0f);
if (completed) {
progress = completionElapsed < 200 ? completionElapsed / 200.0f
: completionElapsed < 600 ? 1.0f : 1.0f - (completionElapsed - 600) / 200.0f;
}
const float eased = 1.0f - std::pow(1.0f - progress, 3.0f);
SDL_Texture* frog = loadingFrogFrames[frameIndex];
if (!frog) {
const std::string asset = "ui/loading/frog" + std::to_string(frameIndex) + ".png";
frog = image(asset);
loadingFrogFrames[frameIndex] = frog;
}
if (frog) {
int sourceW = 0, sourceH = 0;
SDL_QueryTexture(frog, nullptr, nullptr, &sourceW, &sourceH);
const float scale = sourceW > 0 && sourceH > 0 ? std::min(64.0f / sourceW, 64.0f / sourceH) : 1.0f;
const int drawW = std::max(1, static_cast<int>(std::lround(sourceW * scale)));
const int drawH = std::max(1, static_cast<int>(std::lround(sourceH * scale)));
const int x = W - drawW + static_cast<int>(std::lround((1.0f - eased) * drawW));
drawTexture(frog, x, H - drawH - 14, drawW, drawH);
}
}
bool App::loadIdentity() {
std::vector<std::uint8_t> hardware;
if (!getDeviceMaterial(hardware)) {
logStartup("identity: no console-specific device identifier is available");
return false;
}
// Keep old HMAC-signed .sppm files readable, but move this compatibility
// key into the new authenticated, device-wrapped identity record.
std::array<std::uint8_t, 32> legacyKey{};
const std::string legacyPath = join(dataRoot, "identity.bin");
std::vector<std::uint8_t> oldIdentity;
if (readBinaryFile(legacyPath, oldIdentity) && oldIdentity.size() == 48) {
std::copy_n(oldIdentity.begin(), legacyKey.size(), legacyKey.begin());
}
bool found = false;
bool legacyKeyIsWrapped = false;
if (DIR* dir = opendir(dataRoot.c_str())) {
while (dirent* entry = readdir(dir)) {
const std::string name = entry->d_name;
if (name.rfind("identity-v2-", 0) != 0 || !endsWith(name, ".bin")) continue;
if (loadEncryptedIdentity(join(dataRoot, name), hardware, signingIdentity)) {
found = true;
break;
}
}
closedir(dir);
}
if (!found) {
std::string identityPath;
if (!createEncryptedIdentity(dataRoot, hardware, legacyKey, signingIdentity, identityPath)) {
logStartup("identity: could not create encrypted console-bound signing identity");
return false;
}
sppm::SigningIdentity check{};
if (!loadEncryptedIdentity(identityPath, hardware, check) ||
check.secretKey != signingIdentity.secretKey || check.legacyHmacKey != signingIdentity.legacyHmacKey) {
std::remove(identityPath.c_str());
logStartup("identity: encrypted key could not be reopened after writing");
return false;
}
signingIdentity = check;
legacyKeyIsWrapped = true;
} else {
legacyKeyIsWrapped = signingIdentity.legacyHmacKey == legacyKey;
if (std::all_of(signingIdentity.legacyHmacKey.begin(), signingIdentity.legacyHmacKey.end(),
[](std::uint8_t value) { return value == 0; }) &&
std::any_of(legacyKey.begin(), legacyKey.end(), [](std::uint8_t value) { return value != 0; })) {
// Recover a pre-v2 profile created by an earlier build if it has
// the same persisted local key. Keep its plaintext source until
// a future encrypted migration can be safely confirmed.
signingIdentity.legacyHmacKey = legacyKey;
}
}
myFsid = sppm::deriveFsid(signingIdentity.publicIdentity);
if (oldIdentity.size() == 48 && legacyKeyIsWrapped) {
// The compatibility key has already been authenticated by the new
// device-bound encrypted record, so the old plaintext copy is no
// longer needed.
std::remove(legacyPath.c_str());
}
#ifndef __SWITCH__
std::ifstream nameFile(join(dataRoot, "profile.txt"), std::ios::binary);
if (nameFile) {
std::getline(nameFile, userName);
if (userName.empty() || userName.size() > 48) userName = "Switchnote User";
}
#endif
return true;
}
std::string App::newId(const char* domain) {
std::array<std::uint8_t, 32> idSecret{};
std::array<std::uint8_t, 16> nonce{};
if (!randomBytes(idSecret.data(), idSecret.size()) || !randomBytes(nonce.data(), nonce.size())) return {};
return sppm::deriveFsid(idSecret, nonce, domain);
}
void App::scanLibrary() {
library.clear();
const std::string currentDir = join(notesRoot, folder);
ensureDir(currentDir);
if (DIR* dir = opendir(currentDir.c_str())) {
while (dirent* entry = readdir(dir)) {
const std::string name = entry->d_name;
const std::size_t marker = name.find(".sppm.bak-");
if (marker == std::string::npos) continue;
const std::string target = join(currentDir, name.substr(0, marker + 5));
struct stat targetStat{};
if (stat(target.c_str(), &targetStat) != 0) {
const std::string backup = join(currentDir, name);
if (std::rename(backup.c_str(), target.c_str()) == 0)
logStartup("recovered previous Switchnote save: " + target);
}
}
closedir(dir);
}
if (DIR* dir = opendir(currentDir.c_str())) {
while (dirent* entry = readdir(dir)) {
std::string name = entry->d_name;
if (!endsWith(name, ".sppm")) continue;
sppm::Document file;
std::string error;
const std::string path = join(currentDir, name);
if (sppm::loadSignedFile(path, file, signingIdentity.legacyHmacKey, error)) {
Item item{};
item.title = file.metadata.title.empty() ? defaultSwitchnoteTitle(userName, noteSingular) : file.metadata.title;
item.path = path;
item.author = file.metadata.authorName;
item.description = file.metadata.description;
item.originalAuthor = file.metadata.originalAuthorName;
item.authorFsid = file.metadata.authorFsid;
item.originalAuthorFsid = file.metadata.originalAuthorFsid;
item.currentId = file.metadata.currentId;
item.rootId = file.metadata.rootId;
item.createdUnixSeconds = file.metadata.createdUnixSeconds;
item.modifiedUnixSeconds = file.metadata.modifiedUnixSeconds;
item.allowSpinoffs = file.metadata.allowSpinoffs;
item.locked = file.metadata.locked;
item.ownerIdentity = sppm::identityFingerprint(file.metadata.authorIdentity);
library.push_back(std::move(item));
}
}
closedir(dir);
}
std::sort(library.begin(), library.end(), [](const Item& a, const Item& b) {
if (a.modifiedUnixSeconds != b.modifiedUnixSeconds) return a.modifiedUnixSeconds > b.modifiedUnixSeconds;
if (a.createdUnixSeconds != b.createdUnixSeconds) return a.createdUnixSeconds > b.createdUnixSeconds;
return a.path < b.path;
});
if (library.empty()) selected = 0;
else selected = std::min(selected, library.size() - 1);
page = static_cast<int>(selected / 6);
}
void App::scanImports() {
importFiles.clear();
if (DIR* dir = opendir(importRoot.c_str())) {
while (dirent* entry = readdir(dir)) {
const std::string name = entry->d_name;
if (endsWith(name, ".wav") || endsWith(name, ".WAV")) importFiles.push_back(name);
}
closedir(dir);
}
std::sort(importFiles.begin(), importFiles.end());
importSelection = 0;
}
bool App::startAudioUploadServer(bool photoTransfer) {
#ifdef __SWITCH__
if (audioUploadServer.isRunning()) return photoTransferActive == photoTransfer;
if (!gallerySocketsReady) {
const Result sockets = socketInitializeDefault();
if (R_FAILED(sockets)) {
audioUploadStatus = "Network initialization failed.";
return false;
}
gallerySocketsReady = true;
}
if (!nifmReady) {
const Result network = nifmInitialize(NifmServiceType_User);
if (R_FAILED(network)) {
audioUploadStatus = "Could not read the Switch network address.";
return false;
}
nifmReady = true;
}
u32 rawAddress = 0;
if (R_FAILED(nifmGetCurrentIpAddress(&rawAddress))) {
audioUploadStatus = "Connect the Switch to your local Wi-Fi first.";
return false;
}
const std::uint32_t hostAddress = ntohl(rawAddress);
const bool privateAddress = (hostAddress >> 24) == 10 ||
(hostAddress & 0xfff00000U) == 0xac100000U ||
(hostAddress & 0xffff0000U) == 0xc0a80000U ||
(hostAddress & 0xffff0000U) == 0xa9fe0000U ||
(hostAddress & 0xffc00000U) == 0x64400000U;
if (!privateAddress) {
audioUploadStatus = "Connect to a private local network to upload audio.";
return false;
}
in_addr address{};
address.s_addr = rawAddress;
char addressText[INET_ADDRSTRLEN]{};
if (!::inet_ntop(AF_INET, &address, addressText, sizeof(addressText))) {
audioUploadStatus = "Could not format the Switch network address.";
return false;
}
// Rejection sampling keeps every six-digit code equally likely.
std::uint32_t randomValue = 0;
do {
if (!randomBytes(&randomValue, sizeof(randomValue))) {
audioUploadStatus = "Could not create a secure upload code.";
return false;
}
} while (randomValue >= 4294000000U);
const unsigned int pin = randomValue % 1000000U;
char formattedCode[8]{};
std::snprintf(formattedCode, sizeof(formattedCode), "%03u-%03u", pin / 1000U, pin % 1000U);
const std::string code = formattedCode;
std::string error;
if (!audioUploadServer.start(addressText, importRoot, assetsRoot, code, error, photoTransfer, noteSingular)) {
audioUploadStatus = error.empty() ? "Could not start the local upload page." : error;
return false;
}
audioUploadCode = code;
audioUploadUrl = "http://" + std::string(addressText) + ":8766";
photoTransferActive = photoTransfer;
audioUploadStatus = "Enter the code on your PC.";
return true;
#else
(void)photoTransfer;
audioUploadStatus = "The upload server is available in the Switch homebrew app.";
return false;
#endif
}
void App::requestServerStart(ServerStartTask task) {
if (serverStartRunning || task == ServerStartTask::None) return;
serverStartTask = task;
serverStartRunning = true;
serverStartSucceeded = false;
serverStartFinished.store(false, std::memory_order_relaxed);
beginLoading();
#ifdef __SWITCH__
if (R_FAILED(threadCreate(&serverStartThread, serverStartThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) {
serverStartRunning = false;
serverStartTask = ServerStartTask::None;
endLoading();
showToast("Could not start the transfer service.");
return;
}
serverStartThreadCreated = true;
if (R_FAILED(threadStart(&serverStartThread))) {
threadClose(&serverStartThread);
serverStartThreadCreated = false;
serverStartRunning = false;
serverStartTask = ServerStartTask::None;
endLoading();
showToast("Could not start the transfer service.");
}
#else
serverStartSucceeded = task == ServerStartTask::Branding
? startBrandingConfigServer()
: startAudioUploadServer(task == ServerStartTask::Photo);
serverStartFinished.store(true, std::memory_order_release);
#endif
}
void App::requestAudioUploadServer(bool photoTransfer) {
requestServerStart(photoTransfer ? ServerStartTask::Photo : ServerStartTask::Audio);
}
void App::requestBrandingConfigServer() {
requestServerStart(ServerStartTask::Branding);
}
#ifdef __SWITCH__
void App::serverStartThreadEntry(void* argument) {
auto* app = static_cast<App*>(argument);
app->runServerStartThread();
threadExit();
}
void App::runServerStartThread() {
serverStartSucceeded = serverStartTask == ServerStartTask::Branding
? startBrandingConfigServer()
: startAudioUploadServer(serverStartTask == ServerStartTask::Photo);
serverStartFinished.store(true, std::memory_order_release);
}
#endif
void App::consumeServerStart() {
if (!serverStartRunning || !serverStartFinished.exchange(false, std::memory_order_acquire)) return;
#ifdef __SWITCH__
if (serverStartThreadCreated) {
threadWaitForExit(&serverStartThread);
threadClose(&serverStartThread);
serverStartThreadCreated = false;
}
#endif
const ServerStartTask completedTask = serverStartTask;
serverStartRunning = false;
serverStartTask = ServerStartTask::None;
endLoading();
if (serverStartSucceeded) {
if (completedTask == ServerStartTask::Branding) showToast("Branding portal is ready on your local network");
else showToast(completedTask == ServerStartTask::Photo
? "Photo upload page is ready on your local network"
: "Sound upload page is ready on your local network");
} else {
showToast(completedTask == ServerStartTask::Branding ? brandingStatus : audioUploadStatus);
}
}
void App::stopAudioUploadServer() {
audioUploadServer.stop();
photoTransferActive = false;
audioUploadUrl.clear();
audioUploadCode.clear();
}
void App::closeAudioMenu() {
consumeAudioUpload();
if (audioReady) Mix_HaltChannel(4);
stopAudioUploadServer();
audioEraseArmedTrack = -1;
modal = Modal::None;
}
void App::consumeAudioUpload() {
int track = -1;
if (!audioUploadServer.takeCompletedUpload(track)) return;
if (track == 4) {
if (photoImporting) return;
const bool started = importUploadedPhoto();
if (!started) {
std::remove(join(importRoot, "photo-transfer.rgba").c_str());
std::remove(join(importRoot, "photo-transfer.cfg").c_str());
audioUploadServer.finishCompletedUpload(false);
audioUploadStatus = "That image could not be imported.";
} else audioUploadStatus = "Adding image to your Switchnote…";
return;
}
const std::string path = join(importRoot, "audio-transfer-" + std::to_string(track) + ".wav");
const std::string name = track == 0 ? "BGM" : "SE" + std::to_string(track);
const bool imported = importAudioFile(path, track, name + " from PC");
audioEraseArmedTrack = -1;
std::remove(path.c_str());
audioUploadServer.finishCompletedUpload(imported);
if (imported) audioUploadStatus = name + " added from your PC.";
else audioUploadStatus = "That WAV could not be added. Check its duration and format.";
}
bool App::galleryRequest(const std::string& method, const std::string& path,
const std::vector<std::uint8_t>& requestBody,
const std::string& contentType,
std::vector<std::uint8_t>& responseBody, int& status,
std::string& error, std::string* responseHeaders) {
responseBody.clear();
status = 0;
if (responseHeaders) responseHeaders->clear();
#ifdef __SWITCH__
if (!gallerySocketsReady) {
const Result result = socketInitializeDefault();
if (R_FAILED(result)) {
error = "Network setup failed (" + std::to_string(static_cast<unsigned int>(result)) + ")";
return false;
}
gallerySocketsReady = true;
}
if (!gallerySslReady) {
const Result result = sslInitialize(1);
if (R_FAILED(result)) {
error = "Secure Gallery networking could not be initialized (" + std::to_string(static_cast<unsigned int>(result)) + ")";
return false;
}
gallerySslReady = true;
}
std::string authHeaders;
if ((method == "POST" && path != "/api/flipnotes") || method == "DELETE") {
std::vector<std::uint8_t> challengeResponse;
int challengeStatus = 0;
std::string authError;
if (!galleryRequest("GET", "/api/auth/challenge", {}, "text/plain",
challengeResponse, challengeStatus, authError)) {
error = "Gallery authentication challenge failed: " + authError;
return false;
}
std::string nonceHex(challengeResponse.begin(), challengeResponse.end());
while (!nonceHex.empty() && (nonceHex.back() == '\r' || nonceHex.back() == '\n')) nonceHex.pop_back();
std::array<std::uint8_t, 32> nonce{};
if (!hexDecode(nonceHex, nonce.data(), nonce.size())) {
error = "Gallery returned an invalid authentication challenge";
return false;
}
std::vector<std::uint8_t> signedRequest;
const std::string domain = "FlipnoteNX-Gallery-Request-v1";
signedRequest.insert(signedRequest.end(), domain.begin(), domain.end());
signedRequest.push_back(0);
signedRequest.insert(signedRequest.end(), method.begin(), method.end());
signedRequest.push_back(0);
signedRequest.insert(signedRequest.end(), path.begin(), path.end());
signedRequest.push_back(0);
signedRequest.insert(signedRequest.end(), nonce.begin(), nonce.end());
signedRequest.push_back(0);
signedRequest.insert(signedRequest.end(), myFsid.begin(), myFsid.end());
signedRequest.push_back(0);
signedRequest.insert(signedRequest.end(), requestBody.begin(), requestBody.end());
std::array<std::uint8_t, 64> signature{};
if (!sppm::signMessage(signingIdentity,
signedRequest.empty() ? nullptr : signedRequest.data(),
signedRequest.size(), signature, authError)) {
error = "Could not authenticate Gallery action: " + authError;
return false;
}
authHeaders = "X-FSNX-Nonce: " + nonceHex + "\r\n" +
"X-FSNX-FSID: " + myFsid + "\r\n" +
"X-FSNX-Key: " + hexEncode(signingIdentity.publicIdentity.key.data(), signingIdentity.publicIdentity.key.size()) + "\r\n" +
"X-FSNX-Device: " + hexEncode(signingIdentity.publicIdentity.deviceTag.data(), signingIdentity.publicIdentity.deviceTag.size()) + "\r\n" +
"X-FSNX-Signature: " + hexEncode(signature.data(), signature.size()) + "\r\n";
std::fill(signedRequest.begin(), signedRequest.end(), 0);
}
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo* addresses = nullptr;
const std::string service = std::to_string(kGalleryServerPort);
if (::getaddrinfo(kGalleryServerHost, service.c_str(), &hints, &addresses) != 0 || !addresses) {
error = "Could not resolve the secure Gallery address " + std::string(kGalleryServerHost);
if (addresses) ::freeaddrinfo(addresses);
return false;
}
int fd = -1;
for (addrinfo* candidate = addresses; candidate; candidate = candidate->ai_next) {
fd = ::socket(candidate->ai_family, candidate->ai_socktype, candidate->ai_protocol);
if (fd < 0) continue;
timeval timeout{};
timeout.tv_sec = 6;
::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
if (::connect(fd, candidate->ai_addr, candidate->ai_addrlen) == 0) break;
::close(fd);
fd = -1;
}
::freeaddrinfo(addresses);
if (fd < 0) {
error = "Could not reach the secure Gallery at https://" + std::string(kGalleryServerHost) + "/nswitch/";
return false;
}
SslContext sslContext{};
SslConnection sslConnection{};
bool contextCreated = false;
bool connectionCreated = false;
bool socketAttached = false;
int sslSocketToClose = -1;
auto closeTls = [&]() {
if (connectionCreated) {
sslConnectionClose(&sslConnection);
connectionCreated = false;
}
if (contextCreated) {
sslContextClose(&sslContext);
contextCreated = false;
}
if (sslSocketToClose >= 0) {
::close(sslSocketToClose);
sslSocketToClose = -1;
} else if (!socketAttached && fd >= 0) {
::close(fd);
}
fd = -1;
};
const char* tlsStage = "TLS context creation";
Result tlsResult = sslCreateContext(&sslContext, SslVersion_TlsV12 | SslVersion_TlsV13);
contextCreated = R_SUCCEEDED(tlsResult);
if (R_SUCCEEDED(tlsResult)) {
tlsStage = "TLS connection setup";
tlsResult = sslContextCreateConnection(&sslContext, &sslConnection);
connectionCreated = R_SUCCEEDED(tlsResult);
}
if (R_SUCCEEDED(tlsResult)) {
tlsStage = "Gallery hostname setup";
tlsResult = sslConnectionSetHostName(&sslConnection, kGalleryServerHost, std::strlen(kGalleryServerHost));
}
if (R_SUCCEEDED(tlsResult)) {
tlsStage = "certificate verification setup";
tlsResult = sslConnectionSetVerifyOption(&sslConnection,
SslVerifyOption_PeerCa | SslVerifyOption_HostName | SslVerifyOption_DateCheck);
}
if (R_SUCCEEDED(tlsResult)) {
tlsStage = "secure socket setup";
errno = 0;
sslSocketToClose = socketSslConnectionSetSocketDescriptor(&sslConnection, fd);
socketAttached = true;
if (sslSocketToClose < 0 && errno != ENOENT) tlsResult = MAKERESULT(Module_Libnx, LibnxError_BadInput);
}
if (R_SUCCEEDED(tlsResult)) {
tlsStage = "TLS handshake/certificate verification";
tlsResult = sslConnectionDoHandshake(&sslConnection, nullptr, nullptr, nullptr, 0);
}
if (R_FAILED(tlsResult)) {
std::ostringstream detail;
detail << "Secure Gallery connection failed during " << tlsStage << " (" << static_cast<unsigned int>(tlsResult)
<< ", module " << R_MODULE(tlsResult) << ", description " << R_DESCRIPTION(tlsResult) << ")";
if (connectionCreated && std::strcmp(tlsStage, "TLS handshake/certificate verification") == 0) {
const Result verifyResult = sslConnectionGetVerifyCertError(&sslConnection);
if (R_FAILED(verifyResult)) {
detail << " [certificate check " << static_cast<unsigned int>(verifyResult)
<< ", module " << R_MODULE(verifyResult) << ", description " << R_DESCRIPTION(verifyResult) << "]";
} else {
detail << " [no certificate rejection reported]";
}
}
error = detail.str();
closeTls();
return false;
}
const std::string type = contentType.empty() ? "application/octet-stream" : contentType;
const std::string requestPath = std::string(kGalleryServerBasePath) + path;
const auto requestNumber = galleryRequestCounter.fetch_add(1, std::memory_order_relaxed) + 1;
const std::string requestId = "snx-" + std::to_string(SDL_GetTicks()) + "-" + std::to_string(requestNumber);
const std::string headers = method + " " + requestPath + " HTTP/1.1\r\nHost: " + kGalleryServerHost +
"\r\nUser-Agent: SwitchnoteStudio/0.1.51\r\nX-Switchnote-Client: Switchnote-Studio\r\nX-Switchnote-Request-ID: " + requestId +
"\r\nConnection: close\r\nContent-Type: " + type + "\r\nContent-Length: " +
std::to_string(requestBody.size()) + "\r\n" + authHeaders + "\r\n";
auto sendBytes = [&sslConnection](const std::uint8_t* data, std::size_t length) {
std::size_t sent = 0;
while (sent < length) {
u32 written = 0;
const std::size_t amount = std::min<std::size_t>(length - sent, 0x4000);
const Result result = sslConnectionWrite(&sslConnection, data + sent, static_cast<u32>(amount), &written);
if (R_FAILED(result) || written == 0) return false;
sent += written;
}
return true;
};
if (!sendBytes(reinterpret_cast<const std::uint8_t*>(headers.data()), headers.size()) ||
(!requestBody.empty() && !sendBytes(requestBody.data(), requestBody.size()))) {
error = "Could not send the Gallery request";
closeTls();
return false;
}
std::vector<std::uint8_t> received;
received.reserve(4096);
std::array<std::uint8_t, 16384> chunk{};
constexpr std::size_t maxResponse = 72U * 1024U * 1024U;
while (true) {
u32 count = 0;
const Result result = sslConnectionRead(&sslConnection, chunk.data(), chunk.size(), &count);
if (R_FAILED(result)) {
error = "Gallery response timed out or was interrupted";
closeTls();
return false;
}
if (count == 0) break;
if (received.size() + count > maxResponse) {
error = "Gallery response is too large";
closeTls();
return false;
}
received.insert(received.end(), chunk.begin(), chunk.begin() + count);
}
closeTls();
if (received.empty()) { error = "Gallery returned an empty response"; return false; }
const std::string headerText(reinterpret_cast<const char*>(received.data()), received.size());
const std::size_t headerEnd = headerText.find("\r\n\r\n");
if (headerEnd == std::string::npos) { error = "Gallery returned an invalid HTTP response"; return false; }
const std::size_t statusEnd = headerText.find("\r\n");
if (statusEnd == std::string::npos) { error = "Gallery returned an invalid HTTP status"; return false; }
const std::string statusLine = headerText.substr(0, statusEnd);
if (std::sscanf(statusLine.c_str(), "HTTP/%*s %d", &status) != 1) {
error = "Gallery returned an invalid HTTP status";
return false;
}
const std::size_t bodyStart = headerEnd + 4;
if (responseHeaders) responseHeaders->assign(headerText.data(), headerEnd);
responseBody.assign(received.begin() + static_cast<std::ptrdiff_t>(bodyStart), received.end());
if (status < 200 || status >= 300) {
error.assign(responseBody.begin(), responseBody.begin() + static_cast<std::ptrdiff_t>(std::min<std::size_t>(responseBody.size(), 300)));
while (!error.empty() && (error.back() == '\r' || error.back() == '\n')) error.pop_back();
if (error.empty()) error = "Gallery returned HTTP " + std::to_string(status);
return false;
}
error.clear();
return true;
#else
(void)method; (void)path; (void)requestBody; (void)contentType; (void)responseHeaders;
error = "Switchnote Gallery is available from the Switch app";
return false;
#endif
}
void App::enterGallery() {
galleryPage = 0;
gallerySelected = 0;
galleryStatus = "Loading Gallery…";
screen = Screen::Gallery;
refreshGallery();
}
void App::showGalleryNotice() {
modal = Modal::GalleryNotice;
modalTransitionStart = SDL_GetTicks();
}
bool App::internetConnected() {
#ifdef __SWITCH__
if (!nifmReady) {
if (R_FAILED(nifmInitialize(NifmServiceType_User))) return false;
nifmReady = true;
}
NifmInternetConnectionType connectionType{};
NifmInternetConnectionStatus connectionStatus{};
u32 wifiStrength = 0;
return R_SUCCEEDED(nifmGetInternetConnectionStatus(&connectionType, &wifiStrength, &connectionStatus)) &&
connectionStatus == NifmInternetConnectionStatus_Connected;
#else
return true;
#endif
}
void App::confirmGalleryLeave() {
if (internetConnected()) {
modal = Modal::None;
enterGallery();
} else {
modal = Modal::GalleryOffline;
modalTransitionStart = SDL_GetTicks();
}
}
void App::refreshGallery() {
if (galleryLoadRunning) return;
galleryLoadKind = GalleryLoadKind::Feed;
const std::string path = "/api/flipnotes?page=" + std::to_string(galleryPage) +
"&sort=" + (galleryPopular ? "popular" : "recent");
galleryStatus = "Loading Gallery…";
#ifdef __SWITCH__
if (galleryLoadThreadCreated) {
threadWaitForExit(&galleryLoadThread);
threadClose(&galleryLoadThread);
galleryLoadThreadCreated = false;
}
galleryLoadPath = path;
galleryLoadFinished.store(false, std::memory_order_relaxed);
galleryLoadRunning = true;
galleryLoadResponse.clear();
galleryLoadError.clear();
beginLoading();
if (R_FAILED(threadCreate(&galleryLoadThread, galleryLoadThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) {
galleryLoadRunning = false;
endLoading();
galleryStatus = "Could not start the Gallery connection.";
showToast(galleryStatus);
return;
}
galleryLoadThreadCreated = true;
if (R_FAILED(threadStart(&galleryLoadThread))) {
threadClose(&galleryLoadThread);
galleryLoadThreadCreated = false;
galleryLoadRunning = false;
endLoading();
galleryStatus = "Could not start the Gallery connection.";
showToast(galleryStatus);
}
#else
std::vector<std::uint8_t> response;
int status = 0;
std::string error;
if (!galleryRequest("GET", path, {}, "text/plain", response, status, error)) {
finishGalleryRefresh(response, status, error);
return;
}
finishGalleryRefresh(response, status, {});
#endif
}
void App::loadGalleryProfile(const std::string& identity, Screen returnScreen, int pageIndex) {
if (identity.size() != 64) { showToast("This Gallery user has no profile yet"); return; }
if (galleryLoadRunning) return;
if (!galleryProfileActive) {
galleryBeforeProfile = gallery;
gallerySelectedBeforeProfile = gallerySelected;
galleryPageBeforeProfile = galleryPage;
galleryTotalBeforeProfile = galleryTotal;
galleryProfileActive = true;
}
if (galleryProfile.identity != identity) galleryProfile = GalleryProfileData{};
galleryProfile.identity = identity;
galleryProfileReturnScreen = returnScreen;
galleryProfilePage = std::max(0, pageIndex);
galleryProfileDetailMode = false;
galleryProfileIsOwn = identity == sppm::identityFingerprint(signingIdentity.publicIdentity);
galleryLoadKind = GalleryLoadKind::Profile;
galleryLoadPath = "/api/profiles/" + identity + "?viewer=" +
sppm::identityFingerprint(signingIdentity.publicIdentity);
if (galleryProfilePage > 0) galleryLoadPath += "&page=" + std::to_string(galleryProfilePage - 1);
gallery.clear();
screen = Screen::GalleryProfile;
galleryLoadFinished.store(false, std::memory_order_relaxed);
galleryLoadRunning = true;
galleryLoadResponse.clear();
galleryLoadError.clear();
beginLoading();
#ifdef __SWITCH__
if (galleryLoadThreadCreated) {
threadWaitForExit(&galleryLoadThread);
threadClose(&galleryLoadThread);
galleryLoadThreadCreated = false;
}
if (R_FAILED(threadCreate(&galleryLoadThread, galleryLoadThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) {
galleryLoadRunning = false;
endLoading();
galleryStatus = "Could not start the Gallery connection.";
return;
}
galleryLoadThreadCreated = true;
if (R_FAILED(threadStart(&galleryLoadThread))) {
threadClose(&galleryLoadThread);
galleryLoadThreadCreated = false;
galleryLoadRunning = false;
endLoading();
galleryStatus = "Could not start the Gallery connection.";
}
#else
int status = 0;
std::string error;
const bool ok = galleryRequest("GET", galleryLoadPath, {}, "text/plain", galleryLoadResponse, status, error);
galleryLoadRunning = false;
endLoading();
finishGalleryProfile(galleryLoadResponse, status, ok ? std::string{} : error);
#endif
}
void App::leaveGalleryProfile() {
if (galleryProfileDetailMode) {
galleryProfileDetailMode = false;
screen = Screen::GalleryProfile;
return;
}
if (galleryProfileActive) {
gallery = std::move(galleryBeforeProfile);
gallerySelected = gallery.empty() ? 0 : std::min(gallerySelectedBeforeProfile, gallery.size() - 1);
galleryPage = galleryPageBeforeProfile;
galleryTotal = galleryTotalBeforeProfile;
galleryProfileActive = false;
}
screen = galleryProfileReturnScreen;
}
#ifdef __SWITCH__
void App::galleryLoadThreadEntry(void* argument) {
auto* app = static_cast<App*>(argument);
app->runGalleryLoadThread();
threadExit();
}
void App::runGalleryLoadThread() {
galleryLoadSucceeded = galleryRequest("GET", galleryLoadPath, {}, "text/plain",
galleryLoadResponse, galleryLoadHttpStatus, galleryLoadError);
galleryLoadFinished.store(true, std::memory_order_release);
}
#endif
void App::consumeGalleryLoad() {
#ifdef __SWITCH__
if (!galleryLoadRunning || !galleryLoadFinished.exchange(false, std::memory_order_acquire)) return;
if (galleryLoadThreadCreated) {
threadWaitForExit(&galleryLoadThread);
threadClose(&galleryLoadThread);
galleryLoadThreadCreated = false;
}
galleryLoadRunning = false;
endLoading();
if (galleryLoadKind == GalleryLoadKind::Profile)
finishGalleryProfile(galleryLoadResponse, galleryLoadHttpStatus,
galleryLoadSucceeded ? std::string{} : galleryLoadError);
else finishGalleryRefresh(galleryLoadResponse, galleryLoadHttpStatus,
galleryLoadSucceeded ? std::string{} : galleryLoadError);
#endif
}
void App::finishGalleryProfile(const std::vector<std::uint8_t>& response, int status, const std::string& error) {
(void)status;
if (!error.empty()) { galleryStatus = error; showToast(error); return; }
std::string body(response.begin(), response.end());
if (!body.empty() && body.back() == '\n') body.pop_back();
std::istringstream lines(body);
std::string line;
if (!std::getline(lines, line)) line.clear();
const auto fields = splitTabs(line);
if (fields.size() < 11 || fields[0] != "PROFILE") {
galleryStatus = "Gallery returned an invalid profile.";
showToast(galleryStatus);
return;
}
galleryProfile.username = percentDecode(fields[1]);
galleryProfile.bio = percentDecode(fields[2]);
galleryProfile.switchnotes = std::max(0, std::atoi(fields[3].c_str()));
galleryProfile.views = std::max(0, std::atoi(fields[4].c_str()));
galleryProfile.coins = std::max(0, std::atoi(fields[5].c_str()));
galleryProfile.followers = std::max(0, std::atoi(fields[6].c_str()));
galleryProfile.following = std::max(0, std::atoi(fields[7].c_str()));
galleryProfile.followed = fields[8] == "1";
galleryProfile.blocked = fields[9] == "1";
if (fields[10].size() < 400U * 1024U) galleryProfile.avatar = base64Decode(fields[10]);
if (galleryProfileIsOwn) {
galleryProfile.username = userName;
galleryProfile.avatar = profileImageBytes;
}
galleryProfile.uploads.clear();
if (galleryProfilePage > 0) {
bool foundUploads = false;
while (std::getline(lines, line)) {
if (!foundUploads) {
if (line.rfind("UPLOADS\t", 0) == 0) foundUploads = true;
continue;
}
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
const auto noteFields = splitTabs(line);
if (noteFields.size() < 12) continue;
Item item{};
item.galleryId = noteFields[0];
item.title = percentDecode(noteFields[1]);
item.description = percentDecode(noteFields[2]);
item.author = percentDecode(noteFields[3]);
item.fsid = noteFields[4];
item.rootId = noteFields[5];
item.currentId = noteFields[6];
item.modifiedUnixSeconds = static_cast<std::uint64_t>(std::strtoull(noteFields[7].c_str(), nullptr, 10));
item.coins = std::max(0, std::atoi(noteFields[8].c_str()));
item.views = std::max(0, std::atoi(noteFields[9].c_str()));
item.allowSpinoffs = noteFields[10] == "1";
item.locked = noteFields.size() > 13 && noteFields[13] == "1";
if (noteFields[11].size() <= 1024U * 1024U) item.previewBytes = base64Decode(noteFields[11]);
if (noteFields.size() > 12) item.ownerIdentity = noteFields[12];
galleryProfile.uploads.push_back(std::move(item));
}
}
gallery = galleryProfile.uploads;
galleryTotal = galleryProfile.switchnotes;
galleryStatus.clear();
gallerySelected = gallery.empty() ? 0 : std::min(gallerySelected, gallery.size() - 1);
if (galleryProfileAvatarTexture) {
SDL_DestroyTexture(galleryProfileAvatarTexture);
galleryProfileAvatarTexture = nullptr;
}
if (!galleryProfile.avatar.empty() && galleryProfile.avatar.size() <= static_cast<std::size_t>(std::numeric_limits<int>::max())) {
SDL_RWops* stream = SDL_RWFromConstMem(galleryProfile.avatar.data(), static_cast<int>(galleryProfile.avatar.size()));
SDL_Surface* surface = stream ? IMG_Load_RW(stream, 1) : nullptr;
galleryProfileAvatarTexture = surface ? SDL_CreateTextureFromSurface(renderer, surface) : nullptr;
if (surface) SDL_FreeSurface(surface);
}
if (galleryProfileIsOwn && galleryProfilePage == 0) saveGalleryProfile(true);
}
void App::saveGalleryProfile(bool quiet) {
if (!galleryProfileIsOwn) return;
std::string body = "username=" + percentEncode(userName) + "&bio=" + percentEncode(galleryProfile.bio) + "&avatar=";
if (!profileImageBytes.empty()) body += percentEncode(base64Encode(profileImageBytes));
std::vector<std::uint8_t> request(body.begin(), body.end()), response;
int status = 0;
std::string error;
if (!galleryRequest("POST", "/api/profiles/update", request, "application/x-www-form-urlencoded", response, status, error) || status < 200 || status >= 300) {
if (!quiet) showToast(error.empty() ? "Could not save Gallery profile." : error);
return;
}
if (!quiet) showToast("Gallery profile saved");
}
void App::updateGalleryRelationship(const char* action) {
if (galleryProfileIsOwn || galleryProfile.identity.empty()) return;
const std::string bodyText = "action=" + std::string(action) + "&target=" + galleryProfile.identity;
std::vector<std::uint8_t> body(bodyText.begin(), bodyText.end()), response;
int status = 0;
std::string error;
if (!galleryRequest("POST", "/api/profiles/relationship", body, "application/x-www-form-urlencoded", response, status, error) || status < 200 || status >= 300) {
if (error.empty()) error.assign(response.begin(), response.end());
showToast(error.empty() ? "Gallery profile action failed." : error);
return;
}
if (std::string(action) == "follow") { galleryProfile.followed = true; ++galleryProfile.followers; }
else if (std::string(action) == "unfollow") { galleryProfile.followed = false; galleryProfile.followers = std::max(0, galleryProfile.followers - 1); }
else if (std::string(action) == "block") { galleryProfile.blocked = true; galleryProfile.followed = false; }
else if (std::string(action) == "unblock") galleryProfile.blocked = false;
}
void App::finishGalleryRefresh(const std::vector<std::uint8_t>& response, int status, const std::string& error) {
(void)status;
if (!error.empty()) {
gallery.clear();
galleryTotal = 0;
galleryStatus = error;
showToast(error);
return;
}
for (auto it = images.begin(); it != images.end();) {
if (it->first.rfind("gallery-preview:", 0) == 0) {
if (it->second) SDL_DestroyTexture(it->second);
it = images.erase(it);
} else ++it;
}
gallery.clear();
galleryTotal = 0;
const std::string textBody(response.begin(), response.end());
std::istringstream lines(textBody);
std::string line;
bool firstLine = true;
while (std::getline(lines, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
if (firstLine) {
firstLine = false;
const auto header = splitTabs(line);
if (header.size() > 1 && header[0] == "TOTAL") galleryTotal = std::max(0, std::atoi(header[1].c_str()));
continue;
}
const auto fields = splitTabs(line);
if (fields.size() < 12) continue;
Item item{};
item.galleryId = fields[0];
item.title = percentDecode(fields[1]);
item.description = percentDecode(fields[2]);
item.author = percentDecode(fields[3]);
item.fsid = fields[4];
item.rootId = fields[5];
item.currentId = fields[6];
item.modifiedUnixSeconds = static_cast<std::uint64_t>(std::strtoull(fields[7].c_str(), nullptr, 10));
item.coins = std::max(0, std::atoi(fields[8].c_str()));
item.views = std::max(0, std::atoi(fields[9].c_str()));
item.allowSpinoffs = fields[10] == "1";
if (fields[11].size() <= 1024U * 1024U) item.previewBytes = base64Decode(fields[11]);
if (fields.size() > 12) item.ownerIdentity = fields[12];
item.locked = fields.size() > 13 && fields[13] == "1";
gallery.push_back(std::move(item));
}
gallerySelected = gallery.empty() ? 0 : std::min(gallerySelected, gallery.size() - 1);
galleryCommentsLoaded = false;
galleryStatus = gallery.empty()
? (galleryTotal > 0 ? "There are no Switchnotes on this page." : "No Switchnotes have been shared yet.")
: std::string{};
if (!galleryTermsAccepted && screen == Screen::Gallery) {
modal = Modal::GalleryTerms;
modalTransitionStart = SDL_GetTicks();
}
}
void App::refreshGalleryComments() {
galleryComments.clear();
for (auto& entry : galleryCommentTextures) if (entry.second) SDL_DestroyTexture(entry.second);
galleryCommentTextures.clear();
if (gallery.empty()) { galleryCommentsLoaded = true; return; }
const Item& item = gallery[gallerySelected % gallery.size()];
std::vector<std::uint8_t> response;
int status = 0;
std::string error;
if (!galleryRequest("GET", "/api/flipnotes/" + item.galleryId + "/comments", {}, "text/plain", response, status, error)) {
showToast(error);
galleryCommentsLoaded = false;
return;
}
std::istringstream lines(std::string(response.begin(), response.end()));
std::string line;
bool firstLine = true;
while (std::getline(lines, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
if (firstLine) { firstLine = false; continue; }
const auto fields = splitTabs(line);
if (fields.size() < 4) continue;
GalleryComment comment;
comment.author = percentDecode(fields[0]);
comment.text = percentDecode(fields[1]);
const std::uint64_t created = static_cast<std::uint64_t>(std::strtoull(fields[2].c_str(), nullptr, 10));
comment.created = dateText(created);
if (fields[3].size() <= 450U * 1024U) comment.drawing = base64Decode(fields[3]);
if (comment.drawing.size() != 0 && comment.drawing.size() != sppm::kPixelsPerLayer) comment.drawing.clear();
galleryComments.push_back(std::move(comment));
}
galleryCommentsLoaded = true;
}
void App::openGalleryComments() {
refreshGalleryComments();
galleryCommentScroll = 0;
screen = Screen::GalleryComments;
}
SDL_Texture* App::galleryPreviewTexture(const Item& item) {
if (item.previewBytes.empty() || item.previewBytes.size() > static_cast<std::size_t>(std::numeric_limits<int>::max())) return nullptr;
const std::string key = "gallery-preview:" + item.galleryId;
auto found = images.find(key);
if (found != images.end()) return found->second;
SDL_RWops* stream = SDL_RWFromConstMem(item.previewBytes.data(), static_cast<int>(item.previewBytes.size()));
SDL_Surface* surface = stream ? IMG_Load_RW(stream, 1) : nullptr;
SDL_Texture* texture = surface ? SDL_CreateTextureFromSurface(renderer, surface) : nullptr;
if (surface) SDL_FreeSurface(surface);
if (texture) SDL_SetTextureScaleMode(texture, SDL_ScaleModeLinear);
else logStartup("gallery preview decode failed: " + std::string(IMG_GetError()));
images[key] = texture;
return texture;
}
SDL_Texture* App::galleryCommentTexture(const GalleryComment& comment, std::size_t index) {
if (comment.drawing.size() != sppm::kPixelsPerLayer) return nullptr;
const std::string key = "gallery-comment:" + (gallery.empty() ? std::string{} : gallery[gallerySelected % gallery.size()].galleryId) + ":" + std::to_string(index);
auto found = galleryCommentTextures.find(key);
if (found != galleryCommentTextures.end()) return found->second;
SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32);
if (!surface) return nullptr;
auto* pixels = static_cast<std::uint32_t*>(surface->pixels);
for (std::size_t i = 0; i < comment.drawing.size(); ++i)
pixels[i] = comment.drawing[i] ? SDL_MapRGBA(surface->format, 20, 22, 28, 255) : SDL_MapRGBA(surface->format, 0, 0, 0, 0);
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
SDL_FreeSurface(surface);
if (texture) {
SDL_SetTextureScaleMode(texture, SDL_ScaleModeNearest);
SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND);
}
galleryCommentTextures[key] = texture;
return texture;
}
void App::publishGalleryItem(std::size_t index) {
if (index >= library.size()) return;
pendingGalleryPublishIndex = index;
modalTransitionStart = SDL_GetTicks();
modal = Modal::GalleryPostChoice;
}
void App::performGalleryUpload(std::size_t index, bool locked) {
if (index >= library.size()) return;
const Item& item = library[index];
if (item.path.empty()) { showToast("This Switchnote cannot be sent to the Gallery"); return; }
sppm::Document upload;
std::string error;
if (docLoaded && !galleryViewing && docPath == item.path) {
if (dirty && !saveDocument(true)) return;
upload = doc;
} else if (!sppm::loadSignedFile(item.path, upload, signingIdentity.legacyHmacKey, error)) {
showToast("Could not open Switchnote: " + error);
return;
}
upload.metadata.lastEditorName = userName;
upload.metadata.lastEditorFsid = myFsid;
upload.metadata.lastEditorIdentity = signingIdentity.publicIdentity;
upload.metadata.locked = locked;
upload.metadata.modifiedUnixSeconds = nowSeconds();
const std::string tempId = newId("gallery-upload");
if (tempId.empty()) { showToast("Could not create a secure upload ID"); return; }
const std::string tempPath = join(dataRoot, ".gallery-upload-" + tempId + ".sppm");
if (!sppm::savePublicSignedFile(tempPath, upload, signingIdentity, error)) {
showToast("Could not sign Switchnote for Gallery: " + error);
return;
}
std::ifstream input(tempPath, std::ios::binary | std::ios::ate);
std::streamoff size = -1;
if (input) size = static_cast<std::streamoff>(input.tellg());
if (size <= 0 || static_cast<std::uint64_t>(size) > 64U * 1024U * 1024U) {
input.close();
std::remove(tempPath.c_str());
showToast("Switchnote is too large to send to Gallery");
return;
}
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
input.seekg(0, std::ios::beg);
if (!input) { input.close(); std::remove(tempPath.c_str()); showToast("Could not read signed Switchnote"); return; }
input.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(size));
const bool readOk = input.gcount() == static_cast<std::streamsize>(bytes.size());
input.close();
std::remove(tempPath.c_str());
if (!readOk) { showToast("Could not read signed Switchnote"); return; }
std::vector<std::uint8_t> response;
int status = 0;
if (!galleryRequest("POST", "/api/flipnotes", bytes, "application/octet-stream", response, status, error)) {
showToast("Send failed: " + error);
return;
}
showToast("Switchnote sent to Gallery");
enterGallery();
}
void App::addGalleryCoin() {
if (gallery.empty()) return;
Item& item = gallery[gallerySelected % gallery.size()];
std::vector<std::uint8_t> body, response;
int status = 0;
std::string error;
if (!galleryRequest("POST", "/api/flipnotes/" + item.galleryId + "/coins", body,
"application/x-www-form-urlencoded", response, status, error)) {
showToast(error);
return;
}
item.coins = std::max(0, std::atoi(std::string(response.begin(), response.end()).c_str()));
showToast("Coin added");
}
void App::deleteGalleryItem() {
if (gallery.empty()) return;
const std::string noteId = gallery[gallerySelected % gallery.size()].galleryId;
std::vector<std::uint8_t> response;
int status = 0;
std::string error;
if (!galleryRequest("DELETE", "/api/flipnotes/" + noteId, {},
"application/octet-stream", response, status, error)) {
showToast("Could not remove Switchnote: " + error);
modal = Modal::None;
return;
}
modal = Modal::None;
screen = Screen::Gallery;
if (galleryTotal > 0) --galleryTotal;
if (galleryPage > 0 && static_cast<std::size_t>(galleryPage * 6) >= static_cast<std::size_t>(galleryTotal)) --galleryPage;
galleryStatus = "Loading Gallery…";
refreshGallery();
showToast("Switchnote removed from Gallery");
}
void App::postGalleryComment() {
if (gallery.empty()) return;
const bool hasDrawing = std::any_of(commentPixels.begin(), commentPixels.end(), [](std::uint8_t value) { return value != 0; });
if (commentDraft.empty() && !hasDrawing) { showToast("Write or draw a comment first"); return; }
const Item& item = gallery[gallerySelected % gallery.size()];
std::string form = "author=" + percentEncode(userName) + "&text=" + percentEncode(commentDraft);
if (hasDrawing) form += "&drawing=" + base64Encode(commentPixels);
std::vector<std::uint8_t> body(form.begin(), form.end()), response;
int status = 0;
std::string error;
if (!galleryRequest("POST", "/api/flipnotes/" + item.galleryId + "/comments", body,
"application/x-www-form-urlencoded", response, status, error)) {
showToast("Comment failed: " + error);
return;
}
commentDraft.clear();
commentPixels.assign(sppm::kPixelsPerLayer, 0);
commentDirty = true;
refreshGalleryComments();
screen = Screen::GalleryComments;
showToast("Comment posted");
}
void App::openGalleryItem(std::size_t index) {
if (gallery.empty()) return;
gallerySelected = index % gallery.size();
const Item& item = gallery[gallerySelected];
std::vector<std::uint8_t> bytes;
int status = 0;
std::string error;
if (!galleryRequest("GET", "/api/flipnotes/" + item.galleryId + "/file", {}, "application/octet-stream", bytes, status, error)) {
showToast("Could not download Switchnote: " + error);
return;
}
sppm::Document downloaded;
if (!sppm::deserializeSigned(bytes, downloaded, signingIdentity.legacyHmacKey, error)) {
showToast("Gallery Switchnote failed verification: " + error);
return;
}
if (docLoaded && dirty && !galleryViewing && !saveDocument(true)) return;
stopPlayback();
doc = std::move(downloaded);
docLoaded = true;
docPath.clear();
galleryViewing = true;
dirty = false;
frame = 0;
zoom = 1.0f;
panX = panY = 0;
invalidateFrames();
rebuildAudio();
screen = Screen::Viewer;
}
void App::saveGalleryItem(std::size_t index) {
LoadingScope loading(*this);
if (index >= gallery.size()) return;
const Item& item = gallery[index];
for (const Item& local : library) {
if ((!item.currentId.empty() && local.currentId == item.currentId) ||
(!item.fsid.empty() && local.fsid == item.fsid)) {
showToast("This Switchnote is already saved locally");
return;
}
}
std::vector<std::uint8_t> bytes, response;
int status = 0;
std::string error;
if (!galleryRequest("GET", "/api/flipnotes/" + item.galleryId + "/file", {},
"application/octet-stream", bytes, status, error)) {
showToast("Could not save Switchnote: " + error);
return;
}
sppm::Document verified;
if (!sppm::deserializeSigned(bytes, verified, signingIdentity.legacyHmacKey, error)) {
showToast("Gallery Switchnote failed verification: " + error);
return;
}
const std::string directory = join(notesRoot, folder);
ensureDir(directory);
const std::string saveId = newId("gallery-save");
if (saveId.empty()) { showToast("Could not create a secure local filename"); return; }
const std::string destination = join(directory, saveId + ".sppm");
const std::string temporary = destination + ".saving";
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) { showToast("Could not save Switchnote to the SD card"); return; }
output.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
output.flush();
const bool written = static_cast<bool>(output);
output.close();
if (!written || std::rename(temporary.c_str(), destination.c_str()) != 0) {
std::remove(temporary.c_str());
showToast("Could not save Switchnote to the SD card");
return;
}
scanLibrary();
showToast(verified.metadata.locked ? "Locked Switchnote saved locally (read-only)" : "Switchnote saved locally");
}
void App::poll() {
pointerPressed = false;
pointerReleased = false;
touchHoverExpiryPending = false;
clickConsumed = false;
buttonsDown = 0;
buttonsUp = 0;
wheel = 0;
const auto canvasControlAt = [&](int x, int y) {
if (screen != Screen::Canvas || canvasChromeVisible < 0.999f) return false;
if (paintMenu || colorMenu || toolMenu) return true;
const int menuX = 154;
const int menuY = 635 + static_cast<int>(std::lround((1.0f - canvasMenuProgress) * 95.0f));
return (x >= 20 && x < 164 && y >= 18 && y < 72) ||
(x >= 20 && x < 92 && y >= 238 && y < 498) ||
(x >= 1200 && x < 1260 && y >= 12 && y < 212) ||
(x >= 20 && x < 164 && y >= 635 && y < 697) ||
(canvasMenuProgress > 0.0f && x >= menuX && x < menuX + 310 && y >= menuY - 9 && y < menuY + 71) ||
(x >= 474 && x < 800 && y >= 635 && y < 700) ||
(x >= 904 && x < 1230 && y >= 635 && y < 700);
};
const auto closeCanvasMenuOnOutsidePress = [&](int x, int y) {
if (screen != Screen::Canvas || !canvasMenuOpen) return;
const int menuX = 154;
const int menuY = 635 + static_cast<int>(std::lround((1.0f - canvasMenuProgress) * 95.0f));
const bool onToggle = x >= 92 && x < 156 && y >= 635 && y < 697;
const bool onPopup = canvasMenuProgress > 0.0f && x >= menuX && x < menuX + 310 && y >= menuY - 9 && y < menuY + 71;
if (!onToggle && !onPopup) canvasMenuOpen = false;
};
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_QUIT: running = false; break;
case SDL_CONTROLLERDEVICEADDED:
if (!controller && SDL_IsGameController(event.cdevice.which)) controller = SDL_GameControllerOpen(event.cdevice.which);
break;
case SDL_CONTROLLERDEVICEREMOVED:
if (controller && SDL_JoystickInstanceID(SDL_GameControllerGetJoystick(controller)) == event.cdevice.which) {
SDL_GameControllerClose(controller);
controller = nullptr;
}
break;
case SDL_CONTROLLERBUTTONDOWN: {
const auto bit = controllerBit(event.cbutton.button);
buttonsDown |= bit;
buttonsHeld |= bit;
break;
}
case SDL_CONTROLLERBUTTONUP: {
const auto bit = controllerBit(event.cbutton.button);
buttonsHeld &= ~bit;
buttonsUp |= bit;
break;
}
case SDL_KEYDOWN: {
if (event.key.repeat) break;
if (modal == Modal::TextInput) {
if (event.key.keysym.sym == SDLK_BACKSPACE && !inputText.empty()) inputText.pop_back();
else if (event.key.keysym.sym == SDLK_RETURN) applyText();
else if (event.key.keysym.sym == SDLK_ESCAPE) { modal = Modal::None; SDL_StopTextInput(); }
break;
}
std::uint32_t bit = 0;
switch (event.key.keysym.sym) {
case SDLK_RETURN: case SDLK_SPACE: case SDLK_a: bit = BA; break;
case SDLK_ESCAPE: case SDLK_b: bit = BB; break;
case SDLK_x: bit = BX; break;
case SDLK_y: bit = BY; break;
case SDLK_l: bit = BL; break;
case SDLK_r: bit = BR; break;
case SDLK_q: bit = BZL; break;
case SDLK_e: bit = BZR; break;
case SDLK_LEFT: bit = BLEFT; break;
case SDLK_RIGHT: bit = BRIGHT; break;
case SDLK_UP: bit = BUP; break;
case SDLK_DOWN: bit = BDOWN; break;
case SDLK_EQUALS: bit = BPLUS; break;
case SDLK_MINUS: bit = BMINUS; break;
default: break;
}
buttonsDown |= bit;
buttonsHeld |= bit;
break;
}
case SDL_KEYUP: {
std::uint32_t bit = 0;
switch (event.key.keysym.sym) {
case SDLK_RETURN: case SDLK_SPACE: case SDLK_a: bit = BA; break;
case SDLK_ESCAPE: case SDLK_b: bit = BB; break;
case SDLK_x: bit = BX; break;
case SDLK_y: bit = BY; break;
case SDLK_l: bit = BL; break;
case SDLK_r: bit = BR; break;
case SDLK_q: bit = BZL; break;
case SDLK_e: bit = BZR; break;
case SDLK_LEFT: bit = BLEFT; break;
case SDLK_RIGHT: bit = BRIGHT; break;
case SDLK_UP: bit = BUP; break;
case SDLK_DOWN: bit = BDOWN; break;
case SDLK_EQUALS: bit = BPLUS; break;
case SDLK_MINUS: bit = BMINUS; break;
default: break;
}
buttonsHeld &= ~bit;
buttonsUp |= bit;
break;
}
case SDL_TEXTINPUT:
if (modal == Modal::TextInput && inputText.size() < 240) inputText += event.text.text;
break;
case SDL_MOUSEMOTION:
if (event.motion.which != SDL_TOUCH_MOUSEID) { pointerX = event.motion.x; pointerY = event.motion.y; }
break;
case SDL_MOUSEBUTTONDOWN:
if (event.button.which != SDL_TOUCH_MOUSEID && event.button.button == SDL_BUTTON_LEFT) {
pointerX = event.button.x; pointerY = event.button.y; pointerDown = pointerPressed = true; hasLastStroke = false;
closeCanvasMenuOnOutsidePress(pointerX, pointerY);
canvasControlGesture = canvasControlAt(pointerX, pointerY);
const bool inkSurface = (screen == Screen::Canvas && !canvasControlGesture) ||
(screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620);
if (!inkSurface) playEvent("ui_button_down", "none");
}
break;
case SDL_MOUSEBUTTONUP:
if (event.button.which != SDL_TOUCH_MOUSEID && event.button.button == SDL_BUTTON_LEFT) {
pointerX = event.button.x; pointerY = event.button.y; pointerDown = false; pointerReleased = true;
const bool inkSurface = (screen == Screen::Canvas && !canvasControlGesture) ||
(screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620);
if (!inkSurface) playEvent("ui_button_up", "none");
}
break;
case SDL_FINGERDOWN:
pointerX = static_cast<int>(event.tfinger.x * W); pointerY = static_cast<int>(event.tfinger.y * H);
pointerDown = pointerPressed = true; hasLastStroke = false;
closeCanvasMenuOnOutsidePress(pointerX, pointerY);
canvasControlGesture = canvasControlAt(pointerX, pointerY);
if (!(screen == Screen::Canvas && !canvasControlGesture) &&
!(screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620)) playEvent("ui_button_down", "none");
break;
case SDL_FINGERMOTION:
pointerX = static_cast<int>(event.tfinger.x * W); pointerY = static_cast<int>(event.tfinger.y * H);
break;
case SDL_FINGERUP:
pointerX = static_cast<int>(event.tfinger.x * W); pointerY = static_cast<int>(event.tfinger.y * H);
pointerDown = false; pointerReleased = true;
touchHoverExpiryPending = true;
if (!(screen == Screen::Canvas && !canvasControlGesture) &&
!(screen == Screen::CommentCanvas && pointerY >= 72 && pointerY < 620)) playEvent("ui_button_up", "none");
break;
case SDL_MOUSEWHEEL: wheel += event.wheel.y; break;
default: break;
}
}
if (controller) {
const std::uint32_t previousHeld = buttonsHeld;
const bool zl = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERLEFT) > 16000;
const bool zr = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_TRIGGERRIGHT) > 16000;
if (zl && !(buttonsHeld & BZL)) buttonsDown |= BZL;
if (zr && !(buttonsHeld & BZR)) buttonsDown |= BZR;
if (!zl && (previousHeld & BZL)) buttonsUp |= BZL;
if (!zr && (previousHeld & BZR)) buttonsUp |= BZR;
buttonsHeld = (buttonsHeld & ~(BZL | BZR)) |
(zl ? static_cast<std::uint32_t>(BZL) : 0u) |
(zr ? static_cast<std::uint32_t>(BZR) : 0u);
const auto normalizedStick = [](Sint16 value) {
constexpr float deadZone = 0.18f;
const float normalized = value < 0 ? value / 32768.0f : value / 32767.0f;
if (std::abs(normalized) <= deadZone) return 0.0f;
return (normalized - (normalized < 0.0f ? -deadZone : deadZone)) / (1.0f - deadZone);
};
leftStickX = normalizedStick(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTX));
leftStickY = normalizedStick(SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTY));
} else {
leftStickX = leftStickY = 0.0f;
}
const std::array<std::pair<std::uint32_t, const char*>, 17> inputButtons{{
{BA, "a"}, {BB, "b"}, {BX, "x"}, {BY, "y"}, {BL, "l"}, {BR, "r"},
{BZL, "zl"}, {BZR, "zr"}, {BLEFT, "left"}, {BRIGHT, "right"},
{BUP, "up"}, {BDOWN, "down"}, {BPLUS, "plus"}, {BMINUS, "minus"},
{BLS, "ls"}, {BRS, "rs"}, {BMISC, "misc"}
}};
for (const auto& button : inputButtons) {
if (buttonsDown & button.first) playEvent(std::string("button_") + button.second + "_down", "none");
if (buttonsUp & button.first) playEvent(std::string("button_") + button.second + "_up", "none");
}
}
void App::showToast(const std::string& message) {
toast = message;
toastUntil = SDL_GetTicks() + 2500;
}
void App::loadPreferences() {
std::ifstream in(join(dataRoot, "preferences.ini"));
int storedFps = defaultFps;
int storedOnion = onion ? 1 : 0;
int storedGrid = canvasGrid ? 1 : 0;
int storedCopy = copyToNewPage ? 1 : 0;
int storedStabilisation = penStabilisationEnabled ? 1 : 0;
int storedAfter = penStabilisationAfter ? 1 : 0;
int storedStrength = penStabilisationStrength;
if (in >> storedFps >> storedOnion >> storedGrid) {
defaultFps = std::clamp(storedFps, 1, 30);
defaultSpeed = speedFromFps(defaultFps);
onion = storedOnion != 0;
canvasGrid = storedGrid != 0;
if (in >> storedCopy) {
copyToNewPage = storedCopy != 0;
if (in >> storedStabilisation >> storedAfter >> storedStrength) {
penStabilisationEnabled = storedStabilisation != 0;
penStabilisationAfter = storedAfter != 0;
penStabilisationStrength = std::clamp(storedStrength, 0, 100);
}
}
int storedSpeed = 0;
if (in >> storedSpeed) {
if (storedSpeed >= 1 && storedSpeed <= 8) defaultSpeed = storedSpeed;
}
}
std::ifstream branding(join(dataRoot, "branding.ini"), std::ios::binary);
std::string line;
if (branding && std::getline(branding, line) && !line.empty() && line.size() <= 40) appDisplayName = line;
if (branding && std::getline(branding, line) && !line.empty() && line.size() <= 24) noteSingular = line;
if (branding && std::getline(branding, line) && !line.empty() && line.size() <= 24) notePlural = line;
const std::string savedLogo = join(dataRoot, "branding-logo.png");
std::ifstream logo(savedLogo, std::ios::binary);
if (logo) brandingLogoPath = savedLogo;
std::ifstream galleryTerms(join(dataRoot, "gallery-terms-accepted"), std::ios::binary);
galleryTermsAccepted = static_cast<bool>(galleryTerms);
}
bool App::startBrandingConfigServer() {
#ifdef __SWITCH__
if (brandingConfigServer.isRunning()) return true;
if (!gallerySocketsReady) {
const Result sockets = socketInitializeDefault();
if (R_FAILED(sockets)) { brandingStatus = "Network initialization failed."; return false; }
gallerySocketsReady = true;
}
if (!nifmReady) {
const Result network = nifmInitialize(NifmServiceType_User);
if (R_FAILED(network)) { brandingStatus = "Could not read the Switch network address."; return false; }
nifmReady = true;
}
u32 rawAddress = 0;
if (R_FAILED(nifmGetCurrentIpAddress(&rawAddress))) { brandingStatus = "Connect the Switch to local Wi-Fi first."; return false; }
const std::uint32_t hostAddress = ntohl(rawAddress);
const bool privateAddress = (hostAddress >> 24) == 10 || (hostAddress & 0xfff00000U) == 0xac100000U ||
(hostAddress & 0xffff0000U) == 0xc0a80000U || (hostAddress & 0xffff0000U) == 0xa9fe0000U ||
(hostAddress & 0xffc00000U) == 0x64400000U;
if (!privateAddress) { brandingStatus = "Connect to a private local network to use the portal."; return false; }
in_addr address{}; address.s_addr = rawAddress; char addressText[INET_ADDRSTRLEN]{};
if (!::inet_ntop(AF_INET, &address, addressText, sizeof(addressText))) { brandingStatus = "Could not format the Switch address."; return false; }
std::uint32_t randomValue = 0;
do { if (!randomBytes(&randomValue, sizeof(randomValue))) { brandingStatus = "Could not create a secure connection code."; return false; } }
while (randomValue >= 4294000000U);
const unsigned int pin = randomValue % 1000000U; char formattedCode[8]{};
std::snprintf(formattedCode, sizeof(formattedCode), "%03u-%03u", pin / 1000U, pin % 1000U);
std::string error;
if (!brandingConfigServer.start(addressText, dataRoot, assetsRoot, formattedCode, error,
appDisplayName, noteSingular, notePlural)) {
brandingStatus = error.empty() ? "Could not start the branding portal." : error; return false;
}
brandingCode = formattedCode; brandingUrl = "http://" + std::string(addressText) + ":8767";
brandingStatus = "Enter the code on your PC."; return true;
#else
brandingStatus = "The branding portal is available in the Switch homebrew app."; return false;
#endif
}
void App::stopBrandingConfigServer() {
brandingConfigServer.stop(); brandingUrl.clear(); brandingCode.clear();
}
void App::closeBrandingMenu() {
stopBrandingConfigServer(); modal = Modal::None;
}
void App::consumeBrandingConfig() {
if (!brandingConfigServer.takeConfigUpdate()) return;
const std::string configPath = join(dataRoot, "branding.ini");
std::ifstream in(configPath, std::ios::binary); std::string app, singular, plural;
if (!in || !std::getline(in, app) || !std::getline(in, singular) || !std::getline(in, plural)) {
brandingStatus = "Branding file could not be loaded."; return;
}
if (app.empty() || app.size() > 40 || singular.empty() || singular.size() > 24 || plural.empty() || plural.size() > 24) {
brandingStatus = "Branding values are invalid."; return;
}
const std::string pending = join(dataRoot, "branding-logo.pending");
std::ifstream logo(pending, std::ios::binary);
if (logo) {
logo.close();
SDL_Texture* validationTexture = loadBrandingLogo(pending);
if (!validationTexture) { brandingStatus = "The uploaded logo could not be decoded."; showToast(brandingStatus); return; }
SDL_DestroyTexture(validationTexture);
const std::string destination = join(dataRoot, "branding-logo.png");
// FAT/SD rename replacement is unreliable on Switch. The pending
// upload has already been decoded successfully, so copy it into the
// stable logo path and verify the completed file before accepting it.
std::ifstream uploaded(pending, std::ios::binary | std::ios::ate);
if (!uploaded) { brandingStatus = "The uploaded logo could not be opened."; showToast(brandingStatus); return; }
const std::streamoff logoSize = uploaded.tellg();
if (logoSize <= 0 || logoSize > 8 * 1024 * 1024) { brandingStatus = "The uploaded logo has an invalid size."; showToast(brandingStatus); return; }
std::vector<char> logoBytes(static_cast<std::size_t>(logoSize));
uploaded.seekg(0, std::ios::beg);
uploaded.read(logoBytes.data(), static_cast<std::streamsize>(logoBytes.size()));
if (!uploaded) { brandingStatus = "The uploaded logo could not be read."; showToast(brandingStatus); return; }
uploaded.close();
std::ofstream savedLogo(destination, std::ios::binary | std::ios::trunc);
if (!savedLogo) { brandingStatus = "The title logo could not be opened on the SD card."; showToast(brandingStatus); return; }
savedLogo.write(logoBytes.data(), static_cast<std::streamsize>(logoBytes.size()));
savedLogo.flush();
if (!savedLogo) { savedLogo.close(); brandingStatus = "The title logo could not be written to the SD card."; showToast(brandingStatus); return; }
savedLogo.close();
SDL_Texture* savedValidation = savedLogo ? loadBrandingLogo(destination) : nullptr;
if (savedValidation) SDL_DestroyTexture(savedValidation);
if (!savedLogo || !savedValidation) {
brandingStatus = "The title logo could not be verified after saving."; showToast(brandingStatus); return;
}
std::remove(pending.c_str());
brandingLogoPath = destination;
}
bool hasLoadingUpload = false;
for (std::size_t i = 0; i < loadingFrogFrames.size(); ++i) {
const std::string pendingFrame = join(dataRoot, "branding-loading" + std::to_string(i) + ".pending");
std::ifstream pending(pendingFrame, std::ios::binary | std::ios::ate);
if (!pending) continue;
hasLoadingUpload = true;
const std::streamoff length = pending.tellg();
if (length <= 8 || length > 128 * 1024) { brandingStatus = "A loading frame has an invalid size."; showToast(brandingStatus); return; }
pending.close();
SDL_Texture* check = loadBrandingLogo(pendingFrame);
if (!check) { brandingStatus = "A loading frame could not be decoded."; showToast(brandingStatus); return; }
int frameW = 0, frameH = 0;
SDL_QueryTexture(check, nullptr, nullptr, &frameW, &frameH);
SDL_DestroyTexture(check);
if (frameW != 64 || frameH != 64) { brandingStatus = "Loading frames must be 64 by 64 pixels."; showToast(brandingStatus); return; }
}
if (hasLoadingUpload) {
for (std::size_t i = 0; i < loadingFrogFrames.size(); ++i) {
const std::string pendingFrame = join(dataRoot, "branding-loading" + std::to_string(i) + ".pending");
std::ifstream inFrame(pendingFrame, std::ios::binary | std::ios::ate);
if (!inFrame) { brandingStatus = "The loading animation upload is incomplete."; showToast(brandingStatus); return; }
const std::streamoff length = inFrame.tellg();
std::vector<char> frameBytes(static_cast<std::size_t>(length));
inFrame.seekg(0); inFrame.read(frameBytes.data(), length);
if (!inFrame) { brandingStatus = "Could not read the loading animation."; showToast(brandingStatus); return; }
const std::string destination = join(dataRoot, "branding-loading" + std::to_string(i) + ".png");
std::ofstream outFrame(destination, std::ios::binary | std::ios::trunc);
if (!outFrame) { brandingStatus = "Could not open a loading frame on the SD card."; showToast(brandingStatus); return; }
outFrame.write(frameBytes.data(), static_cast<std::streamsize>(frameBytes.size()));
outFrame.close();
SDL_Texture* replacement = loadBrandingLogo(destination);
if (!outFrame || !replacement) { if (replacement) SDL_DestroyTexture(replacement); brandingStatus = "Could not save the loading animation."; showToast(brandingStatus); return; }
if (loadingFrogFrames[i]) SDL_DestroyTexture(loadingFrogFrames[i]);
loadingFrogFrames[i] = replacement;
std::remove(pendingFrame.c_str());
}
}
brandingStatus = "Branding saved. Restart the app for changes to apply.";
if (modal == Modal::Branding) showToast(brandingStatus);
}
void App::exportBrandingConfig() {
branding::Bundle bundle;
bundle.app = appDisplayName; bundle.singular = noteSingular; bundle.plural = notePlural;
const std::string logoPath = brandingLogoPath.empty()
? join(assetsRoot, "art/switchnotedefault.png") : brandingLogoPath;
std::ifstream logo(logoPath, std::ios::binary | std::ios::ate);
if (!logo) { showToast("Could not read the current title logo"); return; }
const std::streamoff size = logo.tellg();
if (size <= 0 || size > 8 * 1024 * 1024) { showToast("The current title logo has an invalid size"); return; }
bundle.logo.resize(static_cast<std::size_t>(size)); logo.seekg(0);
logo.read(reinterpret_cast<char*>(bundle.logo.data()), size);
if (!logo) { showToast("Could not read the current title logo"); return; }
const std::string directory = join(dataRoot, "exports");
mkdir(directory.c_str(), 0777);
std::string error;
if (!branding::writeConfig(join(directory, "branding.config"), bundle, error)) { showToast(error); return; }
showToast("Branding exported to exports/branding.config");
}
void App::importBrandingConfig() {
branding::Bundle bundle;
std::string error;
if (!branding::readConfig(join(dataRoot, "branding.config"), bundle, error)) { showToast(error); return; }
const std::string staging = join(dataRoot, "branding-logo.import");
{
std::ofstream image(staging, std::ios::binary | std::ios::trunc);
if (!image) { showToast("Could not stage the imported logo"); return; }
image.write(reinterpret_cast<const char*>(bundle.logo.data()), static_cast<std::streamsize>(bundle.logo.size()));
image.flush();
if (!image) { image.close(); std::remove(staging.c_str()); showToast("Could not write the imported logo"); return; }
}
SDL_Texture* validation = loadBrandingLogo(staging);
if (!validation) { std::remove(staging.c_str()); showToast("The imported logo is not a valid image"); return; }
SDL_DestroyTexture(validation);
const std::string configPath = join(dataRoot, "branding.ini");
std::ofstream config(configPath, std::ios::binary | std::ios::trunc);
if (!config) { std::remove(staging.c_str()); showToast("Could not save imported branding"); return; }
config << bundle.app << '\n' << bundle.singular << '\n' << bundle.plural << '\n';
config.flush();
if (!config) { config.close(); std::remove(staging.c_str()); showToast("Could not save imported branding"); return; }
config.close();
const std::string logoPath = join(dataRoot, "branding-logo.png");
std::ifstream src(staging, std::ios::binary);
std::ofstream dst(logoPath, std::ios::binary | std::ios::trunc);
dst << src.rdbuf(); dst.flush();
const bool logoSaved = static_cast<bool>(src) && static_cast<bool>(dst);
dst.close(); src.close(); std::remove(staging.c_str());
if (!logoSaved) { showToast("Branding was loaded, but the title logo could not be saved"); return; }
brandingStatus = "Branding imported. Restart the app for changes to apply.";
showToast(brandingStatus);
}
void App::savePreferences() {
std::ofstream out(join(dataRoot, "preferences.ini"), std::ios::trunc);
if (out) {
out << defaultFps << ' ' << (onion ? 1 : 0) << ' ' << (canvasGrid ? 1 : 0) << ' '
<< (copyToNewPage ? 1 : 0) << ' ' << (penStabilisationEnabled ? 1 : 0) << ' '
<< (penStabilisationAfter ? 1 : 0) << ' ' << penStabilisationStrength << ' ' << defaultSpeed << '\n';
}
}
void App::playCue(const std::string& name) {
playEvent("ui_action", name);
}
void App::playEvent(const std::string& event, const std::string& defaultCue) {
std::string cue = defaultCue;
if (event == "ui_button_down" || (event.rfind("button_", 0) == 0 && endsWith(event, "_down"))) cue = "button_on";
else if (event == "ui_button_up" || (event.rfind("button_", 0) == 0 && endsWith(event, "_up"))) cue = "button_off";
else if (event == "frame_previous") cue = "page_backward";
else if (event == "frame_next") cue = "page_forward";
else if (event == "frame_first") cue = "page_first";
else if (event == "frame_last") cue = "page_end";
else if (event == "frame_copy") cue = "edit_page_copy";
else if (event == "frame_paste") cue = "edit_page_paste";
else if (event == "frame_erase") cue = "edit_page_remove";
else if (event == "tool_color") cue = "popup_select";
if (cue.empty() || cue == "none") return;
if (!audioReady) return;
auto found = cues.find(cue);
if (found == cues.end() || !found->second) return;
const int channel = cue == "button_on" ? 50 : cue == "button_off" ? 51 : 15;
Mix_HaltChannel(channel);
Mix_PlayChannel(channel, found->second, 0);
}
SDL_Texture* App::image(const std::string& relative) {
auto found = images.find(relative);
if (found != images.end()) return found->second;
const std::string path = join(assetsRoot, relative);
SDL_Texture* tex = nullptr;
if (endsWith(relative, ".svg")) {
SDL_RWops* source = SDL_RWFromFile(path.c_str(), "rb");
SDL_Surface* surface = source ? IMG_LoadSVG_RW(source) : nullptr;
if (source) SDL_RWclose(source);
if (surface) {
tex = SDL_CreateTextureFromSurface(renderer, surface);
SDL_FreeSurface(surface);
}
} else tex = IMG_LoadTexture(renderer, path.c_str());
if (tex && relative.find("art/") == 0) SDL_SetTextureScaleMode(tex, SDL_ScaleModeNearest);
if (!tex) logStartup("asset load failed: " + path + " :: " + IMG_GetError());
images[relative] = tex;
return tex;
}
SDL_Texture* App::whiteImage(const std::string& relative) {
auto found = whiteImages.find(relative);
if (found != whiteImages.end()) return found->second;
const std::string path = join(assetsRoot, relative);
SDL_Surface* source = nullptr;
if (endsWith(relative, ".svg")) {
SDL_RWops* stream = SDL_RWFromFile(path.c_str(), "rb");
source = stream ? IMG_LoadSVG_RW(stream) : nullptr;
if (stream) SDL_RWclose(stream);
} else source = IMG_Load(path.c_str());
if (!source) {
logStartup("white icon load failed: " + path + " :: " + IMG_GetError());
whiteImages[relative] = nullptr;
return nullptr;
}
SDL_Surface* rgba = SDL_ConvertSurfaceFormat(source, SDL_PIXELFORMAT_RGBA32, 0);
SDL_FreeSurface(source);
if (!rgba) {
whiteImages[relative] = nullptr;
return nullptr;
}
if (SDL_MUSTLOCK(rgba)) SDL_LockSurface(rgba);
for (int y = 0; y < rgba->h; ++y) {
auto* row = reinterpret_cast<Uint32*>(static_cast<Uint8*>(rgba->pixels) + y * rgba->pitch);
for (int x = 0; x < rgba->w; ++x) {
Uint8 r, g, b, a;
SDL_GetRGBA(row[x], rgba->format, &r, &g, &b, &a);
row[x] = SDL_MapRGBA(rgba->format, 255, 255, 255, a);
}
}
if (SDL_MUSTLOCK(rgba)) SDL_UnlockSurface(rgba);
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, rgba);
SDL_FreeSurface(rgba);
if (texture) SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND);
whiteImages[relative] = texture;
return texture;
}
SDL_Texture* App::loadBrandingLogo(const std::string& path) {
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file) return nullptr;
const std::streamoff length = file.tellg();
if (length < 8 || length > 8 * 1024 * 1024 || length > std::numeric_limits<int>::max()) return nullptr;
std::vector<Uint8> bytes(static_cast<std::size_t>(length));
file.seekg(0, std::ios::beg);
if (!file.read(reinterpret_cast<char*>(bytes.data()), length)) return nullptr;
file.close();
SDL_RWops* stream = SDL_RWFromConstMem(bytes.data(), static_cast<int>(bytes.size()));
SDL_Surface* surface = stream ? IMG_Load_RW(stream, 1) : nullptr;
if (!surface) return nullptr;
if (surface->w <= 0 || surface->h <= 0 || surface->w > 8192 || surface->h > 8192) {
SDL_FreeSurface(surface);
return nullptr;
}
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
SDL_FreeSurface(surface);
if (texture) SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND);
return texture;
}
void App::drawTexture(SDL_Texture* texture, int x, int y, int w, int h) {
if (!texture || w <= 0 || h <= 0) return;
SDL_Rect dest{x, y, w, h};
SDL_RenderCopy(renderer, texture, nullptr, &dest);
}
void App::drawImage(const std::string& relative, int x, int y, int w, int h, bool nearest) {
SDL_Texture* tex = image(relative);
if (tex && nearest) SDL_SetTextureScaleMode(tex, SDL_ScaleModeNearest);
drawTexture(tex, x, y, w, h);
}
TTF_Font* App::font(int px, bool bold) {
(void)bold;
const auto key = std::make_pair(px, false);
auto found = fonts.find(key);
if (found != fonts.end()) {
return found->second;
}
const std::string regularPath = join(assetsRoot, "fonts/FOT-Seurat.otf");
TTF_Font* f = TTF_OpenFont(regularPath.c_str(), px);
fonts[key] = f;
return f;
}
std::string App::brandString(const std::string& label) const {
if (label == appDisplayName) return label;
std::string display = label;
auto replaceTerm = [&](const std::string& oldTerm, const std::string& newTerm) {
std::size_t at = 0;
while (!oldTerm.empty() && (at = display.find(oldTerm, at)) != std::string::npos) {
const bool left = at == 0 || !std::isalnum(static_cast<unsigned char>(display[at - 1]));
const std::size_t end = at + oldTerm.size();
const bool right = end == display.size() || !std::isalnum(static_cast<unsigned char>(display[end]));
if (left && right) { display.replace(at, oldTerm.size(), newTerm); at += newTerm.size(); }
else at = end;
}
};
replaceTerm("Switchnote Studio", appDisplayName);
replaceTerm("switchnote studio", appDisplayName);
replaceTerm("Switchnotes", notePlural);
replaceTerm("Switchnote", noteSingular);
replaceTerm("switchnotes", notePlural);
replaceTerm("switchnote", noteSingular);
return display;
}
void App::text(const std::string& label, int x, int y, int px, SDL_Color color, bool bold) {
rawText(brandString(label), x, y, px, color, bold);
}
void App::rawText(const std::string& display, int x, int y, int px, SDL_Color color, bool bold) {
if (display.empty()) return;
TTF_Font* f = font(px, bold);
if (!f) return;
const std::string key = std::to_string(px) + (bold ? "B" : "R") +
std::to_string(color.r) + ":" + std::to_string(color.g) + ":" + std::to_string(color.b) + ":" + display;
SDL_Texture* tex = nullptr;
auto found = textCache.find(key);
if (found != textCache.end()) tex = found->second;
else {
SDL_Surface* surf = TTF_RenderUTF8_Blended(f, display.c_str(), color);
if (!surf) return;
tex = SDL_CreateTextureFromSurface(renderer, surf);
SDL_FreeSurface(surf);
if (!tex) return;
textCache[key] = tex;
if (textCache.size() > 400) {
for (auto& old : textCache) if (old.second != tex) SDL_DestroyTexture(old.second);
textCache.clear();
textCache[key] = tex;
}
}
int w = 0, h = 0;
SDL_QueryTexture(tex, nullptr, nullptr, &w, &h);
drawTexture(tex, x - 3, y + 1, w, h);
}
void App::centered(const std::string& label, int x, int y, int w, int h, int px, SDL_Color color, bool bold) {
const std::string display = brandString(label);
TTF_Font* f = font(px, bold);
if (!f) return;
int tw = 0, th = 0;
TTF_SizeUTF8(f, display.c_str(), &tw, &th);
rawText(display, x + (w - tw) / 2, y + (h - th) / 2, px, color, bold);
}
void App::fill(int x, int y, int w, int h, SDL_Color c) {
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a);
SDL_Rect rect{x, y, w, h};
SDL_RenderFillRect(renderer, &rect);
}
void App::drawPlayTriangle(int x, int y, int size, SDL_Color color) {
if (!renderer || size <= 1) return;
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a);
for (int row = 0; row < size; ++row) {
const int extent = std::min(row, size - 1 - row);
SDL_RenderDrawLine(renderer, x, y + row, x + extent * 2, y + row);
}
}
void App::drawLockIcon(int x, int y) {
rounded(x, y, 28, 28, 14, SDL_Color{20, 22, 28, 220});
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255);
SDL_RenderDrawLine(renderer, x + 8, y + 13, x + 8, y + 10);
SDL_RenderDrawLine(renderer, x + 8, y + 10, x + 10, y + 7);
SDL_RenderDrawLine(renderer, x + 10, y + 7, x + 18, y + 7);
SDL_RenderDrawLine(renderer, x + 18, y + 7, x + 20, y + 10);
SDL_RenderDrawLine(renderer, x + 20, y + 10, x + 20, y + 13);
rounded(x + 6, y + 12, 16, 12, 3, WHITE);
rounded(x + 13, y + 16, 2, 5, 1, INK);
}
void App::rounded(int x, int y, int w, int h, int radius, SDL_Color c) {
if (w <= 0 || h <= 0) return;
radius = std::max(0, std::min(radius, std::min(w, h) / 2));
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a);
for (int row = 0; row < h; ++row) {
int inset = 0;
if (row < radius) {
const float dy = static_cast<float>(radius - row - 1);
inset = radius - static_cast<int>(std::sqrt(static_cast<float>(radius * radius) - dy * dy));
} else if (row >= h - radius) {
const float dy = static_cast<float>(row - (h - radius));
inset = radius - static_cast<int>(std::sqrt(static_cast<float>(radius * radius) - dy * dy));
}
SDL_RenderDrawLine(renderer, x + inset, y + row, x + w - inset - 1, y + row);
}
}
void App::roundedOutline(int x, int y, int w, int h, int radius, int thickness, SDL_Color c) {
if (w <= 0 || h <= 0 || thickness <= 0) return;
radius = std::clamp(radius, 0, std::min(w, h) / 2);
thickness = std::min(thickness, std::min(w, h) / 2);
const int innerW = w - thickness * 2;
const int innerH = h - thickness * 2;
const int innerRadius = std::max(0, radius - thickness);
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a);
auto insetFor = [](int row, int height, int r) {
if (r <= 0) return 0;
if (row < r) {
const float dy = static_cast<float>(r - row - 1);
return r - static_cast<int>(std::sqrt(static_cast<float>(r * r) - dy * dy));
}
if (row >= height - r) {
const float dy = static_cast<float>(row - (height - r));
return r - static_cast<int>(std::sqrt(static_cast<float>(r * r) - dy * dy));
}
return 0;
};
for (int row = 0; row < h; ++row) {
const int outerInset = insetFor(row, h, radius);
const int outerLeft = x + outerInset;
const int outerRight = x + w - outerInset - 1;
const int innerRow = row - thickness;
if (innerRow < 0 || innerRow >= innerH) {
SDL_RenderDrawLine(renderer, outerLeft, y + row, outerRight, y + row);
continue;
}
const int innerInset = insetFor(innerRow, innerH, innerRadius);
const int innerLeft = x + thickness + innerInset;
const int innerRight = x + thickness + innerW - innerInset - 1;
if (outerLeft < innerLeft) SDL_RenderDrawLine(renderer, outerLeft, y + row, innerLeft - 1, y + row);
if (innerRight < outerRight) SDL_RenderDrawLine(renderer, innerRight + 1, y + row, outerRight, y + row);
}
}
bool App::hit(int x, int y, int w, int h) {
return !updateInProgress && !modalClosing && !screen.transitioning() && !(screen == Screen::Canvas && canvasChromeVisible < 0.999f) &&
(modal == Modal::None || renderingModal) && pointerReleased && !clickConsumed &&
pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h;
}
int App::hoverLift(int x, int y, bool hovered) {
const auto key = (static_cast<std::uint64_t>(static_cast<std::uint32_t>(x)) << 32) |
static_cast<std::uint32_t>(y);
float& amount = hoverMotion[key];
if (touchHoverExpiryPending && !pointerDown) {
amount = 0.0f;
return 0;
}
amount += ((hovered ? 1.0f : 0.0f) - amount) * 0.62f;
return static_cast<int>(std::lround(amount * 3.0f));
}
bool App::pill(const std::string& label, int x, int y, int w, int h, bool green, int px, bool enabled, const std::string& cue) {
const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h;
const bool pressed = enabled && pointerDown && hovered;
const int lift = enabled ? hoverLift(x, y, hovered) : 0;
const int press = pressed ? 4 : 0;
const SDL_Color base = !enabled ? SDL_Color{230, 232, 239, 255} : (green ? PALE_GREEN : CREAM);
const SDL_Color fg = !enabled ? SDL_Color{175, 181, 193, 255} : (green ? GREEN : ORANGE);
rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast<Uint8>(pressed ? 30 : 45)});
rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE);
rounded(x, y - lift + press, w, h, h / 2, base);
centered(label, x, y - lift + press, w, h, px, fg, false);
if (enabled && hit(x, y, w, h)) {
clickConsumed = true;
if (!cue.empty()) {
if (cue == "folder_open") playEvent("folder_open", cue);
else if (cue == "tool_color") playEvent("tool_color", "draw_tool_btn_on");
else playEvent("ui_action", cue);
}
return true;
}
return false;
}
bool App::actionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, bool green, bool enabled, const std::string& cue) {
const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h;
const bool pressed = enabled && pointerDown && hovered;
const int lift = enabled ? hoverLift(x, y, hovered) : 0;
const int press = pressed ? 4 : 0;
const SDL_Color base = !enabled ? SDL_Color{230, 232, 239, 255} : (green ? PALE_GREEN : CREAM);
const SDL_Color fg = !enabled ? SDL_Color{175, 181, 193, 255} : (green ? GREEN : ORANGE);
rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast<Uint8>(pressed ? 30 : 45)});
rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE);
rounded(x, y - lift + press, w, h, h / 2, base);
const int iconSize = std::min(32, h - 10);
SDL_Texture* tex = image(icon);
if (tex) {
int sourceW = 0, sourceH = 0;
SDL_QueryTexture(tex, nullptr, nullptr, &sourceW, &sourceH);
const float scale = sourceW > 0 && sourceH > 0 ? std::min(iconSize / static_cast<float>(sourceW), iconSize / static_cast<float>(sourceH)) : 1.0f;
const int iconW = std::max(1, static_cast<int>(sourceW * scale));
const int iconH = std::max(1, static_cast<int>(sourceH * scale));
if (!enabled) SDL_SetTextureAlphaMod(tex, 110);
drawTexture(tex, x + 13 + (iconSize - iconW) / 2, y + (h - iconH) / 2 - lift + press, iconW, iconH);
if (!enabled) SDL_SetTextureAlphaMod(tex, 255);
}
const int textLeft = x + (tex ? 48 : 10);
centered(label, textLeft, y - lift + press, x + w - textLeft, h, 19, fg, false);
if (enabled && hit(x, y, w, h)) {
clickConsumed = true;
if (!cue.empty()) playEvent("ui_action", cue);
return true;
}
return false;
}
bool App::dangerPill(const std::string& label, int x, int y, int w, int h, int px, bool enabled) {
const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h;
const bool pressed = enabled && pointerDown && hovered;
const int lift = enabled ? hoverLift(x, y, hovered) : 0;
const int press = pressed ? 4 : 0;
const SDL_Color base = enabled ? SDL_Color{218, 47, 57, 255} : SDL_Color{230, 232, 239, 255};
const SDL_Color fg = enabled ? WHITE : SDL_Color{175, 181, 193, 255};
rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast<Uint8>(pressed ? 30 : 45)});
rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE);
rounded(x, y - lift + press, w, h, h / 2, base);
centered(label, x, y - lift + press, w, h, px, fg, false);
if (enabled && hit(x, y, w, h)) {
clickConsumed = true;
playEvent("ui_action", "button_on");
return true;
}
return false;
}
bool App::dangerActionPill(const std::string& icon, const std::string& label, int x, int y, int w, int h, int px, bool enabled) {
const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h;
const bool pressed = enabled && pointerDown && hovered;
const int lift = enabled ? hoverLift(x, y, hovered) : 0;
const int press = pressed ? 4 : 0;
const SDL_Color base = enabled ? SDL_Color{218, 47, 57, 255} : SDL_Color{230, 232, 239, 255};
const SDL_Color fg = enabled ? WHITE : SDL_Color{175, 181, 193, 255};
rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast<Uint8>(pressed ? 30 : 45)});
rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE);
rounded(x, y - lift + press, w, h, h / 2, base);
SDL_Texture* tex = image(icon);
if (tex) {
int sourceW = 0, sourceH = 0;
SDL_QueryTexture(tex, nullptr, nullptr, &sourceW, &sourceH);
const int iconSize = std::min(32, h - 10);
const float scale = sourceW > 0 && sourceH > 0 ? std::min(iconSize / static_cast<float>(sourceW), iconSize / static_cast<float>(sourceH)) : 1.0f;
const int iconW = std::max(1, static_cast<int>(sourceW * scale));
const int iconH = std::max(1, static_cast<int>(sourceH * scale));
if (!enabled) SDL_SetTextureAlphaMod(tex, 110);
drawTexture(tex, x + 13 + (iconSize - iconW) / 2, y + (h - iconH) / 2 - lift + press, iconW, iconH);
if (!enabled) SDL_SetTextureAlphaMod(tex, 255);
}
const int textLeft = x + (tex ? 48 : 10);
centered(label, textLeft, y - lift + press, x + w - textLeft, h, px, fg, false);
if (enabled && hit(x, y, w, h)) {
clickConsumed = true;
playEvent("ui_action", "button_on");
return true;
}
return false;
}
bool App::imagePill(const std::string& icon, int x, int y, int w, int h, bool green, bool enabled, const std::string& cue) {
const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h;
const bool pressed = enabled && pointerDown && hovered;
const int lift = enabled ? hoverLift(x, y, hovered) : 0;
const int press = pressed ? 4 : 0;
const SDL_Color base = !enabled ? SDL_Color{230, 232, 239, 255} : (green ? PALE_GREEN : CREAM);
rounded(x, y + 5 - lift + press, w, h, h / 2, SDL_Color{0, 0, 0, static_cast<Uint8>(pressed ? 30 : 45)});
rounded(x - 3, y - 3 - lift + press, w + 6, h + 6, (h + 6) / 2, WHITE);
rounded(x, y - lift + press, w, h, h / 2, base);
SDL_Texture* tex = image(icon);
if (tex) {
int sourceW = 0, sourceH = 0;
SDL_QueryTexture(tex, nullptr, nullptr, &sourceW, &sourceH);
const float scale = sourceW > 0 && sourceH > 0
? std::min((w - 18) / static_cast<float>(sourceW), (h - 12) / static_cast<float>(sourceH)) : 1.0f;
const int iconW = std::max(1, static_cast<int>(sourceW * scale));
const int iconH = std::max(1, static_cast<int>(sourceH * scale));
if (!enabled) SDL_SetTextureAlphaMod(tex, 110);
drawTexture(tex, x + (w - iconW) / 2, y + (h - iconH) / 2 - lift + press, iconW, iconH);
if (!enabled) SDL_SetTextureAlphaMod(tex, 255);
}
if (enabled && hit(x, y, w, h)) {
clickConsumed = true;
if (!cue.empty()) playEvent("ui_action", cue);
return true;
}
return false;
}
bool App::imageButton(const std::string& path, int x, int y, int w, int h, const std::string& cue) {
const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h;
const bool pressed = pointerDown && hovered;
drawImage(path, x, y - hoverLift(x, y, hovered) + (pressed ? 4 : 0), w, h);
if (hit(x, y, w, h)) {
clickConsumed = true;
if (!cue.empty()) playEvent("ui_action", cue);
return true;
}
return false;
}
void App::background() {
if (screen == Screen::Canvas) fill(0, 0, W, H, WHITE);
else drawImage("ui/GridBackground.png", 0, 0, W, H);
}
void App::renderToast() {
if (toast.empty() || SDL_GetTicks() > toastUntil) return;
TTF_Font* body = font(19, false);
if (!body) return;
constexpr int maxTextWidth = W - 120;
auto measure = [body](const std::string& value) {
int width = 0, height = 0;
TTF_SizeUTF8(body, value.c_str(), &width, &height);
return width;
};
std::vector<std::string> lines;
std::istringstream words(toast);
std::string word, line;
while (words >> word) {
const std::string candidate = line.empty() ? word : line + " " + word;
if (!line.empty() && measure(candidate) > maxTextWidth) {
lines.push_back(line);
line = word;
} else line = candidate;
}
if (!line.empty()) lines.push_back(line);
if (lines.empty()) return;
int textWidth = 0;
for (const auto& part : lines) textWidth = std::max(textWidth, measure(part));
const int lineHeight = TTF_FontHeight(body) + 2;
const int width = std::min(W - 48, textWidth + 48);
const int height = static_cast<int>(lines.size()) * lineHeight + 22;
const int x = (W - width) / 2;
const int y = 82;
rounded(x, y, width, height, std::min(23, height / 2), SDL_Color{20, 24, 30, 225});
for (std::size_t i = 0; i < lines.size(); ++i) {
const int lineWidth = measure(lines[i]);
rawText(lines[i], (W - lineWidth) / 2, y + 10 + static_cast<int>(i) * lineHeight, 19, WHITE, false);
}
}
void App::run() {
logStartup("startup: entering main loop");
const std::uint32_t start = SDL_GetTicks();
while (running
#ifdef __SWITCH__
&& appletMainLoop()
#endif
) {
poll();
pulse = static_cast<float>(SDL_GetTicks() - start) / 1000.0f;
update();
render();
if (updateInstallPending && static_cast<std::int32_t>(SDL_GetTicks() - updateInstallReadyAt) >= 0)
completeUpdateInstall();
}
logStartup("startup: main loop ended");
}
void App::startUpdateCheck(bool manual) {
if (updateInProgress || updateThread) return;
if (manual && !internetConnected()) {
showToast("You're not connected to the internet.");
return;
}
updateTask = UpdateTask::Check;
updateManualCheck = manual;
updateAvailable = false;
updateNoChange = false;
updateServerVersion.clear();
updateDownloadUrl.clear();
updateSha256.clear();
updateError.clear();
updateHttpStatus = 0;
updateWorkerDone.store(false, std::memory_order_relaxed);
updateInProgress = true;
beginLoading();
updateStartedAt = SDL_GetTicks();
updateThread = SDL_CreateThread(updateThreadEntry, "switchnote-update", this);
if (!updateThread) {
updateInProgress = false;
endLoading();
loadingCompletionVisible = false;
updateError = "Could not start the update check.";
if (manual) showToast(updateError);
}
}
void App::startUpdateDownload() {
if (updateInProgress || updateThread || updateDownloadUrl.empty()) return;
updateTask = UpdateTask::Download;
updateManualCheck = false;
updateError.clear();
updateDownloadedPath.clear();
updateHttpStatus = 0;
updateWorkerDone.store(false, std::memory_order_relaxed);
updateInProgress = true;
modal = Modal::None;
beginLoading();
updateStartedAt = SDL_GetTicks();
updateThread = SDL_CreateThread(updateThreadEntry, "switchnote-download", this);
if (!updateThread) {
updateInProgress = false;
endLoading();
loadingCompletionVisible = false;
showToast("Could not start the update download.");
}
}
bool App::requestUpdateHandoff() {
#ifdef __SWITCH__
const std::string installTarget = join(dataRoot, "switchnote.nro");
if (!envHasNextLoad()) {
updateError = "This launch method cannot open the downloaded update automatically.";
return false;
}
if (!sameSdPath(launchNroPath, installTarget)) {
updateError = "Auto-install is only available when launched from switch/Switchnote/switchnote.nro.";
logStartup("update: launch path did not match install target; loader path=" +
(launchNroPath.empty() ? std::string("<empty>") : launchNroPath) + "; expected=" + installTarget);
return false;
}
if (!validNroFile(updateDownloadedPath)) {
updateError = "The downloaded update file could not be verified.";
return false;
}
const std::string nextArguments = updateDownloadedPath + " --switchnote-update-handoff";
const Result result = envSetNextLoad(updateDownloadedPath.c_str(), nextArguments.c_str());
if (R_FAILED(result)) {
updateError = "Could not start the downloaded update.";
logStartup("update: envSetNextLoad failed: " + std::to_string(static_cast<unsigned int>(result)));
return false;
}
logStartup("update: chained to downloaded NRO " + updateDownloadedPath);
running = false;
return true;
#else
return false;
#endif
}
void App::completeUpdateInstall() {
updateInstallPending = false;
#ifdef __SWITCH__
const std::string installTarget = join(dataRoot, "switchnote.nro");
const std::string backupPath = installTarget + ".oldbk";
auto exists = [](const std::string& path) {
struct stat st{};
return stat(path.c_str(), &st) == 0;
};
auto fail = [&](const std::string& message) {
updateError = message;
logStartup("update: " + message);
showToast(message);
};
if (!sameSdPath(updateInstallSourcePath, launchNroPath) ||
!validNroFile(updateInstallSourcePath)) {
fail("The running update file could not be verified; the installed app was left unchanged.");
return;
}
if (exists(backupPath)) {
if (validNroFile(installTarget)) {
if (std::remove(backupPath.c_str()) != 0) {
fail("Could not clear the previous update backup; the installed app was left unchanged.");
return;
}
} else {
if (exists(installTarget)) std::remove(installTarget.c_str());
if (!validNroFile(backupPath) || std::rename(backupPath.c_str(), installTarget.c_str()) != 0) {
fail("Could not restore the previous app backup; the update was not installed.");
return;
}
}
}
if (!validNroFile(installTarget)) {
fail("The installed Switchnote NRO was not found; the update was left in the updates folder.");
return;
}
if (std::rename(installTarget.c_str(), backupPath.c_str()) != 0) {
fail("Could not back up the current Switchnote NRO; the update was left in the updates folder.");
return;
}
if (std::rename(updateInstallSourcePath.c_str(), installTarget.c_str()) != 0) {
const bool restored = std::rename(backupPath.c_str(), installTarget.c_str()) == 0;
fail(restored ? "Could not install the update; the previous app was restored." :
"Could not install the update or restore the backup. Keep the .oldbk file for recovery.");
return;
}
if (!validNroFile(installTarget)) {
std::remove(installTarget.c_str());
const bool restored = std::rename(backupPath.c_str(), installTarget.c_str()) == 0;
fail(restored ? "The new NRO failed verification; the previous app was restored." :
"The new NRO failed verification and rollback failed. Keep the .oldbk file for recovery.");
return;
}
if (std::remove(backupPath.c_str()) == 0) {
logStartup("update: installed new NRO and removed backup");
showToast("Update installed successfully.");
} else {
logStartup("update: installed new NRO; backup cleanup failed");
showToast("Update installed. The old backup could not be removed.");
}
#endif
}
int App::updateThreadEntry(void* context) {
App* app = static_cast<App*>(context);
app->runUpdateTask();
app->updateWorkerDone.store(true, std::memory_order_release);
return 0;
}
void App::runUpdateTask() {
std::vector<std::uint8_t> response;
std::string responseHeaders;
std::string error;
if (updateTask == UpdateTask::Check) {
const bool ok = galleryRequest("GET", "/api/update", {}, "application/json",
response, updateHttpStatus, error, &responseHeaders);
if (!ok || updateHttpStatus == 503) {
updateError = updateHttpStatus == 503 ? "Update service is temporarily unavailable." : error;
return;
}
const std::string json(response.begin(), response.end());
updateServerVersion = jsonStringField(json, "version");
updateDownloadUrl = jsonStringField(json, "download_url");
updateSha256 = jsonStringField(json, "sha256");
if (!validSha256Hex(updateSha256)) updateSha256.clear();
std::vector<unsigned long> returnedParts, installedParts;
if (updateServerVersion.empty() || updateDownloadUrl.empty() ||
!versionParts(updateServerVersion, returnedParts) ||
!versionParts(kInstalledAppVersion, installedParts)) {
updateError = "The update service returned invalid version information.";
return;
}
updateAvailable = compareVersions(updateServerVersion, kInstalledAppVersion) > 0;
updateNoChange = !updateAvailable;
if (updateAvailable) {
std::string trustedPath;
if (!trustedUpdatePath(updateDownloadUrl, trustedPath)) {
updateAvailable = false;
updateNoChange = false;
updateError = "The update service returned an untrusted download address.";
}
}
return;
}
if (updateTask != UpdateTask::Download) return;
std::string downloadPath;
if (!trustedUpdatePath(updateDownloadUrl, downloadPath)) {
updateError = "The update service returned an untrusted download address.";
return;
}
bool downloaded = false;
for (int attempt = 0; attempt < 3; ++attempt) {
response.clear();
responseHeaders.clear();
error.clear();
const bool ok = galleryRequest("GET", downloadPath, {}, "application/octet-stream",
response, updateHttpStatus, error, &responseHeaders);
if (ok) { downloaded = true; break; }
if (updateHttpStatus == 503) {
updateError = "The update download is temporarily unavailable. Try again later.";
return;
}
if (updateHttpStatus != 429) {
updateError = error.empty() ? "The update download failed." : error;
return;
}
const int waitSeconds = retryAfterSeconds(responseHeaders);
if (waitSeconds < 0 || attempt == 2) {
updateError = "The download limit was reached. Please try again later.";
return;
}
for (int second = 0; second < waitSeconds; ++second) SDL_Delay(1000);
}
if (!downloaded) { updateError = "The update download failed."; return; }
if (response.size() < 20 || response.size() > 72U * 1024U * 1024U ||
std::memcmp(response.data() + 16, "NRO0", 4) != 0) {
updateError = "The downloaded file is not a valid Switchnote NRO.";
return;
}
std::string versionComponent = updateServerVersion;
if (versionComponent.rfind("Ver ", 0) == 0) versionComponent.erase(0, 4);
else if (!versionComponent.empty() && (versionComponent[0] == 'v' || versionComponent[0] == 'V')) versionComponent.erase(0, 1);
const std::string directory = join(dataRoot, "updates");
ensureDir(directory);
std::string destination = join(directory, "Switchnote-" + versionComponent + ".nro");
struct stat existing{};
for (unsigned suffix = 1; stat(destination.c_str(), &existing) == 0 && suffix < 1000; ++suffix)
destination = join(directory, "Switchnote-" + versionComponent + "-" + std::to_string(suffix) + ".nro");
const std::string temporary = destination + ".part";
std::remove(temporary.c_str());
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) { updateError = "Could not create the update file on the SD card."; return; }
output.write(reinterpret_cast<const char*>(response.data()), static_cast<std::streamsize>(response.size()));
output.flush();
const bool writeOk = static_cast<bool>(output);
output.close();
if (!writeOk || std::rename(temporary.c_str(), destination.c_str()) != 0) {
std::remove(temporary.c_str());
updateError = "Could not save the update to the SD card.";
return;
}
updateDownloadedPath = destination;
}
void App::consumeUpdateTask() {
if (!updateInProgress || !updateWorkerDone.load(std::memory_order_acquire) ||
SDL_GetTicks() - updateStartedAt < 450) return;
if (updateThread) { SDL_WaitThread(updateThread, nullptr); updateThread = nullptr; }
const UpdateTask finishedTask = updateTask;
const bool wasManual = updateManualCheck;
updateTask = UpdateTask::None;
updateInProgress = false;
endLoading();
loadingCompletionVisible = false;
if (finishedTask == UpdateTask::Check) {
if (updateAvailable) {
if (wasManual) { modal = Modal::UpdateAvailable; modalTransitionStart = SDL_GetTicks(); }
else updateOfferPending = true;
}
else if (wasManual && updateNoChange) showToast("This version is up to date.");
else if (wasManual) showToast(updateError.empty() ? "Couldn't check for updates. Try again later." : updateError);
} else if (finishedTask == UpdateTask::Download) {
if (!updateDownloadedPath.empty()) {
if (!requestUpdateHandoff())
showToast(updateError.empty() ? "Update saved to " + updateDownloadedPath + ". Launch that NRO manually." : updateError);
}
else showToast(updateError.empty() ? "The update download failed." : updateError);
}
}
void App::update() {
const std::uint32_t now = SDL_GetTicks();
consumeUpdateTask();
updateFillWave(now);
consumeServerStart();
consumePhotoProbe();
consumeGalleryLoad();
if (screen != Screen::Canvas) canvasMenuOpen = false;
if (screen.phase == ScreenState::Phase::ToWhite && lastScreenCueStart != screen.phaseStart) {
lastScreenCueStart = screen.phaseStart;
playEvent("screen_swap", "screen_swap");
}
screen.advance(now);
if (updateOfferPending && screen.active == Screen::Title && screen.phase == ScreenState::Phase::Idle) {
updateOfferPending = false;
modal = Modal::UpdateAvailable;
modalTransitionStart = now;
}
if (modalClosing) {
if (now - modalTransitionStart >= 150) {
modalClosing = false;
modalVisual = Modal::None;
} else return;
}
if (!serverStartRunning) consumeAudioUpload();
if (!serverStartRunning) consumeBrandingConfig();
advancePhotoImport();
if (photoImporting) return;
updatePlayback(now);
if ((screen != Screen::Canvas && screen != Screen::Sequence) || modal != Modal::None ||
!(buttonsHeld & BRIGHT) || (screen == Screen::Canvas && (buttonsHeld & BY))) {
rightDpadRepeating = false;
} else if (rightDpadRepeating) {
const std::uint32_t interval = std::max<std::uint32_t>(1, static_cast<std::uint32_t>(std::lround(1000.0 / sppm::frameRate(doc.metadata))));
while (static_cast<std::int32_t>(now - rightDpadNextRepeatAt) >= 0) {
if (frame + 1 < doc.frames.size()) setFrame(frame + 1);
else insertFrame(false);
rightDpadNextRepeatAt += interval;
}
}
if (modal == Modal::Photo) updatePhotoCapture();
if (screen.transitioning()) return;
if (dirty && SDL_GetTicks() - dirtyTick > 1500) saveDocument();
auto canvasInputAllowed = [&]() {
if (pointerX < 0 || pointerX >= W || pointerY < 0 || pointerY >= H) return false;
if (canvasControlGesture || paintMenu || colorMenu || toolMenu) return false;
const float scale = 2.0f * zoom;
const int canvasW = static_cast<int>(sppm::kWidth * scale);
const int canvasH = static_cast<int>(sppm::kHeight * scale);
const int canvasX = (W - canvasW) / 2 + panX;
const int canvasY = (H - canvasH) / 2 + panY;
if (pointerX < canvasX || pointerX >= canvasX + canvasW ||
pointerY < canvasY || pointerY >= canvasY + canvasH) return false;
return true;
};
auto canvasPixelAt = [&](int sx, int sy, int& px, int& py) {
const float scale = 2.0f * zoom;
const int canvasW = static_cast<int>(sppm::kWidth * scale);
const int canvasH = static_cast<int>(sppm::kHeight * scale);
const int canvasX = (W - canvasW) / 2 + panX;
const int canvasY = (H - canvasH) / 2 + panY;
if (sx < canvasX || sx >= canvasX + canvasW || sy < canvasY || sy >= canvasY + canvasH) return false;
px = static_cast<int>((sx - canvasX) / (canvasW / static_cast<float>(sppm::kWidth)));
py = static_cast<int>((sy - canvasY) / (canvasH / static_cast<float>(sppm::kHeight)));
return px >= 0 && px < sppm::kWidth && py >= 0 && py < sppm::kHeight;
};
if (screen == Screen::Canvas && docLoaded && zoom > 1.0f && !drawing && modal == Modal::None) {
if (!lastPanTick) lastPanTick = now;
const float elapsed = std::min(0.050f, (now - lastPanTick) / 1000.0f);
const int maxPanX = std::max(0, (static_cast<int>(sppm::kWidth * 2.0f * zoom) - W) / 2);
const int maxPanY = std::max(0, (static_cast<int>(sppm::kHeight * 2.0f * zoom) - H) / 2);
constexpr float panPixelsPerSecond = 720.0f;
panX = std::clamp(panX + static_cast<int>(std::lround(leftStickX * panPixelsPerSecond * elapsed)), -maxPanX, maxPanX);
panY = std::clamp(panY + static_cast<int>(std::lround(leftStickY * panPixelsPerSecond * elapsed)), -maxPanY, maxPanY);
}
lastPanTick = now;
if (screen == Screen::Canvas && docLoaded && pointerPressed && modal == Modal::None && canvasInputAllowed()) {
int px = 0, py = 0;
if (canvasPixelAt(pointerX, pointerY, px, py)) {
afterStrokePoints.clear();
realtimeStrokeInitialized = false;
canvasTapPending = true;
canvasFillCommitted = false;
canvasPressScreenX = pointerX; canvasPressScreenY = pointerY;
canvasPressPixelX = px; canvasPressPixelY = py;
canvasPressStartedAt = now;
drawing = true; // lets the chrome slide away immediately, before ink is committed
hasLastStroke = false;
}
}
if (screen == Screen::Canvas && docLoaded && canvasTapPending && pointerDown && modal == Modal::None) {
const int touchDx = pointerX - canvasPressScreenX;
const int touchDy = pointerY - canvasPressScreenY;
if (touchDx * touchDx + touchDy * touchDy > 25) {
// Five display pixels of touch jitter do not cancel a stationary fill.
canvasTapPending = false;
pushUndo();
hasLastStroke = false;
strokeAt(canvasPressScreenX, canvasPressScreenY);
strokeAt(pointerX, pointerY);
} else if (now - canvasPressStartedAt >= 450) {
canvasTapPending = false;
canvasFillCommitted = true;
beginFillWave(canvasPressPixelX, canvasPressPixelY);
}
} else if (screen == Screen::Canvas && docLoaded && pointerDown && modal == Modal::None && !canvasTapPending) {
if (canvasInputAllowed()) {
if (!drawing) { pushUndo(); drawing = true; hasLastStroke = false; }
strokeAt(pointerX, pointerY);
} else hasLastStroke = false;
}
if (screen == Screen::Canvas && docLoaded && pointerReleased && modal == Modal::None) {
if (canvasTapPending) {
// A short stationary tap remains a normal single-point stroke.
pushUndo();
hasLastStroke = false;
strokeAt(canvasPressScreenX, canvasPressScreenY);
} else if (!canvasFillCommitted && canvasInputAllowed()) {
if (!drawing) { pushUndo(); drawing = true; hasLastStroke = false; }
strokeAt(pointerX, pointerY);
}
finishAfterStroke();
canvasTapPending = false;
canvasFillCommitted = false;
drawing = false;
hasLastStroke = false;
canvasControlGesture = false;
}
if (screen == Screen::CommentCanvas && pointerDown && modal == Modal::None) {
if (pointerY >= 72 && pointerY < 620) {
if (!drawing) { drawing = true; hasLastStroke = false; }
strokeAt(pointerX, pointerY, true);
}
}
if (screen == Screen::CommentCanvas && pointerReleased && modal == Modal::None) {
const bool onButtons = pointerY < 72 || pointerY >= 620;
if (!onButtons) strokeAt(pointerX, pointerY, true);
drawing = false;
hasLastStroke = false;
}
if (lastCanvasMenuTick == 0) lastCanvasMenuTick = now;
if (screen == Screen::GalleryComments) {
if (pointerPressed && pointerX >= 197 && pointerX < 1060 && pointerY >= 130 && pointerY < 620) {
galleryCommentTouchScroll = true;
galleryCommentTouchStartY = pointerY;
galleryCommentTouchStartOffset = galleryCommentScroll;
}
if (galleryCommentTouchScroll && pointerDown) {
galleryCommentScroll = std::max(0, galleryCommentTouchStartOffset + galleryCommentTouchStartY - pointerY);
}
if (pointerReleased) galleryCommentTouchScroll = false;
const float elapsed = lastGalleryCommentScrollTick == 0 ? 0.0f : std::min(0.05f, (now - lastGalleryCommentScrollTick) / 1000.0f);
if (!galleryCommentTouchScroll && std::abs(leftStickY) > 0.18f) {
galleryCommentScroll = std::max(0, galleryCommentScroll + static_cast<int>(std::lround(leftStickY * 700.0f * elapsed)));
}
lastGalleryCommentScrollTick = now;
} else {
galleryCommentTouchScroll = false;
lastGalleryCommentScrollTick = now;
}
constexpr float menuAnimationMs = 125.0f / 1.75f;
const float menuStep = std::min(0.05f, (now - lastCanvasMenuTick) / 1000.0f) / (menuAnimationMs / 1000.0f);
lastCanvasMenuTick = now;
canvasMenuProgress = std::clamp(canvasMenuProgress + (canvasMenuOpen ? menuStep : -menuStep), 0.0f, 1.0f);
if (lastChromeTick == 0) lastChromeTick = now;
const float chromeStep = std::min(1.0f, (now - lastChromeTick) / kCanvasChromeMs);
lastChromeTick = now;
const float chromeTarget = screen == Screen::Canvas && drawing ? 0.0f : 1.0f;
if (chromeTarget < canvasChromeVisible) canvasChromeVisible = std::max(chromeTarget, canvasChromeVisible - chromeStep);
else canvasChromeVisible = std::min(chromeTarget, canvasChromeVisible + chromeStep);
handleKeys();
}
void App::handleKeys() {
if (screen == Screen::AppletWarning) {
if (buttonsDown & (BA | BB | BPLUS)) running = false;
return;
}
if (updateInProgress) return;
if (modal == Modal::UpdateAvailable) {
if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); }
else if (buttonsDown & BA) startUpdateDownload();
return;
}
if (modal == Modal::TextInput) {
if (editingField == Field::None) return;
return;
}
if (modal == Modal::GalleryNotice) {
if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); }
else if (buttonsDown & BA) confirmGalleryLeave();
return;
}
if (modal == Modal::GalleryTerms) {
if (buttonsDown & BB) { modal = Modal::None; screen = Screen::Library; }
else if (buttonsDown & BA) {
galleryTermsAccepted = true;
std::ofstream accepted(join(dataRoot, "gallery-terms-accepted"), std::ios::binary | std::ios::trunc);
if (accepted) accepted << "1\n";
modal = Modal::None;
}
return;
}
if (modal == Modal::GalleryOffline) {
if (buttonsDown & (BB | BA)) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); }
return;
}
if (modal == Modal::ConfirmDelete) {
if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); }
else if (buttonsDown & BZR) deleteDocument();
return;
}
if (modal == Modal::GalleryPostChoice) {
if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); }
else if (buttonsDown & BX) { modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, true); }
else if (buttonsDown & BY) { modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, false); }
return;
}
if (modal == Modal::PhotoProbe && (buttonsDown & BB)) return;
if ((modal == Modal::Photo || modal == Modal::PhotoChoice || modal == Modal::PhotoAlbum || modal == Modal::PhotoUpload) && (buttonsDown & BB)) {
if (modal == Modal::PhotoAlbum) { modal = Modal::PhotoChoice; return; }
if (serverStartRunning && serverStartTask == ServerStartTask::Photo) return;
closePhotoCapture();
return;
}
if (modal == Modal::PhotoChoice) {
if (buttonsDown & BA) { openPhotoAlbum(); }
else if (buttonsDown & BY) {
openPhotoUpload();
photoModalTransitionDim = true;
photoModalTransitionDimAt = SDL_GetTicks();
}
else if ((buttonsDown & BX) && photoProbeHasCamera) { modal = Modal::Photo; modalTransitionStart = SDL_GetTicks(); }
return;
}
if (modal == Modal::Audio && (buttonsDown & BB)) {
if (serverStartRunning && serverStartTask == ServerStartTask::Audio) return;
closeAudioMenu();
return;
}
if (modal == Modal::Branding && (buttonsDown & BB)) {
if (serverStartRunning && serverStartTask == ServerStartTask::Branding) return;
closeBrandingMenu();
return;
}
if (modal != Modal::None) {
if (buttonsDown & BB) { modal = Modal::None; playEvent("cancel", "cursor_cancel"); return; }
if (modal == Modal::Options) {
int speed = docLoaded ? documentSpeed(doc.metadata) : defaultSpeed;
if (buttonsDown & BLEFT) speed = std::max(1, speed - 1);
if (buttonsDown & BRIGHT) speed = std::min(8, speed + 1);
if (buttonsDown & (BLEFT | BRIGHT)) {
defaultSpeed = speed;
defaultFps = kSpeedFps[static_cast<std::size_t>(speed - 1)];
if (docLoaded) {
doc.metadata.fps = static_cast<std::uint8_t>(defaultFps);
doc.metadata.speed = static_cast<std::uint8_t>(speed);
markDirty();
if (doc.metadata.bgmFollowsSpeed) rebuildAudio();
}
savePreferences();
}
if (buttonsDown & BUP) { onion = !onion; savePreferences(); }
if (buttonsDown & BDOWN) { canvasGrid = !canvasGrid; savePreferences(); }
if (buttonsDown & BY) { copyToNewPage = !copyToNewPage; savePreferences(); }
if ((buttonsDown & BX) && docLoaded) { doc.metadata.loopPlayback = !doc.metadata.loopPlayback; markDirty(); }
if (buttonsDown & BA) modal = Modal::None;
return;
}
if (modal == Modal::Audio) {
if (buttonsDown & BLEFT) { audioTrack = std::max(0, audioTrack - 1); audioEraseArmedTrack = -1; }
if (buttonsDown & BRIGHT) { audioTrack = std::min(3, audioTrack + 1); audioEraseArmedTrack = -1; }
if ((buttonsDown & BY) && audioTrack == 0) {
doc.metadata.bgmFollowsSpeed = !doc.metadata.bgmFollowsSpeed;
markDirty();
rebuildAudio();
}
if (buttonsDown & BA) {
if (audioTrack < static_cast<int>(doc.audioTracks.size()) &&
!doc.audioTracks[static_cast<std::size_t>(audioTrack)].pcm.empty()) {
if (Mix_Playing(4)) Mix_HaltChannel(4);
else if (audioReady && trackChunks[static_cast<std::size_t>(audioTrack)])
Mix_PlayChannel(4, trackChunks[static_cast<std::size_t>(audioTrack)], 0);
} else showToast("No audio in this track yet");
}
if ((buttonsDown & (BPLUS | BMINUS)) && audioTrack > 0) {
while (doc.audioTracks.size() <= static_cast<std::size_t>(audioTrack)) {
sppm::AudioTrack blank;
blank.name = "SE" + std::to_string(doc.audioTracks.size());
blank.sampleRate = 48000;
blank.channels = 2;
doc.audioTracks.push_back(std::move(blank));
}
auto& triggers = doc.audioTracks[static_cast<std::size_t>(audioTrack)].frameTriggers;
const auto cue = static_cast<std::uint16_t>(frame);
auto it = std::lower_bound(triggers.begin(), triggers.end(), cue);
if (it != triggers.end() && *it == cue) triggers.erase(it);
else triggers.insert(it, cue);
markDirty();
}
return;
}
if (modal == Modal::Folder) {
if (buttonsDown & BUP) modalSelection = std::max(0, modalSelection - 1);
if (buttonsDown & BDOWN) modalSelection = std::min(5, modalSelection + 1);
if (buttonsDown & BA) {
std::vector<std::string> folders;
if (DIR* dir = opendir(notesRoot.c_str())) {
while (dirent* ent = readdir(dir)) {
const std::string name = ent->d_name;
if (name.empty() || name[0] == '.') continue;
struct stat st{};
if (stat(join(notesRoot, name).c_str(), &st) == 0 && (st.st_mode & S_IFDIR)) folders.push_back(name);
}
closedir(dir);
}
std::sort(folders.begin(), folders.end());
if (!folders.empty()) {
folder = folders[static_cast<std::size_t>(modalSelection) % folders.size()];
selected = 0;
scanLibrary();
modal = Modal::None;
playEvent("folder_open", "folder_open");
}
}
return;
}
if (modal == Modal::Export && (buttonsDown & (BUP | BDOWN | BLEFT | BRIGHT))) modalSelection = (modalSelection + 1) % 2;
if ((modal == Modal::ConfirmDelete || modal == Modal::ConfirmGalleryDelete) &&
(buttonsDown & (BLEFT | BRIGHT))) modalSelection = (modalSelection + 1) % 2;
if (buttonsDown & BA) {
if (modal == Modal::Export) { exportDocument(modalSelection); modal = Modal::None; }
else if (modal == Modal::ConfirmGalleryDelete) {
if (modalSelection == 1) deleteGalleryItem();
else modal = Modal::None;
} else if (modal == Modal::Help) modal = Modal::None;
else if (modal == Modal::TextInput) applyText();
}
return;
}
if (buttonsDown & BB) {
switch (screen) {
case Screen::Title: break;
case Screen::Library: screen = Screen::Title; return;
case Screen::Details: screen = Screen::Library; return;
case Screen::Sequence: screen = Screen::Canvas; return;
case Screen::Canvas:
layer = (layer + 1) % 4;
playEvent("tool_layer", "draw_tool_btn_on");
return;
case Screen::Viewer:
stopPlayback();
screen = galleryViewing ? Screen::GalleryDetails : Screen::Details;
return;
case Screen::Settings: screen = settingsReturnScreen; return;
case Screen::Gallery: screen = Screen::Library; return;
case Screen::GalleryDetails:
if (galleryProfileDetailMode) { galleryProfileDetailMode = false; screen = Screen::GalleryProfile; }
else screen = Screen::Gallery;
return;
case Screen::GalleryProfile: leaveGalleryProfile(); return;
case Screen::GalleryComments: screen = Screen::GalleryDetails; return;
case Screen::CommentCanvas: screen = Screen::GalleryComments; return;
case Screen::AppletWarning: running = false; return;
}
}
if (buttonsDown & BMINUS) {
if (screen == Screen::Title) return;
if (screen == Screen::Settings) { screen = settingsReturnScreen; return; }
if (screen == Screen::Library || screen == Screen::Gallery) screen = Screen::Title;
else if (screen == Screen::Canvas || screen == Screen::Viewer) { stopPlayback(); screen = Screen::Sequence; }
else if (screen == Screen::GalleryComments) screen = Screen::GalleryDetails;
else if (screen == Screen::CommentCanvas) screen = Screen::GalleryComments;
else if (screen == Screen::Sequence) { closeDocument(); screen = Screen::Library; }
else if (screen == Screen::Details || screen == Screen::GalleryDetails) {
if (screen == Screen::GalleryDetails && galleryProfileDetailMode) {
galleryProfileDetailMode = false;
screen = Screen::GalleryProfile;
} else screen = (screen == Screen::GalleryDetails ? Screen::Gallery : Screen::Library);
}
}
if (screen == Screen::Title) {
if (buttonsDown & BR) { settingsReturnScreen = Screen::Title; screen = Screen::Settings; return; }
if ((buttonsDown & (BZL | BZR)) == (BZL | BZR) || (buttonsHeld & (BZL | BZR)) == (BZL | BZR) || (buttonsDown & BA)) {
screen = Screen::Library;
selected = 0;
playEvent("folder_open", "folder_open");
}
} else if (screen == Screen::Library || screen == Screen::Gallery) {
const bool galleryMode = screen == Screen::Gallery;
if (!galleryMode && (buttonsDown & BZR)) { showGalleryNotice(); return; }
if (galleryMode && (buttonsDown & BZL) && galleryPage > 0) {
--galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery(); return;
}
if (galleryMode && (buttonsDown & BZR) && (galleryPage + 1) * 6 < galleryTotal) {
++galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery(); return;
}
if (!galleryMode && (buttonsDown & BR)) {
settingsReturnScreen = Screen::Library;
screen = Screen::Settings;
return;
}
if (!galleryMode && (buttonsDown & BPLUS)) {
newDocument();
return;
}
auto& selection = galleryMode ? gallerySelected : selected;
const auto& items = galleryMode ? gallery : library;
if (buttonsDown & BLEFT) { if (selection > 0) --selection; }
if (buttonsDown & BRIGHT) { if (selection + 1 < items.size()) ++selection; }
if (buttonsDown & BUP) { if (selection >= 3) selection -= 3; }
if (buttonsDown & BDOWN) { if (selection + 3 < items.size()) selection += 3; }
if (buttonsDown & BY) {
if (!galleryMode && !items.empty()) openItem(selection, true);
else if (galleryMode) addGalleryCoin();
}
if (buttonsDown & BA) {
if (!items.empty()) {
if (galleryMode) screen = Screen::GalleryDetails;
else { expandMetadata = false; screen = Screen::Details; }
}
}
if (buttonsDown & BX && !galleryMode && !items.empty()) { modalSelection = 0; modal = Modal::ConfirmDelete; }
} else if (screen == Screen::Details || screen == Screen::GalleryDetails) {
const bool galleryOwner = screen == Screen::GalleryDetails && !gallery.empty() &&
gallery[gallerySelected % gallery.size()].ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity);
if (buttonsDown & BX && galleryOwner) { modalSelection = 0; modal = Modal::ConfirmGalleryDelete; }
if (buttonsDown & BY) {
if (screen == Screen::GalleryDetails) openGalleryComments();
else { openItem(selected, true); }
}
if (buttonsDown & BA) {
if (screen == Screen::GalleryDetails) openGalleryItem(gallerySelected);
else { openItem(selected, false); screen = Screen::Viewer; }
}
if (buttonsDown & BZL && screen == Screen::Details && !library.empty()) { modalSelection = 0; modal = Modal::ConfirmDelete; }
} else if (screen == Screen::Sequence) {
if (buttonsDown & BPLUS) { settingsReturnScreen = Screen::Sequence; screen = Screen::Settings; return; }
if (buttonsDown & BLEFT) setFrame(frame ? frame - 1 : 0);
if (buttonsDown & BRIGHT) {
const bool createsFirstHeldPage = frame + 1 >= doc.frames.size();
if (frame + 1 < doc.frames.size()) setFrame(frame + 1);
else insertFrame(false);
const std::uint32_t period = std::max<std::uint32_t>(1, static_cast<std::uint32_t>(std::lround(1000.0 / sppm::frameRate(doc.metadata))));
rightDpadNextRepeatAt = SDL_GetTicks() + period * (createsFirstHeldPage ? 3u : 1u);
rightDpadRepeating = (buttonsHeld & BRIGHT) != 0;
}
if (buttonsDown & BUP) setFrame(0, true, "frame_first");
if (buttonsDown & BDOWN) setFrame(doc.frames.size() - 1, true, "frame_last");
if (buttonsDown & BA) startPlayback();
if (buttonsDown & BY) {
if (doc.metadata.locked) showToast("This Switchnote is locked and cannot be edited");
else screen = Screen::Canvas;
}
if ((buttonsDown & BX) && !doc.metadata.locked) insertFrame(false);
if (buttonsDown & BZL) { modalSelection = 0; modal = Modal::ConfirmDelete; }
} else if (screen == Screen::Canvas || screen == Screen::Viewer) {
if (screen == Screen::Viewer && (buttonsDown & BLEFT)) { if (frame) setFrame(frame - 1); }
if (screen == Screen::Viewer && (buttonsDown & BRIGHT)) {
const bool createsFirstHeldPage = frame + 1 >= doc.frames.size();
if (frame + 1 < doc.frames.size()) setFrame(frame + 1);
else if (screen == Screen::Canvas) insertFrame(false);
if (screen == Screen::Canvas && modal == Modal::None) {
const std::uint32_t period = std::max<std::uint32_t>(1, static_cast<std::uint32_t>(std::lround(1000.0 / sppm::frameRate(doc.metadata))));
rightDpadNextRepeatAt = SDL_GetTicks() + period * (createsFirstHeldPage ? 3u : 1u);
rightDpadRepeating = (buttonsHeld & BRIGHT) != 0;
}
}
if (screen == Screen::Canvas && (buttonsDown & BUP)) { zoom = std::min(zoom * 1.25f, 8.0f); }
if (screen == Screen::Canvas && (buttonsDown & BDOWN)) { zoom = std::max(zoom / 1.25f, 0.5f); }
if (buttonsDown & BA) { if (playing) stopPlayback(); else startPlayback(); }
if (screen == Screen::Viewer && (buttonsDown & BB)) { stopPlayback(); screen = Screen::Sequence; }
if (screen == Screen::Canvas) {
const bool dpadDown = (buttonsDown & (BLEFT | BRIGHT)) != 0;
if (((buttonsHeld & BY) && dpadDown) || ((buttonsDown & BY) && (buttonsHeld & (BLEFT | BRIGHT))) ||
((buttonsUp & BY) && dpadDown)) {
canvasYChordUsed = true;
if (buttonsDown & BLEFT) undo();
if (buttonsDown & BRIGHT) redo();
if ((buttonsDown & BY) && (buttonsHeld & BLEFT)) undo();
if ((buttonsDown & BY) && (buttonsHeld & BRIGHT)) redo();
}
const bool suppressDpadForY = canvasYChordUsed || (buttonsHeld & BY) != 0;
if ((buttonsUp & BY) && !canvasYChordUsed) {
activeTool = activeTool == Tool::Pen ? Tool::Brush : activeTool == Tool::Brush ? Tool::Eraser : Tool::Pen;
playEvent(activeTool == Tool::Pen ? "tool_pen" : activeTool == Tool::Brush ? "tool_brush" : "tool_eraser", "draw_tool_btn_on");
}
if (buttonsUp & BY) canvasYChordUsed = false;
if (buttonsDown & BZL) undo();
if (buttonsDown & BZR) redo();
if (buttonsDown & BX) {
pushUndo();
auto& pixels = doc.frames[frame].layers[static_cast<std::size_t>(layer)];
std::fill(pixels.begin(), pixels.end(), 0);
markDirty();
playEvent("clear_layer", "erase");
}
if ((buttonsDown & BLEFT) && !suppressDpadForY) { if (frame) setFrame(frame - 1); }
if ((buttonsDown & BRIGHT) && !suppressDpadForY) {
const bool createsFirstHeldPage = frame + 1 >= doc.frames.size();
if (frame + 1 < doc.frames.size()) setFrame(frame + 1);
else insertFrame(false);
const std::uint32_t period = std::max<std::uint32_t>(1, static_cast<std::uint32_t>(std::lround(1000.0 / sppm::frameRate(doc.metadata))));
rightDpadNextRepeatAt = SDL_GetTicks() + period * (createsFirstHeldPage ? 3u : 1u);
rightDpadRepeating = (buttonsHeld & BRIGHT) != 0;
}
if (buttonsDown & BL) screen = Screen::Sequence;
if (buttonsDown & BPLUS) insertFrame(false);
}
} else if (screen == Screen::Settings) {
if (buttonsDown & BB) screen = settingsReturnScreen;
} else if (screen == Screen::GalleryProfile) {
const int profilePages = 1 + (galleryProfile.switchnotes + 5) / 6;
if ((buttonsDown & BLEFT) && galleryProfilePage > 0)
loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage - 1);
if ((buttonsDown & BRIGHT) && galleryProfilePage + 1 < profilePages)
loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage + 1);
if ((buttonsDown & BA) && galleryProfilePage > 0 && !gallery.empty()) {
galleryProfileDetailMode = true;
screen = Screen::GalleryDetails;
} else if ((buttonsDown & BA) && galleryProfilePage == 0 && !galleryProfileIsOwn) {
updateGalleryRelationship(galleryProfile.followed ? "unfollow" : "follow");
}
if (buttonsDown & BX) updateGalleryRelationship(galleryProfile.blocked ? "unblock" : "block");
if ((buttonsDown & BY) && galleryProfileIsOwn) beginText(Field::GalleryBio, galleryProfile.bio);
} else if (screen == Screen::GalleryComments) {
if (buttonsDown & BUP) galleryCommentScroll = std::max(0, galleryCommentScroll - 90);
if (buttonsDown & BDOWN) galleryCommentScroll += 90;
if (buttonsDown & BY) screen = Screen::CommentCanvas;
if (buttonsDown & BB) screen = Screen::GalleryDetails;
} else if (screen == Screen::CommentCanvas) {
if (buttonsDown & BB) screen = Screen::GalleryComments;
if (buttonsDown & BY) postGalleryComment();
if (buttonsDown & BX) { commentPixels.assign(sppm::kPixelsPerLayer, 0); commentDirty = true; }
}
if (wheel && screen == Screen::Canvas) zoom = std::clamp(zoom + wheel * 0.2f, 0.5f, 8.0f);
if (wheel && screen == Screen::GalleryComments)
galleryCommentScroll = std::max(0, galleryCommentScroll - wheel * 90);
if (screen == Screen::Canvas) {
const int maxPanX = std::max(0, (static_cast<int>(sppm::kWidth * 2.0f * zoom) - W) / 2);
const int maxPanY = std::max(0, (static_cast<int>(sppm::kHeight * 2.0f * zoom) - H) / 2);
panX = std::clamp(panX, -maxPanX, maxPanX);
panY = std::clamp(panY, -maxPanY, maxPanY);
}
}
void App::render() {
// Some custom canvas effects alter the renderer blend mode. Restore alpha
// blending every frame so loading dimmers and translucent UI always work.
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
if (screen == Screen::Canvas || screen == Screen::Viewer) SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255);
else SDL_SetRenderDrawColor(renderer, 245, 246, 248, 255);
SDL_RenderClear(renderer);
switch (screen) {
case Screen::Title: renderTitle(); break;
case Screen::Library: renderLibrary(); break;
case Screen::Details: renderDetails(false); break;
case Screen::Settings: renderSettings(); break;
case Screen::Sequence: renderSequence(); break;
case Screen::Canvas: renderCanvas(); break;
case Screen::Viewer: renderViewer(); break;
case Screen::Gallery: renderGallery(); break;
case Screen::GalleryDetails: renderDetails(true); break;
case Screen::GalleryProfile: renderGalleryProfile(); break;
case Screen::GalleryComments: renderComments(); break;
case Screen::CommentCanvas: renderCommentCanvas(); break;
case Screen::AppletWarning: renderAppletWarning(); break;
}
if (modal != lastRenderedModal) {
modalTransitionStart = SDL_GetTicks();
if (modal == Modal::None) {
modalVisual = lastRenderedModal;
modalClosing = modalVisual != Modal::None;
} else {
modalVisual = modal;
modalClosing = false;
playEvent("popup", "popup");
}
lastRenderedModal = modal;
}
if (modal != Modal::None) renderModal();
else if (modalClosing) {
const Modal savedModal = modal;
modal = modalVisual;
renderModal();
modal = savedModal;
}
renderToast();
const Uint8 transitionAlpha = screen.whiteAlpha(SDL_GetTicks());
if (transitionAlpha) fill(0, 0, W, H, SDL_Color{255, 255, 255, transitionAlpha});
renderLoadingOverlay();
SDL_RenderPresent(renderer);
if (touchHoverExpiryPending && !pointerDown) {
pointerX = pointerY = -1;
touchHoverExpiryPending = false;
}
}
void App::renderAppletWarning() {
background();
const SDL_Color panel{239, 119, 122, 255};
const SDL_Color darkRed{194, 24, 24, 255};
rounded(53, 43, 1174, 640, 28, SDL_Color{0, 0, 0, 35});
rounded(59, 47, 1162, 636, 28, WHITE);
rounded(61, 49, 1158, 632, 26, panel);
centered("Cannot run in Applet Mode", 210, 145, 860, 100, 48, darkRed, true);
centered(appDisplayName + " cannot run in Applet Mode.", 165, 268, 950, 52, 28, darkRed, false);
text("Please run the app from the Homebrew Menu in App Override mode, or", 168, 366, 27, darkRed, false);
text("install the homebrew NSP file.", 168, 402, 27, darkRed, false);
const bool hovered = pointerX >= 566 && pointerX < 714 && pointerY >= 524 && pointerY < 584;
const int press = pointerDown && hovered ? 2 : 0;
rounded(578, 530 + press, 124, 53, 28, SDL_Color{0, 0, 0, 35});
rounded(572, 524 + press, 136, 54, 28, WHITE);
rounded(574, 526 + press, 132, 50, 26, panel);
centered("Exit", 574, 526 + press, 132, 50, 24, darkRed, false);
if (hit(572, 524, 136, 54)) { clickConsumed = true; running = false; }
}
void App::renderTitle() {
background();
if (brandingLogoTexture) {
int sourceW = 0, sourceH = 0;
SDL_QueryTexture(brandingLogoTexture, nullptr, nullptr, &sourceW, &sourceH);
const float scale = sourceW > 0 && sourceH > 0 ? std::min(640.0f / sourceW, 218.0f / sourceH) : 1.0f;
const int logoW = static_cast<int>(sourceW * scale), logoH = static_cast<int>(sourceH * scale);
drawTexture(brandingLogoTexture, 640 - logoW / 2, 252 - logoH / 2, logoW, logoH);
}
else {
centered(appDisplayName, 240, 190, 800, 126, 72, CREAM, true);
centered(appDisplayName, 240, 184, 800, 126, 72, ORANGE, true);
}
if (actionPill("ui/icons/CanvasMenu_ConfigToggle.png", "Settings", 1022, 36, 214, 48, false, true, "")) {
settingsReturnScreen = Screen::Title;
screen = Screen::Settings;
}
const float focus = 0.5f + 0.5f * std::sin(pulse * 2.4f);
const auto promptTone = static_cast<Uint8>(220 + 10 * focus);
const bool promptHovered = pointerX >= 474 && pointerX < 806 && pointerY >= 469 && pointerY < 531;
const bool promptPressed = pointerDown && promptHovered;
const int promptLift = hoverLift(474, 469, promptHovered);
const int promptPress = promptPressed ? 4 : 0;
const int promptY = -promptLift + promptPress;
rounded(474, 474 + promptY, 332, 64, 14, SDL_Color{0, 0, 0, 38});
rounded(474, 469 + promptY, 332, 62, 14, WHITE);
rounded(480, 475 + promptY, 320, 50, 10, SDL_Color{255, promptTone, static_cast<Uint8>(197 + 8 * focus), 255});
centered("Press", 516, 475 + promptY, 100, 50, 25, ORANGE, true);
auto promptIcon = [&](const char* path, int x) {
SDL_Texture* texture = image(path);
if (!texture) return;
int sourceW = 0, sourceH = 0;
SDL_QueryTexture(texture, nullptr, nullptr, &sourceW, &sourceH);
const float iconScale = sourceW && sourceH ? std::min(42.0f / sourceW, 32.0f / sourceH) : 1.0f;
const int iconW = std::max(1, static_cast<int>(std::lround(sourceW * iconScale)));
const int iconH = std::max(1, static_cast<int>(std::lround(sourceH * iconScale)));
drawTexture(texture, x + (42 - iconW) / 2, 484 + (32 - iconH) / 2 + promptY, iconW, iconH);
};
promptIcon("ui/icons/ButtonZLText.png", 622);
centered("+", 666, 475 + promptY, 34, 50, 25, ORANGE, true);
promptIcon("ui/icons/ButtonZRText.png", 698);
if (hit(474, 469, 332, 62)) { clickConsumed = true; screen = Screen::Library; selected = 0; playEvent("folder_open", "folder_open"); }
centered("© 2026 RexiMemo!", 390, 634, 500, 44, 25, ORANGE, false);
text("Ver 0.1.51", 1130, 663, 18, ORANGE);
}
void App::renderLibrary() {
background();
drawImage("ui/FolderButton_Active.png", 40, 25, 68, 68);
drawImage("ui/FolderButton_New.png", 122, 25, 68, 68);
centered("Folders", 36, 88, 76, 21, 14, GRAY, true);
centered("New Folder", 115, 88, 84, 21, 13, GRAY, true);
if (pill("Switchnote Gallery", 788, 40, 205, 47, true, 19)) showGalleryNotice();
if (actionPill("ui/icons/FrameNew.png", "New Switchnote", 1017, 40, 220, 47, false, true, "")) newDocument();
if (hit(40, 24, 70, 70)) { clickConsumed = true; modalSelection = 0; modal = Modal::Folder; playEvent("folder_open", "folder_open"); }
if (hit(122, 24, 70, 70)) { clickConsumed = true; beginText(Field::FolderName, ""); }
const int start = static_cast<int>(page) * 6;
if (library.empty()) {
rounded(305, 190, 670, 315, 20, SDL_Color{0, 0, 0, 28});
rounded(305, 182, 670, 315, 20, WHITE);
drawImage("ui/icons/FrameNew.png", 594, 224, 92, 92);
centered("\"" + folder + "\" is empty", 355, 328, 570, 42, 27, INK, true);
centered("Switchnotes added to this folder will show here.", 345, 374, 590, 40, 18, GRAY);
if (actionPill("ui/icons/FrameNew.png", "Create Switchnote", 500, 434, 280, 50)) newDocument();
}
for (int cell = 0; cell < 6; ++cell) {
const int index = start + cell;
if (index >= static_cast<int>(library.size())) break;
const int col = cell % 3;
const int row = cell / 3;
const int x = 104 + col * 372;
const int y = 153 + row * 232;
const bool selectedNow = static_cast<std::size_t>(index) == selected;
const bool hovered = pointerX >= x - 5 && pointerX < x + 326 && pointerY >= y - 4 && pointerY < y + 194;
const int lift = hoverLift(x, y, hovered);
const SDL_Color border = selectedNow ? ORANGE : SDL_Color{210, 214, 220, 255};
rounded(x - 5, y + 4 - lift, 331, 198, 15, SDL_Color{0, 0, 0, 48});
rounded(x - 5, y - 4 - lift, 331, 198, 15, WHITE);
// Every Switchnote preview sits on the same solid paper background,
// regardless of whether its content uses transparent pixels.
rounded(x + 3, y + 4 - lift, 315, 182, 8, WHITE);
const Item& item = library[static_cast<std::size_t>(index)];
if (item.sample) drawImage("art/" + item.artwork, x + 3, y + 4 - lift, 315, 182, true);
else if (item.path == docPath && docLoaded) drawCurrentFrame(x + 3, y + 4 - lift, 315, 182);
else drawTexture(thumbTexture(item), x + 3, y + 4 - lift, 315, 182);
roundedOutline(x - 1, y - lift, 323, 190, 14, 4, border);
if (!item.sample && item.locked) drawLockIcon(x + 10, y + 10 - lift);
if (selectedNow) {
const int pop = lift;
rounded(x + 142 - pop / 2, y + 170 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, SDL_Color{0, 0, 0, 42});
rounded(x + 138 - pop / 2, y + 164 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, WHITE);
rounded(x + 143 - pop / 2, y + 169 - pop / 2 - lift, 48 + pop, 48 + pop, 24 + pop / 2, CREAM);
drawPlayTriangle(x + 158 - pop / 2, y + 182 - pop / 2 - lift, 20 + pop, ORANGE);
}
if (hit(x - 5, y - 4, 331, 198)) {
selected = static_cast<std::size_t>(index); page = selected / 6; clickConsumed = true;
if (selectedNow) { expandMetadata = false; screen = Screen::Details; }
else playCue("button_on");
}
}
const int totalPages = std::max(1, static_cast<int>((library.size() + 5) / 6));
if (totalPages > 1 && page > 0 && pill("<", 25, 327, 60, 78, false, 34)) --page;
if (totalPages > 1 && page + 1 < totalPages && pill(">", 1195, 327, 60, 78, false, 34)) ++page;
if (imageButton("ui/BackButton.png", 39, 620, 65, 65, "button_off")) screen = Screen::Title;
for (int i = 0; i < totalPages; ++i) rounded(582 + i * 18, 653, 12, 12, 6, i == page ? ORANGE : CREAM);
if (actionPill("ui/icons/ButtonYText.png", "Edit", 920, 635, 126, 50, false, !library.empty())) openItem(selected, true);
if (actionPill("ui/icons/ButtonAText.png", "Details", 1060, 635, 164, 50, false, !library.empty())) { expandMetadata = false; screen = Screen::Details; }
if (hit(40, 25, 68, 68)) { modalSelection = 0; modal = Modal::Folder; clickConsumed = true; }
if (hit(122, 25, 68, 68)) { beginText(Field::FolderName, ""); clickConsumed = true; }
}
void App::renderSettings() {
background();
if (imageButton("ui/BackButton.png", 39, 35, 65, 65, "button_off")) screen = settingsReturnScreen;
text("Settings", 132, 40, 34, INK, true);
rounded(103, 139, 514, 150, 18, SDL_Color{0, 0, 0, 28});
rounded(103, 132, 514, 150, 18, WHITE);
rounded(126, 165, 84, 84, 3, PALE_GREEN);
if (profileTexture) drawTexture(profileTexture, 126, 165, 84, 84);
else centered(userName.empty() ? "?" : utf8Prefix(userName, 1), 126, 165, 84, 84, 40, WHITE, true);
text("Nickname", 232, 162, 17, GRAY, true);
text(userName, 232, 196, 28, INK, true);
rounded(645, 139, 532, 150, 18, SDL_Color{0, 0, 0, 28});
rounded(645, 132, 532, 150, 18, LIGHT);
text("Creator FSID", 677, 158, 18, GRAY, true);
text(myFsid, 677, 193, 30, SDL_Color{125, 132, 146, 255}, true);
std::string freeSpace = "Unavailable";
#ifdef __SWITCH__
FsFileSystem sd{};
s64 freeBytes = 0;
if (R_SUCCEEDED(fsOpenSdCardFileSystem(&sd))) {
if (R_SUCCEEDED(fsFsGetFreeSpace(&sd, "/", &freeBytes))) {
const double gib = static_cast<double>(freeBytes) / (1024.0 * 1024.0 * 1024.0);
std::ostringstream amount;
amount.setf(std::ios::fixed);
amount.precision(gib >= 10.0 ? 0 : 1);
amount << gib << " GB free";
freeSpace = amount.str();
}
fsFsClose(&sd);
}
#endif
rounded(103, 329, 1074, 120, 18, SDL_Color{0, 0, 0, 28});
rounded(103, 322, 1074, 120, 18, WHITE);
text("SD Free Space", 138, 350, 19, GRAY, true);
text(freeSpace, 138, 385, 31, INK, true);
if (pill(std::string("Pen Stabilisation: ") + (penStabilisationEnabled ? "On" : "Off"),
103, 466, 330, 48, penStabilisationEnabled, 18)) {
penStabilisationEnabled = !penStabilisationEnabled;
savePreferences();
}
if (penStabilisationEnabled) {
text("Method", 480, 474, 18, GRAY);
if (pill(penStabilisationAfter ? "After" : "Real Time", 556, 466, 185, 48, true, 18)) {
penStabilisationAfter = !penStabilisationAfter;
afterStrokePoints.clear();
realtimeStrokeInitialized = false;
savePreferences();
}
text("Stabilisation", 103, 535, 18, GRAY);
const int sliderX = 263, sliderY = 550, sliderW = 650;
rounded(sliderX, sliderY + 8, sliderW, 8, 4, LIGHT);
rounded(sliderX, sliderY + 8, sliderW * penStabilisationStrength / 100, 8, 4, GREEN);
const int handleX = sliderX + sliderW * penStabilisationStrength / 100;
rounded(handleX - 13, sliderY, 26, 26, 13, WHITE);
rounded(handleX - 8, sliderY + 5, 16, 16, 8, GREEN);
text(std::to_string(penStabilisationStrength), 932, 544, 18, GREEN);
if (pointerDown && pointerX >= sliderX - 8 && pointerX <= sliderX + sliderW + 8 &&
pointerY >= sliderY - 8 && pointerY <= sliderY + 34) {
penStabilisationStrength = std::clamp((pointerX - sliderX) * 100 / sliderW, 0, 100);
}
if (hit(sliderX - 8, sliderY - 8, sliderW + 16, 42)) {
clickConsumed = true;
penStabilisationStrength = std::clamp((pointerX - sliderX) * 100 / sliderW, 0, 100);
savePreferences();
}
}
centered("Import from branding.config in the app folder · exports/branding.config", 300, 590, 650, 30, 15, GRAY);
if (pill("Credits", 1000, 582, 177, 42, false, 17)) { modal = Modal::Credits; modalTransitionStart = SDL_GetTicks(); }
if (pill("Import Branding", 103, 634, 250, 56, false, 17)) importBrandingConfig();
if (pill("Export Branding", 367, 634, 250, 56, false, 17)) exportBrandingConfig();
if (pill("Check for Updates", 631, 634, 246, 56, false, 17)) startUpdateCheck(true);
if (actionPill("ui/icons/CanvasMenu_ConfigToggle.png", "Branding Config Portal", 891, 634, 286, 56, false, true, "")) {
requestBrandingConfigServer(); modal = Modal::Branding; modalTransitionStart = SDL_GetTicks();
}
}
void App::renderGallery() {
background();
const std::string sortLabel = galleryPopular ? "Popular " + notePlural : "Recent " + notePlural;
if (pill(sortLabel, 40, 40, 274, 48, true, 18)) {
galleryPopular = !galleryPopular;
galleryPage = 0;
gallerySelected = 0;
galleryStatus = "Loading Gallery…";
refreshGallery();
}
rounded(1173, 37, 68, 68, 12, SDL_Color{0, 0, 0, 40});
rounded(1173, 32, 68, 68, 12, WHITE);
rounded(1178, 37, 58, 58, 3, PALE_GREEN);
if (profileCornerTexture) drawTexture(profileCornerTexture, 1178, 37, 58, 58);
else centered(userName.empty() ? "?" : utf8Prefix(userName, 1), 1178, 37, 58, 58, 30, GREEN, true);
rounded(1212, 78, 29, 29, 15, SDL_Color{10, 12, 15, 255});
centered("+", 1212, 76, 29, 29, 25, WHITE, true);
if (hit(1173, 32, 68, 68)) {
clickConsumed = true;
loadGalleryProfile(sppm::identityFingerprint(signingIdentity.publicIdentity), Screen::Gallery);
}
if (gallery.empty()) {
rounded(330, 226, 620, 258, 20, SDL_Color{0, 0, 0, 28});
rounded(330, 218, 620, 258, 20, WHITE);
const bool emptyGallery = galleryTotal == 0 && galleryStatus == "No Switchnotes have been shared yet.";
centered(emptyGallery ? "No Switchnotes yet" : "Gallery unavailable",
370, 270, 540, 42, 25, INK, true);
centered(emptyGallery ? "" : utf8Prefix(galleryStatus, 70),
365, 321, 550, 52, 17, GRAY);
if (pill("Refresh", 520, 400, 240, 48, true, 18)) {
galleryStatus = "Loading Gallery…";
refreshGallery();
}
} else {
for (int cell = 0; cell < 6; ++cell) {
const int index = cell;
if (index >= static_cast<int>(gallery.size())) break;
const int col = cell % 3;
const int row = cell / 3;
const int x = 104 + col * 372;
const int y = 153 + row * 232;
const bool selectedNow = static_cast<std::size_t>(index) == gallerySelected;
const bool hovered = pointerX >= x - 5 && pointerX < x + 326 && pointerY >= y - 4 && pointerY < y + 194;
const int lift = hoverLift(x, y, hovered);
const SDL_Color border = selectedNow ? GREEN : SDL_Color{210, 214, 220, 255};
rounded(x - 5, y + 4 - lift, 331, 198, 15, SDL_Color{0, 0, 0, 48});
rounded(x - 5, y - 4 - lift, 331, 198, 15, WHITE);
rounded(x + 3, y + 4 - lift, 315, 182, 8, WHITE);
drawTexture(galleryPreviewTexture(gallery[static_cast<std::size_t>(index)]), x + 3, y + 4 - lift, 315, 182);
roundedOutline(x - 1, y - lift, 323, 190, 12, 4, border);
if (gallery[static_cast<std::size_t>(index)].locked) drawLockIcon(x + 10, y + 10 - lift);
rounded(x + 228, y + 158 - lift, 94, 30, 15, SDL_Color{255, 237, 190, 245});
drawImage("ui/icons/Coin.png", x + 236, y + 162 - lift, 22, 22);
text(std::to_string(gallery[static_cast<std::size_t>(index)].coins), x + 265, y + 162 - lift, 17, ORANGE, true);
if (selectedNow) {
const int pop = lift;
rounded(x + 142 - pop / 2, y + 170 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, SDL_Color{0, 0, 0, 42});
rounded(x + 138 - pop / 2, y + 164 - pop / 2 - lift, 58 + pop, 58 + pop, 30 + pop / 2, WHITE);
rounded(x + 143 - pop / 2, y + 169 - pop / 2 - lift, 48 + pop, 48 + pop, 24 + pop / 2, PALE_GREEN);
drawPlayTriangle(x + 158 - pop / 2, y + 182 - pop / 2 - lift, 20 + pop, WHITE);
}
if (hit(x - 5, y - 4, 331, 198)) {
clickConsumed = true;
gallerySelected = static_cast<std::size_t>(index);
if (selectedNow) screen = Screen::GalleryDetails;
else playCue("button_on");
}
}
const int totalPages = std::max(1, (galleryTotal + 5) / 6);
if (totalPages > 1 && galleryPage > 0 && pill("<", 25, 327, 60, 78, false, 34)) {
--galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery();
}
if (totalPages > 1 && galleryPage + 1 < totalPages && pill(">", 1195, 327, 60, 78, false, 34)) {
++galleryPage; gallerySelected = 0; galleryStatus = "Loading Gallery…"; refreshGallery();
}
if (totalPages > 1) for (int i = 0; i < totalPages; ++i)
rounded(640 - totalPages * 9 + i * 18, 653, 12, 12, 6, i == galleryPage ? GREEN : PALE_GREEN);
if (actionPill("ui/icons/ButtonYGalleryText.png", "Add Coins", 932, 635, 174, 50, true)) addGalleryCoin();
if (actionPill("ui/icons/ButtonAGalleryText.png", "Details", 1118, 635, 120, 50, true)) screen = Screen::GalleryDetails;
}
if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65, "button_off")) {
galleryProfileActive = false;
screen = Screen::Library;
}
}
void App::renderGalleryProfile() {
background();
const std::string username = galleryProfile.username.empty()
? (galleryLoadRunning ? "Loading profile…" : "Gallery User") : galleryProfile.username;
if (pill(galleryProfile.followed ? "Following" : "Follow", 40, 40, 166, 48, true, 19, !galleryProfileIsOwn))
updateGalleryRelationship(galleryProfile.followed ? "unfollow" : "follow");
if (dangerPill(galleryProfile.blocked ? "Unblock" : "Block", 1074, 40, 166, 48, 19, !galleryProfileIsOwn))
updateGalleryRelationship(galleryProfile.blocked ? "unblock" : "block");
if (galleryProfileIsOwn && galleryProfilePage == 0 && pill("Edit Bio", 224, 40, 166, 48, true, 19)) {
beginText(Field::GalleryBio, galleryProfile.bio);
}
if (galleryProfilePage == 0) {
rounded(104, 146, 1072, 474, 22, SDL_Color{0, 0, 0, 30});
rounded(98, 136, 1084, 474, 22, WHITE);
const int avatarX = 132, avatarY = 173, avatarSize = 164;
rounded(avatarX - 4, avatarY - 4, avatarSize + 8, avatarSize + 8, 19, GREEN);
rounded(avatarX, avatarY, avatarSize, avatarSize, 15, WHITE);
SDL_Texture* avatar = galleryProfileIsOwn ? profileTexture : galleryProfileAvatarTexture;
if (avatar) drawTexture(avatar, avatarX + 3, avatarY + 3, avatarSize - 6, avatarSize - 6);
else centered(username.empty() ? "?" : utf8Prefix(username, 1), avatarX + 3, avatarY + 3,
avatarSize - 6, avatarSize - 6, 70, GREEN, true);
text(utf8Prefix(username, 24), 324, 174, 54, GREEN, true);
const std::string bio = galleryProfile.bio.empty() ? "No bio added" : galleryProfile.bio;
text(utf8Prefix(bio, 58), 326, 247, 25, galleryProfile.bio.empty() ? GRAY : INK);
rounded(128, 350, 1024, 4, 2, GREEN);
auto statCard = [&](int x, int y, const std::string& value, const std::string& label) {
rounded(x + 2, y + 4, 392, 82, 10, SDL_Color{0, 0, 0, 30});
rounded(x, y, 392, 82, 10, GREEN);
rounded(x + 2, y + 2, 388, 78, 8, WHITE);
centered(value, x + 10, y + 3, 372, 48, 37, GREEN, true);
centered(label, x + 10, y + 47, 372, 28, 16, PALE_GREEN, true);
};
statCard(182, 374, std::to_string(galleryProfile.switchnotes) + " " + brandString("Switchnotes"),
"Uploaded to " + appDisplayName + " Gallery");
statCard(706, 374, std::to_string(galleryProfile.views) + " Views", "Total on all uploaded " + brandString("Switchnotes"));
statCard(182, 474, std::to_string(galleryProfile.coins) + " Coins", "Added across all uploaded " + brandString("Switchnotes"));
statCard(706, 474, std::to_string(galleryProfile.followers) + " Followers", "Following this user on Gallery");
} else {
if (gallery.empty()) {
rounded(330, 226, 620, 258, 20, SDL_Color{0, 0, 0, 28});
rounded(330, 218, 620, 258, 20, WHITE);
centered(galleryLoadRunning ? "Loading uploads…" : "No uploads on this page",
370, 320, 540, 42, 25, INK, true);
}
for (int cell = 0; cell < 6 && cell < static_cast<int>(gallery.size()); ++cell) {
const int col = cell % 3, row = cell / 3;
const int x = 104 + col * 372, y = 153 + row * 232;
const bool selectedNow = static_cast<std::size_t>(cell) == gallerySelected;
const int lift = hoverLift(x, y, pointerX >= x - 5 && pointerX < x + 326 && pointerY >= y - 4 && pointerY < y + 194);
rounded(x - 5, y + 4 - lift, 331, 198, 15, SDL_Color{0, 0, 0, 48});
rounded(x - 5, y - 4 - lift, 331, 198, 15, WHITE);
rounded(x + 3, y + 4 - lift, 315, 182, 8, WHITE);
drawTexture(galleryPreviewTexture(gallery[static_cast<std::size_t>(cell)]), x + 3, y + 4 - lift, 315, 182);
roundedOutline(x - 1, y - lift, 323, 190, 14, 4, selectedNow ? GREEN : SDL_Color{210, 214, 220, 255});
if (gallery[static_cast<std::size_t>(cell)].locked) drawLockIcon(x + 10, y + 10 - lift);
rounded(x + 228, y + 158 - lift, 94, 30, 15, SDL_Color{255, 237, 190, 245});
drawImage("ui/icons/Coin.png", x + 236, y + 162 - lift, 22, 22);
text(std::to_string(gallery[static_cast<std::size_t>(cell)].coins), x + 265, y + 162 - lift, 17, ORANGE);
if (selectedNow) {
rounded(x + 137, y + 163 - lift, 59, 59, 30, SDL_Color{0, 0, 0, 42});
rounded(x + 133, y + 157 - lift, 59, 59, 30, WHITE);
rounded(x + 138, y + 162 - lift, 49, 49, 25, PALE_GREEN);
drawPlayTriangle(x + 153, y + 174 - lift, 21, WHITE);
}
if (hit(x - 5, y - 4, 331, 198)) {
clickConsumed = true;
if (selectedNow) { galleryProfileDetailMode = true; screen = Screen::GalleryDetails; }
else { gallerySelected = static_cast<std::size_t>(cell); playCue("button_on"); }
}
}
if (gallery.size() > 0 && actionPill("ui/icons/ButtonAGalleryText.png", "Details", 1100, 635, 138, 50, true)) {
galleryProfileDetailMode = true;
screen = Screen::GalleryDetails;
}
}
const int pageCount = 1 + (std::max(galleryProfile.switchnotes, galleryTotal) + 5) / 6;
if (pageCount > 1) {
const bool hasPrevious = galleryProfilePage > 0;
const bool hasNext = galleryProfilePage + 1 < pageCount;
if (pill("<", 24, 326, 60, 78, false, 34, hasPrevious) && hasPrevious)
loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage - 1);
if (pill(">", 1196, 326, 60, 78, true, 34, hasNext) && hasNext)
loadGalleryProfile(galleryProfile.identity, galleryProfileReturnScreen, galleryProfilePage + 1);
}
if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65, "button_off")) leaveGalleryProfile();
const int dotCount = std::max(1, std::min(10, pageCount));
for (int i = 0; i < dotCount; ++i) {
const int pageForDot = dotCount == pageCount ? i : i * (pageCount - 1) / std::max(1, dotCount - 1);
rounded(640 - dotCount * 13 + i * 26, 655, 18, 18, 9, pageForDot == galleryProfilePage ? GREEN : PALE_GREEN);
}
}
void App::renderDetails(bool galleryMode) {
background();
if (galleryMode && gallery.empty()) {
if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65, "button_off")) screen = Screen::Gallery;
return;
}
const Item* item = nullptr;
bool allowSpinoffs = false;
if (galleryMode) item = &gallery[gallerySelected % gallery.size()];
else if (!library.empty()) item = &library[selected % library.size()];
if (!item) { if (pill("Back", 40, 620, 100, 50, galleryMode)) screen = galleryMode ? Screen::Gallery : Screen::Library; return; }
const bool noteLocked = !galleryMode && ((docLoaded && docPath == item->path) ? doc.metadata.locked : item->locked);
const int ix = 108, iy = 260, iw = 338, ih = 197;
rounded(ix - 4, iy - 4, iw + 8, ih + 8, 14, WHITE);
rounded(ix, iy, iw, ih, 10, ORANGE);
rounded(ix + 4, iy + 4, iw - 8, ih - 8, 7, WHITE);
if (galleryMode) drawTexture(galleryPreviewTexture(*item), ix + 4, iy + 4, iw - 8, ih - 8);
else if (item->sample) drawImage("art/" + item->artwork, ix + 4, iy + 4, iw - 8, ih - 8, true);
else if (docLoaded && docPath == item->path) drawCurrentFrame(ix + 4, iy + 4, iw - 8, ih - 8);
else drawTexture(thumbTexture(*item), ix + 4, iy + 4, iw - 8, ih - 8);
if (galleryMode) {
if (item->locked) drawLockIcon(ix + 10, iy + 10);
rounded(508, 181, 665, 67, 13, SDL_Color{226, 230, 242, 255});
text(utf8Prefix(item->title, 38), 525, 197, 28, INK, true);
rounded(508, 262, 665, 110, 13, SDL_Color{226, 230, 242, 255});
text(item->description.empty() ? "No description added" : utf8Prefix(item->description, 48),
527, 284, 24, SDL_Color{112, 120, 141, 255});
rounded(508, 385, 665, 154, 13, SDL_Color{255, 234, 213, 255});
text("Author", 526, 402, 24, ORANGE);
const bool isCurrentAuthor = item->ownerIdentity.empty()
? item->author == userName
: item->ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity);
if (isCurrentAuthor && profileDetailTexture) drawTexture(profileDetailTexture, 932, 397, 32, 32);
text(utf8Prefix(item->author, 24), isCurrentAuthor && profileDetailTexture ? 975 : 930, 402, 24, ORANGE);
if (hit(925, 392, 235, 42) && !item->ownerIdentity.empty() && !galleryProfileDetailMode) {
clickConsumed = true;
loadGalleryProfile(item->ownerIdentity, Screen::GalleryDetails);
}
text("Creator FSID", 526, 441, 24, ORANGE);
text(item->fsid, 930, 441, 24, ORANGE);
text("Updated", 526, 480, 24, ORANGE);
text(dateText(item->modifiedUnixSeconds), 810, 480, 24, ORANGE);
text("Coins " + std::to_string(item->coins) + " Views " + std::to_string(item->views), 526, 554, 20, GREEN);
} else {
const bool isCurrentDocument = docLoaded && docPath == item->path;
if (isCurrentDocument ? doc.metadata.locked : item->locked) drawLockIcon(1127, 201);
allowSpinoffs = isCurrentDocument ? doc.metadata.allowSpinoffs : item->allowSpinoffs;
rounded(508, 183, 665, 67, 13, SDL_Color{226, 230, 242, 255});
text(item->title, 524, 197, 27, INK, true);
if (!noteLocked) drawImage("ui/icons/Edit.png", 1114, 195, 47, 47);
rounded(508, 262, 665, 110, 13, SDL_Color{226, 230, 242, 255});
const std::string description = isCurrentDocument && !doc.metadata.description.empty()
? doc.metadata.description : !item->description.empty() ? item->description : "No description added";
text(utf8Prefix(description, 42), 526, 284, 24, SDL_Color{112, 120, 141, 255});
if (!noteLocked) drawImage("ui/icons/Edit.png", 1114, 292, 47, 47);
const std::string authorName = isCurrentDocument ? doc.metadata.authorName : item->author;
const std::string originalAuthorName = isCurrentDocument ? doc.metadata.originalAuthorName
: (item->originalAuthor.empty() ? item->author : item->originalAuthor);
const std::string authorFsid = isCurrentDocument ? doc.metadata.authorFsid : item->authorFsid;
const std::string originalAuthorFsid = isCurrentDocument ? doc.metadata.originalAuthorFsid : item->originalAuthorFsid;
const std::uint64_t modifiedAt = isCurrentDocument ? doc.metadata.modifiedUnixSeconds : item->modifiedUnixSeconds;
const std::uint64_t createdAt = isCurrentDocument ? doc.metadata.createdUnixSeconds : item->createdUnixSeconds;
centered(expandMetadata ? "Less Details" : "More Details", 540, 326, 590, 35, 23, SDL_Color{112, 120, 141, 255});
rounded(508, 385, 665, expandMetadata ? 220 : 154, 13, SDL_Color{255, 234, 213, 255});
if (expandMetadata) {
text("Author FSID", 526, 397, 19, ORANGE);
text(authorFsid.empty() ? "Unknown" : authorFsid, 820, 397, 19, ORANGE);
text("Original Author FSID", 526, 427, 19, ORANGE);
text(originalAuthorFsid.empty() ? "Unknown" : originalAuthorFsid, 820, 427, 19, ORANGE);
text("Last Edited", 526, 457, 19, ORANGE);
text(dateText(modifiedAt), 820, 457, 19, ORANGE);
text("First Created", 526, 487, 19, ORANGE);
text(dateText(createdAt), 820, 487, 19, ORANGE);
text("Switchnote FSID", 526, 527, 17, GREEN, true);
text(isCurrentDocument ? doc.metadata.currentId : item->currentId, 810, 527, 17, GREEN, true);
text("Root FSID", 526, 553, 17, GREEN, true);
text(isCurrentDocument ? doc.metadata.rootId : item->rootId, 810, 553, 17, GREEN, true);
} else {
text("Author", 526, 402, 24, ORANGE);
const bool isCurrentAuthor = item->ownerIdentity.empty()
? authorName == userName
: item->ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity);
if (isCurrentAuthor && profileDetailTexture) drawTexture(profileDetailTexture, 932, 397, 32, 32);
text(authorName, isCurrentAuthor && profileDetailTexture ? 975 : 977, 402, 24, ORANGE);
text("Original Author", 526, 441, 24, ORANGE);
text(originalAuthorName, 977, 441, 24, ORANGE);
text("Last Edited", 526, 480, 24, ORANGE);
text(dateText(modifiedAt), 800, 480, 24, ORANGE);
}
}
if (imageButton(galleryMode ? "ui/BackButton_Gallery.png" : "ui/BackButton.png", 39, 620, 65, 65, "button_off")) {
if (galleryMode && galleryProfileDetailMode) { galleryProfileDetailMode = false; screen = Screen::GalleryProfile; }
else screen = galleryMode ? Screen::Gallery : Screen::Library;
}
if (galleryMode) {
const bool galleryOwner = item->ownerIdentity == sppm::identityFingerprint(signingIdentity.publicIdentity);
if (galleryOwner && dangerPill("Delete", 40, 36, 156, 50, 18)) {
modalSelection = 0;
modal = Modal::ConfirmGalleryDelete;
}
if (actionPill("ui/icons/ButtonYGalleryText.png", "Comments", 1040, 36, 198, 50, true)) openGalleryComments();
if (actionPill("ui/icons/Coin.png", "Add Coin", 796, 635, 174, 50, true)) addGalleryCoin();
if (pill("Save", 978, 635, 100, 50, true, 19)) saveGalleryItem(gallerySelected);
if (actionPill("ui/icons/ButtonAGalleryText.png", "Watch", 1088, 635, 150, 50, true)) openGalleryItem(gallerySelected);
} else {
if (pill("Upload to Gallery", 1022, 36, 216, 50, true, 17, !item->sample)) publishGalleryItem(selected);
if (actionPill("ui/icons/ButtonZLText.png", "Delete", 40, 36, 174, 50, false)) { modalSelection = 0; modal = Modal::ConfirmDelete; }
if (actionPill("ui/icons/ButtonYText.png", "Edit", 980, 635, 100, 50, false, !noteLocked)) openItem(selected, true);
if (actionPill("ui/icons/ButtonAText.png", "Watch", 1088, 635, 150, 50, false)) { openItem(selected, false); screen = Screen::Viewer; }
if (pill("Spin-off", 798, 635, 174, 50, false, 18, allowSpinoffs && !noteLocked)) { openItem(selected, false); forkDocument(); }
if (pill("Export", 580, 635, 174, 50, false, 19, !item->sample)) { modalSelection = 0; modal = Modal::Export; }
}
if (!galleryMode && !noteLocked && hit(1114, 195, 47, 47)) {
clickConsumed = true;
if (item->sample || !docLoaded || docPath != item->path) openItem(selected, true);
beginText(Field::Title, docLoaded ? doc.metadata.title : item->title);
}
if (!galleryMode && !noteLocked && hit(1114, 292, 47, 47)) {
clickConsumed = true;
if (item->sample || !docLoaded || docPath != item->path) openItem(selected, true);
beginText(Field::Description, docLoaded ? doc.metadata.description : "");
}
if (!galleryMode && hit(508, 262, 665, 110)) { clickConsumed = true; expandMetadata = !expandMetadata; }
if (hit(ix - 4, iy - 4, iw + 8, ih + 8) && !galleryMode) { clickConsumed = true; openItem(selected, false); screen = Screen::Viewer; }
}
bool App::loadSample(const Item& item, sppm::Document& out) {
SDL_Surface* input = IMG_Load(join(assetsRoot, "art/" + item.artwork).c_str());
if (!input) return false;
SDL_Surface* rgba = SDL_ConvertSurfaceFormat(input, SDL_PIXELFORMAT_RGBA32, 0);
SDL_FreeSurface(input);
if (!rgba) return false;
if (rgba->w != sppm::kWidth || rgba->h != sppm::kHeight) {
SDL_FreeSurface(rgba);
return false;
}
out = sppm::Document{};
out.metadata.title = item.title;
out.metadata.description = "Sample animation supplied with Fakenote Studio.";
out.metadata.authorName = item.author;
out.metadata.originalAuthorName = item.author;
out.metadata.lastEditorName = item.author;
out.metadata.authorFsid = newId("sample-creator");
out.metadata.currentId = newId("sample-note");
if (out.metadata.authorFsid.empty() || out.metadata.currentId.empty()) {
SDL_FreeSurface(rgba);
return false;
}
out.metadata.originalAuthorFsid = out.metadata.authorFsid;
out.metadata.lastEditorFsid = out.metadata.authorFsid;
out.metadata.rootId = out.metadata.currentId;
out.metadata.createdUnixSeconds = out.metadata.modifiedUnixSeconds = nowSeconds();
out.metadata.fps = 12;
out.metadata.speed = 6;
out.frames.emplace_back();
std::unordered_map<std::uint32_t, std::uint8_t> exact;
std::map<std::uint32_t, std::uint32_t> frequency;
for (int y = 0; y < rgba->h; ++y) {
const auto* row = reinterpret_cast<const std::uint32_t*>(static_cast<const std::uint8_t*>(rgba->pixels) + y * rgba->pitch);
for (int x = 0; x < rgba->w; ++x) {
const std::uint32_t p = row[x];
std::uint8_t r, g, b, a;
SDL_GetRGBA(p, rgba->format, &r, &g, &b, &a);
if (a >= 16) ++frequency[(static_cast<std::uint32_t>(a) << 24) | (static_cast<std::uint32_t>(r) << 16) | (static_cast<std::uint32_t>(g) << 8) | b];
}
}
std::vector<std::pair<std::uint32_t, std::uint32_t>> colors(frequency.begin(), frequency.end());
std::sort(colors.begin(), colors.end(), [](const auto& a, const auto& b) { return a.second > b.second; });
if (colors.size() > 255) colors.resize(255);
for (std::size_t i = 0; i < colors.size(); ++i) {
const auto key = colors[i].first;
out.palette[i + 1] = {static_cast<std::uint8_t>(key >> 16), static_cast<std::uint8_t>(key >> 8), static_cast<std::uint8_t>(key), static_cast<std::uint8_t>(key >> 24)};
exact[key] = static_cast<std::uint8_t>(i + 1);
}
auto& pixels = out.frames[0].layers[3];
for (int y = 0; y < rgba->h; ++y) {
const auto* row = reinterpret_cast<const std::uint32_t*>(static_cast<const std::uint8_t*>(rgba->pixels) + y * rgba->pitch);
for (int x = 0; x < rgba->w; ++x) {
std::uint8_t r, g, b, a;
SDL_GetRGBA(row[x], rgba->format, &r, &g, &b, &a);
if (a < 16) continue;
const std::uint32_t key = (static_cast<std::uint32_t>(a) << 24) | (static_cast<std::uint32_t>(r) << 16) | (static_cast<std::uint32_t>(g) << 8) | b;
auto found = exact.find(key);
if (found != exact.end()) pixels[static_cast<std::size_t>(y) * rgba->w + x] = found->second;
else {
int bestDistance = std::numeric_limits<int>::max();
std::uint8_t best = 1;
for (std::size_t i = 1; i <= colors.size(); ++i) {
const auto& c = out.palette[i];
int dr = static_cast<int>(r) - c.r, dg = static_cast<int>(g) - c.g, db = static_cast<int>(b) - c.b;
const int d = dr * dr + dg * dg + db * db;
if (d < bestDistance) { bestDistance = d; best = static_cast<std::uint8_t>(i); }
}
pixels[static_cast<std::size_t>(y) * rgba->w + x] = best;
}
}
}
SDL_FreeSurface(rgba);
return true;
}
void App::openItem(std::size_t index, bool edit) {
LoadingScope loading(*this);
if (index >= library.size()) return;
if (docLoaded && dirty) saveDocument();
const Item item = library[index];
if (edit && item.locked) { showToast("This Switchnote is locked and cannot be edited"); return; }
stopPlayback();
if (edit) {
color = 2;
activeTool = Tool::Pen;
paintMenu = colorMenu = false;
toolMenu = false;
brush = 4;
pattern = 0;
square = false;
layer = 0;
}
if (item.sample) {
sppm::Document sample;
if (!loadSample(item, sample)) { showToast("Could not load sample drawing"); return; }
if (edit) {
sppm::Document child;
const std::string childId = newId("spinoff");
if (childId.empty()) { showToast("Secure random ID generation failed"); return; }
std::string forkError;
if (!sppm::createSpinoff(sample, userName, myFsid, childId, nowSeconds(), child, forkError)) {
showToast("Could not create sample spin-off: " + forkError);
return;
}
child.metadata.authorIdentity = signingIdentity.publicIdentity;
child.metadata.lastEditorIdentity = signingIdentity.publicIdentity;
child.metadata.title = item.title + " (spin-off)";
doc = std::move(child);
color = blackInkIndex(doc.palette);
docPath.clear();
docLoaded = true;
dirty = false;
galleryViewing = false;
frame = 0;
screen = Screen::Canvas;
invalidateFrames();
rebuildAudio();
showToast("Editing an included sample; save it as a spin-off");
} else {
doc = std::move(sample);
docPath.clear();
docLoaded = true;
galleryViewing = false;
frame = 0;
screen = Screen::Viewer;
invalidateFrames();
rebuildAudio();
}
return;
}
std::string error;
if (!sppm::loadSignedFile(item.path, doc, signingIdentity.legacyHmacKey, error)) { showToast("Could not open Switchnote: " + error); return; }
docPath = item.path;
if (edit && doc.metadata.locked) { docLoaded = false; docPath.clear(); showToast("This Switchnote is locked and cannot be edited"); return; }
docLoaded = true;
galleryViewing = false;
dirty = false;
frame = 0;
if (edit) color = blackInkIndex(doc.palette);
screen = edit ? Screen::Canvas : Screen::Viewer;
invalidateFrames();
rebuildAudio();
}
void App::newDocument() {
if (docLoaded && dirty) saveDocument();
const std::string id = newId("note");
if (id.empty()) { showToast("Secure random ID generation failed"); return; }
stopPlayback();
doc = sppm::Document{};
rebuildAudio();
doc.frames.emplace_back();
doc.metadata.title = defaultSwitchnoteTitle(userName, noteSingular);
doc.metadata.authorName = userName;
doc.metadata.originalAuthorName = userName;
doc.metadata.lastEditorName = userName;
doc.metadata.authorFsid = myFsid;
doc.metadata.originalAuthorFsid = myFsid;
doc.metadata.lastEditorFsid = myFsid;
doc.metadata.authorIdentity = signingIdentity.publicIdentity;
doc.metadata.originalAuthorIdentity = signingIdentity.publicIdentity;
doc.metadata.lastEditorIdentity = signingIdentity.publicIdentity;
doc.metadata.currentId = id;
doc.metadata.rootId = doc.metadata.currentId;
doc.metadata.createdUnixSeconds = doc.metadata.modifiedUnixSeconds = nowSeconds();
doc.metadata.fps = static_cast<std::uint8_t>(defaultFps);
doc.metadata.speed = static_cast<std::uint8_t>(defaultSpeed);
docPath.clear();
docLoaded = true;
dirty = true;
frame = 0;
layer = 0;
color = blackInkIndex(doc.palette);
brush = 4;
pattern = 0;
square = false;
activeTool = Tool::Pen;
paintMenu = false;
colorMenu = toolMenu = false;
zoom = 1.0f;
panX = panY = 0;
undoStack.clear(); redoStack.clear();
invalidateFrames();
saveDocument();
for (std::size_t i = 0; i < library.size(); ++i) {
if (library[i].path == docPath) { selected = i; page = static_cast<int>(i / 6); break; }
}
screen = Screen::Canvas;
showToast("New Switchnote — draw now; use New to add frames");
playEvent("note_new", "page_new");
}
bool App::saveDocument(bool force) {
LoadingScope loading(*this);
if (!docLoaded) return false;
if (doc.metadata.locked && dirty) { showToast("This Switchnote is locked and cannot be edited"); return false; }
if (!dirty && !force) return true;
if (docPath.empty()) {
// A sample spin-off receives its persistent file only when explicitly saved.
doc.metadata.title = doc.metadata.title.empty() ? defaultSwitchnoteTitle(userName, noteSingular) : doc.metadata.title;
docPath = join(join(notesRoot, folder), doc.metadata.currentId + ".sppm");
}
doc.metadata.lastEditorName = userName;
doc.metadata.lastEditorFsid = myFsid;
doc.metadata.lastEditorIdentity = signingIdentity.publicIdentity;
doc.metadata.modifiedUnixSeconds = nowSeconds();
std::string error;
if (sppm::savePublicSignedFile(docPath, doc, signingIdentity, error)) {
const std::string thumbnailKey = "thumb:" + docPath;
auto thumbnail = images.find(thumbnailKey);
if (thumbnail != images.end()) {
if (thumbnail->second) SDL_DestroyTexture(thumbnail->second);
images.erase(thumbnail);
}
scanLibrary();
bool foundSavedItem = false;
for (std::size_t i = 0; i < library.size(); ++i) {
if (library[i].path == docPath) {
selected = i;
page = static_cast<int>(i / 6);
foundSavedItem = true;
break;
}
}
if (!foundSavedItem) {
sppm::Document persisted;
std::string verifyError;
if (!sppm::loadSignedFile(docPath, persisted, signingIdentity.legacyHmacKey, verifyError)) {
showToast("Saved Switchnote could not be reopened: " + verifyError);
return false;
}
Item saved{};
saved.title = persisted.metadata.title.empty() ? defaultSwitchnoteTitle(userName, noteSingular) : persisted.metadata.title;
saved.path = docPath;
saved.author = persisted.metadata.authorName;
saved.description = persisted.metadata.description;
saved.originalAuthor = persisted.metadata.originalAuthorName;
saved.authorFsid = persisted.metadata.authorFsid;
saved.originalAuthorFsid = persisted.metadata.originalAuthorFsid;
saved.ownerIdentity = sppm::identityFingerprint(persisted.metadata.authorIdentity);
saved.currentId = persisted.metadata.currentId;
saved.rootId = persisted.metadata.rootId;
saved.createdUnixSeconds = persisted.metadata.createdUnixSeconds;
saved.modifiedUnixSeconds = persisted.metadata.modifiedUnixSeconds;
saved.allowSpinoffs = persisted.metadata.allowSpinoffs;
library.push_back(std::move(saved));
std::sort(library.begin(), library.end(), [](const Item& a, const Item& b) {
if (a.modifiedUnixSeconds != b.modifiedUnixSeconds) return a.modifiedUnixSeconds > b.modifiedUnixSeconds;
if (a.createdUnixSeconds != b.createdUnixSeconds) return a.createdUnixSeconds > b.createdUnixSeconds;
return a.path < b.path;
});
for (std::size_t i = 0; i < library.size(); ++i) {
if (library[i].path == docPath) {
selected = i;
page = static_cast<int>(i / 6);
foundSavedItem = true;
break;
}
}
}
if (!foundSavedItem) {
showToast("Switchnote saved, but could not be added to the library");
return false;
}
dirty = false;
return true;
}
showToast("Save failed: " + error);
return false;
}
void App::closeDocument() {
saveDocument();
stopPlayback();
docLoaded = false;
docPath.clear();
frame = 0;
doc = sppm::Document{};
rebuildAudio();
invalidateFrames();
}
void App::deleteDocument() {
if (library.empty() || selected >= library.size()) return;
const Item item = library[selected];
if (item.sample) { showToast("Bundled sample artwork cannot be deleted"); return; }
if (!item.path.empty() && std::remove(item.path.c_str()) != 0) { showToast("Could not delete this Switchnote"); return; }
if (docPath == item.path) {
stopPlayback();
docLoaded = false;
dirty = false;
docPath.clear();
doc = sppm::Document{};
rebuildAudio();
invalidateFrames();
}
scanLibrary();
screen = Screen::Library;
modal = Modal::None;
showToast("Switchnote deleted");
playEvent("delete", "delete_complete");
}
void App::forkDocument() {
if (!docLoaded && !library.empty()) openItem(selected, false);
if (!docLoaded) return;
const std::string id = newId("spinoff");
if (id.empty()) { showToast("Secure random ID generation failed"); return; }
sppm::Document child;
std::string error;
if (!sppm::createSpinoff(doc, userName, myFsid, id, nowSeconds(), child, error)) {
showToast("Spin-off unavailable: " + error);
return;
}
child.metadata.authorIdentity = signingIdentity.publicIdentity;
child.metadata.lastEditorIdentity = signingIdentity.publicIdentity;
stopPlayback();
doc = std::move(child);
color = blackInkIndex(doc.palette);
rebuildAudio();
doc.metadata.title = doc.metadata.title + " (spin-off)";
docPath.clear();
galleryViewing = false;
dirty = true;
frame = std::min(frame, doc.frames.size() - 1);
screen = Screen::Canvas;
saveDocument();
scanLibrary();
showToast("Spin-off created with the original author and lineage preserved");
}
void App::markDirty() {
dirty = true;
dirtyTick = SDL_GetTicks();
++docRevision;
invalidateFrames();
}
void App::invalidateFrames() {
for (auto& item : frameTextures) if (item.second.texture) SDL_DestroyTexture(item.second.texture);
frameTextures.clear();
++docRevision;
}
SDL_Texture* App::frameTexture(std::size_t index) {
if (!docLoaded || index >= doc.frames.size()) return nullptr;
auto& cache = frameTextures[index];
if (cache.texture && cache.revision == docRevision) return cache.texture;
if (cache.texture) SDL_DestroyTexture(cache.texture);
SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32);
if (!surface) return nullptr;
const auto& frameData = doc.frames[index];
auto* pixels = static_cast<std::uint32_t*>(surface->pixels);
for (std::size_t i = 0; i < sppm::kPixelsPerLayer; ++i) {
std::uint32_t out = 0;
for (std::size_t l = sppm::kLayerCount; l-- > 0;) {
const std::uint8_t idx = frameData.layers[l][i];
if (!idx) continue;
const auto& c = doc.palette[idx];
if (!c.a) continue;
const std::uint8_t alpha = c.a;
std::uint8_t r, g, b, oldA;
SDL_GetRGBA(out, surface->format, &r, &g, &b, &oldA);
const int inv = 255 - alpha;
const int nextA = alpha + (oldA * inv + 127) / 255;
if (nextA == 0) { out = 0; continue; }
r = static_cast<std::uint8_t>((c.r * alpha + r * oldA * inv / 255 + nextA / 2) / nextA);
g = static_cast<std::uint8_t>((c.g * alpha + g * oldA * inv / 255 + nextA / 2) / nextA);
b = static_cast<std::uint8_t>((c.b * alpha + b * oldA * inv / 255 + nextA / 2) / nextA);
out = SDL_MapRGBA(surface->format, r, g, b, static_cast<std::uint8_t>(nextA));
}
pixels[i] = out;
}
cache.texture = SDL_CreateTextureFromSurface(renderer, surface);
SDL_FreeSurface(surface);
if (cache.texture) {
SDL_SetTextureScaleMode(cache.texture, SDL_ScaleModeNearest);
SDL_SetTextureBlendMode(cache.texture, SDL_BLENDMODE_BLEND);
}
cache.revision = docRevision;
return cache.texture;
}
void App::drawFrameImage(std::size_t index, int x, int y, int w, int h) { drawTexture(frameTexture(index), x, y, w, h); }
void App::drawCurrentFrame(int x, int y, int w, int h, bool withOnion) {
if (!docLoaded || doc.frames.empty()) return;
if (withOnion && onion && frame > 0) {
SDL_Texture* previous = frameTexture(frame - 1);
if (previous) {
SDL_SetTextureColorMod(previous, 255, 96, 104);
SDL_SetTextureAlphaMod(previous, 55);
drawTexture(previous, x, y, w, h);
SDL_SetTextureColorMod(previous, 255, 255, 255);
SDL_SetTextureAlphaMod(previous, 255);
}
}
drawFrameImage(frame, x, y, w, h);
}
void App::playControl(int x, int y, int w, int h) {
if (!playing) {
if (imagePill("ui/icons/Play.png", x, y, w, h, false, true, "")) startPlayback();
return;
}
const bool hovered = pointerX >= x && pointerX < x + w && pointerY >= y && pointerY < y + h;
const bool pressed = pointerDown && hovered;
const int lift = hoverLift(x, y, hovered);
rounded(x, y + 5 - lift + (pressed ? 4 : 0), w, h, h / 2, SDL_Color{0, 0, 0, 42});
rounded(x - 3, y - 3 - lift + (pressed ? 4 : 0), w + 6, h + 6, h / 2 + 3, WHITE);
rounded(x, y - lift + (pressed ? 4 : 0), w, h, h / 2, CREAM);
const int barW = std::max(5, w / 10), barH = std::max(18, h / 3);
fill(x + w / 2 - barW - 3, y + (h - barH) / 2 - lift + (pressed ? 4 : 0), barW, barH, ORANGE);
fill(x + w / 2 + 3, y + (h - barH) / 2 - lift + (pressed ? 4 : 0), barW, barH, ORANGE);
if (hit(x, y, w, h)) { clickConsumed = true; stopPlayback(); }
}
void App::setFrame(std::size_t index, bool playNavigationCue, const char* event) {
if (!docLoaded || doc.frames.empty()) return;
const std::size_t previous = frame;
frame = std::min(index, doc.frames.size() - 1);
if (playNavigationCue && frame != previous) {
if (event) playEvent(event, "button_on");
else playEvent(frame > previous ? "frame_next" : "frame_previous", "button_on");
}
if (playing) playFrameSound();
}
void App::startPlayback() {
if (!docLoaded || doc.frames.empty()) return;
// Play always means replay from the beginning. Reset audio before changing
// the cursor so BGM and frame-zero cues share the animation's time origin.
stopPlayback(false);
frame = 0;
playing = true;
lastPlaybackTick = SDL_GetTicks();
playbackTickRemainder = 0;
playEvent("playback_start", "movie_play");
playFrameSound();
}
void App::updatePlayback(std::uint32_t now) {
if (!playing || !docLoaded || doc.frames.empty()) return;
const double exactFrameDuration = 1000.0 / sppm::frameRate(doc.metadata);
// Advance against the original frame clock rather than resetting it to
// `now`; if a render takes longer than one frame, catch up and keep the
// displayed counter synchronized with the animation.
while (playing) {
const double accumulated = exactFrameDuration + playbackTickRemainder;
const std::uint32_t frameDuration = std::max<std::uint32_t>(1, static_cast<std::uint32_t>(std::floor(accumulated)));
if (now - lastPlaybackTick < frameDuration) break;
playbackTickRemainder = accumulated - frameDuration;
lastPlaybackTick += frameDuration;
if (frame + 1 < doc.frames.size()) setFrame(frame + 1, false);
else if (!doc.metadata.loopPlayback) stopPlayback(false);
else {
if (audioReady) for (int channel = 0; channel < 4; ++channel) Mix_HaltChannel(channel);
setFrame(0, false);
}
}
}
void App::stopPlayback(bool playStopCue) {
if (playing && playStopCue) playEvent("playback_stop", "movie_stop");
playing = false;
if (audioReady) {
for (int channel = 0; channel < 5; ++channel) Mix_HaltChannel(channel);
}
if (bgmSeekChunk) { Mix_FreeChunk(bgmSeekChunk); bgmSeekChunk = nullptr; }
}
void App::startBgmAtCurrentFrame() {
if (!audioReady || !trackChunks[0]) return;
Mix_HaltChannel(0);
if (bgmSeekChunk) { Mix_FreeChunk(bgmSeekChunk); bgmSeekChunk = nullptr; }
if (!doc.metadata.bgmFollowsSpeed) { Mix_PlayChannel(0, trackChunks[0], 0); return; }
if (frame == 0) { Mix_PlayChannel(0, trackChunks[0], 0); return; }
const auto& buffer = trackAudioBuffers[0];
const std::size_t sampleFrame = static_cast<std::size_t>(
std::floor(frame * 48000.0 / sppm::frameRate(doc.metadata)));
const std::size_t offset = sampleFrame * 4;
if (offset + 4 > buffer.size()) return;
bgmSeekChunk = Mix_QuickLoad_RAW(const_cast<Uint8*>(buffer.data() + offset),
static_cast<Uint32>(buffer.size() - offset));
if (bgmSeekChunk) Mix_PlayChannel(0, bgmSeekChunk, 0);
}
void App::rebuildAudio() {
if (!audioReady) return;
Mix_HaltChannel(4);
for (int channel = 0; channel < static_cast<int>(trackChunks.size()); ++channel) Mix_HaltChannel(channel);
if (bgmSeekChunk) { Mix_FreeChunk(bgmSeekChunk); bgmSeekChunk = nullptr; }
for (auto* chunk : trackChunks) if (chunk) Mix_FreeChunk(chunk);
trackChunks.fill(nullptr);
for (auto& buffer : trackAudioBuffers) buffer.clear();
for (std::size_t i = 0; i < doc.audioTracks.size() && i < trackChunks.size(); ++i) {
auto& track = doc.audioTracks[i];
if (track.pcm.empty()) continue;
if (track.channels < 1 || track.channels > 2 || track.sampleRate < 8000 || track.sampleRate > 48000) continue;
SDL_AudioCVT cvt{};
if (SDL_BuildAudioCVT(&cvt, AUDIO_S16SYS, track.channels, static_cast<int>(track.sampleRate), AUDIO_S16SYS, 2, 48000) < 0) continue;
const auto bytes = track.pcm.size() * sizeof(std::int16_t);
if (bytes > static_cast<std::size_t>(std::numeric_limits<int>::max())) continue;
cvt.len = static_cast<int>(bytes);
cvt.buf = static_cast<Uint8*>(SDL_malloc(static_cast<std::size_t>(cvt.len) * cvt.len_mult));
if (!cvt.buf) continue;
std::memcpy(cvt.buf, track.pcm.data(), bytes);
if (cvt.needed) {
if (SDL_ConvertAudio(&cvt) != 0) { SDL_free(cvt.buf); continue; }
} else {
cvt.len_cvt = cvt.len;
}
if (cvt.len_cvt <= 0) { SDL_free(cvt.buf); continue; }
if (i == 0 && doc.metadata.bgmFollowsSpeed && cvt.len_cvt >= 4) {
const std::size_t inputFrames = static_cast<std::size_t>(cvt.len_cvt) / 4;
const double currentFps = sppm::frameRate(doc.metadata);
const double referenceFps = track.referenceSpeed ? selectedFrameRate(track.referenceSpeed, 0) :
(track.referenceFps ? track.referenceFps : currentFps);
const double playbackRate = currentFps / referenceFps;
constexpr std::size_t maxPlaybackSamples = 32ULL * 1024 * 1024;
const std::size_t maxOutputFrames = maxPlaybackSamples / 2;
const std::size_t requestedFrames = std::max<std::size_t>(
1, static_cast<std::size_t>(std::ceil(inputFrames / playbackRate)));
const std::size_t outputFrames = std::min(requestedFrames, maxOutputFrames);
trackAudioBuffers[i].resize(outputFrames * 4);
const auto* sourceSamples = reinterpret_cast<const std::int16_t*>(cvt.buf);
auto* outputSamples = reinterpret_cast<std::int16_t*>(trackAudioBuffers[i].data());
for (std::size_t outFrame = 0; outFrame < outputFrames; ++outFrame) {
const double sourcePosition = std::min(static_cast<double>(inputFrames - 1), outFrame * playbackRate);
const std::size_t sourceFrame = static_cast<std::size_t>(sourcePosition);
const std::size_t nextFrame = std::min(sourceFrame + 1, inputFrames - 1);
const double fraction = sourcePosition - sourceFrame;
for (int channel = 0; channel < 2; ++channel) {
const double a = sourceSamples[sourceFrame * 2 + channel];
const double b = sourceSamples[nextFrame * 2 + channel];
outputSamples[outFrame * 2 + channel] = static_cast<std::int16_t>(std::clamp(
static_cast<int>(std::lround(a + (b - a) * fraction)), -32768, 32767));
}
}
} else {
trackAudioBuffers[i].assign(cvt.buf, cvt.buf + cvt.len_cvt);
}
SDL_free(cvt.buf);
trackChunks[i] = Mix_QuickLoad_RAW(trackAudioBuffers[i].data(), static_cast<Uint32>(trackAudioBuffers[i].size()));
}
if (playing) startBgmAtCurrentFrame();
}
void App::playFrameSound() {
if (!audioReady || !docLoaded || !playing) return;
if (frame == 0 && trackChunks[0] && (doc.metadata.bgmFollowsSpeed || !Mix_Playing(0)))
startBgmAtCurrentFrame();
for (std::size_t i = 1; i < doc.audioTracks.size() && i < trackChunks.size(); ++i) {
const auto& cuesAt = doc.audioTracks[i].frameTriggers;
if (std::find(cuesAt.begin(), cuesAt.end(), static_cast<std::uint16_t>(frame)) != cuesAt.end() && trackChunks[i])
Mix_PlayChannel(static_cast<int>(i), trackChunks[i], 0);
}
}
void App::pushUndo() {
if (!docLoaded || frame >= doc.frames.size() || layer < 0 || layer >= 4) return;
if (fillWaveActive) { fillWaveActive = false; fillWavePixels.clear(); }
undoStack.push_back({frame, layer, doc.frames[frame].layers[layer]});
if (undoStack.size() > 32) undoStack.erase(undoStack.begin());
redoStack.clear();
}
void App::beginFillWave(int x, int y) {
if (!docLoaded || frame >= doc.frames.size() || layer < 0 || layer >= 4 ||
x < 0 || x >= sppm::kWidth || y < 0 || y >= sppm::kHeight) return;
auto& pixels = doc.frames[frame].layers[static_cast<std::size_t>(layer)];
const std::size_t seed = static_cast<std::size_t>(y) * sppm::kWidth + x;
const std::uint8_t target = pixels[seed];
const std::uint8_t replacement = activeTool == Tool::Eraser
? 0 : static_cast<std::uint8_t>(std::clamp(color, 1, 255));
if (target == replacement) return;
pushUndo();
fillWavePixels.clear();
fillWavePixels.reserve(pixels.size() / 4);
std::vector<std::uint32_t> queue;
queue.reserve(pixels.size() / 4);
std::vector<std::uint8_t> visited(pixels.size(), 0);
std::vector<std::uint16_t> distances(pixels.size(), std::numeric_limits<std::uint16_t>::max());
queue.push_back(static_cast<std::uint32_t>(seed));
visited[seed] = 1;
distances[seed] = 0;
for (std::size_t cursor = 0; cursor < queue.size(); ++cursor) {
const std::uint32_t index = queue[cursor];
const int px = static_cast<int>(index % sppm::kWidth);
const int py = static_cast<int>(index / sppm::kWidth);
fillWavePixels.emplace_back(index, distances[index]);
const std::array<int, 4> neighbors{{px > 0 ? static_cast<int>(index) - 1 : -1,
px + 1 < sppm::kWidth ? static_cast<int>(index) + 1 : -1,
py > 0 ? static_cast<int>(index) - sppm::kWidth : -1,
py + 1 < sppm::kHeight ? static_cast<int>(index) + sppm::kWidth : -1}};
for (const int next : neighbors) {
if (next < 0 || visited[static_cast<std::size_t>(next)] || pixels[static_cast<std::size_t>(next)] != target) continue;
visited[static_cast<std::size_t>(next)] = 1;
distances[static_cast<std::size_t>(next)] = static_cast<std::uint16_t>(distances[index] + 1);
queue.push_back(static_cast<std::uint32_t>(next));
}
}
std::sort(fillWavePixels.begin(), fillWavePixels.end(), [](const auto& a, const auto& b) {
return a.second < b.second;
});
fillWaveFrame = frame;
fillWaveLayer = layer;
fillWaveColor = replacement;
fillWaveOriginX = x;
fillWaveOriginY = y;
fillWaveRadius = 0.0f;
fillWaveCursor = 0;
fillWaveTick = SDL_GetTicks();
fillWaveActive = !fillWavePixels.empty();
}
void App::updateFillWave(std::uint32_t now) {
if (!fillWaveActive) return;
if (!docLoaded || frame != fillWaveFrame || layer != fillWaveLayer || frame >= doc.frames.size()) {
fillWaveActive = false;
fillWavePixels.clear();
return;
}
const float elapsed = std::min(0.1f, (now - fillWaveTick) / 1000.0f);
fillWaveTick = now;
// 25 drawing pixels every 0.25 seconds (100 pixels/second), staying within
// the connected region instead of crossing line-art boundaries.
fillWaveRadius += elapsed * 100.0f;
auto& pixels = doc.frames[frame].layers[static_cast<std::size_t>(layer)];
bool changed = false;
const auto radius = static_cast<std::uint16_t>(std::floor(fillWaveRadius));
while (fillWaveCursor < fillWavePixels.size() && fillWavePixels[fillWaveCursor].second <= radius) {
pixels[fillWavePixels[fillWaveCursor].first] = fillWaveColor;
++fillWaveCursor;
changed = true;
}
if (changed) markDirty();
if (fillWaveCursor >= fillWavePixels.size()) {
fillWaveActive = false;
fillWavePixels.clear();
}
}
void App::undo() {
if (undoStack.empty() || !docLoaded) return;
if (fillWaveActive) { fillWaveActive = false; fillWavePixels.clear(); }
UndoState state = std::move(undoStack.back()); undoStack.pop_back();
redoStack.push_back({state.frame, state.layer, doc.frames[state.frame].layers[state.layer]});
doc.frames[state.frame].layers[state.layer] = std::move(state.pixels);
frame = state.frame; layer = state.layer; markDirty(); playEvent("undo", "undo");
}
void App::redo() {
if (redoStack.empty() || !docLoaded) return;
if (fillWaveActive) { fillWaveActive = false; fillWavePixels.clear(); }
UndoState state = std::move(redoStack.back()); redoStack.pop_back();
undoStack.push_back({state.frame, state.layer, doc.frames[state.frame].layers[state.layer]});
doc.frames[state.frame].layers[state.layer] = std::move(state.pixels);
frame = state.frame; layer = state.layer; markDirty(); playEvent("redo", "redo");
}
void App::insertFrame(bool paste) {
if (!docLoaded || doc.frames.size() >= sppm::kMaxFrames) { showToast("This Switchnote has reached its 9999 frame limit"); return; }
const std::size_t insertAt = std::min(frame + 1, doc.frames.size());
sppm::Frame next;
if (paste && clipboardReady) next = frameClipboard;
else if (copyToNewPage && frame < doc.frames.size()) next = doc.frames[frame];
doc.frames.insert(doc.frames.begin() + static_cast<std::ptrdiff_t>(insertAt), std::move(next));
for (auto& track : doc.audioTracks) for (auto& cue : track.frameTriggers) if (cue >= insertAt) ++cue;
frame = insertAt;
markDirty(); playEvent(paste ? "frame_paste" : "frame_new", paste ? "pic_stamp" : "page_new");
}
void App::eraseFrame() {
if (!docLoaded || doc.frames.size() <= 1) { showToast("A Switchnote must keep at least one frame"); return; }
doc.frames.erase(doc.frames.begin() + static_cast<std::ptrdiff_t>(frame));
for (auto& track : doc.audioTracks) {
track.frameTriggers.erase(std::remove(track.frameTriggers.begin(), track.frameTriggers.end(), static_cast<std::uint16_t>(frame)), track.frameTriggers.end());
for (auto& cue : track.frameTriggers) if (cue > frame) --cue;
}
frame = std::min(frame, doc.frames.size() - 1);
markDirty();
playEvent("frame_erase", "draw_tool_btn_on");
}
void App::copyFrame() {
if (!docLoaded || frame >= doc.frames.size()) return;
frameClipboard = doc.frames[frame];
clipboardReady = true;
playEvent("frame_copy", "pic_stamp");
showToast("Frame copied");
}
SDL_Texture* App::thumbTexture(const Item& item) {
const std::string key = "thumb:" + item.path;
auto cached = images.find(key);
if (cached != images.end()) return cached->second;
sppm::Document source;
std::string error;
if (!sppm::loadSignedFile(item.path, source, signingIdentity.legacyHmacKey, error) || source.frames.empty()) return nullptr;
SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32);
if (!surface) return nullptr;
auto* out = static_cast<std::uint32_t*>(surface->pixels);
const auto& frameData = source.frames[std::min<std::size_t>(source.metadata.coverFrame, source.frames.size() - 1)];
for (std::size_t i = 0; i < sppm::kPixelsPerLayer; ++i) {
unsigned r = 0, g = 0, b = 0, a = 0;
for (std::size_t layerIndex = frameData.layers.size(); layerIndex-- > 0;) {
const auto& layerPixels = frameData.layers[layerIndex];
const auto index = layerPixels[i];
if (!index) continue;
const auto c = source.palette[index];
const unsigned na = c.a + a * (255 - c.a) / 255;
if (na) {
r = (c.r * c.a + r * a * (255 - c.a) / 255) / na;
g = (c.g * c.a + g * a * (255 - c.a) / 255) / na;
b = (c.b * c.a + b * a * (255 - c.a) / 255) / na;
}
a = na;
}
out[i] = SDL_MapRGBA(surface->format, r, g, b, a);
}
SDL_Texture* result = SDL_CreateTextureFromSurface(renderer, surface);
SDL_FreeSurface(surface);
if (result) {
SDL_SetTextureScaleMode(result, SDL_ScaleModeNearest);
SDL_SetTextureBlendMode(result, SDL_BLENDMODE_BLEND);
}
images[key] = result;
return result;
}
void App::renderSequence() {
background();
if (!docLoaded) { if (pill("Back", 40, 620, 100, 48)) screen = Screen::Library; return; }
const bool canUndo = !undoStack.empty();
const bool canRedo = !redoStack.empty();
drawImage(canUndo ? "ui/CanvasUndoButton_Active.png" : "ui/CanvasUndoButton_Inactive.png", 20, 18, 68, 54);
drawImage(canRedo ? "ui/CanvasRedoButton_Active.png" : "ui/CanvasRedoButton_Inactive.png", 96, 18, 68, 54);
if (canUndo && hit(20, 18, 68, 54)) { clickConsumed = true; undo(); }
if (canRedo && hit(96, 18, 68, 54)) { clickConsumed = true; redo(); }
rounded(321, 36, 638, 362, 14, WHITE);
rounded(325, 40, 630, 354, 10, SDL_Color{210, 214, 220, 255});
rounded(329, 44, 622, 346, 8, WHITE);
drawCurrentFrame(331, 46, 618, 342, true);
const std::array<std::string, 4> tools{{"New", "Erase", "Copy", "Paste"}};
const std::array<std::string, 4> icons{{"FrameNew.png", "FrameErase.png", "FrameCopy.png", "FramePaste.png"}};
for (int i = 0; i < 4; ++i) {
const int y = 22 + i * 66;
if (actionPill("ui/icons/" + icons[i], tools[i], 1090, y, 166, 45, false, i != 3 || clipboardReady, "")) {
if (i == 0) insertFrame(false);
else if (i == 1) eraseFrame();
else if (i == 2) copyFrame();
else insertFrame(true);
}
}
rounded(1110, 366, 146, 40, 20, SDL_Color{0, 0, 0, 38});
rounded(1110, 361, 146, 40, 20, WHITE);
centered((frame + 1 < 10 ? "000" : frame + 1 < 100 ? "00" : "0") + std::to_string(frame + 1) + " / " +
(doc.frames.size() < 10 ? "000" : doc.frames.size() < 100 ? "00" : "0") + std::to_string(doc.frames.size()),
1110, 361, 146, 40, 18, ORANGE, true);
fill(0, 420, W, 196, SDL_Color{16, 16, 18, 255});
for (int x = 8; x < W; x += 28) {
rounded(x, 436, 14, 14, 3, WHITE);
rounded(x, 591, 14, 14, 3, WHITE);
}
const int visibleFrames = std::min(6, static_cast<int>(doc.frames.size()));
const int gap = 14;
constexpr int cardW = 174;
constexpr int cardH = 112;
const int firstFrame = std::clamp(static_cast<int>(frame) - 2, 0,
std::max(0, static_cast<int>(doc.frames.size()) - visibleFrames));
const int stripWidth = visibleFrames * cardW + std::max(0, visibleFrames - 1) * gap;
const int firstX = (W - stripWidth) / 2;
const int thumbY = 464;
for (int visible = 0; visible < visibleFrames; ++visible) {
const int idx = firstFrame + visible;
const int x = firstX + visible * (cardW + gap);
const int y = thumbY;
if (static_cast<std::size_t>(idx) == frame) rounded(x - 5, y - 5, cardW + 10, cardH + 10, 14, ORANGE);
rounded(x, y, cardW, cardH, 10, WHITE);
drawFrameImage(static_cast<std::size_t>(idx), x + 4, y + 4, cardW - 8, cardH - 8);
if (hit(x, y, cardW, cardH)) { setFrame(static_cast<std::size_t>(idx)); clickConsumed = true; }
}
if (imageButton("ui/BackButton.png", 20, 635, 62, 62, "button_off")) screen = Screen::Canvas;
drawImage("ui/SeekBar.png", 371, 649, 540, 30);
const int seekX = 373 + (doc.frames.size() > 1 ? static_cast<int>(frame * 535 / (doc.frames.size() - 1)) : 0);
drawImage("ui/SeekBarHandle.png", seekX - 8, 644, 16, 40);
if (hit(370, 640, 540, 48)) {
const float ratio = std::clamp((pointerX - 373.0f) / 535.0f, 0.0f, 1.0f);
setFrame(static_cast<std::size_t>(ratio * (doc.frames.size() - 1)));
clickConsumed = true;
}
if (imagePill("ui/icons/SkipFirst.png", 913, 645, 55, 48, false, frame > 0, "")) setFrame(0, true, "frame_first");
if (imagePill("ui/icons/SkipPrev.png", 974, 645, 55, 48, false, frame > 0, "")) setFrame(frame > 5 ? frame - 5 : 0, true, "frame_previous");
playControl(1044, 635, 62, 62);
if (imagePill("ui/icons/SkipNext.png", 1116, 645, 55, 48, false, true, "")) {
if (frame + 1 < doc.frames.size()) setFrame(frame + 1);
else insertFrame(false);
}
if (imagePill("ui/icons/SkipLast.png", 1177, 645, 58, 48, false, frame + 1 < doc.frames.size(), ""))
setFrame(doc.frames.size() - 1, true, "frame_last");
}
bool switchnoteBrushPattern(int id, int x, int y);
bool switchnoteEraserContains(int slot, int dx, int dy);
void App::renderCanvas() {
if (!docLoaded) { screen = Screen::Library; return; }
if (pointerReleased && !clickConsumed && (paintMenu || colorMenu || toolMenu)) {
const bool insidePopup = pointerX >= 836 && pointerX < 1192 && pointerY >= 76 && pointerY < 362;
const bool onTool = pointerX >= 1200 && pointerX < 1260 && pointerY >= 12 && pointerY < 72;
const bool onBrushDot = pointerX >= 1200 && pointerX < 1260 && pointerY >= 80 && pointerY < 140;
const bool onColorDot = pointerX >= 1200 && pointerX < 1260 && pointerY >= 147 && pointerY < 207;
if (!insidePopup && !onTool && !onBrushDot && !onColorDot) paintMenu = colorMenu = toolMenu = false;
}
background();
const float scale = 2.0f * zoom;
const int cw = static_cast<int>(sppm::kWidth * scale);
const int ch = static_cast<int>(sppm::kHeight * scale);
const int cx = (W - cw) / 2 + panX;
const int cy = (H - ch) / 2 + panY;
rounded(cx - 4, cy - 4, cw + 8, ch + 8, 10, SDL_Color{205, 210, 218, 255});
fill(cx, cy, cw, ch, WHITE);
drawCurrentFrame(cx, cy, cw, ch, true);
if (canvasGrid) {
SDL_Rect canvasClip{cx, cy, cw, ch};
SDL_RenderSetClipRect(renderer, &canvasClip);
SDL_SetRenderDrawColor(renderer, 82, 99, 124, 42);
// One line per image pixel: at 2x or above this is the actual drawing
// pixel grid, not a coarse design grid.
for (int x = 0; x <= sppm::kWidth; ++x) {
const int px = cx + static_cast<int>(x * cw / static_cast<float>(sppm::kWidth));
SDL_RenderDrawLine(renderer, px, cy, px, cy + ch);
}
for (int y = 0; y <= sppm::kHeight; ++y) {
const int py = cy + static_cast<int>(y * ch / static_cast<float>(sppm::kHeight));
SDL_RenderDrawLine(renderer, cx, py, cx + cw, py);
}
SDL_RenderSetClipRect(renderer, nullptr);
}
if (fillWaveActive && fillWaveRadius > 0.0f) {
SDL_Rect rippleClip{cx, cy, cw, ch};
SDL_RenderSetClipRect(renderer, &rippleClip);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
const int front = static_cast<int>(std::floor(fillWaveRadius));
const int pixelW = std::max(1, static_cast<int>(std::ceil(cw / static_cast<float>(sppm::kWidth))));
const int pixelH = std::max(1, static_cast<int>(std::ceil(ch / static_cast<float>(sppm::kHeight))));
// Repeated narrow bands ride the fill front to read as an outward ripple,
// not a single uniform expanding disk.
for (const auto& entry : fillWavePixels) {
const int behindFront = front - static_cast<int>(entry.second);
if (behindFront < 0 || (behindFront > 2 && behindFront % 16 > 1)) continue;
SDL_SetRenderDrawColor(renderer, 255, 119, 43, behindFront <= 2 ? 235 : 155);
const int px = static_cast<int>(entry.first % sppm::kWidth);
const int py = static_cast<int>(entry.first / sppm::kWidth);
SDL_Rect dot{cx + static_cast<int>(px * cw / static_cast<float>(sppm::kWidth)),
cy + static_cast<int>(py * ch / static_cast<float>(sppm::kHeight)), pixelW, pixelH};
SDL_RenderFillRect(renderer, &dot);
}
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_RenderSetClipRect(renderer, nullptr);
}
const int chromeTravel = static_cast<int>(std::lround((1.0f - canvasChromeVisible) * kCanvasChromeTravel));
const int upOffset = -chromeTravel;
const int rightOffset = chromeTravel;
const int downOffset = chromeTravel;
const int leftOffset = -chromeTravel;
for (int i = 0; i < 4; ++i) {
const char c = static_cast<char>('A' + i);
const std::string path = std::string("ui/CanvasLayerButton") + c + ((layer == i) ? "_Active.png" : "_Inactive.png");
drawImage(path, 20 + leftOffset, 238 + i * 65, 72, 54);
if (hit(20, 238 + i * 65, 72, 54)) { layer = i; clickConsumed = true; playEvent("tool_layer", "draw_tool_btn_on"); }
}
const bool canUndo = !undoStack.empty();
const bool canRedo = !redoStack.empty();
drawImage(canUndo ? "ui/CanvasUndoButton_Active.png" : "ui/CanvasUndoButton_Inactive.png", 20, 18 + upOffset, 68, 54);
drawImage(canRedo ? "ui/CanvasRedoButton_Active.png" : "ui/CanvasRedoButton_Inactive.png", 96, 18 + upOffset, 68, 54);
if (canUndo && hit(20, 18, 68, 54)) { clickConsumed = true; undo(); }
if (canRedo && hit(96, 18, 68, 54)) { clickConsumed = true; redo(); }
const auto ink = doc.palette[static_cast<std::size_t>(std::clamp(color, 1, 255))];
auto drawToolIcon = [&](Tool tool, int x, int y, int size) {
if (tool == Tool::Eraser) {
drawImage("ui/icons/EraserTool_HQ.png", x + size / 8, y + size / 8,
size * 3 / 4, size * 3 / 4);
return;
}
const char* icon = tool == Tool::Pen ? "ui/icons/PenTool.png" : "ui/icons/PaintTool.png";
drawImage(icon, x, y, size, size);
};
const int toolsX = 1204 + rightOffset;
rounded(toolsX - 3, 14, 58, 58, 15, WHITE);
rounded(toolsX, 17, 52, 52, 12, activeTool == Tool::Pen ? CREAM : LIGHT);
drawToolIcon(activeTool, toolsX, 17, 52);
if (hit(1204, 17, 52, 52)) { toolMenu = !toolMenu; paintMenu = colorMenu = false; clickConsumed = true; playEvent("tool_pen", "draw_tool_btn_on"); }
rounded(toolsX, 84, 52, 52, 12, LIGHT);
const int dotSize = std::clamp(brush + 5, 8, 24);
const int displayedDotSize = activeTool == Tool::Brush ? (advancedBrush ? 24 : 12) :
activeTool == Tool::Eraser ? (eraserSlot == 0 ? 8 : eraserSlot < 4 ? 14 : 22) : dotSize;
rounded(toolsX + 26 - displayedDotSize / 2, 110 - displayedDotSize / 2, displayedDotSize, displayedDotSize, displayedDotSize / 2,
ink.a ? SDL_Color{ink.r, ink.g, ink.b, 255} : DRAW_BLACK);
if (hit(1204, 84, 52, 52)) { paintMenu = !paintMenu; colorMenu = toolMenu = false; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on"); }
rounded(toolsX, 151, 52, 52, 26, WHITE);
rounded(toolsX + 3, 154, 46, 46, 23, SDL_Color{210, 214, 220, 255});
rounded(toolsX + 7, 158, 38, 38, 19, ink.a ? SDL_Color{ink.r, ink.g, ink.b, 255} : DRAW_BLACK);
if (hit(1204, 151, 52, 52)) { colorMenu = !colorMenu; paintMenu = toolMenu = false; clickConsumed = true; playEvent("color_menu", "popup"); }
rounded(1114, 587 + downOffset, 142, 39, 20, SDL_Color{0, 0, 0, 32});
rounded(1114, 582 + downOffset, 142, 39, 20, WHITE);
centered((frame + 1 < 10 ? "000" : frame + 1 < 100 ? "00" : "0") + std::to_string(frame + 1) + " / " +
(doc.frames.size() < 10 ? "000" : doc.frames.size() < 100 ? "00" : "0") + std::to_string(doc.frames.size()),
1114, 582 + downOffset, 142, 39, 18, ORANGE, true);
if (imageButton("ui/BackButton.png", 20, 635 + downOffset, 64, 62, "button_off")) {
if (saveDocument(true)) {
expandMetadata = false;
screen = Screen::Details;
clickConsumed = true;
}
}
const char* menuIcon = canvasMenuOpen ? "ui/CanvasMenuButton_Close.png" : "ui/CanvasMenuButton.png";
if (imageButton(menuIcon, 92, 635 + downOffset, 64, 62, "button_off")) {
canvasMenuOpen = !canvasMenuOpen;
clickConsumed = true;
}
if (actionPill("ui/icons/CanvasMenu_ConfigToggle.png", "Options", 474, 640 + downOffset, 142, 48, false, true, "")) {
modal = Modal::Options;
clickConsumed = true;
}
if (actionPill("ui/icons/FrameErase.png", "Erase", 630, 640 + downOffset, 162, 48, false, true, "")) {
auto& currentLayer = doc.frames[frame].layers[static_cast<std::size_t>(layer)];
if (std::any_of(currentLayer.begin(), currentLayer.end(), [](std::uint8_t value) { return value != 0; })) {
pushUndo();
std::fill(currentLayer.begin(), currentLayer.end(), 0);
markDirty();
playEvent("frame_erase", "edit_page_remove");
}
clickConsumed = true;
}
if (imagePill("ui/icons/SkipFirst.png", 904, 645 + downOffset, 55, 48, false, frame > 0, "")) setFrame(0, true, "frame_first");
if (imagePill("ui/icons/SkipPrev.png", 965, 645 + downOffset, 55, 48, false, frame > 0, "")) setFrame(frame > 5 ? frame - 5 : 0, true, "frame_previous");
playControl(1036, 635 + downOffset, 62, 62);
if (imagePill("ui/icons/SkipNext.png", 1108, 645 + downOffset, 55, 48, false, true, "")) {
if (frame + 1 < doc.frames.size()) setFrame(frame + 1);
else insertFrame(false);
}
if (imagePill("ui/icons/SkipLast.png", 1169, 645 + downOffset, 58, 48, false, frame + 1 < doc.frames.size(), ""))
setFrame(doc.frames.size() - 1, true, "frame_last");
if (canvasMenuProgress > 0.0f) {
const int menuX = 154;
const int menuY = 635 + downOffset + static_cast<int>(std::lround((1.0f - canvasMenuProgress) * 95.0f));
drawImage("ui/icons/CanvasMenu_PopoutBg.png", menuX, menuY - 9, 310, 80);
auto menuIconButton = [&](const char* icon, int x) {
drawTexture(whiteImage(icon), x + 4, menuY + 5, 50, 50);
return hit(x, menuY + 1, 58, 48);
};
if (menuIconButton("ui/icons/CanvasMenu_MusicToggle.png", menuX + 47)) {
clickConsumed = true;
canvasMenuOpen = false;
stopPlayback();
audioTrack = 0;
audioEraseArmedTrack = -1;
scanImports();
requestAudioUploadServer();
modal = Modal::Audio;
modalTransitionStart = SDL_GetTicks();
} else if (menuIconButton("ui/icons/CanvasMenu_FrameToggle.png", menuX + 126)) {
canvasMenuOpen = false;
screen = Screen::Sequence;
clickConsumed = true;
} else if (menuIconButton("ui/icons/Camera.svg", menuX + 205)) {
canvasMenuOpen = false;
openPhotoCapture();
clickConsumed = true;
}
}
if (paintMenu) {
rounded(845, 88, 342, 270, 14, SDL_Color{0, 0, 0, 45});
rounded(842, 82, 342, 270, 14, WHITE);
rounded(846, 86, 334, 262, 11, LIGHT);
const auto inkPreview = ink.a ? SDL_Color{ink.r, ink.g, ink.b, 255} : DRAW_BLACK;
rounded(1144, 91, 26, 26, 13, inkPreview);
if (activeTool == Tool::Pen) {
text("Pen size", 858, 89, 17, INK, true);
const std::array<int, 6> brushSizes{{1, 2, 4, 8, 16, 32}};
int selectedBrush = 0;
for (int i = 1; i < static_cast<int>(brushSizes.size()); ++i)
if (std::abs(brushSizes[i] - brush) < std::abs(brushSizes[selectedBrush] - brush)) selectedBrush = i;
for (int i = 0; i < 6; ++i) {
const int bx = 866 + i * 51;
if (i == selectedBrush && !square) rounded(bx - 3, 109, 37, 37, 7, ORANGE);
if (i == selectedBrush && square) rounded(bx - 3, 158, 37, 37, 7, ORANGE);
const int markSize = std::clamp(brushSizes[i], 4, 29);
const int markOffset = (31 - markSize) / 2;
rounded(bx + markOffset, 112 + markOffset, markSize, markSize, markSize / 2, SDL_Color{28, 30, 35, 255});
rounded(bx + markOffset, 161 + markOffset, markSize, markSize, 2, SDL_Color{28, 30, 35, 255});
if (hit(bx, 112, 31, 31) || hit(bx, 161, 31, 31)) {
brush = brushSizes[i]; square = pointerY > 150; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on");
}
}
static const std::array<const char*, 16> penNames{{
"1 px", "Dir 2 px", "3x3 square", "Squiggle",
"Directional =", "Sparse air", "Jitter line", "Dense spray",
"5x5 square", "7x7 square", "9x9 square", "Dynamic width",
"Hollow diamond", "Large air", "Wide jitter", "Heavy spray"
}};
for (int i = 0; i < static_cast<int>(penNames.size()); ++i) {
const int x = 855 + (i % 4) * 80;
const int y = 203 + (i / 4) * 20;
rounded(x, y, 77, 18, 5, penType == i ? ORANGE : WHITE);
centered(penNames[static_cast<std::size_t>(i)], x + 1, y, 75, 18, 11, penType == i ? WHITE : INK);
if (hit(x, y, 77, 18)) {
penType = i;
squiggleAnchorX = squiggleAnchorY = ringPreviousX = ringPreviousY = -1;
clickConsumed = true;
playEvent("tool_pen", "draw_tool_btn_on");
}
}
if (pill("−", 1093, 291, 38, 32, false, 20)) brush = std::max(1, brush - 1);
if (pill("+", 1134, 291, 38, 32, false, 20)) brush = std::min(32, brush + 1);
rounded(864, 287, 32, 32, 16, inkPreview);
} else if (activeTool == Tool::Brush) {
text("Brush patterns", 858, 89, 17, INK, true);
for (int i = 0; i < 8; ++i) {
const int x = 859 + (i % 4) * 78;
const int y = 124 + (i / 4) * 48;
rounded(x - 2, y - 2, 70, 42, 6, pattern == i ? ORANGE : WHITE);
rounded(x, y, 66, 38, 4, SDL_Color{255, 255, 255, 255});
SDL_Rect clip{x, y, 66, 38};
SDL_RenderSetClipRect(renderer, &clip);
for (int py = 0; py < 38; ++py) {
for (int px = 0; px < 66; ++px) {
if (switchnoteBrushPattern(i, px / 2, py / 2)) fill(x + px, y + py, 1, 1, inkPreview);
}
}
SDL_RenderSetClipRect(renderer, nullptr);
if (hit(x - 2, y - 2, 70, 42)) {
pattern = i; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on");
}
}
rounded(859, 227, 183, 39, 11, advancedBrush ? CREAM : WHITE);
text(advancedBrush ? "Advanced brush ON" : "Advanced brush OFF", 873, 237, 16,
advancedBrush ? ORANGE : INK, true);
if (hit(859, 227, 183, 39)) {
advancedBrush = !advancedBrush; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on");
}
} else {
text("Erase tools", 858, 89, 17, INK, true);
const std::array<const char*, 8> eraseLabels{{"Pixel", "3×3", "Round M", "Round L", "9×9", "15×15", "Vertical", "Horizontal"}};
for (int i = 0; i < 8; ++i) {
const int x = 854 + (i % 4) * 81;
const int y = 118 + (i / 4) * 83;
rounded(x, y, 72, 73, 9, eraserSlot == i ? CREAM : WHITE);
const int centerX = x + 36, centerY = y + 28;
for (int dy = -7; dy <= 7; ++dy) for (int dx = -7; dx <= 7; ++dx)
if (switchnoteEraserContains(i, dx, dy)) fill(centerX + dx * 2 - 1, centerY + dy * 2 - 1, 2, 2, INK);
centered(eraseLabels[static_cast<std::size_t>(i)], x + 2, y + 51, 68, 17, 12, INK);
if (hit(x, y, 72, 73)) {
eraserSlot = i; clickConsumed = true; playEvent("tool_brush", "draw_tool_btn_on");
}
}
}
}
if (colorMenu) {
rounded(845, 88, 342, 162, 14, SDL_Color{0, 0, 0, 45});
rounded(842, 82, 342, 162, 14, WHITE);
rounded(846, 86, 334, 154, 11, LIGHT);
text("Ink color", 858, 89, 17, INK, true);
for (int i = 0; i < static_cast<int>(kInkPresetIndexes.size()); ++i) {
const int index = kInkPresetIndexes[static_cast<std::size_t>(i)];
const int x = 875 + (i % 5) * 57, y = 126 + (i / 5) * 54;
const auto c = doc.palette[static_cast<std::size_t>(index)];
if (index == color) rounded(x - 3, y - 3, 42, 42, 9, ORANGE);
rounded(x, y, 36, 36, 18, SDL_Color{c.r, c.g, c.b, 255});
if (hit(x - 3, y - 3, 42, 42)) {
color = index;
colorMenu = false;
clickConsumed = true;
playEvent("tool_color", "draw_tool_btn_on");
}
}
}
if (toolMenu) {
rounded(845, 88, 342, 154, 14, SDL_Color{0, 0, 0, 45});
rounded(842, 82, 342, 154, 14, WHITE);
rounded(846, 86, 334, 146, 11, LIGHT);
text("Tools", 858, 89, 17, INK, true);
const std::array<Tool, 3> tools{{Tool::Pen, Tool::Brush, Tool::Eraser}};
const std::array<const char*, 3> labels{{"Pen", "Brush", "Eraser"}};
for (int i = 0; i < 3; ++i) {
const int x = 856 + i * 106;
const int y = 121;
rounded(x - 2, y - 2, 96, 78, 12, activeTool == tools[i] ? CREAM : WHITE);
drawToolIcon(tools[i], x + 20, y + 1, 48);
centered(labels[i], x, y + 47, 92, 24, 15, activeTool == tools[i] ? ORANGE : INK);
if (hit(x - 2, y - 2, 96, 78)) {
activeTool = tools[i];
toolMenu = false;
clickConsumed = true;
playEvent("tool_pen", "draw_tool_btn_on");
}
}
}
}
void App::renderViewer() {
if (!docLoaded) { screen = Screen::Library; return; }
const float scale = std::min(W / static_cast<float>(sppm::kWidth), H / static_cast<float>(sppm::kHeight));
const int cw = static_cast<int>(sppm::kWidth * scale);
const int ch = static_cast<int>(sppm::kHeight * scale);
const int cx = (W - cw) / 2 + panX;
const int cy = (H - ch) / 2 + panY;
fill(0, 0, W, H, SDL_Color{255, 255, 255, 255});
drawCurrentFrame(cx, cy, cw, ch, false);
if (pill("Back", 20, 18, 104, 46)) {
stopPlayback();
screen = galleryViewing ? Screen::GalleryDetails : Screen::Details;
}
if (galleryViewing && pill("Comments", 1080, 18, 170, 46, true)) openGalleryComments();
if (imagePill("ui/icons/SkipFirst.png", 913, 645, 55, 48, false, frame > 0, "")) setFrame(0, true, "frame_first");
if (imagePill("ui/icons/SkipPrev.png", 974, 645, 55, 48, false, frame > 0, "")) setFrame(frame > 5 ? frame - 5 : 0, true, "frame_previous");
playControl(1044, 633, 70, 66);
if (imagePill("ui/icons/SkipNext.png", 1116, 645, 55, 48, false, frame + 1 < doc.frames.size(), "")) setFrame(frame + 1, true, "frame_next");
if (imagePill("ui/icons/SkipLast.png", 1177, 645, 58, 48, false, frame + 1 < doc.frames.size(), "")) setFrame(doc.frames.size() - 1, true, "frame_last");
rounded(1114, 584, 142, 40, 20, WHITE);
centered(std::to_string(frame + 1) + " / " + std::to_string(doc.frames.size()), 1114, 584, 142, 40, 18, ORANGE, true);
}
void App::renderComments() {
background();
if (gallery.empty()) { if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65)) screen = Screen::Gallery; return; }
const Item& item = gallery[gallerySelected % gallery.size()];
if (imageButton("ui/BackButton_Gallery.png", 39, 35, 65, 65)) screen = Screen::GalleryDetails;
text("Comments", 126, 39, 32, INK, true);
text(utf8Prefix(item.title, 44), 126, 79, 19, GRAY);
if (!galleryCommentsLoaded && galleryComments.empty()) centered("Connecting to Gallery…", 350, 245, 580, 45, 20, GRAY);
else if (galleryComments.empty()) centered("No comments yet.", 350, 245, 580, 45, 24, INK);
else {
constexpr int viewportTop = 130;
constexpr int viewportHeight = 490;
auto commentHasInk = [](const GalleryComment& comment) {
return comment.drawing.size() == sppm::kPixelsPerLayer &&
std::any_of(comment.drawing.begin(), comment.drawing.end(), [](std::uint8_t pixel) { return pixel != 0; });
};
std::vector<std::vector<std::string>> wrappedComments(galleryComments.size());
std::vector<int> heights;
heights.reserve(galleryComments.size());
int contentHeight = 0;
TTF_Font* commentFont = font(19, false);
for (std::size_t index = 0; index < galleryComments.size(); ++index) {
const auto& comment = galleryComments[index];
const bool hasDrawing = commentHasInk(comment);
if (!comment.text.empty() && commentFont) {
const std::string display = brandString(comment.text);
std::string line, word;
std::istringstream words(display);
while (words >> word) {
int wordWidth = 0, wordHeight = 0;
TTF_SizeUTF8(commentFont, word.c_str(), &wordWidth, &wordHeight);
if (wordWidth > 620) {
if (!line.empty()) { wrappedComments[index].push_back(line); line.clear(); }
std::string fragment;
for (std::size_t offset = 0; offset < word.size();) {
const unsigned char lead = static_cast<unsigned char>(word[offset]);
const std::size_t count = lead < 0x80 ? 1 : (lead & 0xE0) == 0xC0 ? 2 : (lead & 0xF0) == 0xE0 ? 3 : 4;
const std::string glyph = word.substr(offset, count);
const std::string candidate = fragment + glyph;
int width = 0, height = 0;
TTF_SizeUTF8(commentFont, candidate.c_str(), &width, &height);
if (!fragment.empty() && width > 620) { wrappedComments[index].push_back(fragment); fragment = glyph; }
else fragment = candidate;
offset += count;
}
line = fragment;
continue;
}
const std::string candidate = line.empty() ? word : line + " " + word;
int measured = 0, measuredHeight = 0;
TTF_SizeUTF8(commentFont, candidate.c_str(), &measured, &measuredHeight);
if (!line.empty() && measured > 620) {
wrappedComments[index].push_back(line);
line = word;
} else line = candidate;
}
if (!line.empty()) wrappedComments[index].push_back(line);
}
const int lineCount = static_cast<int>(wrappedComments[index].size());
const int textBottom = lineCount ? 43 + lineCount * 23 : 0;
const int drawingTop = std::max(88, textBottom + 6);
const int height = hasDrawing ? drawingTop + 332 + 10 : std::max(88, textBottom + 12);
heights.push_back(height);
contentHeight += height + 12;
}
const int maxScroll = std::max(0, contentHeight - viewportHeight);
galleryCommentScroll = std::clamp(galleryCommentScroll, 0, maxScroll);
SDL_Rect clip{197, viewportTop, 862, viewportHeight};
SDL_RenderSetClipRect(renderer, &clip);
int virtualY = viewportTop + 7 - galleryCommentScroll;
for (std::size_t index = 0; index < galleryComments.size(); ++index) {
const auto& comment = galleryComments[index];
const int cardHeight = heights[index];
const int y = virtualY;
if (y + cardHeight >= viewportTop && y <= viewportTop + viewportHeight) {
rounded(318, y + 5, 652, cardHeight, 13, SDL_Color{0, 0, 0, 35});
rounded(318, y, 652, cardHeight, 13, WHITE);
const bool hasDrawing = commentHasInk(comment);
const int avatarSize = hasDrawing ? 60 : 52;
rounded(208, y + 8, avatarSize, avatarSize, 10, LIGHT);
centered(comment.author.empty() ? "?" : utf8Prefix(comment.author, 1),
208, y + 8, avatarSize, avatarSize, hasDrawing ? 27 : 23, INK, true);
text(utf8Prefix(comment.author, 25), 284, y + 11, 21, INK, true);
text(comment.created, 784, y + 13, 17, GRAY);
for (std::size_t line = 0; line < wrappedComments[index].size(); ++line)
text(wrappedComments[index][line], 284, y + 43 + static_cast<int>(line) * 23, 19, INK);
if (hasDrawing) if (SDL_Texture* drawing = galleryCommentTexture(comment, index)) {
const int drawingY = y + std::max(88, (wrappedComments[index].empty() ? 0 : 43 + static_cast<int>(wrappedComments[index].size()) * 23) + 6);
// SPPM comment ink is 640x360. Keep its 16:9 drawing area intact.
drawTexture(drawing, 340, drawingY, 590, 332);
}
}
virtualY += cardHeight + 12;
}
SDL_RenderSetClipRect(renderer, nullptr);
if (maxScroll > 0) {
fill(1205, 146, 6, 450, SDL_Color{255, 190, 218, 255});
const int thumbHeight = std::max(54, viewportHeight * viewportHeight / contentHeight);
const int thumbY = 146 + ((viewportHeight - thumbHeight) * galleryCommentScroll / maxScroll);
fill(1205, thumbY, 6, thumbHeight, GREEN);
}
}
if (imageButton("ui/BackButton_Gallery.png", 39, 620, 65, 65)) screen = Screen::GalleryDetails;
if (pill("Post Comment Y", 1022, 633, 218, 47, true, 20)) screen = Screen::CommentCanvas;
}
void App::renderCommentCanvas() {
background();
if (!commentTexture || commentDirty) {
if (commentTexture) SDL_DestroyTexture(commentTexture);
SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, sppm::kWidth, sppm::kHeight, 32, SDL_PIXELFORMAT_RGBA32);
if (surface) {
auto* rgba = static_cast<std::uint32_t*>(surface->pixels);
for (std::size_t i = 0; i < sppm::kPixelsPerLayer; ++i) rgba[i] = commentPixels[i] ? SDL_MapRGBA(surface->format, 20, 22, 28, 255) : SDL_MapRGBA(surface->format, 0, 0, 0, 0);
commentTexture = SDL_CreateTextureFromSurface(renderer, surface);
if (commentTexture) SDL_SetTextureScaleMode(commentTexture, SDL_ScaleModeNearest);
SDL_FreeSurface(surface);
}
commentDirty = false;
}
fill(0, 0, W, H, WHITE);
if (commentTexture) drawTexture(commentTexture, 0, 0, W, H);
if (pill("Text", 20, 20, 115, 44, true, 18)) beginText(Field::CommentText, "");
if (!commentDraft.empty()) text(utf8Prefix(commentDraft, 72), 158, 27, 19, INK);
if (pill("Cancel", 22, 644, 134, 45, true, 20)) screen = Screen::GalleryComments;
if (pill("Clear X", 1003, 644, 117, 45, true, 20)) { commentPixels.assign(sppm::kPixelsPerLayer, 0); commentDirty = true; }
if (pill("Send Y", 1142, 644, 116, 45, true, 20)) postGalleryComment();
}
void App::beginText(Field field, const std::string& current) {
#ifdef __SWITCH__
SwkbdConfig keyboard;
if (R_SUCCEEDED(swkbdCreate(&keyboard, 0))) {
swkbdConfigMakePresetDefault(&keyboard);
swkbdConfigSetInitialText(&keyboard, current.c_str());
const char* header = field == Field::Title ? "Switchnote title" : field == Field::Description ? "Description" :
field == Field::FolderName ? "New folder" : field == Field::GalleryBio ? "Gallery bio" : "Comment";
swkbdConfigSetHeaderText(&keyboard, header);
char buffer[257]{};
if (R_SUCCEEDED(swkbdShow(&keyboard, buffer, sizeof(buffer)))) {
editingField = field;
inputText = buffer;
applyText();
}
swkbdClose(&keyboard);
}
#else
editingField = field;
inputText = current;
modal = Modal::TextInput;
SDL_StartTextInput();
#endif
}
void App::applyText() {
SDL_StopTextInput();
const std::string value = inputText.substr(0, editingField == Field::GalleryBio ? 200 : 240);
switch (editingField) {
case Field::Title:
if (docLoaded && !value.empty()) { doc.metadata.title = value; markDirty(); }
break;
case Field::Description:
if (docLoaded) { doc.metadata.description = value; markDirty(); }
break;
case Field::FolderName: {
std::string safe;
for (unsigned char ch : value) if (std::isalnum(ch) || ch == ' ' || ch == '_' || ch == '-') safe.push_back(static_cast<char>(ch));
if (!safe.empty()) {
ensureDir(join(notesRoot, safe));
folder = safe;
selected = 0;
scanLibrary();
showToast("Folder created");
}
break;
}
case Field::CommentText:
if (!value.empty()) { commentDraft = value; screen = Screen::CommentCanvas; }
break;
case Field::GalleryBio:
if (galleryProfileIsOwn) { galleryProfile.bio = value; saveGalleryProfile(); }
break;
case Field::None: break;
}
inputText.clear();
editingField = Field::None;
modal = Modal::None;
}
bool App::importAudioFile(const std::string& path, int targetTrack, const std::string& displayName) {
if (!docLoaded || targetTrack < 0 || targetTrack >= 4) return false;
SDL_AudioSpec source{};
Uint8* raw = nullptr;
Uint32 rawLength = 0;
if (!SDL_LoadWAV(path.c_str(), &source, &raw, &rawLength)) {
showToast("Could not read WAV: " + std::string(SDL_GetError()));
return false;
}
const int bytesPerSample = SDL_AUDIO_BITSIZE(source.format) / 8;
if (source.freq <= 0 || source.channels < 1 || source.channels > 2 || bytesPerSample <= 0 ||
rawLength == 0 || rawLength > static_cast<Uint32>(std::numeric_limits<int>::max())) {
SDL_FreeWAV(raw);
showToast("Use a valid mono or stereo WAV file");
return false;
}
const std::size_t bytesPerFrame = static_cast<std::size_t>(source.channels) * bytesPerSample;
const std::size_t availableFrames = rawLength / bytesPerFrame;
const int maximumSeconds = targetTrack == 0 ? 60 : 2;
const std::size_t maximumFrames = static_cast<std::size_t>(maximumSeconds) * static_cast<std::size_t>(source.freq);
const std::size_t framesToKeep = std::min(availableFrames, maximumFrames);
if (!framesToKeep) {
SDL_FreeWAV(raw);
showToast("This audio file is empty");
return false;
}
const bool trimmed = framesToKeep < availableFrames;
SDL_AudioCVT cvt{};
const int build = SDL_BuildAudioCVT(&cvt, source.format, source.channels, source.freq, AUDIO_S16SYS, 2, 48000);
if (build < 0) { SDL_FreeWAV(raw); showToast("WAV format is not supported"); return false; }
cvt.len = static_cast<int>(framesToKeep * bytesPerFrame);
cvt.buf = static_cast<Uint8*>(SDL_malloc(static_cast<std::size_t>(cvt.len) * cvt.len_mult));
if (!cvt.buf) { SDL_FreeWAV(raw); showToast("Not enough memory to import audio"); return false; }
std::memcpy(cvt.buf, raw, static_cast<std::size_t>(cvt.len));
SDL_FreeWAV(raw);
if (cvt.needed && SDL_ConvertAudio(&cvt) != 0) { SDL_free(cvt.buf); showToast("Could not convert WAV audio"); return false; }
if (!cvt.needed) cvt.len_cvt = cvt.len;
const std::size_t sampleCount = static_cast<std::size_t>(cvt.len_cvt) / sizeof(std::int16_t);
constexpr std::size_t maxAudioSamples = 32ULL * 1024 * 1024;
std::size_t otherSamples = 0;
for (std::size_t i = 0; i < doc.audioTracks.size(); ++i)
if (i != static_cast<std::size_t>(targetTrack)) otherSamples += doc.audioTracks[i].pcm.size();
if (!cvt.len_cvt || sampleCount > maxAudioSamples || otherSamples > maxAudioSamples - sampleCount) {
SDL_free(cvt.buf);
showToast("There is not enough Switchnote space for this audio");
return false;
}
while (doc.audioTracks.size() <= static_cast<std::size_t>(targetTrack)) {
sppm::AudioTrack blank;
blank.name = doc.audioTracks.empty() ? "BGM" : "SE" + std::to_string(doc.audioTracks.size());
blank.sampleRate = 48000; blank.channels = 2;
doc.audioTracks.push_back(std::move(blank));
}
auto& track = doc.audioTracks[static_cast<std::size_t>(targetTrack)];
track.name = targetTrack == 0 ? "BGM" : "SE" + std::to_string(targetTrack);
track.sampleRate = 48000; track.channels = 2;
track.referenceFps = 0;
track.referenceSpeed = targetTrack == 0 ? doc.metadata.speed : 0;
track.pcm.resize(sampleCount);
std::memcpy(track.pcm.data(), cvt.buf, sampleCount * sizeof(std::int16_t));
SDL_free(cvt.buf);
if (targetTrack > 0 && track.frameTriggers.empty()) track.frameTriggers = {static_cast<std::uint16_t>(frame)};
markDirty();
rebuildAudio();
if (!saveDocument(true)) return false;
showToast("Imported " + displayName + " to " + track.name + (trimmed ? " (trimmed)" : ""));
return true;
}
void App::importAudio() {
if (!docLoaded || importFiles.empty()) { showToast("Add WAV files to the sdmc:/switch/Switchnote/import folder"); return; }
const std::string filename = importFiles[importSelection % importFiles.size()];
importAudioFile(join(importRoot, filename), audioTrack, filename);
}
void App::exportDocument(int destination) {
if (!docLoaded) return;
std::string base = doc.metadata.title.empty() ? "Switchnote" : doc.metadata.title;
for (char& ch : base) if (!std::isalnum(static_cast<unsigned char>(ch)) && ch != '-' && ch != '_') ch = '_';
std::string error;
if (destination == 0) {
const std::string directory = "sdmc:/Switchnotes";
ensureDir(directory);
const std::string path = join(directory, base + ".mp4");
const bool ok = sppm::exportMp4(doc, path, error);
showToast(ok ? "Silent MP4 exported to " + path : "Export failed: " + error);
return;
}
#ifdef __SWITCH__
std::vector<std::uint8_t> image(static_cast<std::size_t>(1280) * 720 * 4, 255);
const auto& cover = doc.frames[doc.metadata.coverFrame < doc.frames.size() ? doc.metadata.coverFrame : 0];
for (std::size_t y = 0; y < 720; ++y) {
for (std::size_t x = 0; x < 1280; ++x) {
const std::size_t pixel = (y / 2) * sppm::kWidth + x / 2;
unsigned red = 255, green = 255, blue = 255;
for (std::size_t layerIndex = cover.layers.size(); layerIndex-- > 0;) {
const std::uint8_t index = cover.layers[layerIndex][pixel];
if (!index) continue;
const auto& color = doc.palette[index];
const unsigned alpha = color.a;
red = (color.r * alpha + red * (255 - alpha) + 127) / 255;
green = (color.g * alpha + green * (255 - alpha) + 127) / 255;
blue = (color.b * alpha + blue * (255 - alpha) + 127) / 255;
}
const std::size_t offset = (y * 1280 + x) * 4;
image[offset] = static_cast<std::uint8_t>(red);
image[offset + 1] = static_cast<std::uint8_t>(green);
image[offset + 2] = static_cast<std::uint8_t>(blue);
image[offset + 3] = 255;
}
}
Result initResult = capssuInitialize();
if (R_FAILED(initResult)) {
showToast("Could not open System Captures (" + std::to_string(initResult) + ")");
return;
}
CapsApplicationAlbumEntry entry{};
const Result saveResult = capssuSaveScreenShot(image.data(), image.size(),
AlbumReportOption_Enable, AlbumImageOrientation_Unknown0, &entry);
capssuExit();
showToast(R_SUCCEEDED(saveResult) ? "Cover saved to System Captures" :
"System Captures export failed (" + std::to_string(saveResult) + ")");
#else
const std::string path = join(exportRoot, base + ".png");
const bool ok = sppm::exportPngCover(doc, path, error);
showToast(ok ? "System Captures export is Switch-only; saved cover to " + path : "Export failed: " + error);
#endif
}
void App::openPhotoCapture() {
if (!docLoaded || frame >= doc.frames.size()) return;
stopPlayback(false);
#ifdef __SWITCH__
if (photoProbeRunning) return;
irPhotoTaken = false;
irRotatePhoto = false;
irImageReady = false;
irImageState = {};
photoProbeRunning = true;
photoProbeHasCamera = false;
photoProbeFinished.store(false, std::memory_order_relaxed);
beginLoading();
modal = Modal::PhotoProbe;
modalTransitionStart = SDL_GetTicks();
if (R_FAILED(threadCreate(&photoProbeThread, photoProbeThreadEntry, this, nullptr, 0x10000, 0x3B, -2))) {
photoProbeRunning = false;
endLoading();
modal = Modal::None;
openPhotoUpload();
return;
}
photoProbeThreadCreated = true;
if (R_FAILED(threadStart(&photoProbeThread))) {
threadClose(&photoProbeThread);
photoProbeThreadCreated = false;
photoProbeRunning = false;
endLoading();
modal = Modal::None;
openPhotoUpload();
}
#else
openPhotoUpload();
#endif
}
#ifdef __SWITCH__
void App::photoProbeThreadEntry(void* argument) {
auto* app = static_cast<App*>(argument);
app->runPhotoProbeThread();
threadExit();
}
void App::runPhotoProbeThread() {
irCameraRunning = false;
irsReady = irsInitialize() == 0;
if (irsReady) {
// IR probing can take several service round-trips on consoles with
// detached/reassigned Joy-Cons. Keep all of it off the render thread.
const bool handheld = (hidGetNpadStyleSet(HidNpadIdType_Handheld) & HidNpadStyleTag_NpadHandheld) != 0;
const std::array<HidNpadIdType, 9> cameraCandidates = handheld
? std::array<HidNpadIdType, 9>{HidNpadIdType_Handheld, HidNpadIdType_No1, HidNpadIdType_No2,
HidNpadIdType_No3, HidNpadIdType_No4, HidNpadIdType_No5, HidNpadIdType_No6, HidNpadIdType_No7, HidNpadIdType_No8}
: std::array<HidNpadIdType, 9>{HidNpadIdType_No1, HidNpadIdType_No2, HidNpadIdType_No3,
HidNpadIdType_No4, HidNpadIdType_No5, HidNpadIdType_No6, HidNpadIdType_No7, HidNpadIdType_No8, HidNpadIdType_Handheld};
IrsImageTransferProcessorConfig config{};
irsGetDefaultImageTransferProcessorConfig(&config);
config.format = IrsImageTransferProcessorFormat_320x240;
for (const HidNpadIdType id : cameraCandidates) {
IrsIrCameraHandle candidate{};
if (R_FAILED(irsGetIrCameraHandle(&candidate, id))) continue;
IrsIrCameraStatus status{};
if (R_FAILED(irsGetIrCameraStatus(candidate, &status)) || status != IrsIrCameraStatus_Available) continue;
if (R_FAILED(irsRunImageTransferProcessor(candidate, &config, 0x100000))) continue;
irCamera = candidate;
irCameraRunning = true;
break;
}
if (!irCameraRunning) for (const HidNpadIdType id : cameraCandidates) {
IrsIrCameraHandle candidate{};
if (R_FAILED(irsGetIrCameraHandle(&candidate, id))) continue;
if (R_FAILED(irsRunImageTransferProcessor(candidate, &config, 0x100000))) continue;
irCamera = candidate;
irCameraRunning = true;
break;
}
}
if (!irCameraRunning && irsReady) {
irsExit();
irsReady = false;
}
photoProbeHasCamera = irCameraRunning;
photoProbeFinished.store(true, std::memory_order_release);
}
#endif
void App::consumePhotoProbe() {
#ifdef __SWITCH__
if (!photoProbeRunning || !photoProbeFinished.exchange(false, std::memory_order_acquire)) return;
if (photoProbeThreadCreated) {
threadWaitForExit(&photoProbeThread);
threadClose(&photoProbeThread);
photoProbeThreadCreated = false;
}
photoProbeRunning = false;
endLoading();
if (photoProbeHasCamera) {
if (!irPreviewTexture && renderer)
irPreviewTexture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_STREAMING, 320, 240);
modal = Modal::PhotoChoice;
modalTransitionStart = SDL_GetTicks();
} else {
modal = Modal::None;
openPhotoUpload();
}
#endif
}
void App::openPhotoUpload() {
stopPlayback(false);
modal = Modal::PhotoUpload;
modalTransitionStart = SDL_GetTicks();
requestAudioUploadServer(true);
}
void App::openPhotoAlbum() {
#ifdef __SWITCH__
for (SDL_Texture*& texture : photoAlbumThumbs) {
if (texture) SDL_DestroyTexture(texture);
texture = nullptr;
}
photoAlbumEntries.clear();
photoAlbumPage = 0;
photoAlbumThumbIndexes.fill(std::numeric_limits<std::size_t>::max());
if (R_FAILED(capsaInitialize())) { showToast("Could not open the Switch photo album."); return; }
u64 count = 0;
Result result = capsaGetAlbumFileCount(CapsAlbumStorage_Sd, &count);
if (R_FAILED(result) || count == 0) result = capsaGetAlbumFileCount(CapsAlbumStorage_Nand, &count);
if (R_FAILED(result) || count == 0) {
capsaExit();
showToast(count == 0 ? "No photos were found in the album." : "Could not read the Switch photo album.");
return;
}
count = std::min<u64>(count, 2048);
std::vector<CapsAlbumEntry> entries(static_cast<std::size_t>(count));
u64 written = 0;
result = capsaGetAlbumFileList(CapsAlbumStorage_Sd, &written, entries.data(), entries.size());
if (R_FAILED(result) || written == 0) result = capsaGetAlbumFileList(CapsAlbumStorage_Nand, &written, entries.data(), entries.size());
capsaExit();
if (R_FAILED(result) || written == 0) { showToast("Could not read photos from the Switch album."); return; }
entries.resize(static_cast<std::size_t>(std::min<u64>(written, entries.size())));
for (const auto& entry : entries) if (entry.file_id.content == CapsContentType_Screenshot) photoAlbumEntries.push_back(entry);
std::sort(photoAlbumEntries.begin(), photoAlbumEntries.end(), [](const CapsAlbumEntry& a, const CapsAlbumEntry& b) {
const auto& x = a.file_id.datetime; const auto& y = b.file_id.datetime;
if (x.year != y.year) return x.year > y.year;
if (x.month != y.month) return x.month > y.month;
if (x.day != y.day) return x.day > y.day;
if (x.hour != y.hour) return x.hour > y.hour;
if (x.minute != y.minute) return x.minute > y.minute;
if (x.second != y.second) return x.second > y.second;
return x.id > y.id;
});
if (photoAlbumEntries.empty()) { showToast("No screenshots were found in the Switch photo album."); return; }
modal = Modal::PhotoAlbum;
modalTransitionStart = SDL_GetTicks();
#else
showToast("The Switch photo album is only available on Nintendo Switch.");
#endif
}
void App::importPhotoAlbumEntry(std::size_t index) {
#ifdef __SWITCH__
if (!docLoaded || frame >= doc.frames.size() || index >= photoAlbumEntries.size()) return;
if (R_FAILED(capsaInitialize())) { showToast("Could not open the Switch photo album."); return; }
const CapsAlbumEntry& entry = photoAlbumEntries[index];
if (entry.size == 0 || entry.size > 32U * 1024U * 1024U) {
capsaExit(); showToast("That album image is too large to import."); return;
}
std::vector<std::uint8_t> encoded(static_cast<std::size_t>(entry.size));
u64 loaded = 0;
const Result result = capsaLoadAlbumFile(&entry.file_id, &loaded, encoded.data(), encoded.size());
capsaExit();
if (R_FAILED(result) || loaded == 0 || loaded > encoded.size()) { showToast("Could not load that album image."); return; }
SDL_RWops* stream = SDL_RWFromConstMem(encoded.data(), static_cast<int>(loaded));
SDL_Surface* decoded = stream ? IMG_Load_RW(stream, 1) : nullptr;
if (!decoded || decoded->w <= 0 || decoded->h <= 0 || decoded->w > 8192 || decoded->h > 8192) {
if (decoded) SDL_FreeSurface(decoded);
showToast("That album image could not be decoded."); return;
}
SDL_Surface* rgba = SDL_ConvertSurfaceFormat(decoded, SDL_PIXELFORMAT_RGBA32, 0);
SDL_FreeSurface(decoded);
if (!rgba) { showToast("Could not prepare the album image."); return; }
photoImportRgba.assign(600U * sppm::kHeight * 4U, 0);
const float scale = std::min(600.0f / rgba->w, static_cast<float>(sppm::kHeight) / rgba->h);
const int drawW = std::max(1, static_cast<int>(std::lround(rgba->w * scale)));
const int drawH = std::max(1, static_cast<int>(std::lround(rgba->h * scale)));
const int left = (600 - drawW) / 2, top = (static_cast<int>(sppm::kHeight) - drawH) / 2;
if (SDL_MUSTLOCK(rgba)) SDL_LockSurface(rgba);
for (int y = 0; y < drawH; ++y) {
const int sy = std::min(rgba->h - 1, y * rgba->h / drawH);
const auto* row = static_cast<const std::uint8_t*>(rgba->pixels) + static_cast<std::size_t>(sy) * rgba->pitch;
for (int x = 0; x < drawW; ++x) {
const int sx = std::min(rgba->w - 1, x * rgba->w / drawW);
const auto* src = row + static_cast<std::size_t>(sx) * 4U;
auto* dst = photoImportRgba.data() + (static_cast<std::size_t>(top + y) * 600U + left + x) * 4U;
std::memcpy(dst, src, 4U);
}
}
if (SDL_MUSTLOCK(rgba)) SDL_UnlockSurface(rgba);
SDL_FreeSurface(rgba);
photoImportLayer = std::clamp(layer, 0, 3);
photoImportRow = 0;
pushUndo();
std::fill(doc.frames[frame].layers[static_cast<std::size_t>(photoImportLayer)].begin(),
doc.frames[frame].layers[static_cast<std::size_t>(photoImportLayer)].end(), 0);
photoImporting = true;
modal = Modal::None;
beginLoading();
#else
(void)index;
#endif
}
void App::closePhotoCapture() {
#ifdef __SWITCH__
if (irCameraRunning) {
irsStopImageProcessor(irCamera);
irCameraRunning = false;
}
if (irsReady) {
irsExit();
irsReady = false;
}
#endif
if (photoTransferActive) stopAudioUploadServer();
modal = Modal::None;
}
void App::updatePhotoCapture() {
#ifdef __SWITCH__
if (!irsReady || !irCameraRunning || irPhotoTaken) return;
const std::uint64_t previousSample = irImageState.sampling_number;
if (R_FAILED(irsGetImageTransferProcessorState(irCamera, irImage.data(), irImage.size(), &irImageState))) return;
if (irImageReady && irImageState.sampling_number == previousSample) return;
irImageReady = true;
for (std::size_t i = 0; i < irImage.size(); ++i) {
irPreviewRgba[i * 4] = irImage[i];
irPreviewRgba[i * 4 + 1] = irImage[i];
irPreviewRgba[i * 4 + 2] = irImage[i];
}
if (irPreviewTexture) SDL_UpdateTexture(irPreviewTexture, nullptr, irPreviewRgba.data(), 320 * 4);
#endif
}
void App::importPhoto() {
#ifdef __SWITCH__
if (!docLoaded || !irPhotoTaken || frame >= doc.frames.size()) return;
pushUndo();
auto& pixels = doc.frames[frame].layers[static_cast<std::size_t>(layer)];
std::fill(pixels.begin(), pixels.end(), 0);
// Keep the IR image's 4:3 proportions, centered on the 16:9 Switchnote
// canvas. Its native 320x240 pixels are doubled without interpolation.
const int targetW = irRotatePhoto ? 270 : 480;
constexpr int targetH = 360;
const int left = (sppm::kWidth - targetW) / 2;
const auto palette = doc.palette;
std::array<std::uint8_t, 256> grayToPalette{};
for (int shade = 0; shade < 256; ++shade) {
int bestError = std::numeric_limits<int>::max();
std::uint8_t bestIndex = 1;
for (int index = 16; index < 256; ++index) {
const auto& c = palette[static_cast<std::size_t>(index)];
const int luminance = (c.r * 299 + c.g * 587 + c.b * 114) / 1000;
const int error = std::abs(luminance - shade);
if (error < bestError) { bestError = error; bestIndex = static_cast<std::uint8_t>(index); }
}
grayToPalette[static_cast<std::size_t>(shade)] = bestIndex;
}
for (int y = 0; y < targetH; ++y) {
for (int x = 0; x < targetW; ++x) {
const int rotatedX = irRotatePhoto ? x * 240 / targetW : 0;
const int rotatedY = irRotatePhoto ? y * 320 / targetH : 0;
const int sourceX = irRotatePhoto ? rotatedY : x / 1.5;
const int sourceY = irRotatePhoto ? 239 - rotatedX : y / 1.5;
const auto gray = irPhoto[static_cast<std::size_t>(sourceY) * 320 + sourceX];
pixels[static_cast<std::size_t>(y) * sppm::kWidth + left + x] = grayToPalette[gray];
}
}
markDirty();
invalidateFrames();
showToast("Photo imported to the selected layer");
#endif
closePhotoCapture();
}
bool App::importUploadedPhoto() {
if (photoImporting) return false;
if (!docLoaded || frame >= doc.frames.size()) return false;
const std::string imagePath = join(importRoot, "photo-transfer.rgba");
const std::string optionsPath = join(importRoot, "photo-transfer.cfg");
std::ifstream image(imagePath, std::ios::binary | std::ios::ate);
constexpr std::size_t photoBytes = 600U * sppm::kHeight * 4U;
if (!image || image.tellg() != static_cast<std::streamoff>(photoBytes)) return false;
photoImportRgba.resize(photoBytes);
image.seekg(0, std::ios::beg);
image.read(reinterpret_cast<char*>(photoImportRgba.data()), static_cast<std::streamsize>(photoImportRgba.size()));
if (!image) return false;
std::ifstream options(optionsPath, std::ios::binary);
int targetLayer = -1;
std::string mode, dither, size, offsetX, offsetY;
std::string line;
if (!std::getline(options, line)) return false;
targetLayer = std::atoi(line.c_str());
if (!std::getline(options, mode) || !std::getline(options, dither) ||
!std::getline(options, size) || !std::getline(options, offsetX) || !std::getline(options, offsetY) ||
targetLayer < 0 || targetLayer >= 4) return false;
(void)mode; (void)dither; (void)size; (void)offsetX; (void)offsetY;
photoImportLayer = targetLayer;
photoImportRow = 0;
layer = targetLayer;
pushUndo();
std::fill(doc.frames[frame].layers[static_cast<std::size_t>(targetLayer)].begin(),
doc.frames[frame].layers[static_cast<std::size_t>(targetLayer)].end(), 0);
photoImporting = true;
beginLoading();
return true;
}
void App::advancePhotoImport() {
if (!photoImporting) return;
constexpr int rowsPerUpdate = 4;
auto& pixels = doc.frames[frame].layers[static_cast<std::size_t>(photoImportLayer)];
const int endRow = std::min<int>(sppm::kHeight, photoImportRow + rowsPerUpdate);
for (int y = photoImportRow; y < endRow; ++y) {
for (int x = 0; x < sppm::kWidth; ++x) {
// The browser preview is 600x360 while the Switchnote canvas is
// 640x360. Center that one preview exactly and never walk through
// the next row or past the uploaded buffer.
const int sourceX = x - 20;
if (sourceX < 0 || sourceX >= 600) continue;
const std::size_t source = (static_cast<std::size_t>(y) * 600U + static_cast<std::size_t>(sourceX)) * 4U;
if (photoImportRgba[source + 3] < 24) continue;
int bestError = std::numeric_limits<int>::max();
std::uint8_t bestIndex = 1;
for (int index : kInkPresetIndexes) {
const auto& candidate = doc.palette[static_cast<std::size_t>(index)];
const int dr = static_cast<int>(candidate.r) - photoImportRgba[source];
const int dg = static_cast<int>(candidate.g) - photoImportRgba[source + 1];
const int db = static_cast<int>(candidate.b) - photoImportRgba[source + 2];
const int error = dr * dr * 3 + dg * dg * 6 + db * db * 2;
if (error < bestError) { bestError = error; bestIndex = static_cast<std::uint8_t>(index); }
}
pixels[static_cast<std::size_t>(y) * sppm::kWidth + static_cast<std::size_t>(x)] = bestIndex;
}
}
photoImportRow = endRow;
if (photoImportRow < sppm::kHeight) return;
markDirty();
invalidateFrames();
const bool saved = saveDocument();
std::remove(join(importRoot, "photo-transfer.rgba").c_str());
std::remove(join(importRoot, "photo-transfer.cfg").c_str());
audioUploadServer.finishCompletedUpload(saved);
audioUploadStatus = saved ? "Photo added to your Switchnote." : "Photo import could not be saved.";
if (saved) showToast("Photo imported to layer " + std::string(1, static_cast<char>('A' + photoImportLayer)));
photoImportRgba.clear();
photoImporting = false;
endLoading();
}
void App::renderModal() {
renderingModal = true;
fill(0, 0, W, H, SDL_Color{10, 15, 24, 126});
const float progress = std::clamp((SDL_GetTicks() - modalTransitionStart) / 150.0f, 0.0f, 1.0f);
const float eased = modalClosing ? progress * progress * progress
: 1.0f - (1.0f - progress) * (1.0f - progress) * (1.0f - progress);
const bool galleryTerms = modal == Modal::GalleryTerms;
const bool updateScreen = modal == Modal::UpdateAvailable;
const int x = (galleryTerms || updateScreen) ? 42 : 330;
const int w = (galleryTerms || updateScreen) ? 1196 : 620;
const int h = (galleryTerms || updateScreen) ? 630 : 460;
const int targetY = (galleryTerms || updateScreen) ? 45 : 130;
const int y = modalClosing ? targetY - static_cast<int>(eased * (targetY + h))
: -h + static_cast<int>(eased * (targetY + h));
rounded(x, y + 6, w, h, 22, SDL_Color{0, 0, 0, 55});
rounded(x, y, w, h, 22, WHITE);
auto title = [&](const std::string& value) { centered(value, x + 24, y + 20, w - 48, 42, 28, INK, true); };
auto dismiss = [&]() {
if (pill("Close", x + w - 135, y + h - 64, 108, 42, false, 18)) {
if (modal == Modal::Photo || modal == Modal::PhotoChoice || modal == Modal::PhotoUpload) closePhotoCapture();
else if (modal == Modal::Audio) closeAudioMenu();
else if (modal == Modal::Branding) closeBrandingMenu();
else modal = Modal::None;
}
};
switch (modal) {
case Modal::GalleryTerms: {
centered("Switchnote Gallery: Alpha Server!", x + 56, y + 64, w - 112, 62, 39, INK, true);
text("By using services provided by RexiMemo, you agree to the Terms of Use", x + 128, y + 170, 23, INK);
text("and acknowledge the Privacy Policy.", x + 128, y + 204, 23, INK);
text("You must be 13 years of age or older to use RexiMemo, including", x + 128, y + 258, 23, INK);
text("Switchnote Gallery.", x + 128, y + 292, 23, INK);
text("Switchnote/RexiMemo does not sell personal information or use it for", x + 128, y + 346, 23, INK);
text("targeted advertising.", x + 128, y + 380, 23, INK);
text("Terms:", x + 128, y + 436, 22, INK);
text("https://reximemo.net/terms", x + 690, y + 436, 22, INK);
text("Privacy:", x + 128, y + 474, 22, INK);
text("https://reximemo.net/privacy", x + 690, y + 474, 22, INK);
if (actionPill("ui/icons/ButtonBText.png", "Back", x + 415, y + 548, 170, 54, false, true, "cursor_cancel")) {
modal = Modal::None;
screen = Screen::Library;
}
if (actionPill("ui/icons/ButtonAGalleryText.png", "I agree", x + 610, y + 548, 190, 54, true, true, "cursor_select")) {
galleryTermsAccepted = true;
std::ofstream accepted(join(dataRoot, "gallery-terms-accepted"), std::ios::binary | std::ios::trunc);
if (accepted) accepted << "1\n";
modal = Modal::None;
}
break;
}
case Modal::Credits:
title("Credits");
centered("Homebrew application by: RexiMemo!", x + 36, y + 120, w - 72, 36, 20, INK);
centered("Testing during development: meulin, LatteWiiU", x + 36, y + 180, w - 72, 36, 20, INK);
centered("UI Design originally by: Jaames", x + 36, y + 240, w - 72, 36, 20, INK);
dismiss();
break;
case Modal::GalleryPostChoice:
title("Post to Gallery");
centered("Choose whether other users can edit this Switchnote.", x + 34, y + 116, w - 68, 54, 18, INK);
centered("A locked Switchnote stays read-only when downloaded.", x + 34, y + 166, w - 68, 42, 16, GRAY);
if (actionPill("ui/icons/ButtonXText.png", "Post with Lock", x + 58, y + 258, 240, 56, false, true, "cursor_select")) {
modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, true);
}
if (actionPill("ui/icons/ButtonYText.png", "Post without Lock", x + 322, y + 258, 240, 56, false, true, "cursor_select")) {
modal = Modal::None; performGalleryUpload(pendingGalleryPublishIndex, false);
}
if (actionPill("ui/icons/ButtonBText.png", "Cancel", x + 226, y + 350, 168, 52, false, true, "cursor_cancel")) modal = Modal::None;
break;
case Modal::GalleryNotice: {
title("Leaving " + appDisplayName);
centered("You are now leaving " + appDisplayName + ".", x + 42, y + 112, w - 84, 38, 20, INK);
centered("The following services are provided by", x + 42, y + 165, w - 84, 34, 19, GRAY);
centered("RexiMemo.net.", x + 42, y + 201, w - 84, 34, 20, INK, true);
if (actionPill("ui/icons/ButtonBText.png", "Back", x + 95, y + h - 82, 170, 54, false, true, "cursor_cancel")) {
modal = Modal::None;
}
if (actionPill("ui/icons/ButtonAText.png", "OK", x + 355, y + h - 82, 170, 54, false, true, "cursor_select")) {
confirmGalleryLeave();
}
break;
}
case Modal::GalleryOffline: {
title("No Internet Connection");
centered("You don't have an active internet connection.", x + 38, y + 118, w - 76, 34, 18, INK);
const std::string before = "Please ";
const std::string emphasized = "safely";
const std::string after = " connect your Nintendo Switch";
TTF_Font* bodyFont = font(18, false);
int beforeWidth = 0, emphasizedWidth = 0, afterWidth = 0, ignoredHeight = 0;
if (bodyFont) {
TTF_SizeUTF8(bodyFont, before.c_str(), &beforeWidth, &ignoredHeight);
TTF_SizeUTF8(bodyFont, emphasized.c_str(), &emphasizedWidth, &ignoredHeight);
TTF_SizeUTF8(bodyFont, after.c_str(), &afterWidth, &ignoredHeight);
}
const int lineWidth = beforeWidth + emphasizedWidth + afterWidth;
const int lineX = x + (w - lineWidth) / 2;
rawText(before, lineX, y + 177, 18, INK, false);
rawText(emphasized, lineX + beforeWidth, y + 177, 18, INK, false);
rawText(emphasized, lineX + beforeWidth + 1, y + 177, 18, INK, false);
rawText(after, lineX + beforeWidth + emphasizedWidth, y + 177, 18, INK, false);
centered("to the internet, then try again.", x + 38, y + 207, w - 76, 34, 18, INK);
if (actionPill("ui/icons/ButtonBText.png", "Back", x + 95, y + h - 82, 170, 54, false, true, "cursor_cancel")) {
modal = Modal::None;
}
if (actionPill("ui/icons/ButtonAText.png", "OK", x + 355, y + h - 82, 170, 54, false, true, "cursor_select")) {
modal = Modal::None;
}
break;
}
case Modal::Folder: {
title("Folder Options");
std::vector<std::string> folders;
if (DIR* dir = opendir(notesRoot.c_str())) {
while (dirent* ent = readdir(dir)) {
const std::string name = ent->d_name;
if (name.empty() || name[0] == '.') continue;
const std::string full = join(notesRoot, name);
struct stat st{};
if (stat(full.c_str(), &st) == 0 && (st.st_mode & S_IFDIR)) folders.push_back(name);
}
closedir(dir);
}
std::sort(folders.begin(), folders.end());
for (std::size_t i = 0; i < folders.size() && i < 6; ++i) {
const int by = y + 92 + static_cast<int>(i) * 50;
if (pill(folders[i], x + 52, by, w - 104, 42,
folders[i] == folder || static_cast<int>(i) == modalSelection, 19, true, "folder_open")) {
folder = folders[i]; selected = 0; scanLibrary(); modal = Modal::None;
}
}
if (pill("New Folder +", x + 70, y + h - 67, 220, 42, false, 19)) { modal = Modal::None; beginText(Field::FolderName, ""); }
dismiss();
break;
}
case Modal::Audio: {
title("Sound");
rounded(x + 40, y + 75, w - 80, 75, 12, LIGHT);
if (serverStartRunning && serverStartTask == ServerStartTask::Audio) {
centered("Starting the local sound server…", x + 52, y + 84, w - 104, 54, 18, GRAY);
} else if (audioUploadServer.isRunning()) {
text(audioUploadUrl, x + 53, y + 82, 18, INK);
if (audioUploadServer.isConnected()) text("Connected", x + 53, y + 111, 22, GREEN, true);
else text("Code " + audioUploadCode, x + 53, y + 111, 22, ORANGE, true);
} else {
centered(audioUploadStatus, x + 52, y + 83, w - 104, 56, 17, GRAY);
}
for (int i = 0; i < 4; ++i) {
const int bx = x + 46 + i * 132;
if (pill(i == 0 ? "BGM" : "SE" + std::to_string(i), bx, y + 164, 122, 42, i == audioTrack, 17)) {
audioTrack = i;
audioEraseArmedTrack = -1;
if (audioReady) Mix_HaltChannel(4);
}
}
const bool trackPresent = audioTrack < static_cast<int>(doc.audioTracks.size()) &&
!doc.audioTracks[static_cast<std::size_t>(audioTrack)].pcm.empty();
const sppm::AudioTrack* selectedTrack = trackPresent ? &doc.audioTracks[static_cast<std::size_t>(audioTrack)] : nullptr;
const double seconds = selectedTrack && selectedTrack->channels && selectedTrack->sampleRate
? selectedTrack->pcm.size() / static_cast<double>(selectedTrack->channels * selectedTrack->sampleRate) : 0.0;
const std::string durationText = std::to_string(static_cast<int>(std::lround(seconds * 10)) / 10) + "." +
std::to_string(static_cast<int>(std::lround(seconds * 10)) % 10) + " s";
text(trackPresent ? "Audio: " + durationText : "No audio in this track", x + 53, y + 221, 18,
trackPresent ? INK : GRAY);
if (audioTrack == 0) {
text("Music timing", x + 53, y + 255, 17, GRAY);
if (pill("Attached to speed", x + 48, y + 282, 241, 43, doc.metadata.bgmFollowsSpeed, 16)) {
if (!doc.metadata.bgmFollowsSpeed) { doc.metadata.bgmFollowsSpeed = true; markDirty(); rebuildAudio(); }
}
if (pill("Loose timing", x + 312, y + 282, 241, 43, !doc.metadata.bgmFollowsSpeed, 16)) {
if (doc.metadata.bgmFollowsSpeed) { doc.metadata.bgmFollowsSpeed = false; markDirty(); rebuildAudio(); }
}
} else {
bool cueHere = false;
if (audioTrack < static_cast<int>(doc.audioTracks.size())) {
const auto& cues = doc.audioTracks[static_cast<std::size_t>(audioTrack)].frameTriggers;
cueHere = std::find(cues.begin(), cues.end(), static_cast<std::uint16_t>(frame)) != cues.end();
}
text("Frame " + std::to_string(frame + 1) + " sound effect", x + 53, y + 255, 17, GRAY);
if (pill(cueHere ? "Cue on this frame: On" : "Cue on this frame: Off", x + 48, y + 282, 260, 43, cueHere, 16)) {
while (doc.audioTracks.size() <= static_cast<std::size_t>(audioTrack)) {
sppm::AudioTrack blank;
blank.name = doc.audioTracks.empty() ? "BGM" : "SE" + std::to_string(doc.audioTracks.size());
blank.sampleRate = 48000; blank.channels = 2;
doc.audioTracks.push_back(std::move(blank));
}
auto& cues = doc.audioTracks[static_cast<std::size_t>(audioTrack)].frameTriggers;
const auto cue = static_cast<std::uint16_t>(frame);
auto it = std::lower_bound(cues.begin(), cues.end(), cue);
if (it != cues.end() && *it == cue) cues.erase(it); else cues.insert(it, cue);
markDirty();
}
text("SE stays at its recorded speed.", x + 329, y + 293, 15, GRAY);
}
if (actionPill("ui/icons/Play.png", audioReady && Mix_Playing(4) ? "Stop" : "Preview",
x + 48, y + 342, 180, 42, false,
trackPresent && audioReady && trackChunks[static_cast<std::size_t>(audioTrack)], "")) {
if (Mix_Playing(4)) Mix_HaltChannel(4);
else if (trackChunks[static_cast<std::size_t>(audioTrack)])
Mix_PlayChannel(4, trackChunks[static_cast<std::size_t>(audioTrack)], 0);
}
if (!importFiles.empty()) {
const std::string& filename = importFiles[importSelection % importFiles.size()];
centered(filename, x + 245, y + 342, 225, 42, 14, INK);
if (pill("<", x + 475, y + 342, 38, 42, false, 17))
importSelection = importSelection ? importSelection - 1 : importFiles.size() - 1;
if (pill(">", x + 518, y + 342, 38, 42, false, 17))
importSelection = (importSelection + 1) % importFiles.size();
}
if (audioEraseArmedTrack == audioTrack) {
if (dangerPill("Confirm erase", x + 48, y + 405, 145, 42, 15, trackPresent)) {
if (audioReady) Mix_HaltChannel(4);
auto& removed = doc.audioTracks[static_cast<std::size_t>(audioTrack)];
removed.pcm.clear();
removed.frameTriggers.clear();
removed.referenceFps = 0;
removed.referenceSpeed = 0;
markDirty();
rebuildAudio();
saveDocument(true);
audioEraseArmedTrack = -1;
}
} else if (pill("Erase track", x + 48, y + 405, 145, 42, false, 16, trackPresent)) {
audioEraseArmedTrack = audioTrack;
}
if (pill("Import SD WAV", x + 208, y + 405, 195, 42, false, 16, !importFiles.empty())) {
audioEraseArmedTrack = -1;
importAudio();
}
if (pill("Close", x + 478, y + 405, 108, 42, false, 18,
!(serverStartRunning && serverStartTask == ServerStartTask::Audio))) closeAudioMenu();
break;
}
case Modal::Branding: {
title("Branding Config Portal");
rounded(x + 40, y + 92, w - 80, 105, 12, LIGHT);
if (serverStartRunning && serverStartTask == ServerStartTask::Branding) {
centered("Starting the branding portal…", x + 56, y + 113, w - 112, 56, 18, GRAY);
} else if (brandingConfigServer.isRunning()) {
text(brandingUrl, x + 58, y + 108, 21, INK);
if (brandingConfigServer.isConnected()) text("Connected", x + 58, y + 150, 22, GREEN, true);
else text("Code " + brandingCode, x + 58, y + 150, 22, ORANGE, true);
} else centered(brandingStatus, x + 56, y + 111, w - 112, 68, 18, GRAY);
centered("Open the address on your PC, then enter the code", x + 56, y + 225, w - 112, 32, 18, INK);
centered("to change the app name, logo, and Switchnote names.", x + 56, y + 256, w - 112, 32, 18, INK);
centered("Restart the app before branding changes appear.", x + 56, y + 327, w - 112, 42, 16, GRAY);
if (pill("Close", x + w - 135, y + h - 64, 108, 42, false, 18,
!(serverStartRunning && serverStartTask == ServerStartTask::Branding))) closeBrandingMenu();
break;
}
case Modal::Export: {
title("Export Switchnote");
text("Choose where to export this Switchnote.", x + 60, y + 100, 20, INK);
if (pill("Export to SD:\\Switchnotes · MP4", x + 74, y + 151, 465, 58, modalSelection == 0, 20)) {
modal = Modal::None; exportDocument(0);
}
if (pill("Export to System Captures", x + 74, y + 226, 465, 58, modalSelection == 1, 20)) {
modal = Modal::None; exportDocument(1);
}
text("MP4 export includes animation only; audio is not included.", x + 60, y + 306, 17, GRAY);
dismiss();
break;
}
case Modal::ConfirmDelete:
title("Delete this Switchnote?");
centered("This action cannot be undone.", x + 25, y + 126, w - 50, 38, 20, GRAY);
if (actionPill("ui/icons/ButtonBText.png", "Cancel", x + 108, y + 234, 160, 52, false, true, "cursor_cancel")) modal = Modal::None;
if (dangerActionPill("ui/icons/ButtonZRText.png", "Delete", x + 342, y + 234, 160, 52, 19)) deleteDocument();
break;
case Modal::ConfirmGalleryDelete:
title("Remove from Switchnote Gallery?");
centered("Only the original poster can remove this Switchnote.", x + 25, y + 126, w - 50, 38, 18, GRAY);
if (pill("Cancel", x + 108, y + 234, 160, 52, modalSelection == 0, 20)) modal = Modal::None;
if (dangerPill("Remove", x + 342, y + 234, 160, 52, 20)) deleteGalleryItem();
break;
case Modal::Options: {
title("Editor Options");
auto setSpeed = [&](int speed) {
defaultSpeed = speed;
defaultFps = kSpeedFps[static_cast<std::size_t>(speed - 1)];
if (docLoaded) {
doc.metadata.fps = static_cast<std::uint8_t>(defaultFps);
doc.metadata.speed = static_cast<std::uint8_t>(speed);
markDirty();
if (doc.metadata.bgmFollowsSpeed) rebuildAudio();
}
savePreferences();
};
text("Animation speed", x + 60, y + 88, 18, GRAY);
const int currentSpeed = docLoaded ? documentSpeed(doc.metadata) : defaultSpeed;
for (int speed = 1; speed <= 8; ++speed) {
const int bx = x + 58 + (speed - 1) * 64;
if (pill(std::to_string(speed), bx, y + 118, 54, 46, currentSpeed == speed, 20)) setSpeed(speed);
}
if (pill(std::string("Onion skin: ") + (onion ? "On" : "Off"), x + 58, y + 202, 220, 50, onion, 18)) {
onion = !onion;
savePreferences();
}
if (pill(std::string("Canvas grid: ") + (canvasGrid ? "On" : "Off"), x + 312, y + 202, 220, 50, canvasGrid, 18)) {
canvasGrid = !canvasGrid;
savePreferences();
}
if (pill(std::string("Copy to new page: ") + (copyToNewPage ? "On" : "Off"),
x + 58, y + 274, 260, 48, copyToNewPage, 17)) {
copyToNewPage = !copyToNewPage;
savePreferences();
}
if (pill(std::string("Loop: ") + (docLoaded && doc.metadata.loopPlayback ? "On" : "Off"),
x + 312, y + 274, 220, 48, docLoaded && doc.metadata.loopPlayback, 18, docLoaded)) {
doc.metadata.loopPlayback = !doc.metadata.loopPlayback;
markDirty();
}
if (pill(std::string("Allow spin-offs: ") + (docLoaded && doc.metadata.allowSpinoffs ? "On" : "Off"),
x + 58, y + 338, 260, 48, docLoaded && doc.metadata.allowSpinoffs, 17, docLoaded)) {
doc.metadata.allowSpinoffs = !doc.metadata.allowSpinoffs;
markDirty();
}
dismiss();
break;
}
case Modal::PhotoProbe: {
title("Add a Photo");
centered("Checking for an IR camera…", x + 52, y + 155, w - 104, 68, 20, GRAY);
break;
}
case Modal::PhotoChoice: {
title("Add a Photo");
centered("Choose how to add an image to this Switchnote.", x + 55, y + 88, w - 110, 36, 18, GRAY);
if (pill("Import Image from Album", x + 80, y + 135, 460, 58, false, 20)) openPhotoAlbum();
if (pill("Upload from Another Device", x + 80, y + 207, 460, 58, false, 20)) {
openPhotoUpload();
photoModalTransitionDim = true;
photoModalTransitionDimAt = SDL_GetTicks();
}
#ifdef __SWITCH__
if (irCameraRunning && pill("Take Photo with IR Camera", x + 80, y + 279, 460, 58, false, 20)) {
modal = Modal::Photo;
modalTransitionStart = SDL_GetTicks();
}
#endif
if (pill("Cancel", x + 440, y + 377, 120, 48, false, 16)) closePhotoCapture();
break;
}
case Modal::PhotoAlbum: {
title("Import Image from Album");
#ifdef __SWITCH__
const std::size_t pageStart = static_cast<std::size_t>(photoAlbumPage) * photoAlbumThumbs.size();
if (R_SUCCEEDED(capsaInitialize())) {
for (std::size_t slot = 0; slot < photoAlbumThumbs.size(); ++slot) {
const std::size_t index = pageStart + slot;
if (photoAlbumThumbIndexes[slot] == index || index >= photoAlbumEntries.size()) continue;
if (photoAlbumThumbs[slot]) SDL_DestroyTexture(photoAlbumThumbs[slot]);
photoAlbumThumbs[slot] = nullptr;
std::vector<std::uint8_t> jpeg(1024U * 1024U);
u64 bytes = 0;
if (R_SUCCEEDED(capsaLoadAlbumFileThumbnail(&photoAlbumEntries[index].file_id, &bytes,
jpeg.data(), jpeg.size())) && bytes > 0 && bytes <= jpeg.size()) {
SDL_RWops* thumbStream = SDL_RWFromConstMem(jpeg.data(), static_cast<int>(bytes));
SDL_Surface* thumbSurface = thumbStream ? IMG_Load_RW(thumbStream, 1) : nullptr;
if (thumbSurface) {
photoAlbumThumbs[slot] = SDL_CreateTextureFromSurface(renderer, thumbSurface);
SDL_FreeSurface(thumbSurface);
}
}
photoAlbumThumbIndexes[slot] = index;
}
capsaExit();
}
if (photoAlbumEntries.empty()) centered("No screenshots in the album.", x + 45, y + 150, w - 90, 46, 19, GRAY);
for (std::size_t slot = 0; slot < photoAlbumThumbs.size(); ++slot) {
const std::size_t index = pageStart + slot;
if (index >= photoAlbumEntries.size()) break;
const int col = static_cast<int>(slot % 3), row = static_cast<int>(slot / 3);
const int tx = x + 35 + col * 182, ty = y + 82 + row * 124;
rounded(tx - 2, ty - 2, 168, 104, 10, SDL_Color{210, 214, 220, 255});
rounded(tx, ty, 164, 100, 8, WHITE);
if (photoAlbumThumbs[slot]) drawTexture(photoAlbumThumbs[slot], tx + 2, ty + 2, 160, 96);
else centered("Image", tx + 2, ty + 25, 160, 40, 17, GRAY);
if (hit(tx - 3, ty - 3, 170, 106)) importPhotoAlbumEntry(index);
}
const int pages = std::max(1, static_cast<int>((photoAlbumEntries.size() + photoAlbumThumbs.size() - 1) / photoAlbumThumbs.size()));
if (photoAlbumPage > 0 && pill("<", x + 35, y + 350, 64, 44, false, 22)) {
--photoAlbumPage;
photoAlbumThumbIndexes.fill(std::numeric_limits<std::size_t>::max());
}
centered(std::to_string(photoAlbumPage + 1) + " / " + std::to_string(pages), x + 225, y + 350, 170, 44, 17, GRAY);
if (photoAlbumPage + 1 < pages && pill(">", x + 521, y + 350, 64, 44, false, 22)) {
++photoAlbumPage;
photoAlbumThumbIndexes.fill(std::numeric_limits<std::size_t>::max());
}
#else
centered("The Switch photo album is only available on Nintendo Switch.", x + 45, y + 150, w - 90, 56, 18, GRAY);
#endif
break;
}
case Modal::PhotoUpload: {
title("Upload Photo");
const bool photoServerStarting = serverStartRunning && serverStartTask == ServerStartTask::Photo;
if (photoServerStarting) centered("Starting the local photo server…", x + 42, y + 108, w - 84, 40, 18, GRAY);
else {
if (!audioUploadUrl.empty()) centered(audioUploadUrl, x + 42, y + 94, w - 84, 36, 19, INK);
if (audioUploadServer.isConnected()) centered("Connected", x + 70, y + 139, 200, 34, 19, GREEN, true);
else centered("Code " + audioUploadCode, x + 70, y + 139, 300, 34, 19, ORANGE, true);
}
centered("Open this address on your PC to choose an image and set", x + 42, y + 202, w - 84, 28, 16, GRAY);
centered("size, position, layer, color mode and dithering.", x + 42, y + 229, w - 84, 28, 16, GRAY);
centered(photoServerStarting ? "Preparing transfer page…" : audioUploadStatus,
x + 42, y + 281, w - 84, 32, 17, INK);
if (pill("Close", x + 440, y + 372, 120, 48, false, 16, !photoServerStarting)) closePhotoCapture();
break;
}
case Modal::Photo: {
title("IR Photo Import");
#ifdef __SWITCH__
if (irPhotoTaken || irImageReady) {
if (irPreviewTexture) {
SDL_Rect previewRect{x + 150, y + 78, 320, 240};
SDL_RenderCopyEx(renderer, irPreviewTexture, nullptr, &previewRect, irRotatePhoto ? 90.0 : 0.0, nullptr, SDL_FLIP_NONE);
}
else centered("Camera preview unavailable", x + 50, y + 100, w - 100, 180, 20, GRAY);
if (!irRotatePhoto) centered("Photo replaces the selected layer. Undo is available.", x + 45, y + 322, w - 90, 30, 15, GRAY);
} else centered(irsReady ? "Starting IR camera…" : "Connect a Right Joy-Con with an IR camera.",
x + 50, y + 112, w - 100, 150, 19, INK);
if (!irPhotoTaken) {
if (pill(irImageReady ? "Take Photo" : "Waiting…", x + 40, y + 372, 120, 48, false, 16, irImageReady)) {
irPhoto = irImage;
irPhotoTaken = true;
for (std::size_t i = 0; i < irPhoto.size(); ++i) {
irPreviewRgba[i * 4] = irPhoto[i]; irPreviewRgba[i * 4 + 1] = irPhoto[i]; irPreviewRgba[i * 4 + 2] = irPhoto[i];
}
if (irPreviewTexture) SDL_UpdateTexture(irPreviewTexture, nullptr, irPreviewRgba.data(), 320 * 4);
}
if (pill(std::string("Rotate: ") + (irRotatePhoto ? "On" : "Off"), x + 170, y + 372, 120, 48, irRotatePhoto, 16)) irRotatePhoto = !irRotatePhoto;
} else {
if (pill("Retake", x + 40, y + 372, 120, 48, false, 16)) irPhotoTaken = false;
if (pill(std::string("Rotate: ") + (irRotatePhoto ? "On" : "Off"), x + 170, y + 372, 120, 48, irRotatePhoto, 16)) irRotatePhoto = !irRotatePhoto;
if (pill("Use Photo", x + 300, y + 372, 120, 48, true, 16)) importPhoto();
}
#else
centered("Photo capture is available on Nintendo Switch.", x + 50, y + 125, w - 100, 130, 19, INK);
#endif
if (pill("Cancel", x + 440, y + 372, 120, 48, false, 16)) closePhotoCapture();
break;
}
case Modal::Help:
title("Controls");
text("Touch / mouse", x + 52, y + 90, 19, ORANGE, true);
text("Draw on the canvas, select tools, colors and frames.", x + 52, y + 121, 17, INK);
text("A / Enter Select or play", x + 52, y + 164, 18, INK);
text("B / Esc Back", x + 52, y + 200, 18, INK);
text("Y Edit selected Switchnote", x + 52, y + 236, 18, INK);
text("L / R Previous / next layer", x + 52, y + 272, 18, INK);
text("ZL / ZR Undo / redo", x + 52, y + 308, 18, INK);
dismiss();
break;
case Modal::UpdateAvailable: {
rounded(x, y + 7, w, h, 24, SDL_Color{0, 0, 0, 45});
rounded(x, y, w, h, 24, UPDATE_ORANGE);
roundedOutline(x, y, w, h, 24, 2, WHITE);
centered("Update Available", x + 36, y + 48, w - 72, 62, 39, WHITE);
text("A new update is available for " + appDisplayName + ":", x + 134, y + 194, 27, WHITE);
centered(appDisplayName + ", " + updateServerVersion, x + 100, y + 276, w - 200, 42, 27, WHITE);
text("Author:", x + 134, y + 344, 26, WHITE);
text("Version:", x + 134, y + 373, 26, WHITE);
text("File SHA256:", x + 134, y + 402, 26, WHITE);
text("RexiMemo!", x + 922, y + 344, 26, WHITE);
text(updateServerVersion, x + 946, y + 373, 26, WHITE);
text(updateSha256.empty() ? "Not provided by update service" : updateSha256,
x + 448, y + 410, updateSha256.empty() ? 18 : 15, WHITE);
text("Please run the app from the Homebrew Menu in App Override mode, or", x + 134, y + 462, 25, WHITE);
text("install the official NSP file.", x + 134, y + 491, 25, WHITE);
if (pill("Update", x + 528, y + 548, 140, 50, true, 26, true, "cursor_select")) startUpdateDownload();
break;
}
case Modal::TextInput:
title(editingField == Field::Title ? "Switchnote Title" : editingField == Field::Description ? "Description" :
editingField == Field::FolderName ? "New Folder" : editingField == Field::GalleryBio ? "Gallery Bio" : "Comment");
rounded(x + 44, y + 118, w - 88, 64, 12, LIGHT);
text(inputText + "▏", x + 60, y + 132, 21, INK);
if (pill("Cancel", x + 92, y + 244, 160, 48, false, 19)) { modal = Modal::None; editingField = Field::None; SDL_StopTextInput(); }
if (pill("Done", x + 365, y + 244, 160, 48, false, 19)) applyText();
break;
case Modal::None: break;
}
renderingModal = false;
}
bool switchnoteBrushPattern(int id, int x, int y) {
switch (id & 7) {
case 0: return x % 3 == 1 && y % 3 == 1;
case 1: return (x & 1) == 0 && (y & 1) == 0;
case 2: return (x & 1) == 1;
case 3: return (y & 1) == 1;
case 4: return ((x + y) & 1) == 1;
case 5: return (x & 1) != 0 || (y & 1) != 0;
case 6: return !(x % 3 == 1 && y % 3 == 1);
case 7: return ((x - y) % 5 == 0) || ((x + y) % 5 == 0);
default: return false;
}
}
bool switchnoteEraserContains(int slot, int dx, int dy) {
switch (slot & 7) {
case 0: return dx == 0 && dy == 0;
case 1: return std::abs(dx) <= 1 && std::abs(dy) <= 1;
case 2: return dx * dx + dy * dy <= 12;
case 3: return dx * dx + dy * dy <= 42;
case 4: return std::abs(dx) <= 4 && std::abs(dy) <= 4;
case 5: return std::abs(dx) <= 7 && std::abs(dy) <= 7;
case 6: return std::abs(dx) <= 1 && std::abs(dy) <= 6;
case 7: return std::abs(dy) <= 1 && std::abs(dx) <= 6;
default: return false;
}
}
bool App::paintPixel(int sx, int sy, std::vector<std::uint8_t>& pixels) {
const float scale = screen == Screen::CommentCanvas ? 2.0f : 2.0f * zoom;
const int width = static_cast<int>(sppm::kWidth * scale);
const int left = screen == Screen::CommentCanvas ? 0 : (W - width) / 2 + panX;
const int height = static_cast<int>(sppm::kHeight * scale);
const int top = screen == Screen::CommentCanvas ? 0 : (H - height) / 2 + panY;
// Use the same rounded destination dimensions as the renderer. Dividing
// by the requested float scale drifted by a pixel at fractional zoom.
const float pixelWidth = width / static_cast<float>(sppm::kWidth);
const float pixelHeight = height / static_cast<float>(sppm::kHeight);
const int px = static_cast<int>(std::floor((sx - left) / pixelWidth));
const int py = static_cast<int>(std::floor((sy - top) / pixelHeight));
if (px < 0 || py < 0 || px >= sppm::kWidth || py >= sppm::kHeight) return false;
int previousPx = px, previousPy = py;
if (hasLastStroke) {
previousPx = static_cast<int>(std::floor((lastStrokeX - left) / pixelWidth));
previousPy = static_cast<int>(std::floor((lastStrokeY - top) / pixelHeight));
}
const int motionX = hasLastStroke ? px - previousPx : 0;
const int motionY = hasLastStroke ? py - previousPy : 0;
const bool usePattern = screen != Screen::CommentCanvas && activeTool == Tool::Brush;
const bool erase = screen != Screen::CommentCanvas && activeTool == Tool::Eraser;
const bool usePen = screen != Screen::CommentCanvas && activeTool == Tool::Pen;
bool changed = false;
const std::uint8_t value = screen == Screen::CommentCanvas ? 1 :
(erase ? 0 : static_cast<std::uint8_t>(std::clamp(color, 1, 255)));
auto write = [&](int x, int y) {
if (x < 0 || y < 0 || x >= sppm::kWidth || y >= sppm::kHeight) return;
const auto index = static_cast<std::size_t>(y) * sppm::kWidth + x;
if (pixels[index] != value) { pixels[index] = value; changed = true; }
};
auto clearInk = [&](int x, int y) {
if (x < 0 || y < 0 || x >= sppm::kWidth || y >= sppm::kHeight) return;
const auto index = static_cast<std::size_t>(y) * sppm::kWidth + x;
if (pixels[index] != 0) { pixels[index] = 0; changed = true; }
};
auto writePen = [&](int x, int y) {
const int size = std::clamp(brush, 1, 32);
const int start = -((size - 1) / 2);
const float center = static_cast<float>(start) + (size - 1) / 2.0f;
const float radius = std::max(0.5f, size / 2.0f);
for (int oy = 0; oy < size; ++oy) for (int ox = 0; ox < size; ++ox) {
const int dx = start + ox, dy = start + oy;
const float ddx = dx - center, ddy = dy - center;
if (!square && ddx * ddx + ddy * ddy > radius * radius) continue;
write(x + dx, y + dy);
}
};
auto stampSquare = [&](int x, int y, int radius) {
for (int dy = -radius; dy <= radius; ++dy)
for (int dx = -radius; dx <= radius; ++dx) writePen(x + dx, y + dy);
};
if (usePattern) {
// Match the web editor's eight paint predicates on absolute canvas
// coordinates. Normal paint is a 3x3 mask; Advanced is a 15x15 mask.
const int radius = advancedBrush ? 7 : 1;
for (int dy = -radius; dy <= radius; ++dy)
for (int dx = -radius; dx <= radius; ++dx)
if (switchnoteBrushPattern(pattern, px + dx, py + dy)) write(px + dx, py + dy);
} else if (erase) {
static constexpr std::array<int, 8> radii{{0, 1, 3, 6, 4, 7, 6, 6}};
const int radius = radii[static_cast<std::size_t>(eraserSlot & 7)];
for (int dy = -radius; dy <= radius; ++dy)
for (int dx = -radius; dx <= radius; ++dx)
if (switchnoteEraserContains(eraserSlot, dx, dy)) write(px + dx, py + dy);
} else if (!usePen) {
const int size = std::clamp(brush, 1, 32);
const int start = -((size - 1) / 2);
const float center = static_cast<float>(start) + (size - 1) / 2.0f;
const float radius = std::max(0.5f, size / 2.0f);
for (int oy = 0; oy < size; ++oy) for (int ox = 0; ox < size; ++ox) {
const int dx = start + ox, dy = start + oy;
const float ddx = dx - center, ddy = dy - center;
if (!square && ddx * ddx + ddy * ddy > radius * radius) continue;
write(px + dx, py + dy);
}
} else {
auto random16 = [&]() -> std::uint16_t {
const std::uint16_t old = penRngState;
const std::uint16_t bit0 = old & 1u;
const std::uint16_t bit3 = (old >> 3) & 1u;
penRngState = old >> 1;
if (bit0 == bit3) penRngState = static_cast<std::uint16_t>(penRngState | 0x8000u);
return penRngState;
};
auto randomRange = [&](int range) { return static_cast<int>(random16() % static_cast<std::uint16_t>(std::max(1, range))); };
auto drawLine = [&](int x0, int y0, int x1, int y1, bool includeStart) {
int x = x0, y = y0;
const int dx = std::abs(x1 - x), stepX = x < x1 ? 1 : -1;
const int dyAbs = std::abs(y1 - y), stepY = y < y1 ? 1 : -1;
int error = dx - dyAbs;
bool first = true;
while (true) {
if (includeStart || !first) writePen(x, y);
if (x == x1 && y == y1) break;
first = false;
const int twice = 2 * error;
if (twice > -dyAbs) { error -= dyAbs; x += stepX; }
if (twice < dx) { error += dx; y += stepY; }
}
};
auto stampDirectionAware = [&]() {
writePen(px, py);
if (motionX || motionY) {
writePen(px + motionY, py);
writePen(px, py + motionX);
}
};
auto stampDirectionalEqual = [&]() {
const bool horizontal = motionX != 0 && motionY == 0;
const bool vertical = motionY != 0 && motionX == 0;
if (horizontal) { writePen(px, py - 1); writePen(px, py + 1); }
else if (vertical) { writePen(px - 1, py); writePen(px + 1, py); }
else if (motionX == motionY) { writePen(px - 1, py); writePen(px + 1, py); }
else { writePen(px, py - 1); writePen(px, py + 1); }
clearInk(px, py);
if (std::abs(motionX) >= std::abs(motionY)) writePen(px, py + (motionX ? motionX : 1));
else writePen(px + (motionY ? motionY : 1), py);
};
auto randomOffset = [&](bool wide) { return randomRange(wide ? 10 : 5) - (wide ? 5 : 2); };
auto stampJitter = [&](bool wide) {
const int ax = std::clamp(previousPx + randomOffset(wide), 0, sppm::kWidth - 1);
const int ay = std::clamp(previousPy + randomOffset(wide), 0, sppm::kHeight - 1);
const int bx = std::clamp(px + randomOffset(wide), 0, sppm::kWidth - 1);
const int by = std::clamp(py + randomOffset(wide), 0, sppm::kHeight - 1);
drawLine(ax, ay, bx, by, true);
};
auto sprayClass = [&]() {
const int d2 = motionX * motionX + motionY * motionY;
if (d2 < 4) return 6;
if (d2 < 8) return 5;
if (d2 < 12) return 4;
if (d2 < 24) return 3;
if (d2 < 48) return 2;
return 1;
};
auto stampSpray = [&](bool heavy) {
const int cls = sprayClass();
const int count = cls + (heavy ? 3 : 2);
const int spread = (7 - cls) + (heavy ? 5 : 2);
for (int i = 0; i < count; ++i) {
const int scale = heavy && (i & 1) ? 2 : 1;
const int ax = std::clamp(previousPx + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kWidth - 1);
const int ay = std::clamp(previousPy + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kHeight - 1);
const int bx = std::clamp(px + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kWidth - 1);
const int by = std::clamp(py + (randomRange(spread * 2 + 1) - spread) * scale, 0, sppm::kHeight - 1);
drawLine(ax, ay, bx, by, true);
}
};
auto stampAirbrush = [&](bool large) {
static const std::array<const char*, 5> smallMask{{"..#..", ".###.", "#####", ".###.", "..#.."}};
static const std::array<const char*, 15> largeMask{{
"......###......", ".....#####.....", "...#########...", "..###########..", ".#############.",
"###############", "###############", "###############", "###############", "###############",
".#############.", "..###########..", "...#########...", ".....#####.....", "......###......"
}};
const int width = large ? 15 : 5;
const int height = large ? 15 : 5;
const int segmentMagnitude = std::max(std::abs(motionX), std::abs(motionY));
const int base = large ? 72 : 98;
const int penalty = std::min(38, std::max(0, segmentMagnitude - 1) * (large ? 2 : 4));
const int threshold = std::max(24, base - penalty);
for (int row = 0; row < height; ++row) for (int col = 0; col < width; ++col) {
const char mark = large ? largeMask[static_cast<std::size_t>(row)][col] : smallMask[static_cast<std::size_t>(row)][col];
if (mark != '#' || static_cast<int>(random16() & 0xffu) >= threshold) continue;
writePen(px + col - width / 2, py + row - height / 2);
}
};
switch (penType) {
case 0: writePen(px, py); break;
case 1: stampDirectionAware(); break;
case 2: stampSquare(px, py, 1); break;
case 3: {
const int qx = px & ~1, qy = py & ~1;
if (squiggleAnchorX >= 0 && (squiggleAnchorX != qx || squiggleAnchorY != qy)) {
const int mx = ((squiggleAnchorX + qx) >> 1) & ~1;
const int my = ((squiggleAnchorY + qy) >> 1) & ~1;
writePen(mx, my); writePen(mx + 1, my); writePen(mx, my + 1); writePen(mx + 1, my + 1);
}
writePen(qx, qy); writePen(qx + 1, qy); writePen(qx, qy + 1); writePen(qx + 1, qy + 1);
squiggleAnchorX = qx; squiggleAnchorY = qy;
break;
}
case 4: stampDirectionalEqual(); break;
case 5: stampAirbrush(false); break;
case 6: if (motionX || motionY) stampJitter(false); else writePen(px, py); break;
case 7: if (motionX || motionY) stampSpray(false); else writePen(px, py); break;
case 8: stampSquare(px, py, 2); break;
case 9: stampSquare(px, py, 3); break;
case 10: stampSquare(px, py, 4); break;
case 11: {
const int d2 = motionX * motionX + motionY * motionY;
const int cls = d2 < 4 ? 3 : d2 < 32 ? 2 : 1;
if (cls == 1) writePen(px, py);
else if (cls == 2) stampDirectionAware();
else stampSquare(px, py, 1);
break;
}
case 12: {
static const std::array<const char*, 7> outer{{"..###..", ".#####.", "#######", "#######", "#######", ".#####.", "..###.."}};
static const std::array<const char*, 5> inner{{"..#..", ".###.", "#####", ".###.", "..#.."}};
for (int y = 0; y < 7; ++y) for (int x = 0; x < 7; ++x)
if (outer[static_cast<std::size_t>(y)][x] == '#') write(px + x - 3, py + y - 3);
const int holeX = ringPreviousX < 0 ? px : ringPreviousX;
const int holeY = ringPreviousY < 0 ? py : ringPreviousY;
for (int y = 0; y < 5; ++y) for (int x = 0; x < 5; ++x)
if (inner[static_cast<std::size_t>(y)][x] == '#') clearInk(holeX + x - 2, holeY + y - 2);
ringPreviousX = px; ringPreviousY = py;
break;
}
case 13: stampAirbrush(true); break;
case 14: if (motionX || motionY) stampJitter(true); else writePen(px, py); break;
case 15: if (motionX || motionY) stampSpray(true); else writePen(px, py); break;
default: writePen(px, py); break;
}
}
return changed;
}
void App::strokeAt(int sx, int sy, bool comment) {
const bool stabilise = !comment && penStabilisationEnabled && activeTool != Tool::Eraser;
if (stabilise && !stabilisedAfterReplay) {
if (penStabilisationAfter) {
if (afterStrokePoints.size() < 50000) afterStrokePoints.emplace_back(sx, sy);
return;
}
const float amount = penStabilisationStrength / 100.0f;
const float follow = 1.0f - amount * 0.88f;
if (!realtimeStrokeInitialized) {
realtimeStrokeX = static_cast<float>(sx);
realtimeStrokeY = static_cast<float>(sy);
realtimeStrokeInitialized = true;
} else {
realtimeStrokeX += (sx - realtimeStrokeX) * follow;
realtimeStrokeY += (sy - realtimeStrokeY) * follow;
}
sx = static_cast<int>(std::lround(realtimeStrokeX));
sy = static_cast<int>(std::lround(realtimeStrokeY));
}
std::vector<std::uint8_t>* pixels = nullptr;
if (comment) pixels = &commentPixels;
else if (docLoaded && frame < doc.frames.size() && layer >= 0 && layer < 4) pixels = &doc.frames[frame].layers[layer];
if (!pixels) return;
bool changed = false;
if (hasLastStroke) {
const int startX = lastStrokeX, startY = lastStrokeY;
const int dx = sx - startX, dy = sy - startY;
const int count = std::max(std::abs(dx), std::abs(dy));
for (int i = 1; i <= count; ++i) {
const int x = startX + dx * i / count;
const int y = startY + dy * i / count;
changed |= paintPixel(x, y, *pixels);
lastStrokeX = x;
lastStrokeY = y;
}
} else {
squiggleAnchorX = squiggleAnchorY = ringPreviousX = ringPreviousY = -1;
changed = paintPixel(sx, sy, *pixels);
lastStrokeX = sx;
lastStrokeY = sy;
hasLastStroke = true;
}
if (changed) {
if (comment) commentDirty = true;
else markDirty();
}
}
void App::finishAfterStroke() {
if (!penStabilisationEnabled || !penStabilisationAfter || afterStrokePoints.empty() ||
!docLoaded || frame >= doc.frames.size() || layer < 0 || layer >= 4) {
afterStrokePoints.clear();
realtimeStrokeInitialized = false;
return;
}
const std::size_t strokeFrame = frame;
const int strokeLayer = layer;
auto& pixels = doc.frames[strokeFrame].layers[static_cast<std::size_t>(strokeLayer)];
if (!undoStack.empty() && undoStack.back().frame == strokeFrame && undoStack.back().layer == strokeLayer &&
undoStack.back().pixels.size() == pixels.size()) {
pixels = undoStack.back().pixels;
markDirty();
}
const auto points = std::move(afterStrokePoints);
afterStrokePoints.clear();
const int radius = std::clamp(static_cast<int>(std::lround(penStabilisationStrength * 0.06f)), 0, 6);
stabilisedAfterReplay = true;
hasLastStroke = false;
for (std::size_t i = 0; i < points.size(); ++i) {
int x = 0, y = 0, count = 0;
const std::size_t from = i > static_cast<std::size_t>(radius) ? i - static_cast<std::size_t>(radius) : 0;
const std::size_t to = std::min(points.size() - 1, i + static_cast<std::size_t>(radius));
for (std::size_t j = from; j <= to; ++j) {
x += points[j].first;
y += points[j].second;
++count;
}
strokeAt(x / count, y / count);
}
stabilisedAfterReplay = false;
hasLastStroke = false;
realtimeStrokeInitialized = false;
}
} // namespace