util/gettext: Parse unicode escape sequences
Closes #351 Part-of: <https://gitlab.gnome.org/GNOME/loupe/-/merge_requests/426>
This commit is contained in:
parent
3ad5560e2c
commit
0b33553144
12 changed files with 208 additions and 113 deletions
|
|
@ -11,6 +11,7 @@ src/decoder/formats/svg.rs
|
||||||
src/decoder/tiling.rs
|
src/decoder/tiling.rs
|
||||||
src/editing.rs
|
src/editing.rs
|
||||||
src/file_model.rs
|
src/file_model.rs
|
||||||
|
src/lib.rs
|
||||||
src/main.rs
|
src/main.rs
|
||||||
src/metadata.rs
|
src/metadata.rs
|
||||||
src/metadata/file.rs
|
src/metadata/file.rs
|
||||||
|
|
|
||||||
121
src/lib.rs
Normal file
121
src/lib.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// Copyright (c) 2024 Sophie Herold
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#![allow(clippy::new_without_default)]
|
||||||
|
|
||||||
|
/*!
|
||||||
|
# Loupe Image Viewer
|
||||||
|
|
||||||
|
Code documentation
|
||||||
|
|
||||||
|
# Widget Structure
|
||||||
|
|
||||||
|
Simplified widget arrangement including the most important widgets.
|
||||||
|
|
||||||
|
- [`LpWindow`]
|
||||||
|
- [`AdwFlap`]
|
||||||
|
- [`LpPropertiesView`]
|
||||||
|
- [`LpImageView`]
|
||||||
|
- [`LpSlidingView`]
|
||||||
|
- [`LpImagePage`]
|
||||||
|
- [`GtkScrolledWindow`]
|
||||||
|
- [`LpImage`]
|
||||||
|
- …
|
||||||
|
|
||||||
|
[`AdwFlap`]: adw::Flap
|
||||||
|
[`GtkScrolledWindow`]: gtk::ScrolledWindow
|
||||||
|
[`LpImagePage`]: widgets::LpImagePage
|
||||||
|
[`LpImageView`]: widgets::LpImageView
|
||||||
|
[`LpImage`]: widgets::LpImage
|
||||||
|
[`LpPropertiesView`]: widgets::LpPropertiesView
|
||||||
|
[`LpSlidingView`]: widgets::LpSlidingView
|
||||||
|
[`LpWindow`]: window::LpWindow
|
||||||
|
*/
|
||||||
|
|
||||||
|
use gettextrs::*;
|
||||||
|
use gtk::gio::prelude::*;
|
||||||
|
use gtk::gio::{self};
|
||||||
|
|
||||||
|
pub mod about;
|
||||||
|
pub mod application;
|
||||||
|
pub mod config;
|
||||||
|
pub mod decoder;
|
||||||
|
pub mod editing;
|
||||||
|
pub mod file_model;
|
||||||
|
pub mod metadata;
|
||||||
|
pub mod util;
|
||||||
|
pub mod widgets;
|
||||||
|
pub mod window;
|
||||||
|
|
||||||
|
mod deps {
|
||||||
|
pub use gtk::{cairo, gdk, gio, glib, graphene, gsk};
|
||||||
|
}
|
||||||
|
|
||||||
|
use application::LpApplication;
|
||||||
|
use deps::*;
|
||||||
|
use tracing_subscriber::layer::*;
|
||||||
|
use tracing_subscriber::util::*;
|
||||||
|
|
||||||
|
static GRESOURCE_BYTES: &[u8] =
|
||||||
|
gvdb_macros::include_gresource_from_dir!("/org/gnome/Loupe", "data/resources");
|
||||||
|
|
||||||
|
pub fn main() -> glib::ExitCode {
|
||||||
|
// Don't use ngl renderer by default due to performance reasons
|
||||||
|
// <https://gitlab.gnome.org/GNOME/gtk/-/issues/6411>
|
||||||
|
if std::env::var("GSK_RENDERER").map_or(true, |x| x.is_empty()) {
|
||||||
|
std::env::set_var("GSK_RENDERER", "gl");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't use gl renderer does not properly support fractional scaling
|
||||||
|
// <https://gitlab.gnome.org/GNOME/loupe/-/issues/346>
|
||||||
|
if std::env::var("GDK_DEBUG").is_err() {
|
||||||
|
std::env::set_var("GDK_DEBUG", "gl-no-fractional");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow G_MESSAGES_DEBUG env variable
|
||||||
|
let default_level =
|
||||||
|
if !glib::log_writer_default_would_drop(glib::LogLevel::Debug, Some("loupe")) {
|
||||||
|
tracing_subscriber::filter::LevelFilter::DEBUG
|
||||||
|
} else {
|
||||||
|
tracing_subscriber::filter::LevelFilter::ERROR
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::builder()
|
||||||
|
.with_default_directive(default_level.into())
|
||||||
|
.from_env_lossy(),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::Layer::default().compact())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
log::debug!("Logger initialized");
|
||||||
|
|
||||||
|
setlocale(LocaleCategory::LcAll, "");
|
||||||
|
bindtextdomain("loupe", config::LOCALEDIR).unwrap();
|
||||||
|
textdomain("loupe").unwrap();
|
||||||
|
|
||||||
|
log::trace!("gettext initialized");
|
||||||
|
|
||||||
|
gio::resources_register(
|
||||||
|
&gio::Resource::from_data(&glib::Bytes::from_static(GRESOURCE_BYTES)).unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
log::trace!("Gio resources registered");
|
||||||
|
|
||||||
|
LpApplication::new().run()
|
||||||
|
}
|
||||||
110
src/main.rs
110
src/main.rs
|
|
@ -1,5 +1,5 @@
|
||||||
// Copyright (c) 2020-2022 Christopher Davis
|
// Copyright (c) 2020 Christopher Davis
|
||||||
// Copyright (c) 2023-2024 Sophie Herold
|
// Copyright (c) 2024 Sophie Herold
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
// it under the terms of the GNU General Public License as published by
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -16,107 +16,7 @@
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
#![allow(clippy::new_without_default)]
|
use gtk::glib;
|
||||||
|
pub fn main() -> glib::ExitCode {
|
||||||
/*!
|
loupe::main()
|
||||||
# Loupe Image Viewer
|
|
||||||
|
|
||||||
Code documentation
|
|
||||||
|
|
||||||
# Widget Structure
|
|
||||||
|
|
||||||
Simplified widget arrangement including the most important widgets.
|
|
||||||
|
|
||||||
- [`LpWindow`]
|
|
||||||
- [`AdwFlap`]
|
|
||||||
- [`LpPropertiesView`]
|
|
||||||
- [`LpImageView`]
|
|
||||||
- [`LpSlidingView`]
|
|
||||||
- [`LpImagePage`]
|
|
||||||
- [`GtkScrolledWindow`]
|
|
||||||
- [`LpImage`]
|
|
||||||
- …
|
|
||||||
|
|
||||||
[`AdwFlap`]: adw::Flap
|
|
||||||
[`GtkScrolledWindow`]: gtk::ScrolledWindow
|
|
||||||
[`LpImagePage`]: widgets::LpImagePage
|
|
||||||
[`LpImageView`]: widgets::LpImageView
|
|
||||||
[`LpImage`]: widgets::LpImage
|
|
||||||
[`LpPropertiesView`]: widgets::LpPropertiesView
|
|
||||||
[`LpSlidingView`]: widgets::LpSlidingView
|
|
||||||
[`LpWindow`]: window::LpWindow
|
|
||||||
*/
|
|
||||||
|
|
||||||
use gettextrs::*;
|
|
||||||
use gtk::gio::prelude::*;
|
|
||||||
use gtk::gio::{self};
|
|
||||||
|
|
||||||
mod about;
|
|
||||||
mod application;
|
|
||||||
mod config;
|
|
||||||
mod decoder;
|
|
||||||
mod editing;
|
|
||||||
mod file_model;
|
|
||||||
mod metadata;
|
|
||||||
mod util;
|
|
||||||
mod widgets;
|
|
||||||
mod window;
|
|
||||||
|
|
||||||
mod deps {
|
|
||||||
pub use gtk::{cairo, gdk, gio, glib, graphene, gsk};
|
|
||||||
}
|
|
||||||
|
|
||||||
use application::LpApplication;
|
|
||||||
use deps::*;
|
|
||||||
use tracing_subscriber::layer::*;
|
|
||||||
use tracing_subscriber::util::*;
|
|
||||||
|
|
||||||
static GRESOURCE_BYTES: &[u8] =
|
|
||||||
gvdb_macros::include_gresource_from_dir!("/org/gnome/Loupe", "data/resources");
|
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
|
||||||
// Don't use ngl renderer by default due to performance reasons
|
|
||||||
// <https://gitlab.gnome.org/GNOME/gtk/-/issues/6411>
|
|
||||||
if std::env::var("GSK_RENDERER").map_or(true, |x| x.is_empty()) {
|
|
||||||
std::env::set_var("GSK_RENDERER", "gl");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't use gl renderer does not properly support fractional scaling
|
|
||||||
// <https://gitlab.gnome.org/GNOME/loupe/-/issues/346>
|
|
||||||
if std::env::var("GDK_DEBUG").is_err() {
|
|
||||||
std::env::set_var("GDK_DEBUG", "gl-no-fractional");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Follow G_MESSAGES_DEBUG env variable
|
|
||||||
let default_level =
|
|
||||||
if !glib::log_writer_default_would_drop(glib::LogLevel::Debug, Some("loupe")) {
|
|
||||||
tracing_subscriber::filter::LevelFilter::DEBUG
|
|
||||||
} else {
|
|
||||||
tracing_subscriber::filter::LevelFilter::ERROR
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(
|
|
||||||
tracing_subscriber::EnvFilter::builder()
|
|
||||||
.with_default_directive(default_level.into())
|
|
||||||
.from_env_lossy(),
|
|
||||||
)
|
|
||||||
.with(tracing_subscriber::fmt::Layer::default().compact())
|
|
||||||
.init();
|
|
||||||
|
|
||||||
log::debug!("Logger initialized");
|
|
||||||
|
|
||||||
setlocale(LocaleCategory::LcAll, "");
|
|
||||||
bindtextdomain("loupe", config::LOCALEDIR).unwrap();
|
|
||||||
textdomain("loupe").unwrap();
|
|
||||||
|
|
||||||
log::trace!("gettext initialized");
|
|
||||||
|
|
||||||
gio::resources_register(
|
|
||||||
&gio::Resource::from_data(&glib::Bytes::from_static(GRESOURCE_BYTES)).unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
log::trace!("Gio resources registered");
|
|
||||||
|
|
||||||
LpApplication::new().run()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,11 @@ impl Metadata {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|exif| exif.get_field(exif::Tag::FNumber, exif::In::PRIMARY))
|
.and_then(|exif| exif.get_field(exif::Tag::FNumber, exif::In::PRIMARY))
|
||||||
{
|
{
|
||||||
return Some(format!("ƒ\u{2215}{}", field.display_value()));
|
// Translators: Display of the f-number <https://en.wikipedia.org/wiki/F-number>. {} will be replaced with the number
|
||||||
|
return Some(gettext_f(
|
||||||
|
r"\u{192}\u{2215}{}",
|
||||||
|
[field.display_value().to_string()],
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
|
|
@ -259,7 +263,7 @@ impl Metadata {
|
||||||
if speed <= 0.5 {
|
if speed <= 0.5 {
|
||||||
let exposure = format!("{:.0}", 1. / speed);
|
let exposure = format!("{:.0}", 1. / speed);
|
||||||
// Translators: Fractional exposure time (photography) in seconds
|
// Translators: Fractional exposure time (photography) in seconds
|
||||||
return Some(gettext_f("1\u{2215}{}\u{202F}s", [exposure]));
|
return Some(gettext_f(r"1\u{2215}{}\u{202F}s", [exposure]));
|
||||||
} else {
|
} else {
|
||||||
let exposure = if speed < 5. {
|
let exposure = if speed < 5. {
|
||||||
format!("{:.1}", speed)
|
format!("{:.1}", speed)
|
||||||
|
|
@ -267,7 +271,7 @@ impl Metadata {
|
||||||
format!("{:.0}", speed)
|
format!("{:.0}", speed)
|
||||||
};
|
};
|
||||||
// Translators: Exposure time (photography) in seconds
|
// Translators: Exposure time (photography) in seconds
|
||||||
return Some(gettext_f("{}\u{202F}s", [exposure]));
|
return Some(gettext_f(r"{}\u{202F}s", [exposure]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -293,7 +297,7 @@ impl Metadata {
|
||||||
if let exif::Value::Rational(rational) = &field.value {
|
if let exif::Value::Rational(rational) = &field.value {
|
||||||
let length = format!("{:.0}", rational.first()?.to_f32());
|
let length = format!("{:.0}", rational.first()?.to_f32());
|
||||||
// Translators: Unit for focal length in millimeters
|
// Translators: Unit for focal length in millimeters
|
||||||
return Some(gettext_f("{}\u{202F}mm", [length]));
|
return Some(gettext_f(r"{}\u{202F}mm", [length]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,64 @@ fn freplace(mut s: String, args: impl IntoIterator<Item = impl AsRef<str>>) -> S
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn gettext_f(format: &str, args: impl IntoIterator<Item = impl AsRef<str>>) -> String {
|
pub fn gettext_f(format: &str, args: impl IntoIterator<Item = impl AsRef<str>>) -> String {
|
||||||
let s = gettextrs::gettext(format);
|
let s = ugettext(format);
|
||||||
freplace(s, args)
|
freplace(s, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same as `gettext` but evaluates unicode escape sequences
|
||||||
|
pub fn ugettext(msgid: &str) -> String {
|
||||||
|
let msg = gettext(msgid);
|
||||||
|
apply_unicode_escapes(&msg).unwrap_or(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
None,
|
||||||
|
UnicodeHex,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace unicode escape sequences with the actual `char`
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// assert_eq!(
|
||||||
|
/// loupe::util::gettext::apply_unicode_escapes(r"abc \u{03a6} \u{2764} d"),
|
||||||
|
/// Some("abc \u{03a6} \u{2764} d".into())
|
||||||
|
/// );
|
||||||
|
pub fn apply_unicode_escapes(s: impl AsRef<str>) -> Option<String> {
|
||||||
|
let mut state = State::None;
|
||||||
|
let mut new = String::new();
|
||||||
|
let mut hex = String::new();
|
||||||
|
let mut char_iter = s.as_ref().chars();
|
||||||
|
while let Some(c) = char_iter.next() {
|
||||||
|
match state {
|
||||||
|
State::None if c == '\\' => {
|
||||||
|
if let (Some(c1), Some(c2)) = (char_iter.next(), char_iter.next()) {
|
||||||
|
if c1 == 'u' && c2 == '{' {
|
||||||
|
state = State::UnicodeHex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::None => new.push(c),
|
||||||
|
State::UnicodeHex => {
|
||||||
|
if c == '}' {
|
||||||
|
new.push(hex_to_char(&hex)?);
|
||||||
|
hex.clear();
|
||||||
|
state = State::None;
|
||||||
|
} else {
|
||||||
|
hex.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert hex string to char
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// assert_eq!(loupe::util::gettext::hex_to_char("03a6"), Some('Φ'))
|
||||||
|
/// ```
|
||||||
|
pub fn hex_to_char(hex: &str) -> Option<char> {
|
||||||
|
let u = u32::from_str_radix(hex, 16).ok()?;
|
||||||
|
char::from_u32(u)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Copyright (c) 2023-2024 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
|
// Copyright (c) 2024 Fina Wilke
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
// it under the terms of the GNU General Public License as published by
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Copyright (c) 2023-2024 Sophie Herold
|
// Copyright (c) 2023-2024 Sophie Herold
|
||||||
|
// Copyright (c) 2024 Fina Wilke
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
// it under the terms of the GNU General Public License as published by
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Copyright (c) 2022-2024 Sophie Herold
|
// Copyright (c) 2022-2024 Sophie Herold
|
||||||
// Copyright (c) 2022 Christopher Davis
|
// Copyright (c) 2022, 2024 Christopher Davis
|
||||||
// Copyright (c) 2024 Anders Jonsson
|
// Copyright (c) 2024 Anders Jonsson
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
// Copyright (c) 2023 FineFindus
|
// Copyright (c) 2023 FineFindus
|
||||||
// Copyright (c) 2023 Huan Nguyen
|
// Copyright (c) 2023 Huan Nguyen
|
||||||
// Copyright (c) 2023 Philipp Kiemle
|
// Copyright (c) 2023 Philipp Kiemle
|
||||||
|
// Copyright (c) 2024 Fina Wilke
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
// it under the terms of the GNU General Public License as published by
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
// Copyright (c) 2022-2024 Sophie Herold
|
// Copyright (c) 2022-2024 Sophie Herold
|
||||||
// Copyright (c) 2022 Christopher Davis
|
// Copyright (c) 2022 Christopher Davis
|
||||||
|
// Copyright (c) 2024 Lukáš Tyrychtr
|
||||||
|
// Copyright (c) 2024 Fina Wilke
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
// it under the terms of the GNU General Public License as published by
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -264,7 +266,7 @@ mod imp {
|
||||||
|
|
||||||
if let Some(bit_depth) = metadata.bit_depth() {
|
if let Some(bit_depth) = metadata.bit_depth() {
|
||||||
// Translators: Addition of bit size to format name
|
// Translators: Addition of bit size to format name
|
||||||
format.push_str(&gettext_f(", {}\u{202F}bit", [bit_depth.to_string()]));
|
format.push_str(&gettext_f(r", {}\u{202F}bit", [bit_depth.to_string()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(format)
|
Some(format)
|
||||||
|
|
@ -279,7 +281,11 @@ mod imp {
|
||||||
_ => {
|
_ => {
|
||||||
let (width, height) = image.image_size();
|
let (width, height) = image.image_size();
|
||||||
if width > 0 && height > 0 {
|
if width > 0 && height > 0 {
|
||||||
Some(format!("{width}\u{202F}\u{D7}\u{202F}{height}"))
|
// Translators: Image "<width> x <height>"
|
||||||
|
Some(gettext_f(
|
||||||
|
r"{}\u{202F}\u{D7}\u{202F}{}",
|
||||||
|
[width.to_string(), height.to_string()],
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// Copyright (c) 2023 Alice Mikhaylenko
|
// Copyright (c) 2023 Alice Mikhaylenko
|
||||||
// Copyright (c) 2023 Christopher Davis
|
// Copyright (c) 2023 Christopher Davis
|
||||||
// Copyright (c) 2023 Lubosz Sarnecki
|
// Copyright (c) 2023 Lubosz Sarnecki
|
||||||
|
// Copyright (c) 2024 Fina Wilke
|
||||||
//
|
//
|
||||||
// This program is free software: you can redistribute it and/or modify
|
// This program is free software: you can redistribute it and/or modify
|
||||||
// it under the terms of the GNU General Public License as published by
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
// Copyright (c) 2023 FineFindus
|
// Copyright (c) 2023 FineFindus
|
||||||
// Copyright (c) 2023 qwel
|
// Copyright (c) 2023 qwel
|
||||||
// Copyright (c) 2023 Huan Thieu Nguyen
|
// Copyright (c) 2023 Huan Thieu Nguyen
|
||||||
|
// Copyright (c) 2024 Fina Wilke
|
||||||
// Copyright (c) 2024 DaPigGuy
|
// Copyright (c) 2024 DaPigGuy
|
||||||
// Copyright (c) 2024 James Frost
|
// Copyright (c) 2024 James Frost
|
||||||
//
|
//
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue