Restructured how UI works so there's views
This commit is contained in:
parent
ba5d07db91
commit
c76c2eaf5c
7 changed files with 273 additions and 76 deletions
|
@ -5,6 +5,12 @@
|
||||||
//! extensions. Although its primary purpose is to be used in panorama, it should be usable for
|
//! extensions. Although its primary purpose is to be used in panorama, it should be usable for
|
||||||
//! general-purpose IMAP usage. See the [client][crate::client] module for more information on how
|
//! general-purpose IMAP usage. See the [client][crate::client] module for more information on how
|
||||||
//! to get started with a client quickly.
|
//! to get started with a client quickly.
|
||||||
|
//!
|
||||||
|
//! RFCs:
|
||||||
|
//!
|
||||||
|
//! - RFC3501 (IMAP4) : work-in-progress
|
||||||
|
//! - RFC2177 (IDLE) : implemented
|
||||||
|
//! - RFC5256 (SORT / THREAD) : planned
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate anyhow;
|
extern crate anyhow;
|
||||||
|
|
|
@ -99,7 +99,7 @@ fn run_ui(
|
||||||
let localset = LocalSet::new();
|
let localset = LocalSet::new();
|
||||||
|
|
||||||
localset.spawn_local(async {
|
localset.spawn_local(async {
|
||||||
ui::run_ui(stdout, exit_tx, mail2ui_rx)
|
ui::run_ui2(stdout, exit_tx, mail2ui_rx)
|
||||||
.unwrap_or_else(report_err)
|
.unwrap_or_else(report_err)
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
|
|
|
@ -27,7 +27,6 @@ impl HandlesInput for ColonPrompt {
|
||||||
let KeyEvent { code, .. } = evt;
|
let KeyEvent { code, .. } = evt;
|
||||||
match code {
|
match code {
|
||||||
KeyCode::Esc => return Ok(InputResult::Pop),
|
KeyCode::Esc => return Ok(InputResult::Pop),
|
||||||
// KeyCode::Char('q') => return Ok(InputResult::Pop),
|
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
let mut b = [0; 2];
|
let mut b = [0; 2];
|
||||||
self.value += c.encode_utf8(&mut b);
|
self.value += c.encode_utf8(&mut b);
|
||||||
|
|
|
@ -43,10 +43,6 @@ impl HandlesInput for BaseInputHandler {
|
||||||
KeyCode::Char(':') => {
|
KeyCode::Char(':') => {
|
||||||
let colon_prompt = Box::new(ColonPrompt::init(term));
|
let colon_prompt = Box::new(ColonPrompt::init(term));
|
||||||
return Ok(InputResult::Push(colon_prompt));
|
return Ok(InputResult::Push(colon_prompt));
|
||||||
// let rect = term.size()?;
|
|
||||||
// term.set_cursor(1, rect.height - 1)?;
|
|
||||||
// term.show_cursor()?;
|
|
||||||
// colon_prompt = Some(ColonPrompt::default());
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,10 +17,10 @@ use tui::{
|
||||||
|
|
||||||
use crate::mail::EmailMetadata;
|
use crate::mail::EmailMetadata;
|
||||||
|
|
||||||
use super::FrameType;
|
use super::{FrameType, HandlesInput, Window, UI};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default, Debug)]
|
||||||
pub struct MailTabState {
|
pub struct MailView {
|
||||||
pub folders: Vec<String>,
|
pub folders: Vec<String>,
|
||||||
pub message_uids: Vec<u32>,
|
pub message_uids: Vec<u32>,
|
||||||
pub message_map: HashMap<u32, EmailMetadata>,
|
pub message_map: HashMap<u32, EmailMetadata>,
|
||||||
|
@ -29,71 +29,20 @@ pub struct MailTabState {
|
||||||
pub change: Arc<AtomicI8>,
|
pub change: Arc<AtomicI8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn humanize_timestamp(date: DateTime<Local>) -> String {
|
impl HandlesInput for MailView {}
|
||||||
let now = Local::now();
|
|
||||||
let diff = now - date;
|
|
||||||
|
|
||||||
if diff < Duration::days(1) {
|
impl Window for MailView {
|
||||||
HumanTime::from(date).to_string()
|
fn name(&self) -> String {
|
||||||
} else if date.year() == now.year() {
|
String::from("email")
|
||||||
date.format("%b %e %T").to_string()
|
|
||||||
} else {
|
|
||||||
date.to_rfc2822()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MailTabState {
|
fn draw(&self, f: FrameType, area: Rect, ui: &UI) {
|
||||||
pub fn move_down(&mut self) {
|
|
||||||
if self.message_uids.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let len = self.message_uids.len();
|
|
||||||
if let Some(selected) = self.message_list.selected() {
|
|
||||||
if selected + 1 < len {
|
|
||||||
self.message_list.select(Some(selected + 1));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.message_list.select(Some(0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_up(&mut self) {
|
|
||||||
if self.message_uids.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let len = self.message_uids.len();
|
|
||||||
if let Some(selected) = self.message_list.selected() {
|
|
||||||
if selected >= 1 {
|
|
||||||
self.message_list.select(Some(selected - 1));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.message_list.select(Some(len - 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render(&mut self, f: &mut FrameType, area: Rect) {
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.margin(0)
|
.margin(0)
|
||||||
.constraints([Constraint::Length(20), Constraint::Max(5000)])
|
.constraints([Constraint::Length(20), Constraint::Max(5000)])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// make the change
|
|
||||||
if self
|
|
||||||
.change
|
|
||||||
.compare_exchange(-1, 0, Ordering::Relaxed, Ordering::Relaxed)
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
self.move_up();
|
|
||||||
}
|
|
||||||
if self
|
|
||||||
.change
|
|
||||||
.compare_exchange(1, 0, Ordering::Relaxed, Ordering::Relaxed)
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
self.move_down();
|
|
||||||
}
|
|
||||||
|
|
||||||
// folder list
|
// folder list
|
||||||
let items = self
|
let items = self
|
||||||
.folders
|
.folders
|
||||||
|
@ -155,6 +104,67 @@ impl MailTabState {
|
||||||
.highlight_style(Style::default().bg(Color::DarkGray));
|
.highlight_style(Style::default().bg(Color::DarkGray));
|
||||||
|
|
||||||
f.render_widget(dirlist, chunks[0]);
|
f.render_widget(dirlist, chunks[0]);
|
||||||
f.render_stateful_widget(table, chunks[1], &mut self.message_list);
|
f.render_widget(table, chunks[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn humanize_timestamp(date: DateTime<Local>) -> String {
|
||||||
|
let now = Local::now();
|
||||||
|
let diff = now - date;
|
||||||
|
|
||||||
|
if diff < Duration::days(1) {
|
||||||
|
HumanTime::from(date).to_string()
|
||||||
|
} else if date.year() == now.year() {
|
||||||
|
date.format("%b %e %T").to_string()
|
||||||
|
} else {
|
||||||
|
date.to_rfc2822()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MailView {
|
||||||
|
pub fn move_down(&mut self) {
|
||||||
|
if self.message_uids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let len = self.message_uids.len();
|
||||||
|
if let Some(selected) = self.message_list.selected() {
|
||||||
|
if selected + 1 < len {
|
||||||
|
self.message_list.select(Some(selected + 1));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.message_list.select(Some(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_up(&mut self) {
|
||||||
|
if self.message_uids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let len = self.message_uids.len();
|
||||||
|
if let Some(selected) = self.message_list.selected() {
|
||||||
|
if selected >= 1 {
|
||||||
|
self.message_list.select(Some(selected - 1));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.message_list.select(Some(len - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self) {
|
||||||
|
// make the change
|
||||||
|
if self
|
||||||
|
.change
|
||||||
|
.compare_exchange(-1, 0, Ordering::Relaxed, Ordering::Relaxed)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
self.move_up();
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.change
|
||||||
|
.compare_exchange(1, 0, Ordering::Relaxed, Ordering::Relaxed)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
self.move_down();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
166
src/ui/mod.rs
166
src/ui/mod.rs
|
@ -3,7 +3,7 @@
|
||||||
mod colon_prompt;
|
mod colon_prompt;
|
||||||
mod input;
|
mod input;
|
||||||
mod keybinds;
|
mod keybinds;
|
||||||
mod mail_tab;
|
mod mail_view;
|
||||||
mod messages;
|
mod messages;
|
||||||
mod windows;
|
mod windows;
|
||||||
|
|
||||||
|
@ -41,15 +41,171 @@ use crate::mail::{EmailMetadata, MailEvent};
|
||||||
|
|
||||||
use self::colon_prompt::ColonPrompt;
|
use self::colon_prompt::ColonPrompt;
|
||||||
use self::input::{BaseInputHandler, HandlesInput, InputResult};
|
use self::input::{BaseInputHandler, HandlesInput, InputResult};
|
||||||
use self::mail_tab::MailTabState;
|
use self::mail_view::MailView;
|
||||||
pub(crate) use self::messages::*;
|
pub(crate) use self::messages::*;
|
||||||
use self::windows::*;
|
use self::windows::*;
|
||||||
|
|
||||||
pub(crate) type FrameType<'a, 'b> = Frame<'a, CrosstermBackend<&'b mut Stdout>>;
|
pub(crate) type FrameType<'a, 'b, 'c> = &'c mut Frame<'a, CrosstermBackend<&'b mut Stdout>>;
|
||||||
pub(crate) type TermType<'a, 'b> = &'b mut Terminal<CrosstermBackend<&'a mut Stdout>>;
|
pub(crate) type TermType<'a, 'b> = &'b mut Terminal<CrosstermBackend<&'a mut Stdout>>;
|
||||||
|
|
||||||
const FRAME_DURATION: Duration = Duration::from_millis(17);
|
const FRAME_DURATION: Duration = Duration::from_millis(17);
|
||||||
|
|
||||||
|
/// Main entrypoint for the UI
|
||||||
|
pub async fn run_ui2(
|
||||||
|
mut stdout: Stdout,
|
||||||
|
exit_tx: mpsc::Sender<()>,
|
||||||
|
mut mail2ui_rx: mpsc::UnboundedReceiver<MailEvent>,
|
||||||
|
) -> Result<()> {
|
||||||
|
execute!(stdout, cursor::Hide, terminal::EnterAlternateScreen)?;
|
||||||
|
terminal::enable_raw_mode()?;
|
||||||
|
|
||||||
|
let backend = CrosstermBackend::new(&mut stdout);
|
||||||
|
let mut term = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let should_exit = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let mut ui = UI {
|
||||||
|
should_exit: should_exit.clone(),
|
||||||
|
window_layout: WindowLayout::default(),
|
||||||
|
windows: HashMap::new(),
|
||||||
|
page_names: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.open_window(MailView::default());
|
||||||
|
|
||||||
|
// let mut input_states: Vec<Box<dyn HandlesInput>> = vec![];
|
||||||
|
|
||||||
|
while !should_exit.load(Ordering::Relaxed) {
|
||||||
|
term.draw(|f| {
|
||||||
|
ui.draw(f);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// handle events coming from the UI
|
||||||
|
if event::poll(FRAME_DURATION)? {
|
||||||
|
let event = event::read()?;
|
||||||
|
ui.process_event(event)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
select! {
|
||||||
|
// got an event from the mail thread
|
||||||
|
evt = mail2ui_rx.recv().fuse() => if let Some(evt) = evt {
|
||||||
|
ui.process_mail_event(evt);
|
||||||
|
},
|
||||||
|
|
||||||
|
// wait for approx 60fps
|
||||||
|
_ = time::sleep(FRAME_DURATION).fuse() => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mem::drop(term);
|
||||||
|
mem::drop(ui);
|
||||||
|
|
||||||
|
execute!(
|
||||||
|
stdout,
|
||||||
|
style::ResetColor,
|
||||||
|
cursor::Show,
|
||||||
|
terminal::LeaveAlternateScreen
|
||||||
|
)?;
|
||||||
|
terminal::disable_raw_mode()?;
|
||||||
|
|
||||||
|
exit_tx.send(()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI
|
||||||
|
pub struct UI {
|
||||||
|
should_exit: Arc<AtomicBool>,
|
||||||
|
window_layout: WindowLayout,
|
||||||
|
windows: HashMap<LayoutId, Box<dyn Window>>,
|
||||||
|
page_names: HashMap<PageId, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UI {
|
||||||
|
fn draw(&mut self, f: FrameType) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.margin(0)
|
||||||
|
.constraints([Constraint::Max(5000), Constraint::Length(1)])
|
||||||
|
.split(f.size());
|
||||||
|
|
||||||
|
let pages = self.window_layout.list_pages();
|
||||||
|
|
||||||
|
// draw a list of pages at the bottom
|
||||||
|
let titles = self
|
||||||
|
.window_layout
|
||||||
|
.list_pages()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, id)| {
|
||||||
|
self.page_names
|
||||||
|
.get(id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| i.to_string())
|
||||||
|
})
|
||||||
|
.map(|s| Spans::from(s))
|
||||||
|
.collect();
|
||||||
|
let tabs = Tabs::new(titles).style(Style::default().bg(Color::DarkGray));
|
||||||
|
f.render_widget(tabs, chunks[1]);
|
||||||
|
|
||||||
|
// render all other windows
|
||||||
|
let visible = self.window_layout.visible_windows(chunks[0]);
|
||||||
|
for (layout_id, area) in visible.into_iter() {
|
||||||
|
if let Some(window) = self.windows.get(&layout_id) {
|
||||||
|
debug!("drawing window {:?}", window.name());
|
||||||
|
window.draw(f, area, self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_window(&mut self, window: impl Window) {
|
||||||
|
debug!("opened window {:?}", window.name());
|
||||||
|
let (layout_id, page_id) = self.window_layout.new_page();
|
||||||
|
|
||||||
|
let window = Box::new(window);
|
||||||
|
self.windows.insert(layout_id, window);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main entrypoint for handling any kind of event coming from the terminal
|
||||||
|
fn process_event(&mut self, evt: Event) -> Result<()> {
|
||||||
|
if let Event::Key(evt) = evt {
|
||||||
|
if let KeyEvent {
|
||||||
|
code: KeyCode::Char('q'),
|
||||||
|
..
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
self.should_exit.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle states in the state stack
|
||||||
|
// although this is written in a for loop, every case except one should break
|
||||||
|
// let mut should_pop = false;
|
||||||
|
// for input_state in input_states.iter_mut().rev() {
|
||||||
|
// match input_state.handle_key(&mut term, evt)? {
|
||||||
|
// InputResult::Ok => break,
|
||||||
|
// InputResult::Push(state) => {
|
||||||
|
// input_states.push(state);
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// InputResult::Pop => {
|
||||||
|
// should_pop = true;
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if should_pop {
|
||||||
|
// input_states.pop();
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_mail_event(&mut self, evt: MailEvent) {
|
||||||
|
debug!("received mail event: {:?}", evt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Main entrypoint for the UI
|
/// Main entrypoint for the UI
|
||||||
pub async fn run_ui(
|
pub async fn run_ui(
|
||||||
mut stdout: Stdout,
|
mut stdout: Stdout,
|
||||||
|
@ -61,7 +217,7 @@ pub async fn run_ui(
|
||||||
|
|
||||||
let backend = CrosstermBackend::new(&mut stdout);
|
let backend = CrosstermBackend::new(&mut stdout);
|
||||||
let mut term = Terminal::new(backend)?;
|
let mut term = Terminal::new(backend)?;
|
||||||
let mut mail_tab = MailTabState::default();
|
let mut mail_tab = MailView::default();
|
||||||
|
|
||||||
// state stack for handling inputs
|
// state stack for handling inputs
|
||||||
let should_exit = Arc::new(AtomicBool::new(false));
|
let should_exit = Arc::new(AtomicBool::new(false));
|
||||||
|
@ -101,7 +257,7 @@ pub async fn run_ui(
|
||||||
f.render_widget(tabs, chunks[0]);
|
f.render_widget(tabs, chunks[0]);
|
||||||
|
|
||||||
// this is the main mail tab
|
// this is the main mail tab
|
||||||
mail_tab.render(f, chunks[1]);
|
// mail_tab.render(f, chunks[1]);
|
||||||
|
|
||||||
// this is the status bar
|
// this is the status bar
|
||||||
if let Some(last_state) = input_states.last() {
|
if let Some(last_state) = input_states.last() {
|
||||||
|
|
|
@ -2,13 +2,33 @@ use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
|
||||||
use tui::layout::Rect;
|
use tui::layout::Rect;
|
||||||
|
|
||||||
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
|
use super::{FrameType, HandlesInput, UI};
|
||||||
|
|
||||||
|
pub trait Window: HandlesInput {
|
||||||
|
// Return some kind of name
|
||||||
|
fn name(&self) -> String;
|
||||||
|
|
||||||
|
// Main draw function
|
||||||
|
fn draw(&self, f: FrameType, area: Rect, ui: &UI);
|
||||||
|
|
||||||
|
/// Draw function, except the window is not the actively focused one
|
||||||
|
///
|
||||||
|
/// By default, this just calls the regular draw function but the window may choose to perform
|
||||||
|
/// a less intensive draw if it's known to not be active
|
||||||
|
fn draw_inactive(&mut self, f: FrameType, area: Rect, ui: &UI) {
|
||||||
|
self.draw(f, area, ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downcast_rs::impl_downcast!(Window);
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct LayoutId(usize);
|
pub struct LayoutId(usize);
|
||||||
|
|
||||||
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct PageId(usize);
|
pub struct PageId(usize);
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default, Debug)]
|
||||||
pub struct WindowLayout {
|
pub struct WindowLayout {
|
||||||
ctr: usize,
|
ctr: usize,
|
||||||
currently_active: Option<LayoutId>,
|
currently_active: Option<LayoutId>,
|
||||||
|
@ -31,6 +51,12 @@ impl WindowLayout {
|
||||||
self.ctr += 1;
|
self.ctr += 1;
|
||||||
self.pages.insert(pid, pg);
|
self.pages.insert(pid, pg);
|
||||||
self.page_order.push(pid);
|
self.page_order.push(pid);
|
||||||
|
self.ids.insert(id, pid);
|
||||||
|
|
||||||
|
if let None = self.currently_active {
|
||||||
|
self.currently_active = Some(id);
|
||||||
|
}
|
||||||
|
|
||||||
(id, pid)
|
(id, pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,8 +64,8 @@ impl WindowLayout {
|
||||||
&self.page_order
|
&self.page_order
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a set of all windows visible on the current page
|
/// Get a set of all windows visible on the current page, given the size of the allotted space
|
||||||
pub fn visible_windows(&self) -> HashMap<LayoutId, Rect> {
|
pub fn visible_windows(&self, area: Rect) -> HashMap<LayoutId, Rect> {
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
if let Some(page) = self
|
if let Some(page) = self
|
||||||
.currently_active
|
.currently_active
|
||||||
|
@ -52,17 +78,21 @@ impl WindowLayout {
|
||||||
|
|
||||||
while !q.is_empty() {
|
while !q.is_empty() {
|
||||||
let front = q.pop_front().expect("not empty");
|
let front = q.pop_front().expect("not empty");
|
||||||
|
// TODO: how to subdivide properly?
|
||||||
|
map.insert(front, area);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
map
|
map
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct PageGraph {
|
struct PageGraph {
|
||||||
root: LayoutId,
|
root: LayoutId,
|
||||||
adj: HashMap<LayoutId, HashSet<(LayoutId, Dir)>>,
|
adj: HashMap<LayoutId, HashSet<(LayoutId, Dir)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
enum Dir {
|
enum Dir {
|
||||||
H,
|
H,
|
||||||
V,
|
V,
|
||||||
|
|
Loading…
Reference in a new issue