Switch to TUI. This should improve the usability
This commit is contained in:
parent
009dd4657f
commit
5d04f23458
0
.editorconfig
Normal file → Executable file
0
.editorconfig
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.vscode/launch.json
vendored
Normal file → Executable file
0
.vscode/launch.json
vendored
Normal file → Executable file
227
Cargo.lock
generated
Normal file → Executable file
227
Cargo.lock
generated
Normal file → Executable file
@ -8,7 +8,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"color-eyre",
|
||||
"env_logger",
|
||||
"crossbeam",
|
||||
"log",
|
||||
"posix-acl",
|
||||
"ratatui",
|
||||
"walkdir",
|
||||
@ -38,71 +39,12 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.97"
|
||||
@ -193,12 +135,6 @@ dependencies = [
|
||||
"tracing-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.1"
|
||||
@ -213,6 +149,62 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"crossbeam-deque",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-queue",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
@ -288,29 +280,6 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3716d7a920fb4fac5d84e9d4bce8ceb321e9414b4409da61b07b75c1e3d0697"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@ -403,12 +372,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@ -424,30 +387,6 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c102670231191d07d37a35af3eb77f1f0dbf7a71be51a962dcd57ea607be7260"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cdde31a9d349f1b1f51a0b3714a5940ac022976f4b49485fc04be052b183b4c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@ -589,21 +528,6 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "posix-acl"
|
||||
version = "0.1.6"
|
||||
@ -669,35 +593,6 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.24"
|
||||
@ -967,12 +862,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
|
4
Cargo.toml
Normal file → Executable file
4
Cargo.toml
Normal file → Executable file
@ -10,7 +10,9 @@ default = []
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
color-eyre = "0.6.3"
|
||||
env_logger = { version = "0.11", features = ["auto-color", "humantime"] }
|
||||
crossbeam = "0.8.4"
|
||||
# env_logger = { version = "0.11", features = ["auto-color", "humantime"] }
|
||||
log = { version = "0.4.27", features = ["std"] }
|
||||
posix-acl = { git = "https://git.hibas.dev/hibas123/PosixACL", tag = "0.1.6" }
|
||||
ratatui = { version = "0.29.0", features = ["all-widgets"] }
|
||||
walkdir = "2"
|
||||
|
Binary file not shown.
3
src/helper/acl_writer.rs
Normal file → Executable file
3
src/helper/acl_writer.rs
Normal file → Executable file
@ -1,6 +1,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use log::trace;
|
||||
use posix_acl::PosixACL;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@ -8,7 +9,7 @@ pub fn write_acl_recursive<P: AsRef<Path>>(path: P, acl: PosixACL) -> Result<()>
|
||||
for entry in WalkDir::new(path).min_depth(0).follow_links(false) {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
println!("Writing ACL for: {:?} {:?}", path, acl);
|
||||
trace!("Writing ACL for: {:?} {:?}", path, acl);
|
||||
match entry.file_type().is_dir() {
|
||||
true => {
|
||||
acl.write(&path)?;
|
||||
|
0
src/helper/getent.rs
Normal file → Executable file
0
src/helper/getent.rs
Normal file → Executable file
0
src/helper/mod.rs
Normal file → Executable file
0
src/helper/mod.rs
Normal file → Executable file
6
src/main.rs
Normal file → Executable file
6
src/main.rs
Normal file → Executable file
@ -1,14 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use ratatui::{
|
||||
crossterm::event::{self, Event},
|
||||
DefaultTerminal, Frame,
|
||||
};
|
||||
|
||||
mod helper;
|
||||
mod ui;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).
|
||||
// env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).
|
||||
|
||||
color_eyre::install().expect("failed to install color_eyre");
|
||||
|
||||
|
828
src/ui/editor.rs
Normal file → Executable file
828
src/ui/editor.rs
Normal file → Executable file
@ -1,7 +1,19 @@
|
||||
use std::{borrow::Borrow, io, path::PathBuf, thread::JoinHandle};
|
||||
use std::{
|
||||
borrow::Borrow,
|
||||
io,
|
||||
path::PathBuf,
|
||||
thread::{self, JoinHandle},
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use ratatui::{widgets::Widget, DefaultTerminal, Frame};
|
||||
use ratatui::{
|
||||
crossterm::event::{Event, KeyCode, KeyModifiers},
|
||||
layout::{Constraint, Flex, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Clear, Paragraph, Row, Table, TableState, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::helper::getent::{get_groups, get_users, Group, User};
|
||||
|
||||
@ -10,17 +22,24 @@ use posix_acl::{ACLEntry, PermSet, PosixACL, Qualifier, ACL_RWX};
|
||||
use super::Component;
|
||||
|
||||
pub struct ACLEditor {
|
||||
pub path: PathBuf,
|
||||
pub is_changed: bool,
|
||||
pub available_users: Vec<User>,
|
||||
pub available_groups: Vec<Group>,
|
||||
pub acl: PosixACL,
|
||||
path: PathBuf,
|
||||
is_changed: bool,
|
||||
available_users: Vec<User>,
|
||||
available_groups: Vec<Group>,
|
||||
acl: PosixACL,
|
||||
|
||||
pub search_user: String,
|
||||
pub search_group: String,
|
||||
pub exited: bool,
|
||||
|
||||
pub save_thread: Option<JoinHandle<Result<()>>>,
|
||||
pub save_thread_error: Option<String>,
|
||||
save_thread: Option<JoinHandle<Result<()>>>,
|
||||
save_thread_error: Option<String>,
|
||||
save_thread_ok: Option<String>,
|
||||
|
||||
table_state: TableState,
|
||||
|
||||
user_popup: Option<SelectUserOrGroupPopup<User>>,
|
||||
group_popup: Option<SelectUserOrGroupPopup<Group>>,
|
||||
|
||||
focused: bool,
|
||||
}
|
||||
|
||||
impl Default for ACLEditor {
|
||||
@ -31,25 +50,31 @@ impl Default for ACLEditor {
|
||||
available_groups: get_groups().unwrap(),
|
||||
available_users: get_users().unwrap(),
|
||||
acl: PosixACL::new(ACL_RWX, ACL_RWX, ACL_RWX),
|
||||
search_user: String::new(),
|
||||
search_group: String::new(),
|
||||
exited: false,
|
||||
save_thread: None,
|
||||
save_thread_error: None,
|
||||
save_thread_ok: None,
|
||||
table_state: TableState::default(),
|
||||
user_popup: None,
|
||||
group_popup: None,
|
||||
focused: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ACLEditor {
|
||||
fn render(&mut self, f: &mut Frame, rect: ratatui::prelude::Rect, evt: super::AppEventClient) {
|
||||
let block =
|
||||
|
||||
// todo!("Implement the rendering logic for ACLEditor");
|
||||
fn update(&mut self, mut event: super::AppEventClient) {
|
||||
if let Some(save_thread) = &self.save_thread {
|
||||
// Mark all events as handled, since this task should not be interrupted
|
||||
// by any other event
|
||||
event.mark_as_handled();
|
||||
|
||||
if save_thread.is_finished() {
|
||||
let save_thread = std::mem::replace(&mut self.save_thread, None).unwrap();
|
||||
let result = save_thread.join();
|
||||
match result {
|
||||
Ok(Ok(_)) => {
|
||||
f.set_title("Saved successfully!");
|
||||
self.save_thread_ok = Some("Saved successfully!".to_string());
|
||||
self.save_thread_error = None;
|
||||
self.is_changed = false;
|
||||
}
|
||||
@ -60,345 +85,474 @@ impl Component for ACLEditor {
|
||||
self.save_thread_error = Some(format!("Failed to join {:?}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
f.set_title("Saving...");
|
||||
}
|
||||
} else if let Some(err) = &self.save_thread_error {
|
||||
f.set_title(err);
|
||||
} else if self.save_thread_error.is_some() {
|
||||
// TODO: Might want to do something, or not
|
||||
if event.is_focused_and_key_pressed(KeyCode::Char('o'), None) {
|
||||
event.mark_as_handled();
|
||||
self.save_thread_error = None;
|
||||
}
|
||||
|
||||
if event.has_focus() && self.save_thread_error.is_some() {
|
||||
event.register_helper("o", "Accept error");
|
||||
}
|
||||
} else if self.save_thread_ok.is_some() {
|
||||
if event.is_focused_and_key_pressed(KeyCode::Char('o'), None) {
|
||||
event.mark_as_handled();
|
||||
self.save_thread_ok = None;
|
||||
}
|
||||
|
||||
if event.has_focus() && self.save_thread_ok.is_some() {
|
||||
event.register_helper("o", "Accept message");
|
||||
}
|
||||
} else {
|
||||
f.set_title(format!("Editing: {:?}", self.path));
|
||||
if let Some(user_popup) = &mut self.user_popup {
|
||||
user_popup.update(event.get_client(true));
|
||||
if user_popup.exited() {
|
||||
if let Some(selected) = user_popup.get_result() {
|
||||
self.acl
|
||||
.entries
|
||||
.push(ACLEntry(Qualifier::User(selected.uid), PermSet::ACL_RWX));
|
||||
self.is_changed = true;
|
||||
}
|
||||
self.user_popup = None;
|
||||
} else {
|
||||
event.has_focus = false;
|
||||
}
|
||||
} else if let Some(group_popup) = &mut self.group_popup {
|
||||
group_popup.update(event.get_client(true));
|
||||
if group_popup.exited() {
|
||||
if let Some(selected) = group_popup.get_result() {
|
||||
self.acl
|
||||
.entries
|
||||
.push(ACLEntry(Qualifier::Group(selected.gid), PermSet::ACL_RWX));
|
||||
self.is_changed = true;
|
||||
}
|
||||
self.group_popup = None;
|
||||
} else {
|
||||
event.has_focus = false;
|
||||
}
|
||||
}
|
||||
|
||||
if event.is_focused_and_key_pressed(KeyCode::Esc, None) {
|
||||
event.mark_as_handled();
|
||||
self.exited = true;
|
||||
event.has_focus = false;
|
||||
} else if event.is_focused_and_key_pressed(KeyCode::Up, None) {
|
||||
event.mark_as_handled();
|
||||
self.table_state.select_previous();
|
||||
} else if event.is_focused_and_key_pressed(KeyCode::Down, None) {
|
||||
event.mark_as_handled();
|
||||
self.table_state.select_next();
|
||||
} else if event.is_focused_and_key_pressed(KeyCode::Char('d'), None) {
|
||||
event.mark_as_handled();
|
||||
if let Some(selected) = self.table_state.selected() {
|
||||
let _entry = self.acl.entries.remove(selected);
|
||||
self.is_changed = true;
|
||||
self.table_state.select(None);
|
||||
}
|
||||
} else if event.is_focused_and_key_pressed(KeyCode::Char('u'), None) {
|
||||
event.mark_as_handled();
|
||||
let mut popup = SelectUserOrGroupPopup::new_users();
|
||||
popup.update(event.get_client(true));
|
||||
self.user_popup = Some(popup);
|
||||
event.has_focus = false;
|
||||
} else if event.is_focused_and_key_pressed(KeyCode::Char('g'), None) {
|
||||
event.mark_as_handled();
|
||||
let mut popup = SelectUserOrGroupPopup::new_groups();
|
||||
popup.update(event.get_client(true));
|
||||
self.group_popup = Some(popup);
|
||||
event.has_focus = false;
|
||||
} else if event.is_focused_and_key_pressed(KeyCode::Char('a'), None) {
|
||||
event.mark_as_handled();
|
||||
let path = self.path.clone();
|
||||
let mut acl = self.acl.clone();
|
||||
acl.set(ACLEntry(Qualifier::Mask, PermSet::all())); // Make sure mask is set!
|
||||
|
||||
self.save_thread_error = None;
|
||||
self.save_thread_ok = None;
|
||||
self.save_thread = Some(std::thread::spawn(move || {
|
||||
// thread::sleep(std::time::Duration::from_secs());
|
||||
crate::helper::acl_writer::write_acl_recursive(path, acl)
|
||||
}));
|
||||
}
|
||||
|
||||
self.acl.entries.iter_mut().enumerate().for_each(|(i, e)| {
|
||||
if self.table_state.selected() == Some(i) {
|
||||
event
|
||||
.is_focused_and_key_pressed(KeyCode::Char('x'), None)
|
||||
.then(|| {
|
||||
event.mark_as_handled();
|
||||
if e.1.contains(PermSet::ACL_EXECUTE) {
|
||||
e.1.remove(PermSet::ACL_EXECUTE);
|
||||
} else {
|
||||
e.1.insert(PermSet::ACL_EXECUTE);
|
||||
}
|
||||
self.is_changed = true;
|
||||
});
|
||||
|
||||
event
|
||||
.is_focused_and_key_pressed(KeyCode::Char('r'), None)
|
||||
.then(|| {
|
||||
event.mark_as_handled();
|
||||
if e.1.contains(PermSet::ACL_READ) {
|
||||
e.1.remove(PermSet::ACL_READ);
|
||||
} else {
|
||||
e.1.insert(PermSet::ACL_READ);
|
||||
}
|
||||
self.is_changed = true;
|
||||
});
|
||||
|
||||
event
|
||||
.is_focused_and_key_pressed(KeyCode::Char('w'), None)
|
||||
.then(|| {
|
||||
event.mark_as_handled();
|
||||
if e.1.contains(PermSet::ACL_WRITE) {
|
||||
e.1.remove(PermSet::ACL_WRITE);
|
||||
} else {
|
||||
e.1.insert(PermSet::ACL_WRITE);
|
||||
}
|
||||
self.is_changed = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if event.has_focus() {
|
||||
event.register_helper("Esc", "Close editor");
|
||||
event.register_helper("↑", "Select previous entry");
|
||||
event.register_helper("↓", "Select next entry");
|
||||
event.register_helper("r", "Toggle Read permission");
|
||||
event.register_helper("w", "Toggle Write permission");
|
||||
event.register_helper("x", "Toggle Execute permission");
|
||||
event.register_helper("d", "Delete selected entry");
|
||||
event.register_helper("u", "Create new user entry");
|
||||
event.register_helper("g", "Create new group entry");
|
||||
event.register_helper("a", "Apply changes");
|
||||
}
|
||||
}
|
||||
|
||||
self.focused = event.has_focus();
|
||||
}
|
||||
|
||||
fn render(&mut self, f: &mut Frame, rect: ratatui::prelude::Rect) {
|
||||
let block = Block::bordered()
|
||||
.title(format!("Editing: {:?}", self.path))
|
||||
.borders(ratatui::widgets::Borders::ALL)
|
||||
.border_style(ratatui::style::Style::default().fg(if self.focused {
|
||||
ratatui::style::Color::Cyan
|
||||
} else {
|
||||
ratatui::style::Color::Gray
|
||||
}));
|
||||
let inner_area = block.inner(rect);
|
||||
f.render_widget(block, rect);
|
||||
|
||||
// todo!("Implement the rendering logic for ACLEditor");
|
||||
if self.save_thread.is_some() {
|
||||
let lines = vec![
|
||||
Line::from(Span::styled("Saving...", Style::new().fg(Color::Yellow))),
|
||||
// Line::from("Press any key to cancel..."),
|
||||
];
|
||||
let pg = Paragraph::new(lines).wrap(Wrap::default());
|
||||
f.render_widget(pg, inner_area);
|
||||
} else if let Some(ok) = &self.save_thread_ok {
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
"Saved successfully!",
|
||||
Style::new().fg(Color::Green),
|
||||
)),
|
||||
Line::from(ok.as_str()),
|
||||
// Line::from("Press any key to continue..."),
|
||||
];
|
||||
let pg = Paragraph::new(lines).wrap(Wrap::default());
|
||||
f.render_widget(pg, inner_area);
|
||||
} else if let Some(err) = &self.save_thread_error {
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
"Error while saving!",
|
||||
Style::new().fg(Color::Red),
|
||||
)),
|
||||
Line::from(err.as_str()),
|
||||
// Line::from("Press any key to continue..."),
|
||||
];
|
||||
let pg = Paragraph::new(lines).wrap(Wrap::default());
|
||||
f.render_widget(pg, inner_area);
|
||||
} else {
|
||||
let layout = Layout::vertical([Constraint::Fill(1), Constraint::Fill(1)]);
|
||||
let rects = layout.split(inner_area);
|
||||
|
||||
let acl_table = Table::new(
|
||||
self.acl.entries.iter_mut().map(|e| {
|
||||
let mut cols = vec![];
|
||||
let label = match e.0 {
|
||||
Qualifier::User(_) => "U",
|
||||
Qualifier::Group(_) => "G",
|
||||
Qualifier::Other => "",
|
||||
Qualifier::GroupObj => "G",
|
||||
Qualifier::UserObj => "U",
|
||||
Qualifier::Mask => "",
|
||||
};
|
||||
cols.push(label.to_string());
|
||||
|
||||
let label = match e.0 {
|
||||
Qualifier::User(uid) => self
|
||||
.available_users
|
||||
.iter()
|
||||
.find(|user| user.uid == uid)
|
||||
.map(|user| user.name.clone())
|
||||
.unwrap_or_else(|| {
|
||||
self.available_groups
|
||||
.iter()
|
||||
.find(|group| group.gid == uid)
|
||||
.map(|group| format!("({})", group.name))
|
||||
.unwrap_or_else(|| format!("Unknown user {}", uid))
|
||||
}),
|
||||
Qualifier::Group(gid) => self
|
||||
.available_groups
|
||||
.iter()
|
||||
.find(|group| group.gid == gid)
|
||||
.map(|group| group.name.clone())
|
||||
.unwrap_or_else(|| {
|
||||
self.available_users
|
||||
.iter()
|
||||
.find(|user| user.uid == gid)
|
||||
.map(|user| format!("({})", user.name))
|
||||
.unwrap_or_else(|| format!("Unknown group {}", gid))
|
||||
}),
|
||||
Qualifier::Other => "Jeder".to_string(),
|
||||
Qualifier::GroupObj => "Gruppe".to_string(),
|
||||
Qualifier::UserObj => "Benutzer".to_string(),
|
||||
Qualifier::Mask => "Mask".to_string(),
|
||||
};
|
||||
|
||||
cols.push(label);
|
||||
|
||||
let r = e.1.contains(PermSet::ACL_READ);
|
||||
cols.push((if r { "✔" } else { "x" }).to_string());
|
||||
let w = e.1.contains(PermSet::ACL_WRITE);
|
||||
cols.push((if w { "✔" } else { "x" }).to_string());
|
||||
let x = e.1.contains(PermSet::ACL_EXECUTE);
|
||||
cols.push((if x { "✔" } else { "x" }).to_string());
|
||||
|
||||
Row::new(cols)
|
||||
}),
|
||||
[
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(30),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
],
|
||||
)
|
||||
.header(Row::new(vec!["Type", "Name", "R", "W", "X"]))
|
||||
.row_highlight_style(Style::default().bg(Color::Blue).fg(Color::White))
|
||||
.highlight_symbol(">>");
|
||||
|
||||
if self.table_state.selected().is_none() && self.acl.entries.len() > 0 {
|
||||
self.table_state.select(Some(0));
|
||||
}
|
||||
|
||||
f.render_stateful_widget(acl_table, rects[0], &mut self.table_state);
|
||||
|
||||
if let Some(user_popup) = &mut self.user_popup {
|
||||
user_popup.render(f, rects[1]);
|
||||
} else if let Some(group_popup) = &mut self.group_popup {
|
||||
group_popup.render(f, rects[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ACLEditor {
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
|
||||
let path: PathBuf = path.into();
|
||||
|
||||
let acl = PosixACL::new_from_file(&path, false).unwrap();
|
||||
|
||||
Self {
|
||||
path,
|
||||
acl,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
// pub fn show(&mut self, ui: &mut egui::Ui, selected_path: &PathBuf) {
|
||||
// let mut show_save_or_dicard_message = false;
|
||||
// ui.vertical(|ui| {
|
||||
// if self.save_thread.is_some() {
|
||||
// if self.save_thread.as_ref().unwrap().is_finished() {
|
||||
// let save_thread = std::mem::replace(&mut self.save_thread, None);
|
||||
// let result = save_thread.unwrap().join();
|
||||
// match result {
|
||||
// Ok(Ok(_)) => {
|
||||
// ui.label("Saved successfully!");
|
||||
// self.save_thread_error = None;
|
||||
// self.is_changed = false;
|
||||
// }
|
||||
// Ok(Err(e)) => {
|
||||
// self.save_thread_error = Some(format!("Failed to save {:?}", e));
|
||||
// }
|
||||
// Err(e) => {
|
||||
// self.save_thread_error = Some(format!("Failed to join {:?}", e));
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// egui::Frame::default()
|
||||
// .fill(egui::Color32::GREEN)
|
||||
// .inner_margin(4.0)
|
||||
// .show(ui, |ui| {
|
||||
// ui.label("Apply changes...");
|
||||
// });
|
||||
// }
|
||||
// } else if let Some(err) = &self.save_thread_error.clone() {
|
||||
// egui::Frame::default()
|
||||
// .fill(egui::Color32::DARK_RED)
|
||||
// .inner_margin(4.0)
|
||||
// .show(ui, |ui| {
|
||||
// ui.label(err);
|
||||
|
||||
// if ui.button("OK").clicked() {
|
||||
// self.save_thread_error = None;
|
||||
// }
|
||||
// });
|
||||
// } else {
|
||||
// ui.heading(format!("Editing: {:?}", self.path));
|
||||
// ui.separator();
|
||||
|
||||
// if self.path != *selected_path {
|
||||
// if !self.is_changed {
|
||||
// // Load ACLs
|
||||
// self.path = selected_path.clone();
|
||||
// self.acl = PosixACL::new_from_file(&self.path, false).unwrap();
|
||||
// } else {
|
||||
// show_save_or_dicard_message = true;
|
||||
// }
|
||||
// }
|
||||
|
||||
// self.acl_table(ui);
|
||||
|
||||
// ui.separator();
|
||||
// ui.label("Add new entry:");
|
||||
|
||||
// self.new_user(ui);
|
||||
// self.new_group(ui);
|
||||
|
||||
// ui.separator();
|
||||
|
||||
// if show_save_or_dicard_message {
|
||||
// egui::Frame::default()
|
||||
// .fill(egui::Color32::YELLOW)
|
||||
// .inner_margin(4.0)
|
||||
// .show(ui, |ui| {
|
||||
// ui.label("You have unsaved changes! Save or discard to continue.");
|
||||
// });
|
||||
// }
|
||||
|
||||
// ui.horizontal(|ui| {
|
||||
// if ui.button("Save").clicked() {
|
||||
// let path = self.path.clone();
|
||||
// let mut acl = self.acl.clone();
|
||||
// acl.set(ACLEntry(Qualifier::Mask, PermSet::all())); // Make sure mask is set!
|
||||
|
||||
// self.save_thread_error = None;
|
||||
// self.save_thread = Some(std::thread::spawn(move || {
|
||||
// crate::helper::acl_writer::write_acl_recursive(path, acl)
|
||||
// }));
|
||||
// }
|
||||
|
||||
// if ui.button("Discard").clicked() {
|
||||
// self.is_changed = false;
|
||||
// self.path.clear();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// fn new_group(&mut self, ui: &mut egui::Ui) {
|
||||
// ui.label("Group:");
|
||||
// let selected = filter_dropbox(
|
||||
// ui,
|
||||
// "group_popup",
|
||||
// &mut self.search_group,
|
||||
// self.available_groups.iter(),
|
||||
// |group, filter| group.name.contains(filter),
|
||||
// |group| group.name.as_str(),
|
||||
// );
|
||||
|
||||
// if let Some(selected) = selected {
|
||||
// self.acl
|
||||
// .entries
|
||||
// .push(ACLEntry(Qualifier::Group(selected.gid), PermSet::ACL_RWX));
|
||||
// self.is_changed = true;
|
||||
// }
|
||||
// }
|
||||
|
||||
// fn new_user(&mut self, ui: &mut egui::Ui) {
|
||||
// ui.label("User:");
|
||||
// let selected = filter_dropbox(
|
||||
// ui,
|
||||
// "user_popup",
|
||||
// &mut self.search_user,
|
||||
// self.available_users.iter(),
|
||||
// |user, filter| user.name.contains(filter),
|
||||
// |user| user.name.as_str(),
|
||||
// );
|
||||
|
||||
// if let Some(selected) = selected {
|
||||
// self.acl
|
||||
// .entries
|
||||
// .push(ACLEntry(Qualifier::User(selected.uid), PermSet::ACL_RWX));
|
||||
// self.is_changed = true;
|
||||
// }
|
||||
// }
|
||||
|
||||
// fn acl_table(&mut self, ui: &mut egui::Ui) {
|
||||
// TableBuilder::new(ui)
|
||||
// .auto_shrink([false, true])
|
||||
// .striped(true)
|
||||
// .column(Column::auto().resizable(false))
|
||||
// .column(Column::remainder().resizable(false))
|
||||
// .column(Column::auto().resizable(false))
|
||||
// .column(Column::auto().resizable(false))
|
||||
// .column(Column::auto().resizable(false))
|
||||
// .column(Column::auto().resizable(false))
|
||||
// .header(20.0, |mut header| {
|
||||
// header.col(|ui| {
|
||||
// ui.heading("Type");
|
||||
// });
|
||||
// header.col(|ui| {
|
||||
// ui.heading("Name");
|
||||
// });
|
||||
// header.col(|ui| {
|
||||
// ui.heading("R");
|
||||
// });
|
||||
// header.col(|ui| {
|
||||
// ui.heading("W");
|
||||
// });
|
||||
// header.col(|ui| {
|
||||
// ui.heading("X");
|
||||
// });
|
||||
// header.col(|ui| {
|
||||
// ui.heading("D");
|
||||
// });
|
||||
// })
|
||||
// .body(|mut body| {
|
||||
// let mut to_delete: Vec<Qualifier> = Vec::new();
|
||||
|
||||
// self.acl.entries.sort();
|
||||
// for entry in &mut self.acl.entries {
|
||||
// body.row(30.0, |mut row| {
|
||||
// row.col(|ui| {
|
||||
// let label = match entry.0 {
|
||||
// Qualifier::User(_) => "U",
|
||||
// Qualifier::Group(_) => "G",
|
||||
// Qualifier::Other => "",
|
||||
// Qualifier::GroupObj => "G",
|
||||
// Qualifier::UserObj => "U",
|
||||
// Qualifier::Mask => "",
|
||||
// };
|
||||
// ui.label(label);
|
||||
// });
|
||||
// row.col(|ui| {
|
||||
// let label = match entry.0 {
|
||||
// Qualifier::User(uid) => self
|
||||
// .available_users
|
||||
// .iter()
|
||||
// .find(|user| user.uid == uid)
|
||||
// .map(|user| user.name.clone())
|
||||
// .unwrap_or_else(|| {
|
||||
// self.available_groups
|
||||
// .iter()
|
||||
// .find(|group| group.gid == uid)
|
||||
// .map(|group| format!("({})", group.name))
|
||||
// .unwrap_or_else(|| format!("Unknown user {}", uid))
|
||||
// }),
|
||||
// Qualifier::Group(gid) => self
|
||||
// .available_groups
|
||||
// .iter()
|
||||
// .find(|group| group.gid == gid)
|
||||
// .map(|group| group.name.clone())
|
||||
// .unwrap_or_else(|| {
|
||||
// self.available_users
|
||||
// .iter()
|
||||
// .find(|user| user.uid == gid)
|
||||
// .map(|user| format!("({})", user.name))
|
||||
// .unwrap_or_else(|| format!("Unknown group {}", gid))
|
||||
// }),
|
||||
// Qualifier::Other => "Other".to_string(),
|
||||
// Qualifier::GroupObj => "GroupObj".to_string(),
|
||||
// Qualifier::UserObj => "UserObj".to_string(),
|
||||
// Qualifier::Mask => "Mask".to_string(),
|
||||
// };
|
||||
|
||||
// ui.label(label);
|
||||
// });
|
||||
// row.col(|ui| {
|
||||
// let mut checked = entry.1.contains(PermSet::ACL_READ);
|
||||
// if ui.checkbox(&mut checked, "").changed() {
|
||||
// if checked {
|
||||
// entry.1.insert(PermSet::ACL_READ);
|
||||
// self.is_changed = true;
|
||||
// } else {
|
||||
// entry.1.remove(PermSet::ACL_READ);
|
||||
// self.is_changed = true;
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// row.col(|ui| {
|
||||
// let mut checked = entry.1.contains(PermSet::ACL_WRITE);
|
||||
// if ui.checkbox(&mut checked, "").changed() {
|
||||
// if checked {
|
||||
// entry.1.insert(PermSet::ACL_WRITE);
|
||||
// self.is_changed = true;
|
||||
// } else {
|
||||
// entry.1.remove(PermSet::ACL_WRITE);
|
||||
// self.is_changed = true;
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// row.col(|ui| {
|
||||
// let mut checked = entry.1.contains(PermSet::ACL_EXECUTE);
|
||||
// if ui.checkbox(&mut checked, "").changed() {
|
||||
// if checked {
|
||||
// entry.1.insert(PermSet::ACL_EXECUTE);
|
||||
// self.is_changed = true;
|
||||
// } else {
|
||||
// entry.1.remove(PermSet::ACL_EXECUTE);
|
||||
// self.is_changed = true;
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
// row.col(|ui| match entry.0 {
|
||||
// Qualifier::User(_) | Qualifier::Group(_) => {
|
||||
// if ui.button("🗑").clicked() {
|
||||
// to_delete.push(entry.0.clone());
|
||||
// }
|
||||
// }
|
||||
// _ => {}
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// for entry in to_delete {
|
||||
// self.acl.entries.retain(|e| e.0 != entry);
|
||||
// self.is_changed = true;
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
// fn searchable_dropdown()
|
||||
trait PermissionObject: Clone + std::fmt::Debug + Send + Sync {
|
||||
fn get_name(&self) -> String;
|
||||
}
|
||||
|
||||
// fn filter_dropbox<
|
||||
// 'a,
|
||||
// T: PartialEq + 'a,
|
||||
// I: Iterator<Item = &'a T> + 'a,
|
||||
// F: Fn(&'a T, &str) -> bool,
|
||||
// L: Fn(&'a T) -> &str,
|
||||
// >(
|
||||
// ui: &mut egui::Ui,
|
||||
// popup_id: &str,
|
||||
// search_buf: &mut String,
|
||||
// items: I,
|
||||
// filter_items: F,
|
||||
// get_label: L,
|
||||
// ) -> Option<&'a T> {
|
||||
// let search_field = ui.text_edit_singleline(search_buf);
|
||||
// let popup_id = ui.make_persistent_id(popup_id);
|
||||
impl PermissionObject for User {
|
||||
fn get_name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// if search_field.gained_focus() {
|
||||
// ui.memory_mut(|mem| mem.open_popup(popup_id));
|
||||
// }
|
||||
impl PermissionObject for Group {
|
||||
fn get_name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// let mut selected = None;
|
||||
// egui::popup_above_or_below_widget(
|
||||
// &ui,
|
||||
// popup_id,
|
||||
// &search_field,
|
||||
// egui::AboveOrBelow::Below,
|
||||
// egui::PopupCloseBehavior::CloseOnClick,
|
||||
// |ui| {
|
||||
// ui.set_min_width(200.0);
|
||||
// ui.set_max_height(300.0);
|
||||
// egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
// let filtered = items.filter(|elm| filter_items(elm, &search_buf));
|
||||
struct SelectUserOrGroupPopup<T: PermissionObject> {
|
||||
search: String,
|
||||
available_objects: Vec<T>,
|
||||
selected: Option<T>,
|
||||
exited: bool,
|
||||
table_state: TableState,
|
||||
}
|
||||
|
||||
// for user in filtered {
|
||||
// let value = ui.selectable_value(&mut selected, Some(user), get_label(user));
|
||||
// if value.clicked() {
|
||||
// search_field.surrender_focus();
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// },
|
||||
// );
|
||||
impl SelectUserOrGroupPopup<User> {
|
||||
fn new_users() -> Self {
|
||||
Self {
|
||||
search: String::new(),
|
||||
available_objects: get_users().unwrap(),
|
||||
selected: None,
|
||||
table_state: TableState::default(),
|
||||
exited: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if selected.is_some() {
|
||||
// search_buf.clear();
|
||||
// }
|
||||
impl SelectUserOrGroupPopup<Group> {
|
||||
fn new_groups() -> Self {
|
||||
Self {
|
||||
search: String::new(),
|
||||
available_objects: get_groups().unwrap(),
|
||||
selected: None,
|
||||
table_state: TableState::default(),
|
||||
exited: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// selected
|
||||
// }
|
||||
impl<T: PermissionObject> SelectUserOrGroupPopup<T> {
|
||||
fn exited(&self) -> bool {
|
||||
self.exited
|
||||
}
|
||||
|
||||
fn get_result(&self) -> Option<T> {
|
||||
self.selected.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PermissionObject> SelectUserOrGroupPopup<T> {
|
||||
fn get_filtered(&self) -> Vec<T> {
|
||||
let search = self.search.to_lowercase();
|
||||
self.available_objects
|
||||
.iter()
|
||||
.filter(|obj| obj.get_name().to_lowercase().contains(&search))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PermissionObject> Component for SelectUserOrGroupPopup<T> {
|
||||
fn update(&mut self, mut event: super::AppEventClient) {
|
||||
if event.has_focus() && !event.was_handled() {
|
||||
if event.is_focused_and_key_pressed(KeyCode::Up, None) {
|
||||
event.mark_as_handled();
|
||||
self.table_state.select_previous();
|
||||
} else if event.is_focused_and_key_pressed(KeyCode::Down, None) {
|
||||
event.mark_as_handled();
|
||||
self.table_state.select_next();
|
||||
} else if event.is_focused_and_key_pressed(KeyCode::Tab, None) {
|
||||
event.mark_as_handled();
|
||||
} else if event.is_focused_and_key_pressed(KeyCode::Enter, None) {
|
||||
// TODO: Handle selection
|
||||
event.mark_as_handled();
|
||||
self.selected = self
|
||||
.table_state
|
||||
.selected()
|
||||
.and_then(|idx| self.get_filtered().get(idx).cloned());
|
||||
event.has_focus = false;
|
||||
self.exited = true;
|
||||
} else if let Some(evt) = &event.event {
|
||||
if let Event::Key(key) = evt {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
event.mark_as_handled();
|
||||
self.selected = None;
|
||||
self.exited = true;
|
||||
event.has_focus = false;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if key.modifiers == KeyModifiers::empty() {
|
||||
self.search.push(c);
|
||||
event.mark_as_handled();
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.search.pop();
|
||||
event.mark_as_handled();
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
event.mark_as_handled();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event.has_focus() {
|
||||
event.register_helper("Ctrl + q", "Select user");
|
||||
event.register_helper("Esc", "Close popup");
|
||||
event.register_helper("↑", "Select previous entry");
|
||||
event.register_helper("↓", "Select next entry");
|
||||
event.register_helper("Enter", "Select entry");
|
||||
event.register_helper("Tab", "Switch focus");
|
||||
}
|
||||
|
||||
if self.table_state.selected().is_none() && self.get_filtered().len() > 0 {
|
||||
self.table_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, _rect: ratatui::prelude::Rect) {
|
||||
let area = frame.area();
|
||||
|
||||
let popup_area = popup_area(area, 50, 50);
|
||||
frame.render_widget(Clear, popup_area);
|
||||
|
||||
let block = Block::bordered()
|
||||
.title("Select User or Group")
|
||||
.borders(ratatui::widgets::Borders::ALL)
|
||||
.border_style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan));
|
||||
let inner_area = block.inner(popup_area);
|
||||
frame.render_widget(block, popup_area);
|
||||
|
||||
let vertical = Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]);
|
||||
let [input_area, content_area] = vertical.areas(inner_area);
|
||||
|
||||
let input = Paragraph::new(self.search.as_str())
|
||||
.block(
|
||||
Block::default()
|
||||
.title("Search")
|
||||
.borders(ratatui::widgets::Borders::ALL),
|
||||
)
|
||||
.style(Style::default().fg(Color::White).bg(Color::Black));
|
||||
|
||||
frame.render_widget(input, input_area);
|
||||
|
||||
let content = Table::new(
|
||||
self.get_filtered().iter().map(|user| {
|
||||
let mut cols = vec![];
|
||||
cols.push(user.get_name());
|
||||
Row::new(cols)
|
||||
}),
|
||||
[Constraint::Fill(1)],
|
||||
)
|
||||
.header(Row::new(vec!["Name"]))
|
||||
.row_highlight_style(Style::default().bg(Color::Blue).fg(Color::White))
|
||||
.highlight_symbol(">>");
|
||||
|
||||
frame.render_stateful_widget(content, content_area, &mut self.table_state);
|
||||
}
|
||||
}
|
||||
|
||||
fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
|
||||
let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center);
|
||||
let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center);
|
||||
let [area] = vertical.areas(area);
|
||||
let [area] = horizontal.areas(area);
|
||||
area
|
||||
}
|
||||
|
84
src/ui/log.rs
Normal file
84
src/ui/log.rs
Normal file
@ -0,0 +1,84 @@
|
||||
use crossbeam::channel::{Receiver, Sender};
|
||||
use log::{Level, Metadata, Record};
|
||||
use ratatui::{text::Line, widgets::Wrap};
|
||||
|
||||
use crate::ui::Component;
|
||||
|
||||
pub struct SimpleLogger {
|
||||
sender: Sender<String>,
|
||||
pub receiver: crossbeam::channel::Receiver<String>,
|
||||
}
|
||||
|
||||
impl SimpleLogger {
|
||||
pub fn new() -> Self {
|
||||
let (sender, receiver) = crossbeam::channel::unbounded();
|
||||
SimpleLogger { sender, receiver }
|
||||
}
|
||||
|
||||
pub fn init(&self) -> Receiver<String> {
|
||||
let logger = SimpleLogger::new();
|
||||
let receiver = logger.receiver.clone();
|
||||
log::set_boxed_logger(Box::new(logger)).unwrap();
|
||||
log::set_max_level(log::LevelFilter::Trace);
|
||||
return receiver;
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for SimpleLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= Level::Trace
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
if self.enabled(record.metadata()) {
|
||||
self.sender
|
||||
.send(format!("{} - {}", record.level(), record.args()))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
pub struct SimpleLoggerConsumer {
|
||||
receiver: crossbeam::channel::Receiver<String>,
|
||||
log: Vec<String>,
|
||||
}
|
||||
|
||||
impl SimpleLoggerConsumer {
|
||||
pub fn new(receiver: crossbeam::channel::Receiver<String>) -> Self {
|
||||
SimpleLoggerConsumer {
|
||||
receiver,
|
||||
log: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for SimpleLoggerConsumer {
|
||||
fn update(&mut self, event: crate::ui::AppEventClient) {
|
||||
// todo!()
|
||||
while self.receiver.len() > 0 {
|
||||
match self.receiver.try_recv() {
|
||||
Ok(msg) => {
|
||||
while self.log.len() >= 20 {
|
||||
self.log.remove(0);
|
||||
}
|
||||
self.log.push(msg);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut ratatui::Frame, rect: ratatui::prelude::Rect) {
|
||||
let mut lines: Vec<Line> = self.log.iter().map(|l| Line::from(l.as_str())).collect();
|
||||
lines.reverse();
|
||||
if lines.len() > 20 {
|
||||
lines.truncate(20);
|
||||
}
|
||||
let paragraph = ratatui::widgets::Paragraph::new(lines)
|
||||
.block(ratatui::widgets::Block::default())
|
||||
.wrap(Wrap::default());
|
||||
frame.render_widget(paragraph, rect);
|
||||
}
|
||||
}
|
201
src/ui/mod.rs
Normal file → Executable file
201
src/ui/mod.rs
Normal file → Executable file
@ -1,23 +1,25 @@
|
||||
use std::{io, rc::Rc, sync::RwLock};
|
||||
use std::{io, rc::Rc, sync::RwLock, time::Duration};
|
||||
|
||||
use anyhow::Result;
|
||||
use editor::ACLEditor;
|
||||
use log::SimpleLogger;
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
crossterm::event::{self, Event, KeyCode},
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
widgets::Widget,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
DefaultTerminal, Frame,
|
||||
};
|
||||
use tree::Tree;
|
||||
|
||||
mod editor;
|
||||
pub mod log;
|
||||
mod tree;
|
||||
|
||||
pub struct App {
|
||||
exit: bool,
|
||||
tree: Tree,
|
||||
editor: Option<ACLEditor>,
|
||||
log: crate::ui::log::SimpleLoggerConsumer,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
@ -28,10 +30,14 @@ impl Default for App {
|
||||
std::env::current_dir().unwrap()
|
||||
};
|
||||
|
||||
let log = SimpleLogger::new().init();
|
||||
let log = crate::ui::log::SimpleLoggerConsumer::new(log);
|
||||
|
||||
Self {
|
||||
exit: false,
|
||||
tree: Tree::new(initial_path),
|
||||
editor: None,
|
||||
log,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -40,6 +46,7 @@ impl App {
|
||||
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
let mut app_event = AppEventHost::default();
|
||||
while !self.exit {
|
||||
self.update(&mut app_event);
|
||||
terminal.draw(|frame| self.render(frame, app_event))?;
|
||||
app_event = self.handle_events()?;
|
||||
}
|
||||
@ -50,72 +57,74 @@ impl App {
|
||||
// Handle events here
|
||||
// For example, you can check for key presses and update the state accordingly
|
||||
|
||||
let evt = event::read()?;
|
||||
if let Event::Key(key) = evt {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
self.exit = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AppEventHost::new(Some(evt)))
|
||||
}
|
||||
|
||||
pub fn render(&mut self, frame: &mut Frame, event: AppEventHost) {
|
||||
if let Some(evt) = &event.event {
|
||||
match evt {
|
||||
Event::Key(key) => match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
self.exit = true;
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('e') => {
|
||||
self.editor = Some(ACLEditor::new(self.tree.get_selected()));
|
||||
}
|
||||
KeyCode::Esc | KeyCode::Char('c') => {
|
||||
self.editor = None;
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
let evt = event::read()?;
|
||||
if let Event::Key(key) = evt {
|
||||
match key.code {
|
||||
KeyCode::Char('c') => {
|
||||
if key.modifiers == event::KeyModifiers::CONTROL {
|
||||
self.exit = true;
|
||||
return Ok(AppEventHost::default());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(AppEventHost::new(Some(evt)))
|
||||
} else {
|
||||
Ok(AppEventHost::new(None))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, event: &mut AppEventHost) {
|
||||
self.log.update(event.get_client(false));
|
||||
event.get_client(true).register_helper("Ctrl + C", "Quit");
|
||||
|
||||
let cc = event.get_client(self.editor.is_none());
|
||||
if cc.is_focused_and_key_pressed(KeyCode::Enter, None)
|
||||
|| cc.is_focused_and_key_pressed(KeyCode::Char('e'), None)
|
||||
{
|
||||
self.editor = Some(ACLEditor::new(self.tree.get_selected()));
|
||||
}
|
||||
|
||||
event.get_client(true).register_helper("q", "Quit");
|
||||
if let Some(editor) = &mut self.editor {
|
||||
editor.update(event.get_client(true));
|
||||
if editor.exited {
|
||||
self.editor = None;
|
||||
}
|
||||
}
|
||||
if self.editor.is_none() {
|
||||
event.get_client(true).register_helper("Enter", "Select");
|
||||
}
|
||||
|
||||
let layout1 = Layout::default()
|
||||
self.tree.update(event.get_client(self.editor.is_none()));
|
||||
}
|
||||
|
||||
pub fn render(&mut self, frame: &mut Frame, mut event: AppEventHost) {
|
||||
event.event = None;
|
||||
let [content_area, log_area, help_area] = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Fill(1), Constraint::Length(1)])
|
||||
.split(frame.area());
|
||||
.constraints([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.areas(frame.area());
|
||||
|
||||
let content_area = layout1[0];
|
||||
let help_area = layout1[1];
|
||||
|
||||
let layout2 = Layout::default()
|
||||
let [content_left_area, content_right_area] = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Fill(1)])
|
||||
.split(content_area);
|
||||
.areas(content_area);
|
||||
|
||||
let content_left_area = layout2[0];
|
||||
let content_right_area = layout2[1];
|
||||
|
||||
self.tree.render(
|
||||
frame,
|
||||
content_left_area,
|
||||
event.get_client(self.editor.is_none()),
|
||||
);
|
||||
self.tree.render(frame, content_left_area);
|
||||
|
||||
if let Some(editor) = self.editor.as_mut() {
|
||||
event
|
||||
.get_client(true)
|
||||
.register_helper("Esc", "Close editor");
|
||||
editor.render(frame, content_right_area, event.get_client(true));
|
||||
editor.render(frame, content_right_area);
|
||||
} else {
|
||||
event.get_client(true).register_helper("Enter", "Select");
|
||||
frame.render_widget("Select folder from the left", content_right_area);
|
||||
}
|
||||
|
||||
self.log.render(frame, log_area);
|
||||
event.render_help(frame, help_area);
|
||||
}
|
||||
}
|
||||
@ -123,6 +132,7 @@ impl App {
|
||||
pub struct AppEventHost {
|
||||
pub event: Option<Event>,
|
||||
pub registered_helper: Rc<RwLock<Vec<AppEventHelper>>>,
|
||||
pub was_handled: Rc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
impl Default for AppEventHost {
|
||||
@ -130,6 +140,7 @@ impl Default for AppEventHost {
|
||||
Self {
|
||||
event: None,
|
||||
registered_helper: Rc::from(RwLock::new(vec![])),
|
||||
was_handled: Rc::from(RwLock::new(false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -143,11 +154,27 @@ impl AppEventHost {
|
||||
}
|
||||
|
||||
fn render_help(&self, frame: &mut Frame, area: Rect) {
|
||||
let mut help = String::new();
|
||||
let mut spans = vec![];
|
||||
let infos = self.registered_helper.read().unwrap();
|
||||
let mut first = true;
|
||||
for info in infos.iter() {
|
||||
help.push_str(&format!("{}: {} | \n", info.key, info.help));
|
||||
if first {
|
||||
first = false;
|
||||
spans.push(Span::from("Help: "));
|
||||
} else {
|
||||
spans.push(Span::from(", "));
|
||||
}
|
||||
|
||||
let b = Span::styled(&info.key, Style::default().add_modifier(Modifier::BOLD));
|
||||
spans.push(b);
|
||||
|
||||
spans.push(Span::from(" - "));
|
||||
let s = Span::styled(&info.help, Style::default());
|
||||
spans.push(s);
|
||||
}
|
||||
|
||||
let help = ratatui::widgets::Paragraph::new(Line::from(spans))
|
||||
.wrap(ratatui::widgets::Wrap { trim: true });
|
||||
frame.render_widget(help, area);
|
||||
}
|
||||
|
||||
@ -156,6 +183,7 @@ impl AppEventHost {
|
||||
host: self.registered_helper.clone(),
|
||||
event: self.event.clone(),
|
||||
has_focus: focus,
|
||||
was_handled: self.was_handled.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -163,6 +191,7 @@ impl AppEventHost {
|
||||
pub struct AppEventClient {
|
||||
host: Rc<RwLock<Vec<AppEventHelper>>>,
|
||||
event: Option<Event>,
|
||||
pub was_handled: Rc<RwLock<bool>>,
|
||||
pub has_focus: bool,
|
||||
}
|
||||
|
||||
@ -175,11 +204,48 @@ impl AppEventClient {
|
||||
Self {
|
||||
host: self.host.clone(),
|
||||
event: self.event.clone(),
|
||||
was_handled: self.was_handled.clone(),
|
||||
has_focus: focus,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_helper(&mut self, key: &str, help: &str) {
|
||||
pub fn was_handled(&self) -> bool {
|
||||
*self.was_handled.read().unwrap()
|
||||
}
|
||||
|
||||
pub fn mark_as_handled(&mut self) {
|
||||
*self.was_handled.write().unwrap() = true;
|
||||
}
|
||||
|
||||
pub fn is_key_pressed(&self, key: KeyCode, modifiers: Option<event::KeyModifiers>) -> bool {
|
||||
if *self.was_handled.read().unwrap() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(evt) = &self.event {
|
||||
if let Event::Key(k) = evt {
|
||||
if let Some(mods) = modifiers {
|
||||
return k.code == key && k.modifiers == mods;
|
||||
}
|
||||
return k.code == key;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn is_focused_and_key_pressed(
|
||||
&self,
|
||||
key: KeyCode,
|
||||
modifiers: Option<event::KeyModifiers>,
|
||||
) -> bool {
|
||||
if self.has_focus() {
|
||||
self.is_key_pressed(key, modifiers)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_helper(&self, key: &str, help: &str) {
|
||||
self.host.write().unwrap().push(AppEventHelper {
|
||||
key: key.to_string(),
|
||||
help: help.to_string(),
|
||||
@ -192,26 +258,7 @@ pub struct AppEventHelper {
|
||||
pub help: String,
|
||||
}
|
||||
|
||||
// impl eframe::App for App {
|
||||
// fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// egui::SidePanel::right("side_panel")
|
||||
// .min_width(300.0)
|
||||
// .max_width(ctx.available_rect().width() - 100.0)
|
||||
// .resizable(true)
|
||||
// .show(ctx, |ui| {
|
||||
// self.editor.show(ui, &self.tree.selected);
|
||||
// });
|
||||
|
||||
// egui::CentralPanel::default().show(ctx, |ui| {
|
||||
// egui::ScrollArea::both()
|
||||
// .auto_shrink([false; 2])
|
||||
// .show(ui, |ui| {
|
||||
// self.tree.show(ui);
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
pub trait Component {
|
||||
fn render(&mut self, f: &mut Frame, rect: Rect, evt: AppEventClient);
|
||||
fn update(&mut self, event: AppEventClient);
|
||||
fn render(&mut self, frame: &mut Frame, rect: Rect);
|
||||
}
|
||||
|
72
src/ui/tree.rs
Normal file → Executable file
72
src/ui/tree.rs
Normal file → Executable file
@ -1,73 +1,64 @@
|
||||
// Build a tree of folders (not files)
|
||||
// Folders can be expanded and collapsed
|
||||
// Folders can by selected
|
||||
// Folders will fetch their children when expanded
|
||||
|
||||
use std::{default, fmt::format, path::PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ratatui::{
|
||||
crossterm::event::{Event, KeyCode, KeyEvent},
|
||||
crossterm::event::{Event, KeyCode},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Widget},
|
||||
Frame,
|
||||
widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
||||
};
|
||||
|
||||
use super::Component;
|
||||
|
||||
// use eframe::egui;
|
||||
|
||||
pub struct Tree {
|
||||
root: Folder,
|
||||
// pub selected: PathBuf,
|
||||
vertical_scroll: usize,
|
||||
vertical_scroll_state: ScrollbarState,
|
||||
selected_idx: usize,
|
||||
focused: bool,
|
||||
}
|
||||
|
||||
impl Component for Tree {
|
||||
fn render(
|
||||
&mut self,
|
||||
f: &mut ratatui::Frame,
|
||||
rect: ratatui::prelude::Rect,
|
||||
mut evt: super::AppEventClient,
|
||||
) {
|
||||
if evt.has_focus() {
|
||||
if let Some(evt) = &evt.event {
|
||||
if let Event::Key(key) = evt {
|
||||
match key.code {
|
||||
KeyCode::Up => {
|
||||
if self.selected_idx > 0 {
|
||||
self.selected_idx -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let entries = self.root.get_entries();
|
||||
if self.selected_idx < entries.len() - 1 {
|
||||
self.selected_idx += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn update(&mut self, mut evt: super::AppEventClient) {
|
||||
if evt.is_focused_and_key_pressed(KeyCode::Up, None) {
|
||||
evt.mark_as_handled();
|
||||
if self.selected_idx > 0 {
|
||||
self.selected_idx -= 1;
|
||||
}
|
||||
} else if evt.is_focused_and_key_pressed(KeyCode::Down, None) {
|
||||
evt.mark_as_handled();
|
||||
let entries = self.root.get_entries();
|
||||
if self.selected_idx < entries.len() - 1 {
|
||||
self.selected_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if evt.has_focus() {
|
||||
evt.register_helper("->", "Expand");
|
||||
evt.register_helper("<-", "Collapse");
|
||||
evt.register_helper("↑", "Up");
|
||||
evt.register_helper("↓", "Down");
|
||||
}
|
||||
|
||||
let mut tmp = self.selected_idx;
|
||||
self.root.update(&evt, &mut tmp);
|
||||
|
||||
self.focused = evt.has_focus();
|
||||
}
|
||||
|
||||
fn render(&mut self, f: &mut ratatui::Frame, rect: ratatui::prelude::Rect) {
|
||||
let block = Block::bordered()
|
||||
.title("Select Folder")
|
||||
.borders(ratatui::widgets::Borders::ALL)
|
||||
.border_style(ratatui::style::Style::default().fg(ratatui::style::Color::White));
|
||||
.border_style(ratatui::style::Style::default().fg(if self.focused {
|
||||
ratatui::style::Color::Cyan
|
||||
} else {
|
||||
ratatui::style::Color::Gray
|
||||
}));
|
||||
|
||||
let inner_area = block.inner(rect);
|
||||
f.render_widget(block, rect);
|
||||
|
||||
let mut tmp = self.selected_idx;
|
||||
self.root.apply_event(&evt, &mut tmp);
|
||||
let entries = self.root.get_entries();
|
||||
let lines: Vec<Line> = entries.iter().map(|entry| entry.get_line()).collect();
|
||||
|
||||
@ -111,6 +102,7 @@ impl Tree {
|
||||
vertical_scroll: 0,
|
||||
vertical_scroll_state: ScrollbarState::default(),
|
||||
selected_idx: 0,
|
||||
focused: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -179,7 +171,7 @@ impl Folder {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_event(&mut self, evt: &super::AppEventClient, selected_idx: &mut usize) {
|
||||
pub fn update(&mut self, evt: &super::AppEventClient, selected_idx: &mut usize) {
|
||||
if *selected_idx == 0 {
|
||||
self.selected = true;
|
||||
*selected_idx = usize::MAX;
|
||||
@ -207,7 +199,7 @@ impl Folder {
|
||||
if self.expanded {
|
||||
if let Some(children) = &mut self.children {
|
||||
for child in children {
|
||||
child.apply_event(evt, selected_idx);
|
||||
child.update(evt, selected_idx);
|
||||
// if child.selected {
|
||||
// self.selected = true;
|
||||
// }
|
||||
|
Loading…
x
Reference in New Issue
Block a user