i treesittered
This commit is contained in:
parent
638a9a112b
commit
7424dc67b7
9 changed files with 219 additions and 148 deletions
97
Cargo.lock
generated
97
Cargo.lock
generated
|
@ -500,6 +500,10 @@ dependencies = [
|
|||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "careless"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.1.2"
|
||||
|
@ -1651,6 +1655,22 @@ dependencies = [
|
|||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inkjet"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd9fd670f1a42725a90e7a97f5e6a777fc56f9031c1ee0ebcea8f91a6bb9c2f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cc",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"toml",
|
||||
"tree-sitter",
|
||||
"tree-sitter-highlight",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.3"
|
||||
|
@ -2166,13 +2186,13 @@ name = "nite"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"careless",
|
||||
"funnylog",
|
||||
"inkjet",
|
||||
"kaydle",
|
||||
"log",
|
||||
"ming",
|
||||
"serde",
|
||||
"tree-sitter-highlight",
|
||||
"tree-sitter-rust",
|
||||
"widestring",
|
||||
]
|
||||
|
||||
|
@ -2689,7 +2709,7 @@ version = "3.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.21.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -3140,6 +3160,15 @@ dependencies = [
|
|||
"syn 2.0.63",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
|
@ -3592,11 +3621,26 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit 0.22.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
|
@ -3606,7 +3650,20 @@ checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1"
|
|||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"winnow",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3328d4f68a705b2a4498da1d580585d39a6510f98318a2cec3018a7ec61ddef"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"winnow 0.6.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -3653,9 +3710,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.22.6"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df7cc499ceadd4dcdf7ec6d4cbc34ece92c3fa07821e287aedecd4416c516dca"
|
||||
checksum = "e747b1f9b7b931ed39a548c1fae149101497de3c1fc8d9e18c62c1a66c683d3d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
|
@ -3663,26 +3720,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tree-sitter-highlight"
|
||||
version = "0.22.6"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaca0fe34fa96eec6aaa8e63308dbe1bafe65a6317487c287f93938959b21907"
|
||||
checksum = "042342584c5a7a0b833d9fc4e2bdab3f9868ddc6c4b339a1e01451c6720868bc"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"regex",
|
||||
"thiserror",
|
||||
"tree-sitter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-rust"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "277690f420bf90741dea984f3da038ace46c4fe6047cba57a66822226cde1c93"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.20.0"
|
||||
|
@ -4250,6 +4296,15 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3c52e9c97a68071b23e836c9380edae937f17b9c4667bd021973efc689f618d"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wio"
|
||||
version = "0.2.2"
|
||||
|
@ -4471,7 +4526,3 @@ dependencies = [
|
|||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[patch.unused]]
|
||||
name = "careless"
|
||||
version = "0.1.0"
|
||||
|
|
|
@ -9,10 +9,10 @@ ming.workspace = true
|
|||
anyhow.workspace = true
|
||||
widestring = "1.1.0"
|
||||
log.workspace = true
|
||||
tree-sitter-highlight = "0.22.6"
|
||||
tree-sitter-rust = "0.21.2"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
kaydle = "0.2.0"
|
||||
inkjet = { version = "0.10.5", features = ["language-rust", "language-toml", "language-zig"], default-features = false }
|
||||
careless = { git = "https://codeberg.org/minky/careless", version = "0.1.0" }
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
|
|
|
@ -109,6 +109,7 @@ pub trait IntoElement: Sized {
|
|||
}
|
||||
}
|
||||
|
||||
impl FluentBuilder for crate::TextRun {}
|
||||
impl<T: IntoElement> FluentBuilder for T {}
|
||||
|
||||
/// An object that can be drawn to the screen. This is the trait that distinguishes `Views` from
|
||||
|
|
|
@ -656,6 +656,8 @@ impl<'de> serde::Deserialize<'de> for FontWeight {
|
|||
.map_err(|()| E::custom("expected a font weight value (e.g. 'normal' or 'bold')"))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(Visitor)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,13 @@ pub trait FluentBuilder {
|
|||
{
|
||||
f(self)
|
||||
}
|
||||
|
||||
/// Imperatively modify self with the given closure.
|
||||
fn apply(mut self, f: impl FnOnce(&mut Self)) -> Self where Self: Sized {
|
||||
f(&mut self);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Conditionally modify self with the given closure.
|
||||
fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
|
||||
|
|
148
src/buffer.rs
148
src/buffer.rs
|
@ -1,7 +1,10 @@
|
|||
use careless::prelude::*;
|
||||
use inkjet::tree_sitter_highlight::{Error, HighlightEvent, Highlighter};
|
||||
use std::{ops::Range, path::PathBuf};
|
||||
use tree_sitter_highlight::{HighlightConfiguration, Highlighter};
|
||||
use widestring::Utf16String;
|
||||
|
||||
use crate::editor::Theme;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub struct Buffer {
|
||||
|
@ -31,71 +34,43 @@ impl Buffer {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn styled(
|
||||
&self,
|
||||
default_style: &TextStyle,
|
||||
theme: &crate::editor::Theme,
|
||||
syntax_set: &SyntaxSet,
|
||||
) -> Vec<StyledText> {
|
||||
pub fn styled(&self, default_style: &TextStyle, theme: &Theme) -> Vec<StyledText> {
|
||||
let text = self.text.to_string();
|
||||
|
||||
if let Some(syntax) = self
|
||||
if let Some(language) = self
|
||||
.path
|
||||
.as_ref()
|
||||
.and_then(|path| path.extension())
|
||||
.and_then(|extension| {
|
||||
Some(
|
||||
match extension.to_str().expect("non-utf8 file ext") {
|
||||
"rs" => HighlightConfiguration::new(
|
||||
tree_sitter_rust::language(),
|
||||
"rust",
|
||||
tree_sitter_rust::HIGHLIGHTS_QUERY,
|
||||
tree_sitter_rust::INJECTIONS_QUERY,
|
||||
"",
|
||||
),
|
||||
_ => return None,
|
||||
}
|
||||
.unwrap(),
|
||||
)
|
||||
inkjet::Language::from_token(extension.to_str()?).map(|lang| lang.config())
|
||||
})
|
||||
{
|
||||
let mut parse = ParseState::new(syntax);
|
||||
let hler = Highlighter::new(theme);
|
||||
let mut hl = HighlightState::new(&hler, ScopeStack::new());
|
||||
let mut hl = Highlighter::new();
|
||||
let mut lines = vec![];
|
||||
|
||||
text.lines()
|
||||
.flat_map(|line| {
|
||||
parse.parse_line(line, &syntax_set).map(|parsed| {
|
||||
StyledText::new(Arc::from(line)).with_highlights(
|
||||
default_style,
|
||||
RangedHighlightIterator::new(&mut hl, &parsed, line, &hler).map(
|
||||
move |(style, _text, range)| {
|
||||
(
|
||||
range,
|
||||
HighlightStyle {
|
||||
color: Some(hsla_from_syntect(style.foreground)),
|
||||
background_color: None,
|
||||
font_style: style
|
||||
.font_style
|
||||
.contains(SFontStyle::ITALIC)
|
||||
.then(|| FontStyle::Italic),
|
||||
underline: style
|
||||
.font_style
|
||||
.contains(SFontStyle::UNDERLINE)
|
||||
.then(|| UnderlineStyle::default()),
|
||||
font_weight: style
|
||||
.font_style
|
||||
.contains(SFontStyle::BOLD)
|
||||
.then(|| FontWeight::BOLD),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
for line in text.lines() {
|
||||
// damnation
|
||||
match hl
|
||||
.highlight(language, line.as_bytes(), None, |lang| {
|
||||
inkjet::Language::from_token(lang).map(|lang| lang.config())
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.and_then(|hls| {
|
||||
Ok(StyledText::new(Arc::from(line)).with_runs(
|
||||
MingHighlighter::new(hls, default_style, theme).try_to_collect()?,
|
||||
))
|
||||
}) {
|
||||
Ok(styled) => lines.push(styled),
|
||||
Err(error) => {
|
||||
log::error!("highlight error: {error}");
|
||||
|
||||
lines.push(StyledText::new(Arc::from(line)));
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
} else {
|
||||
text.lines()
|
||||
.map(|line| StyledText::new(Arc::from(line)))
|
||||
|
@ -104,12 +79,57 @@ impl Buffer {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn hsla_from_syntect(color: Color) -> Hsla {
|
||||
Rgba {
|
||||
r: color.r as f32 / 255.0,
|
||||
g: color.g as f32 / 255.0,
|
||||
b: color.b as f32 / 255.0,
|
||||
a: color.a as f32 / 255.0,
|
||||
}
|
||||
.into()
|
||||
pub struct MingHighlighter<'a, I> {
|
||||
iter: I,
|
||||
default_style: &'a TextStyle,
|
||||
theme: &'a Theme,
|
||||
current_hl_idx: Option<usize>,
|
||||
current_span: Option<Range<usize>>,
|
||||
}
|
||||
|
||||
impl<'a, I> MingHighlighter<'a, I> {
|
||||
pub fn new(iter: I, default_style: &'a TextStyle, theme: &'a Theme) -> Self {
|
||||
Self {
|
||||
iter,
|
||||
default_style,
|
||||
theme,
|
||||
current_hl_idx: None,
|
||||
current_span: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: Iterator<Item = Result<HighlightEvent, Error>>> Iterator for MingHighlighter<'_, I> {
|
||||
type Item = Result<TextRun, Error>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
use careless::prelude::*;
|
||||
|
||||
TryOption::into(
|
||||
try {
|
||||
match self.iter.try_next()? {
|
||||
HighlightEvent::Source { start, end } => {
|
||||
self.current_span = Some(start..end);
|
||||
|
||||
self.default_style.to_run(end - start).apply(|run| {
|
||||
if let Some(hl) = self
|
||||
.current_hl_idx
|
||||
.and_then(|idx| self.theme.highlight_indices.get(idx))
|
||||
{
|
||||
hl.apply_to_run(run)
|
||||
}
|
||||
})
|
||||
}
|
||||
HighlightEvent::HighlightStart(hl_idx) => {
|
||||
self.current_hl_idx = Some(hl_idx.0);
|
||||
self.try_next()?
|
||||
}
|
||||
HighlightEvent::HighlightEnd => {
|
||||
self.current_hl_idx = None;
|
||||
self.try_next()?
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ pub struct EditorSettings {
|
|||
style: EditorStyle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
pub struct Highlight {
|
||||
pub color: Option<Rgba>,
|
||||
#[serde(default)]
|
||||
|
@ -20,17 +20,41 @@ pub struct Highlight {
|
|||
pub weight: Option<FontWeight>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
impl Highlight {
|
||||
pub fn apply_to_run(&self, run: &mut TextRun) {
|
||||
if let Some(color) = self.color {
|
||||
run.color = color.into();
|
||||
}
|
||||
|
||||
if let Some(style) = self.style {
|
||||
run.font.style = style;
|
||||
}
|
||||
|
||||
if let Some(weight) = self.weight {
|
||||
run.font.weight = weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Theme {
|
||||
pub name: String,
|
||||
pub background_color: Rgba,
|
||||
pub text_color: Rgba,
|
||||
pub highlights: HashMap<String, Highlight>,
|
||||
#[serde(skip)]
|
||||
pub highlight_indices: Vec<Highlight>
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn load(text: &str) -> Result<Self, kaydle::serde::de::Error> {
|
||||
kaydle::serde::from_str(text)
|
||||
kaydle::serde::from_str(text).map(|mut me: Self| {
|
||||
me.highlight_indices = inkjet::constants::HIGHLIGHT_NAMES.iter().flat_map(|hl| {
|
||||
me.highlights.get(*hl).cloned()
|
||||
}).collect();
|
||||
|
||||
me
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,6 +85,7 @@ pub struct Editor {
|
|||
buf: Model<crate::buffer::Buffer>,
|
||||
settings: Model<EditorSettings>,
|
||||
cursor: Point<usize>,
|
||||
scroll: Point<Pixels>,
|
||||
logger: Logger,
|
||||
}
|
||||
|
||||
|
@ -82,6 +107,7 @@ impl Editor {
|
|||
settings,
|
||||
logger,
|
||||
cursor: Point::default(),
|
||||
scroll: Point::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -107,10 +133,10 @@ impl Render for Editor {
|
|||
me.cursor.y = me.cursor.y.saturating_sub(1);
|
||||
funnylog::trace!(me.logger, ?me.cursor, "up");
|
||||
}))
|
||||
.overflow_hidden()
|
||||
.child(element::EditorElement::new(
|
||||
cx.view(),
|
||||
self.settings.read(cx).style.clone(),
|
||||
self.cursor,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,15 @@
|
|||
use crate::buffer::hsla_from_syntect;
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
pub struct EditorElement {
|
||||
editor: View<Editor>,
|
||||
style: EditorStyle,
|
||||
cursor: Point<usize>,
|
||||
}
|
||||
|
||||
impl EditorElement {
|
||||
pub fn new(viewref: &View<Editor>, style: EditorStyle, cursor: Point<usize>) -> Self {
|
||||
pub fn new(viewref: &View<Editor>, style: EditorStyle) -> Self {
|
||||
Self {
|
||||
editor: viewref.clone(),
|
||||
style,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -66,14 +61,9 @@ impl Element for EditorElement {
|
|||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_content: Some(AlignContent::FlexStart),
|
||||
overflow: Point::all(Overflow::Scroll),
|
||||
overflow: Point::all(Overflow::Hidden),
|
||||
size: Size::full(),
|
||||
background: self
|
||||
.style
|
||||
.theme
|
||||
.settings
|
||||
.background
|
||||
.map(|bg| Fill::Color(hsla_from_syntect(bg))),
|
||||
background: Some(Fill::Color(self.style.theme.background_color.into())),
|
||||
..Default::default()
|
||||
},
|
||||
children.iter().copied(),
|
||||
|
@ -95,35 +85,12 @@ impl Element for EditorElement {
|
|||
request_layout: &mut Self::RequestLayoutState,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
if self.cursor.y > 0 {
|
||||
let offset = Point::new(
|
||||
px(0.),
|
||||
request_layout.styled.iter_mut().take(self.cursor.y).fold(
|
||||
px(0.),
|
||||
|px, (id, _, bounds)| {
|
||||
*bounds = cx.layout_bounds(*id);
|
||||
px + bounds.size.height
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
cx.with_element_offset(offset, |cx| {
|
||||
request_layout
|
||||
.styled
|
||||
.iter_mut()
|
||||
.skip(self.cursor.y)
|
||||
.for_each(|(_id, styled, bounds)| styled.prepaint(None, *bounds, &mut (), cx));
|
||||
});
|
||||
} else {
|
||||
cx.with_element_offset(self.editor.read(cx).scroll, |cx| {
|
||||
request_layout
|
||||
.styled
|
||||
.iter_mut()
|
||||
.skip(self.cursor.y)
|
||||
.for_each(|(id, styled, bounds)| {
|
||||
*bounds = cx.layout_bounds(*id);
|
||||
styled.prepaint(None, *bounds, &mut (), cx)
|
||||
});
|
||||
}
|
||||
.for_each(|(_id, styled, bounds)| styled.prepaint(None, *bounds, &mut (), cx));
|
||||
});
|
||||
|
||||
EditorLayout {}
|
||||
}
|
||||
|
@ -151,8 +118,7 @@ impl Element for EditorElement {
|
|||
log::trace!("{scroll:#?}");
|
||||
view.update(cx, |editor, cx| match scroll.delta {
|
||||
ScrollDelta::Lines(lines) => {
|
||||
let lines_y = lines.y.ceil() as i32;
|
||||
editor.cursor.y = editor.cursor.y.saturating_add_signed(lines_y as isize)
|
||||
|
||||
}
|
||||
ScrollDelta::Pixels(pix) => {
|
||||
let px_y = (pix.y.ceil().0 / font_size.0) as i32;
|
||||
|
@ -163,22 +129,18 @@ impl Element for EditorElement {
|
|||
|
||||
cx.with_text_style(
|
||||
Some(TextStyleRefinement {
|
||||
color: self
|
||||
.style
|
||||
.theme
|
||||
.settings
|
||||
.foreground
|
||||
.map(hsla_from_syntect),
|
||||
color: Some(self.style.theme.text_color.into()),
|
||||
..Default::default()
|
||||
}),
|
||||
|cx| {
|
||||
request_layout
|
||||
.styled
|
||||
.iter_mut()
|
||||
.skip(self.cursor.y)
|
||||
.for_each(|(id, styled, bounds)| {
|
||||
styled.paint(None, *bounds, &mut (), &mut (), cx);
|
||||
});
|
||||
cx.with_element_offset(self.editor.read(cx).scroll, |cx| {
|
||||
request_layout
|
||||
.styled
|
||||
.iter_mut()
|
||||
.for_each(|(_id, styled, bounds)| {
|
||||
styled.paint(None, *bounds, &mut (), &mut (), cx)
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
#![feature(try_blocks)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use funnylog::{filter::LevelFilter, span, Drain, Level};
|
||||
use ming::*;
|
||||
use ming::{*, prelude::*};
|
||||
|
||||
mod buffer;
|
||||
mod editor;
|
||||
|
|
Loading…
Reference in a new issue