colors: basic bar program
This commit is contained in:
parent
f605b0a80c
commit
c2cf314dcd
11
userspace/Cargo.lock
generated
11
userspace/Cargo.lock
generated
@ -396,9 +396,11 @@ checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
|
||||
name = "colors"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"cross",
|
||||
"libcolors",
|
||||
"libpsf",
|
||||
"log",
|
||||
"logsink",
|
||||
"runtime",
|
||||
@ -1338,6 +1340,13 @@ version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
|
||||
|
||||
[[package]]
|
||||
name = "libpsf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.3"
|
||||
@ -2670,9 +2679,9 @@ dependencies = [
|
||||
name = "term"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"clap",
|
||||
"libcolors",
|
||||
"libpsf",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
|
@ -20,7 +20,7 @@ members = [
|
||||
"lib/runtime",
|
||||
"lib/uipc",
|
||||
"lib/logsink"
|
||||
]
|
||||
, "lib/libpsf"]
|
||||
exclude = ["dynload-program", "test-kernel-module", "lib/ygglibc"]
|
||||
|
||||
[workspace.dependencies]
|
||||
@ -36,6 +36,8 @@ sha2 = { version = "0.10.8" }
|
||||
chrono = { version = "0.4.31", default-features = false }
|
||||
postcard = { version = "1.1.1", features = ["alloc"] }
|
||||
|
||||
raqote = { version = "0.8.3", default-features = false }
|
||||
|
||||
# Vendored/patched dependencies
|
||||
rand = { git = "https://git.alnyan.me/yggdrasil/rand.git", branch = "alnyan/yggdrasil" }
|
||||
ring = { git = "https://git.alnyan.me/yggdrasil/ring.git", branch = "alnyan/yggdrasil" }
|
||||
@ -48,6 +50,7 @@ ed25519-dalek = { git = "https://git.alnyan.me/yggdrasil/curve25519-dalek.git",
|
||||
libterm.path = "lib/libterm"
|
||||
runtime.path = "lib/runtime"
|
||||
libcolors = { path = "lib/libcolors", default-features = false }
|
||||
libpsf.path = "lib/libpsf"
|
||||
cross.path = "lib/cross"
|
||||
uipc.path = "lib/uipc"
|
||||
yggdrasil-rt.path = "../lib/runtime"
|
||||
|
@ -4,16 +4,21 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Mark Poliakov <mark@alnyan.me>"]
|
||||
|
||||
[[bin]]
|
||||
name = "colors-bar"
|
||||
|
||||
[dependencies]
|
||||
uipc.workspace = true
|
||||
cross.workspace = true
|
||||
logsink.workspace = true
|
||||
libcolors = { workspace = true, default-features = false }
|
||||
libcolors.workspace = true
|
||||
libpsf.workspace = true
|
||||
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
clap.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "yggdrasil")'.dependencies]
|
||||
yggdrasil-abi.workspace = true
|
||||
|
95
userspace/colors/src/bin/colors-bar.rs
Normal file
95
userspace/colors/src/bin/colors-bar.rs
Normal file
@ -0,0 +1,95 @@
|
||||
#![feature(yggdrasil_os, rustc_private)]
|
||||
use std::{os::fd::AsRawFd, process::ExitCode, sync::Arc, time::Duration};
|
||||
|
||||
use chrono::{DateTime, Timelike};
|
||||
use cross::io::{Poll, TimerFd};
|
||||
use libcolors::{
|
||||
application::{window::Window, Application},
|
||||
error::Error,
|
||||
message::{CreateWindowInfo, WindowType},
|
||||
};
|
||||
use libpsf::PcScreenFont;
|
||||
use runtime::rt::time::get_real_time;
|
||||
|
||||
fn draw_string(dt: &mut [u32], font: &PcScreenFont, x: u32, y: u32, text: &str, stride: usize) {
|
||||
let y = y as usize;
|
||||
|
||||
for (i, ch) in text.chars().enumerate() {
|
||||
let x = x as usize + i * font.width() as usize;
|
||||
font.map_glyph_pixels(ch as u32, |px, py, set| {
|
||||
let color = if set { 0xFFFFFFFF } else { 0xFF000000 };
|
||||
dt[(y + py) * stride + x + px] = color;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn string_width(font: &PcScreenFont, text: &str) -> u32 {
|
||||
(font.width() as usize * text.len()) as u32
|
||||
}
|
||||
|
||||
fn redraw(dt: &mut [u32], font: &PcScreenFont, width: u32, _height: u32) {
|
||||
dt.fill(0);
|
||||
|
||||
let now = get_real_time().unwrap();
|
||||
if let Some(now) = DateTime::from_timestamp(now.seconds() as _, now.subsec_nanos() as _) {
|
||||
let (pm, hour) = now.hour12();
|
||||
let ampm = if pm { "PM" } else { "AM" };
|
||||
let text = format!("{}:{:02} {ampm}", hour, now.minute());
|
||||
let tw = string_width(font, &text);
|
||||
draw_string(dt, font, width - tw - 4, 2, &text, width as usize);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<ExitCode, Error> {
|
||||
let font = Arc::new(PcScreenFont::default());
|
||||
let mut poll = Poll::new()?;
|
||||
let mut timer = TimerFd::new()?;
|
||||
let mut app = Application::new()?;
|
||||
let mut window = Window::new_with_info(
|
||||
&app,
|
||||
CreateWindowInfo {
|
||||
ty: WindowType::Reservation(24),
|
||||
},
|
||||
)?;
|
||||
|
||||
window.set_on_redraw_requested(move |dt, width, height| {
|
||||
redraw(dt, &font, width, height);
|
||||
});
|
||||
|
||||
app.add_window(window);
|
||||
|
||||
// TODO signals, events in Application?
|
||||
let app_fd = app.connection().lock().unwrap().as_raw_fd();
|
||||
let timer_fd = timer.as_raw_fd();
|
||||
poll.add(&app_fd)?;
|
||||
poll.add(&timer_fd)?;
|
||||
|
||||
timer.start(Duration::from_secs(1))?;
|
||||
|
||||
while app.is_running() {
|
||||
let fd = poll.wait(None)?.unwrap();
|
||||
|
||||
if fd == app_fd {
|
||||
app.poll_events()?;
|
||||
} else if fd == timer_fd {
|
||||
app.redraw()?;
|
||||
timer.start(Duration::from_secs(1))?;
|
||||
} else {
|
||||
log::warn!("Unexpected poll event: {fd:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
logsink::setup_logging();
|
||||
log::info!("colors-bar starting");
|
||||
match run() {
|
||||
Ok(code) => code,
|
||||
Err(error) => {
|
||||
log::error!("colors-bar: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
@ -58,9 +58,7 @@ impl<'a> Server<'a> {
|
||||
workspace: Workspace::new(800, 600),
|
||||
last_window_id: 1,
|
||||
|
||||
_pd: PhantomData, // windows: BTreeMap::new(),
|
||||
// rows: vec![],
|
||||
// focused_frame: None,
|
||||
_pd: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
@ -197,6 +195,8 @@ impl WindowServer for Server<'_> {
|
||||
self.workspace.resize(surface.width() as u32, surface.height() as u32);
|
||||
surface.fill(self.background);
|
||||
surface.present();
|
||||
|
||||
Command::new("/bin/colors-bar").spawn().ok();
|
||||
}
|
||||
|
||||
fn handle_client_message(
|
||||
@ -253,7 +253,7 @@ impl WindowServer for Server<'_> {
|
||||
let dst = Point(frame.x as _, frame.y as _);
|
||||
let src = Point(x as _, y as _);
|
||||
|
||||
log::info!("Blit {src:?} {w}x{h} -> {dst:?}");
|
||||
log::trace!("Blit {src:?} {w}x{h} -> {dst:?}");
|
||||
|
||||
surface.blit_buffer(
|
||||
window.surface_data,
|
||||
|
@ -508,7 +508,7 @@ impl<T: Eq + Hash + Copy> Workspace<T> {
|
||||
self.update_layout();
|
||||
}
|
||||
|
||||
pub fn update_layout(&mut self) {
|
||||
fn update_reservation_layout(&mut self) -> u32 {
|
||||
let mut res_y = 0;
|
||||
for reservation in self.reservations_top.iter() {
|
||||
reservation.layout.set(Rect {
|
||||
@ -520,7 +520,11 @@ impl<T: Eq + Hash + Copy> Workspace<T> {
|
||||
res_y += reservation.size;
|
||||
}
|
||||
self.reservation_top = res_y;
|
||||
res_y
|
||||
}
|
||||
|
||||
pub fn update_layout(&mut self) {
|
||||
let res_y = self.update_reservation_layout();
|
||||
self.update_layout_for(
|
||||
self.root,
|
||||
Rect {
|
||||
@ -533,7 +537,15 @@ impl<T: Eq + Hash + Copy> Workspace<T> {
|
||||
}
|
||||
|
||||
pub fn create_window(&mut self, wid: T) -> bool {
|
||||
self.create_window_in(self.root, wid)
|
||||
if self.create_window_in(self.root, wid) {
|
||||
for res in self.reservations_top.iter() {
|
||||
res.layout.clear();
|
||||
}
|
||||
self.update_reservation_layout();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_window(&mut self, wid: T) -> bool {
|
||||
|
@ -6,11 +6,11 @@ authors = ["Mark Poliakov <mark@alnyan.me>"]
|
||||
|
||||
[[example]]
|
||||
name = "bar"
|
||||
required-features = ["client", "client_raqote"]
|
||||
required-features = ["client"]
|
||||
|
||||
[[example]]
|
||||
name = "window"
|
||||
required-features = ["client", "client_raqote"]
|
||||
required-features = ["client"]
|
||||
|
||||
[dependencies]
|
||||
cross.workspace = true
|
||||
@ -21,15 +21,11 @@ serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# client_raqote
|
||||
raqote = { version = "0.8.3", default-features = false, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
raqote = { version = "0.8.3", default-features = false }
|
||||
raqote.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
client_raqote = ["client", "raqote"]
|
||||
default = ["client"]
|
||||
client = []
|
||||
|
||||
[lints]
|
||||
|
@ -41,10 +41,6 @@ impl Connection {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn as_poll_fd(&self) -> RawFd {
|
||||
self.poll.as_raw_fd()
|
||||
}
|
||||
|
||||
pub fn filter_events<T, F: Fn(Event) -> Result<T, Event>>(
|
||||
&mut self,
|
||||
predicate: F,
|
||||
@ -107,3 +103,9 @@ impl Connection {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRawFd for Connection {
|
||||
fn as_raw_fd(&self) -> RawFd {
|
||||
self.poll.as_raw_fd()
|
||||
}
|
||||
}
|
||||
|
@ -46,8 +46,12 @@ impl<'a> Application<'a> {
|
||||
self.windows.insert(window.id(), window);
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
!EXIT_SIGNAL.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn run_inner(mut self) -> Result<ExitCode, Error> {
|
||||
while !EXIT_SIGNAL.load(Ordering::Acquire) {
|
||||
while self.is_running() {
|
||||
self.poll_events()?;
|
||||
}
|
||||
Ok(ExitCode::SUCCESS)
|
||||
|
@ -15,11 +15,7 @@ pub trait OnKeyInput = Fn(KeyInput) -> EventOutcome;
|
||||
pub trait OnResized = Fn(u32, u32) -> EventOutcome;
|
||||
pub trait OnFocusChanged = Fn(bool) -> EventOutcome;
|
||||
|
||||
#[cfg(any(feature = "client_raqote", rust_analyzer))]
|
||||
pub trait OnRedrawRequested = Fn(&mut raqote::DrawTarget<&mut [u32]>);
|
||||
|
||||
#[cfg(not(feature = "client_raqote"))]
|
||||
pub trait OnRedrawRequested = Fn(&mut [u32]);
|
||||
pub trait OnRedrawRequested = Fn(&mut [u32], u32, u32);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum EventOutcome {
|
||||
@ -32,9 +28,6 @@ pub struct Window<'a> {
|
||||
connection: Arc<Mutex<Connection>>,
|
||||
window_id: u32,
|
||||
surface_mapping: FileMapping,
|
||||
#[cfg(any(feature = "client_raqote", rust_analyzer))]
|
||||
surface_draw_target: raqote::DrawTarget<&'a mut [u32]>,
|
||||
#[cfg(not(feature = "client_raqote"))]
|
||||
surface_data: &'a mut [u32],
|
||||
width: u32,
|
||||
height: u32,
|
||||
@ -71,22 +64,12 @@ impl<'a> Window<'a> {
|
||||
)
|
||||
};
|
||||
|
||||
#[cfg(any(feature = "client_raqote", rust_analyzer))]
|
||||
let surface_draw_target = raqote::DrawTarget::from_backing(
|
||||
create_info.width as _,
|
||||
create_info.height as _,
|
||||
surface_data,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
connection: application.connection.clone(),
|
||||
window_id: create_info.window_id,
|
||||
width: create_info.width,
|
||||
height: create_info.height,
|
||||
surface_mapping,
|
||||
#[cfg(any(feature = "client_raqote", rust_analyzer))]
|
||||
surface_draw_target,
|
||||
#[cfg(not(feature = "client_raqote"))]
|
||||
surface_data,
|
||||
|
||||
focused: false,
|
||||
@ -95,13 +78,7 @@ impl<'a> Window<'a> {
|
||||
// Do nothing
|
||||
EventOutcome::Destroy
|
||||
}),
|
||||
#[cfg(any(feature = "client_raqote", rust_analyzer))]
|
||||
on_redraw_requested: Box::new(|dt| {
|
||||
use raqote::SolidSource;
|
||||
dt.clear(SolidSource::from_unpremultiplied_argb(255, 127, 127, 127));
|
||||
}),
|
||||
#[cfg(not(feature = "client_raqote"))]
|
||||
on_redraw_requested: Box::new(|dt| {
|
||||
on_redraw_requested: Box::new(|dt, _, _| {
|
||||
dt.fill(0xFF888888);
|
||||
}),
|
||||
on_key_input: Box::new(|_ev| EventOutcome::None),
|
||||
@ -143,15 +120,7 @@ impl<'a> Window<'a> {
|
||||
}
|
||||
|
||||
pub fn redraw(&mut self) -> Result<(), Error> {
|
||||
#[cfg(any(feature = "client_raqote", rust_analyzer))]
|
||||
{
|
||||
let dt = &mut self.surface_draw_target;
|
||||
(self.on_redraw_requested)(dt);
|
||||
}
|
||||
#[cfg(not(feature = "client_raqote"))]
|
||||
{
|
||||
(self.on_redraw_requested)(self.surface_data);
|
||||
}
|
||||
(self.on_redraw_requested)(self.surface_data, self.width, self.height);
|
||||
|
||||
// Blit
|
||||
self.blit_rect(0, 0, self.width, self.height)
|
||||
@ -174,18 +143,7 @@ impl<'a> Window<'a> {
|
||||
)
|
||||
};
|
||||
|
||||
#[cfg(any(feature = "client_raqote", rust_analyzer))]
|
||||
{
|
||||
let new_draw_target =
|
||||
raqote::DrawTarget::from_backing(width as _, height as _, new_surface_data);
|
||||
|
||||
self.surface_draw_target = new_draw_target;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "client_raqote"))]
|
||||
{
|
||||
self.surface_data = new_surface_data;
|
||||
}
|
||||
self.surface_data = new_surface_data;
|
||||
|
||||
(self.on_resized)(width, height)
|
||||
}
|
||||
@ -205,11 +163,6 @@ impl<'a> Window<'a> {
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "client_raqote", rust_analyzer))]
|
||||
pub fn as_draw_target(&mut self) -> &mut raqote::DrawTarget<&'a mut [u32]> {
|
||||
&mut self.surface_draw_target
|
||||
}
|
||||
|
||||
fn blit_rect(&mut self, x: u32, y: u32, w: u32, h: u32) -> Result<(), Error> {
|
||||
// Clip to self bounds
|
||||
let x = x.min(self.width);
|
||||
|
10
userspace/lib/libpsf/Cargo.toml
Normal file
10
userspace/lib/libpsf/Cargo.toml
Normal file
@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "libpsf"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
bytemuck.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
@ -9,7 +9,7 @@ struct Align<T, D: ?Sized> {
|
||||
|
||||
static FONT_DATA: &Align<u32, [u8]> = &Align {
|
||||
_align: 0,
|
||||
data: *include_bytes!("../../etc/fonts/regular.psfu"),
|
||||
data: *include_bytes!("../../../etc/fonts/regular.psfu"),
|
||||
};
|
||||
|
||||
#[repr(C)]
|
||||
@ -71,4 +71,28 @@ impl<'a> PcScreenFont<'a> {
|
||||
pub fn raw_glyph_data(&self, index: u32) -> &[u8] {
|
||||
&self.glyph_data[(index * self.header.bytes_per_glyph) as usize..]
|
||||
}
|
||||
|
||||
pub fn map_glyph_pixels<F: FnMut(usize, usize, bool)>(&self, mut c: u32, mut put_pixel: F) {
|
||||
let bytes_per_line = (self.width() as usize + 7) / 8;
|
||||
// Draw character
|
||||
if c >= self.len() {
|
||||
c = b'?' as u32;
|
||||
}
|
||||
let mut glyph = self.raw_glyph_data(c);
|
||||
let mut y = 0;
|
||||
|
||||
while y < self.height() as usize {
|
||||
let mut mask = 1 << (self.width() - 1);
|
||||
let mut x = 0;
|
||||
// let row_off = (cy + y) * self.width;
|
||||
|
||||
while x < self.width() as usize {
|
||||
put_pixel(x, y, glyph[0] & mask != 0);
|
||||
mask >>= 1;
|
||||
x += 1;
|
||||
}
|
||||
glyph = &glyph[bytes_per_line..];
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
}
|
@ -5,7 +5,8 @@ edition = "2021"
|
||||
authors = ["Mark Poliakov <mark@alnyan.me>"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.3.19", features = ["std", "derive"], default-features = false }
|
||||
bytemuck = { workspace = true, features = ["derive"] }
|
||||
libcolors = { workspace = true, default-features = false, features = ["client"] }
|
||||
libpsf.workspace = true
|
||||
libcolors.workspace = true
|
||||
|
||||
thiserror.workspace = true
|
||||
clap.workspace = true
|
||||
|
@ -23,7 +23,7 @@ use std::{
|
||||
};
|
||||
|
||||
use error::Error;
|
||||
use font::PcScreenFont;
|
||||
use libpsf::PcScreenFont;
|
||||
use libcolors::{
|
||||
application::{
|
||||
window::{EventOutcome, Window},
|
||||
@ -35,7 +35,6 @@ use state::{Cursor, State};
|
||||
|
||||
pub mod attr;
|
||||
pub mod error;
|
||||
pub mod font;
|
||||
pub mod state;
|
||||
|
||||
struct DrawState {
|
||||
@ -89,7 +88,6 @@ impl DrawState {
|
||||
state.buffer.set_row_dirty(self.old_cursor.row);
|
||||
}
|
||||
|
||||
let bytes_per_line = (self.font.width() as usize + 7) / 8;
|
||||
let scroll = state.adjust_scroll();
|
||||
let cursor_visible = scroll == 0;
|
||||
state.buffer.visible_rows_mut(scroll, |i, row| {
|
||||
@ -111,32 +109,13 @@ impl DrawState {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Draw character
|
||||
let mut c = cell.char as u32;
|
||||
if c >= self.font.len() {
|
||||
c = b'?' as u32;
|
||||
}
|
||||
let mut glyph = self.font.raw_glyph_data(c);
|
||||
let mut y = 0;
|
||||
|
||||
while y < self.font.height() as usize {
|
||||
let mut mask = 1 << (self.font.width() - 1);
|
||||
let mut x = 0;
|
||||
let row_off = (cy + y) * self.width;
|
||||
|
||||
while x < self.font.width() as usize {
|
||||
let v = if glyph[0] & mask != 0 { fg } else { bg };
|
||||
dt[row_off + cx + x] = v;
|
||||
mask >>= 1;
|
||||
x += 1;
|
||||
}
|
||||
glyph = &glyph[bytes_per_line..];
|
||||
y += 1;
|
||||
}
|
||||
let c = cell.char as u32;
|
||||
self.font.map_glyph_pixels(c, |x, y, set| {
|
||||
let color = if set { fg } else { bg };
|
||||
dt[(cy + y) * self.width + cx + x] = color;
|
||||
});
|
||||
}
|
||||
});
|
||||
// for (i, row) in state.buffer.dirty_rows() {
|
||||
// }
|
||||
|
||||
// TODO check if there's a character under cursor
|
||||
if cursor_visible {
|
||||
@ -281,7 +260,7 @@ impl Terminal<'_> {
|
||||
});
|
||||
|
||||
let state_c = state.clone();
|
||||
window.set_on_redraw_requested(move |dt: &mut [u32]| {
|
||||
window.set_on_redraw_requested(move |dt: &mut [u32], _, _| {
|
||||
let mut s = state_c.lock().unwrap();
|
||||
let mut ds = draw_state.lock().unwrap();
|
||||
|
||||
@ -290,7 +269,7 @@ impl Terminal<'_> {
|
||||
|
||||
app.add_window(window);
|
||||
|
||||
let conn_fd = app.connection().lock().unwrap().as_poll_fd();
|
||||
let conn_fd = app.connection().lock().unwrap().as_raw_fd();
|
||||
|
||||
poll.add(conn_fd)?;
|
||||
poll.add(pty_master_fd)?;
|
||||
|
@ -57,6 +57,7 @@ const PROGRAMS: &[(&str, &str)] = &[
|
||||
("ncap", "sbin/ncap"),
|
||||
// colors
|
||||
("colors", "bin/colors"),
|
||||
("colors-bar", "bin/colors-bar"),
|
||||
("term", "bin/term"),
|
||||
// red
|
||||
("red", "bin/red"),
|
||||
|
Loading…
x
Reference in New Issue
Block a user