Began work on panes and buffers
This commit is contained in:
parent
a58caed9e9
commit
4d4d6a3470
11 changed files with 590 additions and 306 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
target/
|
||||
zte.log
|
||||
101
src/action.rs
101
src/action.rs
|
|
@ -1,8 +1,5 @@
|
|||
use crate::{
|
||||
state::State,
|
||||
terminal::TerminalEvent,
|
||||
};
|
||||
use crossterm::event::{KeyEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use crate::{state::State, terminal::TerminalEvent};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Dir {
|
||||
|
|
@ -14,14 +11,15 @@ pub enum Dir {
|
|||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Action {
|
||||
Char(char), // Insert a character
|
||||
Backspace, // Backspace a character
|
||||
Move(Dir), // Move the cursor
|
||||
Cancel, // Cancels the current context
|
||||
Go, // Search, accept, or select the current option
|
||||
Quit, // Quit the application
|
||||
OpenPrompt, // Open the command prompt
|
||||
Show(String), // Display some arbitrary text to the user
|
||||
Char(char), // Insert a character
|
||||
Backspace, // Backspace a character
|
||||
Move(Dir), // Move the cursor
|
||||
PaneMove(Dir), // Move panes
|
||||
Cancel, // Cancels the current context
|
||||
Go, // Search, accept, or select the current option
|
||||
Quit, // Quit the application
|
||||
OpenPrompt, // Open the command prompt
|
||||
Show(String), // Display some arbitrary text to the user
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
|
|
@ -51,16 +49,38 @@ pub struct RawEvent(TerminalEvent);
|
|||
|
||||
impl RawEvent {
|
||||
pub fn to_char(&self) -> Option<char> {
|
||||
match &self.0 {
|
||||
match self.0 {
|
||||
TerminalEvent::Key(KeyEvent {
|
||||
code,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => match code {
|
||||
KeyCode::Char(c) => Some(*c),
|
||||
KeyCode::Backspace => Some('\x08'),
|
||||
KeyCode::Enter => Some('\n'),
|
||||
KeyCode::Char(c)
|
||||
if matches!(modifiers, KeyModifiers::NONE | KeyModifiers::SHIFT) =>
|
||||
{
|
||||
Some(c)
|
||||
}
|
||||
KeyCode::Backspace if modifiers == KeyModifiers::NONE => Some('\x08'),
|
||||
KeyCode::Enter if modifiers == KeyModifiers::NONE => Some('\n'),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_pane_move(&self) -> Option<Dir> {
|
||||
match &self.0 {
|
||||
TerminalEvent::Key(KeyEvent {
|
||||
code,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => match code {
|
||||
KeyCode::Char('a') => Some(Dir::Left),
|
||||
KeyCode::Char('d') => Some(Dir::Right),
|
||||
KeyCode::Char('w') => Some(Dir::Up),
|
||||
KeyCode::Char('s') => Some(Dir::Down),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
|
|
@ -86,29 +106,38 @@ impl RawEvent {
|
|||
}
|
||||
|
||||
pub fn is_go(&self) -> bool {
|
||||
matches!(&self.0, TerminalEvent::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}))
|
||||
matches!(
|
||||
&self.0,
|
||||
TerminalEvent::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_prompt(&self) -> bool {
|
||||
matches!(&self.0, TerminalEvent::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}))
|
||||
matches!(
|
||||
&self.0,
|
||||
TerminalEvent::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_cancel(&self) -> bool {
|
||||
matches!(&self.0, TerminalEvent::Key(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}))
|
||||
matches!(
|
||||
&self.0,
|
||||
TerminalEvent::Key(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
src/main.rs
25
src/main.rs
|
|
@ -1,21 +1,17 @@
|
|||
mod action;
|
||||
mod ui;
|
||||
mod terminal;
|
||||
mod state;
|
||||
mod terminal;
|
||||
mod theme;
|
||||
mod ui;
|
||||
|
||||
use crate::{
|
||||
terminal::{Terminal, TerminalEvent, Color},
|
||||
action::{Event, Action, Dir},
|
||||
action::{Action, Dir, Event},
|
||||
state::State,
|
||||
terminal::{Color, Terminal, TerminalEvent},
|
||||
ui::{Element as _, Visual as _},
|
||||
};
|
||||
use clap::Parser;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::Duration,
|
||||
io,
|
||||
};
|
||||
use std::{io, path::PathBuf, time::Duration};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct Args {
|
||||
|
|
@ -33,7 +29,8 @@ fn main() -> Result<(), Error> {
|
|||
println!("{args:?}");
|
||||
|
||||
let mut state = State::try_from(args)?;
|
||||
let mut ui = ui::Root::new(&state);
|
||||
let open_buffers = state.buffers.keys().collect::<Vec<_>>();
|
||||
let mut ui = ui::Root::new(&mut state, &open_buffers);
|
||||
|
||||
Terminal::with(move |term| {
|
||||
loop {
|
||||
|
|
@ -46,13 +43,17 @@ fn main() -> Result<(), Error> {
|
|||
|
||||
while let Some(ev) = term.get_event() {
|
||||
// Resize events are special and need handling by the terminal
|
||||
if let TerminalEvent::Resize(cols, rows) = ev { term.set_size([cols, rows]); }
|
||||
if let TerminalEvent::Resize(cols, rows) = ev {
|
||||
term.set_size([cols, rows]);
|
||||
}
|
||||
|
||||
// Have the UI handle events
|
||||
if ui
|
||||
.handle(Event::from_raw(ev))
|
||||
.map_or(false, |r| r.should_end())
|
||||
{ return Ok(()); }
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
74
src/state.rs
74
src/state.rs
|
|
@ -1,49 +1,43 @@
|
|||
use crate::{
|
||||
theme,
|
||||
ui::{self, Resp, Element as _},
|
||||
Action, Event, Args, Error, Color,
|
||||
ui::{self, Element as _, Resp},
|
||||
Action, Args, Color, Error, Event,
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyModifiers, KeyEvent, KeyEventKind};
|
||||
use slotmap::{HopSlotMap, new_key_type};
|
||||
use std::collections::HashMap;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use slotmap::{new_key_type, HopSlotMap};
|
||||
use std::{io, path::PathBuf};
|
||||
|
||||
new_key_type! {
|
||||
// Per-activity
|
||||
pub struct ViewId;
|
||||
pub struct ActivityId;
|
||||
pub struct BufferId;
|
||||
pub struct CursorId;
|
||||
}
|
||||
|
||||
pub struct Cursor {
|
||||
base: (usize, usize),
|
||||
pos: (usize, usize),
|
||||
#[derive(Default)]
|
||||
pub struct Cursor {}
|
||||
|
||||
pub struct Buffer {
|
||||
pub path: PathBuf,
|
||||
pub chars: Vec<char>,
|
||||
pub cursors: HopSlotMap<CursorId, Cursor>,
|
||||
}
|
||||
|
||||
pub struct FileView {
|
||||
line: usize,
|
||||
cursor: Cursor,
|
||||
// For searches
|
||||
// view_cursor: Option<Cursor>,
|
||||
}
|
||||
|
||||
pub struct File {
|
||||
views: HopSlotMap<ViewId, FileView>,
|
||||
}
|
||||
|
||||
pub struct ConsoleView {
|
||||
line: usize,
|
||||
}
|
||||
|
||||
pub struct Console {
|
||||
views: HopSlotMap<ViewId, ConsoleView>,
|
||||
}
|
||||
|
||||
pub enum Activity {
|
||||
File(File),
|
||||
Console(Console),
|
||||
impl Buffer {
|
||||
pub fn new(path: PathBuf) -> Result<Self, Error> {
|
||||
let chars = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s.chars().collect(),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
Ok(Self {
|
||||
path,
|
||||
chars,
|
||||
cursors: HopSlotMap::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub activities: HopSlotMap<ActivityId, Activity>,
|
||||
pub buffers: HopSlotMap<BufferId, Buffer>,
|
||||
pub tick: u64,
|
||||
pub theme: theme::Theme,
|
||||
}
|
||||
|
|
@ -51,11 +45,17 @@ pub struct State {
|
|||
impl TryFrom<Args> for State {
|
||||
type Error = Error;
|
||||
fn try_from(args: Args) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
activities: HopSlotMap::default(),
|
||||
let mut this = Self {
|
||||
buffers: HopSlotMap::default(),
|
||||
tick: 0,
|
||||
theme: theme::Theme::default(),
|
||||
})
|
||||
};
|
||||
|
||||
for path in args.paths {
|
||||
this.buffers.insert(Buffer::new(path)?);
|
||||
}
|
||||
|
||||
Ok(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
271
src/terminal.rs
271
src/terminal.rs
|
|
@ -1,25 +1,17 @@
|
|||
use crate::{theme, Error};
|
||||
|
||||
pub use crossterm::{
|
||||
cursor::SetCursorStyle as CursorStyle,
|
||||
event::Event as TerminalEvent,
|
||||
style::Color,
|
||||
cursor::SetCursorStyle as CursorStyle, event::Event as TerminalEvent, style::Color,
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
cursor, event, style, terminal, ExecutableCommand, QueueableCommand, SynchronizedUpdate,
|
||||
};
|
||||
use std::{
|
||||
borrow::Borrow,
|
||||
io::{self, StdoutLock, Write as _},
|
||||
panic,
|
||||
time::Duration,
|
||||
borrow::Borrow,
|
||||
};
|
||||
use crossterm::{
|
||||
event,
|
||||
style,
|
||||
cursor,
|
||||
terminal,
|
||||
ExecutableCommand,
|
||||
QueueableCommand,
|
||||
SynchronizedUpdate,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, PartialEq)]
|
||||
|
|
@ -45,23 +37,32 @@ pub struct Rect<'a> {
|
|||
origin: [u16; 2],
|
||||
size: [u16; 2],
|
||||
fb: &'a mut Framebuffer,
|
||||
has_focus: bool,
|
||||
}
|
||||
|
||||
impl<'a> Rect<'a> {
|
||||
fn get_mut(&mut self, pos: [usize; 2]) -> Option<&mut Cell> {
|
||||
if pos[0] < self.size()[0] && pos[1] < self.size()[1] {
|
||||
let offs = [self.origin[0] as usize + pos[0], self.origin[1] as usize + pos[1]];
|
||||
let offs = [
|
||||
self.origin[0] as usize + pos[0],
|
||||
self.origin[1] as usize + pos[1],
|
||||
];
|
||||
Some(&mut self.fb.cells[offs[1] * self.fb.size[0] as usize + offs[0]])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with<R>(&mut self, f: impl FnOnce(&mut Rect) -> R) -> R { f(self) }
|
||||
pub fn with<R>(&mut self, f: impl FnOnce(&mut Rect) -> R) -> R {
|
||||
f(self)
|
||||
}
|
||||
|
||||
pub fn rect(&mut self, origin: [usize; 2], size: [usize; 2]) -> Rect {
|
||||
Rect {
|
||||
origin: [self.origin[0] + origin[0] as u16, self.origin[1] + origin[1] as u16],
|
||||
origin: [
|
||||
self.origin[0] + origin[0] as u16,
|
||||
self.origin[1] + origin[1] as u16,
|
||||
],
|
||||
size: [
|
||||
size[0].min((self.size[0] as usize).saturating_sub(origin[0])) as u16,
|
||||
size[1].min((self.size[1] as usize).saturating_sub(origin[1])) as u16,
|
||||
|
|
@ -69,63 +70,140 @@ impl<'a> Rect<'a> {
|
|||
fg: self.fg,
|
||||
bg: self.bg,
|
||||
fb: self.fb,
|
||||
has_focus: self.has_focus,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_border(&mut self, theme: &theme::BorderTheme) -> Rect {
|
||||
let edge = self.size().map(|e| e.saturating_sub(1));
|
||||
for col in 0..edge[0] {
|
||||
self.get_mut([col, 0]).map(|c| c.c = theme.top);
|
||||
self.get_mut([col, edge[1]]).map(|c| c.c = theme.bottom);
|
||||
self.get_mut([col, 0]).map(|c| {
|
||||
c.c = theme.top;
|
||||
c.fg = theme.fg;
|
||||
});
|
||||
self.get_mut([col, edge[1]]).map(|c| {
|
||||
c.c = theme.bottom;
|
||||
c.fg = theme.fg;
|
||||
});
|
||||
}
|
||||
for row in 0..edge[1] {
|
||||
self.get_mut([0, row]).map(|c| c.c = theme.left);
|
||||
self.get_mut([edge[0], row]).map(|c| c.c = theme.right);
|
||||
self.get_mut([0, row]).map(|c| {
|
||||
c.c = theme.left;
|
||||
c.fg = theme.fg;
|
||||
});
|
||||
self.get_mut([edge[0], row]).map(|c| {
|
||||
c.c = theme.right;
|
||||
c.fg = theme.fg;
|
||||
});
|
||||
}
|
||||
self.get_mut([0, 0]).map(|c| c.c = theme.top_left);
|
||||
self.get_mut([edge[0], 0]).map(|c| c.c = theme.top_right);
|
||||
self.get_mut([0, edge[1]]).map(|c| c.c = theme.bottom_left);
|
||||
self.get_mut([edge[0], edge[1]]).map(|c| c.c = theme.bottom_right);
|
||||
self.get_mut([0, 0]).map(|c| {
|
||||
c.c = theme.top_left;
|
||||
c.fg = theme.fg;
|
||||
});
|
||||
self.get_mut([edge[0], 0]).map(|c| {
|
||||
c.c = theme.top_right;
|
||||
c.fg = theme.fg;
|
||||
});
|
||||
self.get_mut([0, edge[1]]).map(|c| {
|
||||
c.c = theme.bottom_left;
|
||||
c.fg = theme.fg;
|
||||
});
|
||||
self.get_mut([edge[0], edge[1]]).map(|c| {
|
||||
c.c = theme.bottom_right;
|
||||
c.fg = theme.fg;
|
||||
});
|
||||
self.rect([1, 1], self.size().map(|e| e.saturating_sub(2)))
|
||||
}
|
||||
|
||||
pub fn with_fg(&mut self, fg: Color) -> Rect {
|
||||
Rect { fg, bg: self.bg, origin: self.origin, size: self.size, fb: self.fb }
|
||||
Rect {
|
||||
fg,
|
||||
bg: self.bg,
|
||||
origin: self.origin,
|
||||
size: self.size,
|
||||
fb: self.fb,
|
||||
has_focus: self.has_focus,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_bg(&mut self, bg: Color) -> Rect {
|
||||
Rect { fg: self.fg, bg, origin: self.origin, size: self.size, fb: self.fb }
|
||||
Rect {
|
||||
fg: self.fg,
|
||||
bg,
|
||||
origin: self.origin,
|
||||
size: self.size,
|
||||
fb: self.fb,
|
||||
has_focus: self.has_focus,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(&self) -> [usize; 2] { self.size.map(|e| e as usize) }
|
||||
pub fn with_focus(&mut self, focus: bool) -> Rect {
|
||||
Rect {
|
||||
fg: self.fg,
|
||||
bg: self.bg,
|
||||
origin: self.origin,
|
||||
size: self.size,
|
||||
fb: self.fb,
|
||||
has_focus: self.has_focus && focus,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_focus(&self) -> bool {
|
||||
self.has_focus
|
||||
}
|
||||
|
||||
pub fn size(&self) -> [usize; 2] {
|
||||
self.size.map(|e| e as usize)
|
||||
}
|
||||
|
||||
pub fn fill(&mut self, c: char) -> Rect {
|
||||
for row in 0..self.size()[1] {
|
||||
for col in 0..self.size()[0] {
|
||||
let cell = Cell { c, fg: self.fg, bg: self.bg };
|
||||
if let Some(c) = self.get_mut([col, row]) { *c = cell; }
|
||||
let cell = Cell {
|
||||
c,
|
||||
fg: self.fg,
|
||||
bg: self.bg,
|
||||
};
|
||||
if let Some(c) = self.get_mut([col, row]) {
|
||||
*c = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.rect([0, 0], self.size())
|
||||
}
|
||||
|
||||
pub fn text<C: Borrow<char>>(&mut self, origin: [usize; 2], text: impl IntoIterator<Item = C>) -> Rect {
|
||||
pub fn text<C: Borrow<char>>(
|
||||
&mut self,
|
||||
origin: [usize; 2],
|
||||
text: impl IntoIterator<Item = C>,
|
||||
) -> Rect {
|
||||
for (idx, c) in text.into_iter().enumerate() {
|
||||
if origin[0] + idx >= self.size()[0] {
|
||||
break;
|
||||
} else {
|
||||
let cell = Cell { c: *c.borrow(), fg: self.fg, bg: self.bg };
|
||||
if let Some(c) = self.get_mut([origin[0] + idx, origin[1]]) { *c = cell; }
|
||||
let cell = Cell {
|
||||
c: *c.borrow(),
|
||||
fg: self.fg,
|
||||
bg: self.bg,
|
||||
};
|
||||
if let Some(c) = self.get_mut([origin[0] + idx, origin[1]]) {
|
||||
*c = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.rect([0, 0], self.size())
|
||||
}
|
||||
|
||||
pub fn set_cursor(&mut self, cursor: [usize; 2], style: CursorStyle) -> Rect {
|
||||
self.fb.cursor = Some((
|
||||
[self.origin[0] + cursor[0] as u16, self.origin[1] + cursor[1] as u16],
|
||||
style,
|
||||
));
|
||||
if self.has_focus {
|
||||
self.fb.cursor = Some((
|
||||
[
|
||||
self.origin[0] + cursor[0] as u16,
|
||||
self.origin[1] + cursor[1] as u16,
|
||||
],
|
||||
style,
|
||||
));
|
||||
}
|
||||
self.rect([0, 0], self.size())
|
||||
}
|
||||
}
|
||||
|
|
@ -145,6 +223,7 @@ impl Framebuffer {
|
|||
origin: [0, 0],
|
||||
size: self.size,
|
||||
fb: self,
|
||||
has_focus: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -167,7 +246,9 @@ impl<'a> Terminal<'a> {
|
|||
let _ = stdout.execute(cursor::Show);
|
||||
}
|
||||
|
||||
pub fn with<T>(f: impl FnOnce(&mut Self) -> Result<T, Error> + panic::UnwindSafe) -> Result<T, Error> {
|
||||
pub fn with<T>(
|
||||
f: impl FnOnce(&mut Self) -> Result<T, Error> + panic::UnwindSafe,
|
||||
) -> Result<T, Error> {
|
||||
let size = terminal::window_size()?;
|
||||
|
||||
Self::enter(io::stdout().lock());
|
||||
|
|
@ -179,7 +260,10 @@ impl<'a> Terminal<'a> {
|
|||
};
|
||||
|
||||
let hook = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |panic| { Self::leave(io::stdout().lock()); hook(panic); }));
|
||||
panic::set_hook(Box::new(move |panic| {
|
||||
Self::leave(io::stdout().lock());
|
||||
hook(panic);
|
||||
}));
|
||||
let res = f(&mut this);
|
||||
|
||||
Self::leave(io::stdout().lock());
|
||||
|
|
@ -195,70 +279,81 @@ impl<'a> Terminal<'a> {
|
|||
// Reset framebuffer
|
||||
if self.fb[0].size != self.size {
|
||||
self.fb[0].size = self.size;
|
||||
self.fb[0].cells.resize(self.size[0] as usize * self.size[1] as usize, Cell::default());
|
||||
self.fb[0].cells.resize(
|
||||
self.size[0] as usize * self.size[1] as usize,
|
||||
Cell::default(),
|
||||
);
|
||||
}
|
||||
self.fb[0].cursor = None;
|
||||
|
||||
render(&mut self.fb[0].rect());
|
||||
|
||||
self.stdout.sync_update(|stdout| {
|
||||
let mut cursor_pos = [0, 0];
|
||||
let mut fg = Color::Reset;
|
||||
let mut bg = Color::Reset;
|
||||
stdout
|
||||
.queue(cursor::MoveTo(cursor_pos[0], cursor_pos[1])).unwrap()
|
||||
.queue(style::SetForegroundColor(fg)).unwrap()
|
||||
.queue(style::SetBackgroundColor(bg)).unwrap()
|
||||
.queue(cursor::Hide).unwrap();
|
||||
self.stdout
|
||||
.sync_update(|stdout| {
|
||||
let mut cursor_pos = [0, 0];
|
||||
let mut fg = Color::Reset;
|
||||
let mut bg = Color::Reset;
|
||||
stdout
|
||||
.queue(cursor::MoveTo(cursor_pos[0], cursor_pos[1]))
|
||||
.unwrap()
|
||||
.queue(style::SetForegroundColor(fg))
|
||||
.unwrap()
|
||||
.queue(style::SetBackgroundColor(bg))
|
||||
.unwrap()
|
||||
.queue(cursor::Hide)
|
||||
.unwrap();
|
||||
|
||||
// Write out changes
|
||||
for row in 0..self.size[1] {
|
||||
for col in 0..self.size[0] {
|
||||
let pos = row as usize * self.size[0] as usize + col as usize;
|
||||
let cell = self.fb[0].cells[pos];
|
||||
// Write out changes
|
||||
for row in 0..self.size[1] {
|
||||
for col in 0..self.size[0] {
|
||||
let pos = row as usize * self.size[0] as usize + col as usize;
|
||||
let cell = self.fb[0].cells[pos];
|
||||
|
||||
let changed = self.fb[0].size != self.fb[1].size
|
||||
|| cell != self.fb[1].cells[pos];
|
||||
let changed =
|
||||
self.fb[0].size != self.fb[1].size || cell != self.fb[1].cells[pos];
|
||||
|
||||
if changed {
|
||||
if cursor_pos != [col, row] {
|
||||
// Minimise the work done to move the cursor around
|
||||
if cursor_pos[1] == row {
|
||||
stdout.queue(cursor::MoveToColumn(col)).unwrap();
|
||||
} else if cursor_pos[0] == col {
|
||||
stdout.queue(cursor::MoveToRow(row)).unwrap();
|
||||
} else {
|
||||
stdout.queue(cursor::MoveTo(col, row)).unwrap();
|
||||
if changed {
|
||||
if cursor_pos != [col, row] {
|
||||
// Minimise the work done to move the cursor around
|
||||
if cursor_pos[1] == row {
|
||||
stdout.queue(cursor::MoveToColumn(col)).unwrap();
|
||||
} else if cursor_pos[0] == col {
|
||||
stdout.queue(cursor::MoveToRow(row)).unwrap();
|
||||
} else {
|
||||
stdout.queue(cursor::MoveTo(col, row)).unwrap();
|
||||
}
|
||||
cursor_pos = [col, row];
|
||||
}
|
||||
if fg != cell.fg {
|
||||
fg = cell.fg;
|
||||
stdout.queue(style::SetForegroundColor(fg)).unwrap();
|
||||
}
|
||||
if bg != cell.bg {
|
||||
bg = cell.bg;
|
||||
stdout.queue(style::SetBackgroundColor(bg)).unwrap();
|
||||
}
|
||||
cursor_pos = [col, row];
|
||||
}
|
||||
if fg != cell.fg {
|
||||
fg = cell.fg;
|
||||
stdout.queue(style::SetForegroundColor(fg)).unwrap();
|
||||
}
|
||||
if bg != cell.bg {
|
||||
bg = cell.bg;
|
||||
stdout.queue(style::SetBackgroundColor(bg)).unwrap();
|
||||
}
|
||||
|
||||
stdout.queue(style::Print(self.fb[0].cells[pos].c)).unwrap();
|
||||
stdout.queue(style::Print(self.fb[0].cells[pos].c)).unwrap();
|
||||
|
||||
// Move cursor
|
||||
cursor_pos[0] += 1;
|
||||
if cursor_pos[0] >= self.size[0] { cursor_pos = [0, cursor_pos[1] + 1]; }
|
||||
// Move cursor
|
||||
cursor_pos[0] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(([col, row], style)) = self.fb[0].cursor {
|
||||
stdout
|
||||
.queue(cursor::MoveTo(col, row)).unwrap()
|
||||
.queue(style).unwrap()
|
||||
.queue(cursor::Show).unwrap();
|
||||
} else {
|
||||
stdout.queue(cursor::Hide).unwrap();
|
||||
}
|
||||
}).unwrap();
|
||||
if let Some(([col, row], style)) = self.fb[0].cursor {
|
||||
stdout
|
||||
.queue(cursor::MoveTo(col, row))
|
||||
.unwrap()
|
||||
.queue(style)
|
||||
.unwrap()
|
||||
.queue(cursor::Show)
|
||||
.unwrap();
|
||||
} else {
|
||||
stdout.queue(cursor::Hide).unwrap();
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
self.stdout.flush().unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub struct BorderTheme {
|
|||
pub top_right: char,
|
||||
pub bottom_left: char,
|
||||
pub bottom_right: char,
|
||||
pub fg: Color,
|
||||
}
|
||||
|
||||
impl Default for BorderTheme {
|
||||
|
|
@ -22,6 +23,7 @@ impl Default for BorderTheme {
|
|||
top_right: '╮',
|
||||
bottom_left: '╰',
|
||||
bottom_right: '╯',
|
||||
fg: Color::DarkGrey,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +32,7 @@ pub struct Theme {
|
|||
pub ui_bg: Color,
|
||||
pub status_bg: Color,
|
||||
pub border: BorderTheme,
|
||||
pub focus_border: BorderTheme,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
|
|
@ -38,6 +41,10 @@ impl Default for Theme {
|
|||
ui_bg: Color::AnsiValue(235),
|
||||
status_bg: Color::AnsiValue(23),
|
||||
border: BorderTheme::default(),
|
||||
focus_border: BorderTheme {
|
||||
fg: Color::White,
|
||||
..BorderTheme::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,28 +16,31 @@ impl Input {
|
|||
|
||||
impl Element for Input {
|
||||
fn handle(&mut self, event: Event) -> Result<Resp, Event> {
|
||||
match event.to_action(|e| e.to_char().map(Action::Char)
|
||||
.or_else(|| e.to_move().map(Action::Move))) {
|
||||
match event.to_action(|e| {
|
||||
e.to_char()
|
||||
.map(Action::Char)
|
||||
.or_else(|| e.to_move().map(Action::Move))
|
||||
}) {
|
||||
Some(Action::Char('\x08')) => {
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
if self.text.len() > self.cursor {
|
||||
self.text.remove(self.cursor);
|
||||
}
|
||||
Ok(Resp::handled(None))
|
||||
},
|
||||
}
|
||||
Some(Action::Char(c)) => {
|
||||
self.text.insert(self.cursor, c);
|
||||
self.cursor += 1;
|
||||
Ok(Resp::handled(None))
|
||||
},
|
||||
}
|
||||
Some(Action::Move(Dir::Left)) => {
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
Ok(Resp::handled(None))
|
||||
},
|
||||
}
|
||||
Some(Action::Move(Dir::Right)) => {
|
||||
self.cursor = (self.cursor + 1).min(self.text.len());
|
||||
Ok(Resp::handled(None))
|
||||
},
|
||||
}
|
||||
_ => Err(event),
|
||||
}
|
||||
}
|
||||
|
|
@ -49,10 +52,12 @@ impl Visual for Input {
|
|||
frame.fill(' ');
|
||||
frame.text([0, 0], self.preamble.chars());
|
||||
|
||||
frame.rect([self.preamble.chars().count(), 0], frame.size()).with(|frame| {
|
||||
frame.text([0, 0], &self.text);
|
||||
frame.set_cursor([self.cursor, 0], CursorStyle::BlinkingBar);
|
||||
});
|
||||
frame
|
||||
.rect([self.preamble.chars().count(), 0], frame.size())
|
||||
.with(|frame| {
|
||||
frame.text([0, 0], &self.text);
|
||||
frame.set_cursor([self.cursor, 0], CursorStyle::BlinkingBar);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
mod input;
|
||||
mod panes;
|
||||
mod prompt;
|
||||
mod root;
|
||||
mod panes;
|
||||
mod status;
|
||||
mod input;
|
||||
|
||||
pub use self::{
|
||||
prompt::{Prompt, Confirm, Show},
|
||||
panes::Panes,
|
||||
status::Status,
|
||||
root::Root,
|
||||
input::Input,
|
||||
panes::Panes,
|
||||
prompt::{Confirm, Prompt, Show},
|
||||
root::Root,
|
||||
status::Status,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
terminal::{Rect, Color},
|
||||
State, Action, Event, Dir,
|
||||
terminal::{Color, Rect},
|
||||
Action, Dir, Event, State,
|
||||
};
|
||||
|
||||
pub enum CannotEnd {}
|
||||
|
|
@ -33,7 +33,9 @@ impl Resp<CanEnd> {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn should_end(&self) -> bool { self.should_end.is_some() }
|
||||
pub fn should_end(&self) -> bool {
|
||||
self.should_end.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Resp<T> {
|
||||
|
|
@ -68,18 +70,17 @@ pub struct Label(String);
|
|||
|
||||
impl std::ops::Deref for Label {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target { &self.0 }
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Visual for Label {
|
||||
fn render(&self, state: &State, frame: &mut Rect) {
|
||||
frame
|
||||
.with_bg(state.theme.ui_bg)
|
||||
.fill(' ')
|
||||
.with(|frame| {
|
||||
for (idx, line) in self.lines().enumerate() {
|
||||
frame.text([0, idx], line.chars());
|
||||
}
|
||||
});
|
||||
frame.with_bg(state.theme.ui_bg).fill(' ').with(|frame| {
|
||||
for (idx, line) in self.lines().enumerate() {
|
||||
frame.text([0, idx], line.chars());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
src/ui/panes.rs
111
src/ui/panes.rs
|
|
@ -1,12 +1,115 @@
|
|||
use super::*;
|
||||
use crate::state::{BufferId, Cursor, CursorId};
|
||||
|
||||
pub struct Panes;
|
||||
#[derive(Clone)]
|
||||
pub struct Doc {
|
||||
buffer: BufferId,
|
||||
cursor: CursorId,
|
||||
}
|
||||
|
||||
impl Element for Panes {
|
||||
impl Doc {
|
||||
pub fn new(state: &mut State, buffer: BufferId) -> Self {
|
||||
Self {
|
||||
buffer,
|
||||
cursor: state.buffers[buffer].cursors.insert(Cursor::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Doc {
|
||||
fn handle(&mut self, event: Event) -> Result<Resp, Event> {
|
||||
match event.to_action(|e| None) {
|
||||
//Some(_) => todo!(),
|
||||
match event.to_action(|e| {
|
||||
e.to_char()
|
||||
.map(Action::Char)
|
||||
.or_else(|| e.to_move().map(Action::Move))
|
||||
.or_else(|| e.to_pane_move().map(Action::PaneMove))
|
||||
}) {
|
||||
_ => Err(event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visual for Doc {
|
||||
fn render(&self, state: &State, frame: &mut Rect) {
|
||||
if let Some(buffer) = state.buffers.get(self.buffer) {
|
||||
for (i, line) in buffer.chars.split(|c| *c == '\n').enumerate() {
|
||||
frame.text([0, i], line);
|
||||
}
|
||||
} else {
|
||||
frame.text([0, 0], "[Error: no buffer]".chars());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Pane {
|
||||
Doc(Doc),
|
||||
}
|
||||
|
||||
pub struct Panes {
|
||||
selected: usize,
|
||||
panes: Vec<Pane>,
|
||||
}
|
||||
|
||||
impl Panes {
|
||||
pub fn new(state: &mut State, buffers: &[BufferId]) -> Self {
|
||||
Self {
|
||||
selected: 0,
|
||||
panes: buffers
|
||||
.iter()
|
||||
.map(|b| Pane::Doc(Doc::new(state, *b)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Panes {
|
||||
fn handle(&mut self, event: Event) -> Result<Resp, Event> {
|
||||
match event.to_action(|e| e.to_pane_move().map(Action::PaneMove)) {
|
||||
Some(Action::PaneMove(Dir::Left)) => {
|
||||
self.selected = (self.selected + self.panes.len() - 1) % self.panes.len();
|
||||
Ok(Resp::handled(None))
|
||||
}
|
||||
Some(Action::PaneMove(Dir::Right)) => {
|
||||
self.selected = (self.selected + 1) % self.panes.len();
|
||||
Ok(Resp::handled(None))
|
||||
}
|
||||
// Pass anything else through to the active pane
|
||||
err => {
|
||||
if let Some(pane) = self.panes.get_mut(self.selected) {
|
||||
// Pass to pane
|
||||
match pane {
|
||||
Pane::Doc(doc) => doc.handle(event),
|
||||
}
|
||||
} else {
|
||||
// No active pane, don't handle
|
||||
Err(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visual for Panes {
|
||||
fn render(&self, state: &State, frame: &mut Rect) {
|
||||
for (i, pane) in self.panes.iter().enumerate() {
|
||||
let boundary = |i| frame.size()[0] * i / self.panes.len();
|
||||
|
||||
let (x0, x1) = (boundary(i), boundary(i + 1));
|
||||
|
||||
let is_selected = self.selected == i;
|
||||
let border_theme = if frame.has_focus() && is_selected {
|
||||
&state.theme.focus_border
|
||||
} else {
|
||||
&state.theme.border
|
||||
};
|
||||
frame
|
||||
.rect([x0, 0], [x1 - x0, frame.size()[1]])
|
||||
.with_border(border_theme)
|
||||
.with_focus(is_selected)
|
||||
.with(|frame| match pane {
|
||||
Pane::Doc(doc) => doc.render(state, frame),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,14 @@ impl Prompt {
|
|||
pub fn get_action(&self) -> Option<Action> {
|
||||
match self.input.get_text().as_str() {
|
||||
"quit" => Some(Action::Quit),
|
||||
"version" => Some(Action::Show(format!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")))),
|
||||
"help" => Some(Action::Show(format!("Temporary help info:\n- quit\n- version\n- help"))),
|
||||
"version" => Some(Action::Show(format!(
|
||||
"{} {}",
|
||||
env!("CARGO_PKG_NAME"),
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))),
|
||||
"help" => Some(Action::Show(format!(
|
||||
"Temporary help info:\n- quit\n- version\n- help"
|
||||
))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -36,21 +42,28 @@ impl Prompt {
|
|||
|
||||
impl Element<CanEnd> for Prompt {
|
||||
fn handle(&mut self, event: Event) -> Result<Resp<CanEnd>, Event> {
|
||||
match event.to_action(|e| if e.is_cancel() {
|
||||
Some(Action::Cancel)
|
||||
} else if e.is_go() {
|
||||
Some(Action::Go)
|
||||
} else if e.is_prompt() {
|
||||
Some(Action::OpenPrompt)
|
||||
} else {
|
||||
None
|
||||
match event.to_action(|e| {
|
||||
if e.is_cancel() {
|
||||
Some(Action::Cancel)
|
||||
} else if e.is_go() {
|
||||
Some(Action::Go)
|
||||
} else if e.is_prompt() {
|
||||
Some(Action::OpenPrompt)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
Some(Action::Cancel /*| Action::Prompt*/) => Ok(Resp::end(None)),
|
||||
Some(Action::Go) => if let Some(action) = self.get_action() {
|
||||
Ok(Resp::end(action))
|
||||
} else {
|
||||
Ok(Resp::end(Action::Show(format!("unknown command `{}`", self.input.get_text()))))
|
||||
},
|
||||
Some(Action::Go) => {
|
||||
if let Some(action) = self.get_action() {
|
||||
Ok(Resp::end(action))
|
||||
} else {
|
||||
Ok(Resp::end(Action::Show(format!(
|
||||
"unknown command `{}`",
|
||||
self.input.get_text()
|
||||
))))
|
||||
}
|
||||
}
|
||||
_ => self.input.handle(event).map(Resp::into_can_end),
|
||||
}
|
||||
}
|
||||
|
|
@ -58,8 +71,7 @@ impl Element<CanEnd> for Prompt {
|
|||
|
||||
impl Visual for Prompt {
|
||||
fn render(&self, state: &State, frame: &mut Rect) {
|
||||
frame
|
||||
.with(|f| self.input.render(state, f));
|
||||
frame.with(|f| self.input.render(state, f));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,10 +81,12 @@ pub struct Show {
|
|||
|
||||
impl Element<CanEnd> for Show {
|
||||
fn handle(&mut self, event: Event) -> Result<Resp<CanEnd>, Event> {
|
||||
match event.to_action(|e| if e.is_cancel() {
|
||||
Some(Action::Cancel)
|
||||
} else {
|
||||
None
|
||||
match event.to_action(|e| {
|
||||
if e.is_cancel() {
|
||||
Some(Action::Cancel)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
Some(Action::Cancel) => Ok(Resp::end(None)),
|
||||
_ => Err(event),
|
||||
|
|
@ -85,7 +99,10 @@ impl Visual for Show {
|
|||
let lines = self.label.lines().count();
|
||||
self.label.render(
|
||||
state,
|
||||
&mut frame.rect([0, frame.size()[1].saturating_sub(3 + lines)], [frame.size()[0], lines]),
|
||||
&mut frame.rect(
|
||||
[0, frame.size()[1].saturating_sub(3 + lines)],
|
||||
[frame.size()[0], lines],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -97,12 +114,14 @@ pub struct Confirm {
|
|||
|
||||
impl Element<CanEnd> for Confirm {
|
||||
fn handle(&mut self, event: Event) -> Result<Resp<CanEnd>, Event> {
|
||||
match event.to_action(|e| if e.is_cancel() || e.to_char() == Some('n') {
|
||||
Some(Action::Cancel)
|
||||
} else if e.to_char() == Some('y') {
|
||||
Some(Action::Go)
|
||||
} else {
|
||||
None
|
||||
match event.to_action(|e| {
|
||||
if e.is_cancel() || e.to_char() == Some('n') {
|
||||
Some(Action::Cancel)
|
||||
} else if e.to_char() == Some('y') {
|
||||
Some(Action::Go)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
Some(Action::Go) => Ok(Resp::end(Some(self.action.clone()))),
|
||||
Some(Action::Cancel) => Ok(Resp::end(None)),
|
||||
|
|
@ -116,7 +135,10 @@ impl Visual for Confirm {
|
|||
let lines = self.label.lines().count();
|
||||
self.label.render(
|
||||
state,
|
||||
&mut frame.rect([0, frame.size()[1].saturating_sub(3 + lines)], [frame.size()[0], lines]),
|
||||
&mut frame.rect(
|
||||
[0, frame.size()[1].saturating_sub(3 + lines)],
|
||||
[frame.size()[0], lines],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use super::*;
|
||||
use crate::state::BufferId;
|
||||
|
||||
pub struct Root {
|
||||
panes: Panes,
|
||||
|
|
@ -13,9 +14,9 @@ pub enum Task {
|
|||
}
|
||||
|
||||
impl Root {
|
||||
pub fn new(state: &State) -> Self {
|
||||
pub fn new(state: &mut State, buffers: &[BufferId]) -> Self {
|
||||
Self {
|
||||
panes: Panes,
|
||||
panes: Panes::new(state, buffers),
|
||||
status: Status,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
|
|
@ -29,16 +30,20 @@ impl Element<CanEnd> for Root {
|
|||
let action = loop {
|
||||
task_idx = match task_idx.checked_sub(1) {
|
||||
Some(task_idx) => task_idx,
|
||||
None => break match self.panes.handle(event) {
|
||||
Ok(resp) => resp.action,
|
||||
Err(event) => event.to_action(|e| if e.is_prompt() {
|
||||
Some(Action::OpenPrompt)
|
||||
} else if e.is_cancel() {
|
||||
Some(Action::Cancel)
|
||||
} else {
|
||||
None
|
||||
}),
|
||||
},
|
||||
None => {
|
||||
break match self.panes.handle(event) {
|
||||
Ok(resp) => resp.action,
|
||||
Err(event) => event.to_action(|e| {
|
||||
if e.is_prompt() {
|
||||
Some(Action::OpenPrompt)
|
||||
} else if e.is_cancel() {
|
||||
Some(Action::Cancel)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let res = match &mut self.tasks[task_idx] {
|
||||
|
|
@ -65,9 +70,12 @@ impl Element<CanEnd> for Root {
|
|||
Action::OpenPrompt => {
|
||||
self.tasks.clear(); // Prompt overrides all
|
||||
self.tasks.push(Task::Prompt(Prompt {
|
||||
input: Input { preamble: "> ", ..Input::default() },
|
||||
input: Input {
|
||||
preamble: "> ",
|
||||
..Input::default()
|
||||
},
|
||||
}));
|
||||
},
|
||||
}
|
||||
Action::Cancel => self.tasks.push(Task::Confirm(Confirm {
|
||||
label: Label("Are you sure you wish to quit? (y/n)".to_string()),
|
||||
action: Action::Quit,
|
||||
|
|
@ -85,25 +93,36 @@ impl Element<CanEnd> for Root {
|
|||
|
||||
impl Visual for Root {
|
||||
fn render(&self, state: &State, frame: &mut Rect) {
|
||||
frame
|
||||
.fill(' ');
|
||||
frame.fill(' ');
|
||||
|
||||
let task_has_focus = self.tasks.last().is_some();
|
||||
|
||||
// Display status bar
|
||||
frame
|
||||
.rect([0, frame.size()[1].saturating_sub(3)], [frame.size()[0], 3])
|
||||
.fill(' ')
|
||||
.with_border(&state.theme.border)
|
||||
.with_border(if task_has_focus {
|
||||
&state.theme.focus_border
|
||||
} else {
|
||||
&state.theme.border
|
||||
})
|
||||
.with(|frame| {
|
||||
if let Some(Task::Prompt(p)) = self.tasks.last() {
|
||||
p.render(state, frame);
|
||||
}
|
||||
});
|
||||
|
||||
frame
|
||||
.rect([0, 0], [frame.size()[0], frame.size()[1].saturating_sub(3)])
|
||||
.with_focus(!task_has_focus)
|
||||
.with(|frame| {
|
||||
self.panes.render(state, frame);
|
||||
});
|
||||
|
||||
if let Some(task) = self.tasks.last() {
|
||||
match task {
|
||||
Task::Show(s) => s.render(state, frame),
|
||||
Task::Confirm(c) => c.render(state, frame),
|
||||
_ => {},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue