Got a working graphical display, text terminal, and tty!
parent
4acc215cf4
commit
dffee5652c
2
run.sh
2
run.sh
|
@ -2,4 +2,4 @@
|
|||
profile=${1:-"debug"}
|
||||
mkdir -p drive/EFI/Boot
|
||||
cp "target/x86_64-unknown-uefi/$profile/bootproof.efi" drive/EFI/Boot/BootX64.efi
|
||||
qemu-system-x86_64 -nodefaults -cpu host -smp 8 -m 512M -machine "q35,accel=kvm:tcg" -drive "if=pflash,format=raw,file=/usr/share/OVMF/OVMF_CODE.fd,readonly=on" -drive "if=pflash,format=raw,file=/usr/share/OVMF/OVMF_VARS.fd,readonly=on" -drive "format=raw,file=fat:rw:drive" -display gtk,gl=on -vga virtio -serial stdio
|
||||
qemu-system-x86_64 -nodefaults -cpu host -smp 8 -m 1G -machine "q35,accel=kvm:tcg" -drive "if=pflash,format=raw,file=/usr/share/OVMF/OVMF_CODE.fd,readonly=on" -drive "if=pflash,format=raw,file=/usr/share/OVMF/OVMF_VARS.fd,readonly=on" -drive "format=raw,file=fat:rw:drive" -display gtk,gl=on -vga virtio -serial stdio
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use alloc::alloc::GlobalAlloc;
|
||||
use core::alloc::Layout;
|
||||
use uefi::table::boot::{AllocateType, MemoryDescriptor, MemoryType};
|
||||
use uefi::table::boot::{AllocateType, MemoryType};
|
||||
|
||||
pub enum Allocator {
|
||||
None,
|
||||
|
@ -11,10 +11,9 @@ unsafe impl GlobalAlloc for Allocator {
|
|||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
match self {
|
||||
Allocator::Uefi(st) => {
|
||||
crate::log!("Allocate {:?}", layout);
|
||||
st.boot_services().allocate_pages(AllocateType::AnyPages, MemoryType::LOADER_DATA, layout.size())
|
||||
.expect("Failed to allocate memory!")
|
||||
.expect("Failed to allocate memory! 2")
|
||||
.unwrap()
|
||||
as *mut u8
|
||||
},
|
||||
Allocator::None => panic!("No allocator available!")
|
||||
|
@ -24,8 +23,9 @@ unsafe impl GlobalAlloc for Allocator {
|
|||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
match self {
|
||||
Allocator::Uefi(st) => {
|
||||
crate::log!("Free {:?}", layout);
|
||||
st.boot_services().free_pages(ptr as u64, layout.size());
|
||||
st.boot_services().free_pages(ptr as u64, layout.size())
|
||||
.expect("Failed to free memory!")
|
||||
.unwrap();
|
||||
},
|
||||
Allocator::None => {
|
||||
panic!("No allocator available!");
|
||||
|
|
105
src/graphics.rs
105
src/graphics.rs
|
@ -1,85 +1,34 @@
|
|||
mod font;
|
||||
pub mod color;
|
||||
pub mod display;
|
||||
pub mod font;
|
||||
pub mod terminal;
|
||||
pub mod tty;
|
||||
|
||||
use crate::println;
|
||||
use uefi::proto::console::gop::*;
|
||||
|
||||
fn px_index(stride: usize, x: usize, y: usize) -> usize {
|
||||
4 * (y * stride + x)
|
||||
}
|
||||
|
||||
fn make_px(r: u8, g: u8, b: u8) -> [u8; 4] {
|
||||
[b, g, r, 0]
|
||||
}
|
||||
|
||||
unsafe fn draw_char(fb: &mut FrameBuffer, stride: usize, cx: usize, cy: usize, c: char) {
|
||||
let font = font::font();
|
||||
let glyph = font.lookup(c).expect("Character missing from font.");
|
||||
let color = make_px(255, 255, 255);
|
||||
for dx in 0..num_integer::div_ceil(glyph.width(), 8) * 8 {
|
||||
for dy in 0..glyph.height() {
|
||||
if glyph.get(dx, dy) {
|
||||
let scale = 2;
|
||||
for sdx in 0..scale {
|
||||
for sdy in 0..scale {
|
||||
let px = cx + scale * dx as usize + sdx;
|
||||
let py = cy + scale * dy as usize + sdy;
|
||||
fb.write_value(px_index(stride, px, py), color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn draw_str(fb: &mut FrameBuffer, stride: usize, mut cx: usize, cy: usize, text: &str) {
|
||||
let width = font::font().width as usize * 2;
|
||||
for c in text.chars() {
|
||||
draw_char(fb, stride, cx, cy, c);
|
||||
cx += width;
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(fb: &mut FrameBuffer, stride: usize, width: usize, height: usize) {
|
||||
for x in 0..width {
|
||||
for y in 0..height {
|
||||
let i = px_index(stride, x, y);
|
||||
let r = (x * 256 / width) as u8;
|
||||
let g = (y * 256 / height) as u8;
|
||||
let b = 255 - ((r as u16 + g as u16) / 2) as u8;
|
||||
let px = make_px(r, g, b);
|
||||
unsafe {
|
||||
fb.write_value(i, px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let c_width = width / 8;
|
||||
let c_height = height / 16;
|
||||
unsafe {
|
||||
draw_str(fb, stride, 8, 8, "✔ Hello, world! ♡");
|
||||
}
|
||||
}
|
||||
use crate::graphics::color::{COLOR_BLACK, COLOR_WHITE};
|
||||
use crate::graphics::display::gop::GopDisplay;
|
||||
use crate::graphics::font::font;
|
||||
use crate::graphics::terminal::display::DisplayTerminal;
|
||||
use crate::graphics::tty::Tty;
|
||||
use crate::graphics::tty::terminal::TerminalTty;
|
||||
|
||||
pub fn do_graphics(st: &uefi::prelude::SystemTable<uefi::prelude::Boot>) {
|
||||
let gop = st.boot_services().locate_protocol::<GraphicsOutput>()
|
||||
.unwrap()
|
||||
.expect("UEFI Graphics Output Protocol (GOP) is not present.");
|
||||
let mut gop = unsafe { &mut *gop.get() };
|
||||
let mut mode = None;
|
||||
for gop_mode in gop.modes() {
|
||||
let gop_mode = gop_mode.expect("Warning while accessing GOP mode.");
|
||||
if let PixelFormat::BGR = gop_mode.info().pixel_format() {
|
||||
mode = Some(gop_mode);
|
||||
} else {
|
||||
println!("Ignoring non-BGR pixel format.");
|
||||
let display = GopDisplay::init(st.boot_services());
|
||||
let terminal = DisplayTerminal::create(display, font(), COLOR_BLACK, COLOR_WHITE);
|
||||
let mut tty = TerminalTty::create(terminal);
|
||||
|
||||
for _ in 0..30 {
|
||||
for c in 'a'..'z' {
|
||||
tty.putc(c);
|
||||
tty.putc('\n');
|
||||
}
|
||||
}
|
||||
let mode = mode.expect("No usable pixel formats found.");
|
||||
let (width, height) = mode.info().resolution();
|
||||
let stride = mode.info().stride();
|
||||
println!("Using mode: {}x{} {:?}", width, height, mode.info().pixel_format());
|
||||
gop.set_mode(&mode).unwrap().expect("Failed to set UEFI Graphics Output mode.");
|
||||
let mut fb = gop.frame_buffer();
|
||||
|
||||
draw(&mut fb, stride, width, height);
|
||||
for _ in 0..20 {
|
||||
for c in 'a'..'z' {
|
||||
tty.putc(c);
|
||||
}
|
||||
}
|
||||
tty.putc('\n');
|
||||
tty.puts("✔ Hello, world! ♡");
|
||||
tty.flush();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
#[derive(Copy, Clone)]
|
||||
pub struct RGB {
|
||||
r: u8,
|
||||
g: u8,
|
||||
b: u8
|
||||
}
|
||||
|
||||
pub trait Color: Copy {
|
||||
fn r(&self) -> u8;
|
||||
fn g(&self) -> u8;
|
||||
fn b(&self) -> u8;
|
||||
|
||||
fn into_rgb(&self) -> RGB {
|
||||
RGB {
|
||||
r: self.r(),
|
||||
g: self.g(),
|
||||
b: self.b()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Color for RGB {
|
||||
fn r(&self) -> u8 { self.r }
|
||||
fn g(&self) -> u8 { self.g }
|
||||
fn b(&self) -> u8 { self.b }
|
||||
}
|
||||
|
||||
pub const COLOR_BLACK: RGB = RGB { r: 0x23, g: 0x23, b: 0x23 };
|
||||
pub const COLOR_WHITE: RGB = RGB { r: 0xFF, g: 0xFF, b: 0xFF };
|
|
@ -0,0 +1,56 @@
|
|||
use crate::graphics::color::Color;
|
||||
use crate::graphics::font::psf::PSFGlyph;
|
||||
|
||||
pub mod gop;
|
||||
|
||||
pub trait Display {
|
||||
fn resolution(&self) -> (usize, usize);
|
||||
fn width(&self) -> usize { self.resolution().0 }
|
||||
fn height(&self) -> usize { self.resolution().1 }
|
||||
|
||||
unsafe fn set_pixel(&mut self, color: impl Color, x: usize, y: usize);
|
||||
fn set_pixel_ignore_oob(&mut self, color: impl Color, x: usize, y: usize) {
|
||||
if x > self.width() || y > self.height() {
|
||||
return;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
self.set_pixel(color, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self, color: impl Color);
|
||||
|
||||
unsafe fn draw_glyph(&mut self, color: impl Color, x: usize, y: usize, glyph: PSFGlyph) {
|
||||
// Glyphs may actually be larger than their nominal bounding box.
|
||||
// In fact, the Cozette font is like this: the heart symbol is 7 pixels wide,
|
||||
// despite nominally being a 6x13 font.
|
||||
// However, despite not being an intended use of the format, that extra pixel
|
||||
// can still be stored in the padding bits of the glyph (and is!).
|
||||
// Therefore, we just continue writing those extra bits if they are present.
|
||||
// Note that there is no similar trick for the height,
|
||||
// because the height doesn't have padding.
|
||||
for glyph_x in 0..glyph.width() {
|
||||
for glyph_y in 0..glyph.height() {
|
||||
if glyph.get(glyph_x, glyph_y) {
|
||||
self.set_pixel(color, x + glyph_x as usize, y + glyph_y as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sometimes, a font may actually have pixels outside its bounding box!
|
||||
// For example, in Cozette, a 6x13 font, ♡ is actually 7 pixels wide.
|
||||
// This data is still stored in the padding bits of the glyph.
|
||||
// Note that there is no similar trick for height because height doesn't have padding.
|
||||
// Futhermore, this only works on fonts whose width is not a multiple of eight.
|
||||
for glyph_x in glyph.width()..num_integer::div_ceil(glyph.width(), 8) * 8 {
|
||||
for glyph_y in 0..glyph.height() {
|
||||
if glyph.get(glyph_x, glyph_y) {
|
||||
// These pixels *nominally* aren't supposed to be there,
|
||||
// so we only force the pixels inside the bounding box.
|
||||
self.set_pixel_ignore_oob(color, x + glyph_x as usize, y + glyph_y as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
use crate::graphics::color::Color;
|
||||
use crate::graphics::display::Display;
|
||||
use uefi::proto::console::gop::{FrameBuffer, GraphicsOutput, ModeInfo, PixelFormat};
|
||||
|
||||
const PIXEL_WIDTH_BYTES: usize = 4;
|
||||
|
||||
pub struct GopDisplay<'a> {
|
||||
fb: FrameBuffer<'a>,
|
||||
mode: ModeInfo,
|
||||
}
|
||||
|
||||
impl GopDisplay<'_> {
|
||||
pub fn init<'boot>(bs: &'boot uefi::table::boot::BootServices) -> GopDisplay<'boot> {
|
||||
let gop = bs.locate_protocol::<GraphicsOutput>()
|
||||
.expect("UEFI Graphics Output Protocol (GOP) is not present.")
|
||||
.unwrap();
|
||||
let gop = unsafe { &mut *gop.get() };
|
||||
let mut mode = None;
|
||||
for gop_mode in gop.modes() {
|
||||
let gop_mode = gop_mode.unwrap();
|
||||
if let PixelFormat::BGR = gop_mode.info().pixel_format() {
|
||||
mode = Some(gop_mode);
|
||||
}
|
||||
}
|
||||
let mode = mode.expect("No usable pixel formats found.");
|
||||
let (width, height) = mode.info().resolution();
|
||||
crate::log!("Using mode: {}x{} {:?}", width, height, mode.info().pixel_format());
|
||||
gop.set_mode(&mode).expect("Failed to set UEFI Graphics Output mode.").unwrap();
|
||||
|
||||
let info = gop.current_mode_info();
|
||||
GopDisplay {
|
||||
fb: gop.frame_buffer(),
|
||||
mode: info,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a color to a BGR-formatted byte array.
|
||||
fn make_pixel(&self, color: impl Color) -> [u8; 4] {
|
||||
[color.b(), color.g(), color.r(), 0]
|
||||
}
|
||||
|
||||
fn pixel_index(&self, x: usize, y: usize) -> usize {
|
||||
PIXEL_WIDTH_BYTES * (self.mode.stride() * y + x)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for GopDisplay<'_> {
|
||||
fn resolution(&self) -> (usize, usize) { self.mode.resolution() }
|
||||
|
||||
unsafe fn set_pixel(&mut self, color: impl Color, x: usize, y: usize) {
|
||||
self.fb.write_value(self.pixel_index(x, y), self.make_pixel(color));
|
||||
}
|
||||
|
||||
fn clear(&mut self, color: impl Color) {
|
||||
let (width, height) = self.resolution();
|
||||
let px = self.make_pixel(color);
|
||||
for x in 0..width {
|
||||
for y in 0..height {
|
||||
unsafe {
|
||||
self.fb.write_value(self.pixel_index(x, y), px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@ use alloc::sync::Arc;
|
|||
use alloc::vec::Vec;
|
||||
use psf::*;
|
||||
|
||||
mod psf;
|
||||
pub mod psf;
|
||||
|
||||
static mut FONT: Option<Arc<PSF>> = None;
|
||||
|
||||
|
@ -16,20 +16,6 @@ pub fn font() -> Arc<PSF> {
|
|||
}
|
||||
}
|
||||
|
||||
fn pad(slice: &[u8]) -> [u8; 4] {
|
||||
if slice.len() == 1 {
|
||||
[slice[0], 0, 0, 0]
|
||||
} else if slice.len() == 2 {
|
||||
[slice[0], slice[1], 0, 0]
|
||||
} else if slice.len() == 3 {
|
||||
[slice[0], slice[1], slice[2], 0]
|
||||
} else if slice.len() == 4 {
|
||||
[slice[0], slice[1], slice[2], slice[3]]
|
||||
} else {
|
||||
crate::panic!("Bad character length {}", slice.len())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_font() -> PSF {
|
||||
use core::convert::TryInto;
|
||||
let font = core::include_bytes!("font/cozette.psf");
|
||||
|
@ -37,14 +23,13 @@ fn parse_font() -> PSF {
|
|||
let charsize = u32::from_le_bytes(font[20..24].try_into().unwrap());
|
||||
let height = u32::from_le_bytes(font[24..28].try_into().unwrap());
|
||||
let width = u32::from_le_bytes(font[28..32].try_into().unwrap());
|
||||
crate::log!("{} {} {}", width, height, charsize);
|
||||
|
||||
let glyphs_size = (length * charsize) as usize;
|
||||
let mut glyphs = Vec::with_capacity(glyphs_size);
|
||||
glyphs.extend_from_slice(&font[32..glyphs_size + 32]);
|
||||
|
||||
let mut unicode_map = Vec::new();
|
||||
let mut unicode_info = &font[glyphs_size + 32..];
|
||||
let unicode_info = &font[glyphs_size + 32..];
|
||||
let mut glyph = 0;
|
||||
let mut i = 0;
|
||||
while i < unicode_info.len() {
|
||||
|
|
|
@ -51,7 +51,6 @@ impl PSFGlyph<'_> {
|
|||
|
||||
pub fn get(&self, x: u32, y: u32) -> bool {
|
||||
let line_size = num_integer::div_ceil(self.width, 8);
|
||||
let char_size = line_size * self.height;
|
||||
let (line_byte_index, bit_index) = num_integer::div_rem(x, 8);
|
||||
let mask = 0b10000000 >> bit_index;
|
||||
let byte = self.bitmap[(y * line_size + line_byte_index) as usize];
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
pub mod display;
|
||||
|
||||
pub trait Terminal {
|
||||
fn resolution(&self) -> (usize, usize);
|
||||
fn width(&self) -> usize { self.resolution().0 }
|
||||
fn height(&self) -> usize { self.resolution().1 }
|
||||
|
||||
fn set_char(&mut self, x: usize, y: usize, c: char);
|
||||
fn clear(&mut self);
|
||||
|
||||
fn refresh(&mut self);
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
use alloc::sync::Arc;
|
||||
use alloc::vec::Vec;
|
||||
use crate::graphics::color::{Color, RGB};
|
||||
use crate::graphics::display::Display;
|
||||
use crate::graphics::display::gop::GopDisplay;
|
||||
use crate::graphics::font::psf::PSF;
|
||||
use crate::graphics::terminal::Terminal;
|
||||
|
||||
pub struct DisplayTerminal<'a> {
|
||||
dp: GopDisplay<'a>,
|
||||
font: Arc<PSF>,
|
||||
buf: Vec<char>,
|
||||
bg: RGB,
|
||||
fg: RGB,
|
||||
}
|
||||
|
||||
impl DisplayTerminal<'_> {
|
||||
pub fn create<'a>(dp: GopDisplay<'a>, font: Arc<PSF>, bg: impl Color, fg: impl Color) -> DisplayTerminal<'a> {
|
||||
let (dp_width, dp_height) = dp.resolution();
|
||||
let (font_width, font_height) = (font.width, font.height);
|
||||
DisplayTerminal {
|
||||
dp: dp,
|
||||
font: font,
|
||||
buf: {
|
||||
let char_count = (dp_width / font_width as usize) * (dp_height / font_height as usize);
|
||||
let mut buf = Vec::with_capacity(char_count);
|
||||
for _ in 0..char_count {
|
||||
buf.push(' ');
|
||||
}
|
||||
buf
|
||||
},
|
||||
bg: bg.into_rgb(),
|
||||
fg: fg.into_rgb(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_index(&self, x: usize, y: usize) -> usize {
|
||||
self.width() * y + x
|
||||
}
|
||||
|
||||
fn get_char(&self, x: usize, y: usize) -> char {
|
||||
let i = self.get_index(x, y);
|
||||
self.buf[i]
|
||||
}
|
||||
}
|
||||
|
||||
impl Terminal for DisplayTerminal<'_> {
|
||||
fn resolution(&self) -> (usize, usize) {
|
||||
let width = self.dp.width() / self.font.width as usize;
|
||||
let height = self.dp.height() / self.font.height as usize;
|
||||
(width, height)
|
||||
}
|
||||
|
||||
fn set_char(&mut self, x: usize, y: usize, c: char) {
|
||||
let i = self.get_index(x, y);
|
||||
self.buf.as_mut_slice()[i] = c;
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
for x in 0..self.width() {
|
||||
for y in 0..self.height() {
|
||||
self.set_char(x, y, ' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.dp.clear(self.bg);
|
||||
for x in 0..self.width() {
|
||||
for y in 0..self.height() {
|
||||
let glyph = self.font.lookup(self.get_char(x, y)).expect("Character missing from font.");
|
||||
unsafe {
|
||||
self.dp.draw_glyph(self.fg, self.font.width as usize * x, self.font.height as usize * y, glyph);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
pub mod terminal;
|
||||
|
||||
pub trait Tty {
|
||||
fn putc(&mut self, c: char);
|
||||
fn puts(&mut self, s: &str);
|
||||
fn clear(&mut self);
|
||||
fn flush(&mut self);
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
use alloc::string::{String, ToString};
|
||||
use alloc::vec::Vec;
|
||||
use crate::graphics::terminal::Terminal;
|
||||
use crate::graphics::terminal::display::DisplayTerminal;
|
||||
use crate::graphics::tty::Tty;
|
||||
|
||||
pub struct TerminalTty<'a> {
|
||||
term: DisplayTerminal<'a>,
|
||||
lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl TerminalTty<'_> {
|
||||
pub fn create<'a>(term: DisplayTerminal<'a>) -> TerminalTty<'a> {
|
||||
TerminalTty {
|
||||
term: term,
|
||||
lines: {
|
||||
let mut vec = Vec::new();
|
||||
vec.push("".to_string());
|
||||
vec
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tty for TerminalTty<'_> {
|
||||
fn putc(&mut self, c: char) {
|
||||
if c == '\n' {
|
||||
self.lines.push("".to_string());
|
||||
return;
|
||||
}
|
||||
let i = self.lines.len() - 1;
|
||||
self.lines[i].push(c);
|
||||
}
|
||||
|
||||
fn puts(&mut self, s: &str) {
|
||||
for c in s.chars() {
|
||||
self.putc(c);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
self.lines.push("".to_string());
|
||||
}
|
||||
|
||||
fn flush(&mut self) {
|
||||
let mut physical_lines = Vec::new();
|
||||
for line in &self.lines {
|
||||
let mut chars = line.chars().collect::<Vec<_>>().into_iter();
|
||||
while chars.len() > 0 {
|
||||
let mut physical_line = String::new();
|
||||
let width = chars.len().min(self.term.width());
|
||||
for _ in 0..width {
|
||||
physical_line.push(chars.next().unwrap());
|
||||
}
|
||||
physical_lines.push(physical_line);
|
||||
}
|
||||
}
|
||||
|
||||
let mut y = physical_lines.len().min(self.term.height() - 1);
|
||||
for line in physical_lines.into_iter().rev() {
|
||||
let mut x = 0;
|
||||
for c in line.chars() {
|
||||
self.term.set_char(x, y, c);
|
||||
x += 1;
|
||||
}
|
||||
|
||||
if y == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
y -= 1;
|
||||
}
|
||||
|
||||
self.term.refresh();
|
||||
}
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
#![no_std]
|
||||
#![no_main]
|
||||
#![feature(abi_efiapi)]
|
||||
#![feature(alloc)]
|
||||
#![feature(alloc_error_handler)]
|
||||
#![feature(asm)]
|
||||
extern crate alloc;
|
||||
|
|
Loading…
Reference in New Issue