# tui_menu 0.2.0
1. Переход на Cursive 2. Менеджер навигации по меню: вызывающая сторона может осуществлять навигацию по меню вне Cursive event loop, например, в потоке обработчика внешнего прерывания GPIO
This commit is contained in:
parent
4880b822df
commit
0922c81c0a
25 changed files with 1247 additions and 680 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
825
Cargo.lock
generated
825
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
BIN
config_lib/.DS_Store
vendored
Normal file
BIN
config_lib/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -4,17 +4,17 @@ use toml_edit::value;
|
||||||
|
|
||||||
use crate::toml_parser::*;
|
use crate::toml_parser::*;
|
||||||
|
|
||||||
/// Структура, содержащая набор параметров Parameter
|
/// Структура, содержащая набор параметров` Parameter`
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DeviceConfig {
|
pub struct DeviceConfig {
|
||||||
scheme: TomlScheme,
|
scheme: TomlScheme,
|
||||||
/// Набор параметров
|
/// Набор параметров
|
||||||
/// key - имя параметра, value - структура, описывающая параметр
|
/// `key` - имя параметра, `value` - структура, описывающая параметр
|
||||||
parameters: HashMap<String, Parameter>,
|
parameters: HashMap<String, Parameter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceConfig {
|
impl DeviceConfig {
|
||||||
/// ## Заполнение струтуры DeviceConfig
|
/// ## Заполнение струтуры `DeviceConfig`
|
||||||
/// * `path_to_scheme` - путь к файлу-cхеме
|
/// * `path_to_scheme` - путь к файлу-cхеме
|
||||||
pub fn create_parameter_list(path_to_scheme: &str) -> Result<DeviceConfig, String> {
|
pub fn create_parameter_list(path_to_scheme: &str) -> Result<DeviceConfig, String> {
|
||||||
let toml_config = TomlScheme::new(path_to_scheme).map_err(|e| e.to_string())?;
|
let toml_config = TomlScheme::new(path_to_scheme).map_err(|e| e.to_string())?;
|
||||||
|
|
@ -43,7 +43,7 @@ impl DeviceConfig {
|
||||||
Ok(device_config)
|
Ok(device_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// # Сохранение текущих значений параметров в TOML-файл self.schema_path
|
/// ## Сохранение текущих значений параметров в TOML-файл `self.schema_path`
|
||||||
pub fn save_parameters_values(&self) -> Result<(), String> {
|
pub fn save_parameters_values(&self) -> Result<(), String> {
|
||||||
let mut toml_config =
|
let mut toml_config =
|
||||||
TomlScheme::new(&self.scheme.get_path_to_scheme_file()).map_err(|e| e.to_string())?;
|
TomlScheme::new(&self.scheme.get_path_to_scheme_file()).map_err(|e| e.to_string())?;
|
||||||
|
|
@ -57,7 +57,7 @@ impl DeviceConfig {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// # Получение списка ключей - имен параметров
|
/// ## Получение списка ключей - имен параметров
|
||||||
pub fn get_parameters_names(&self) -> Result<Vec<String>, String> {
|
pub fn get_parameters_names(&self) -> Result<Vec<String>, String> {
|
||||||
if self.parameters.is_empty() {
|
if self.parameters.is_empty() {
|
||||||
return Err("Parameters list is empty".to_string());
|
return Err("Parameters list is empty".to_string());
|
||||||
|
|
@ -66,7 +66,7 @@ impl DeviceConfig {
|
||||||
return Ok(self.parameters.keys().map(|key| key.clone()).collect());
|
return Ok(self.parameters.keys().map(|key| key.clone()).collect());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// # Получение описания параметра соответствующего key
|
/// ## Получение описания параметра соответствующего `key`
|
||||||
pub fn get_parameter_description(&self, key: &str) -> Result<String, String> {
|
pub fn get_parameter_description(&self, key: &str) -> Result<String, String> {
|
||||||
match self.parameters.get(key) {
|
match self.parameters.get(key) {
|
||||||
Some(parameter) => Ok(parameter.get_description()),
|
Some(parameter) => Ok(parameter.get_description()),
|
||||||
|
|
@ -74,7 +74,7 @@ impl DeviceConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// # Получение списка возможных значений параметра соответствующего key
|
/// ## Получение списка возможных значений параметра соответствующего `key`
|
||||||
pub fn get_parameter_possible_values(&self, key: &str) -> Result<Vec<String>, String> {
|
pub fn get_parameter_possible_values(&self, key: &str) -> Result<Vec<String>, String> {
|
||||||
match self.parameters.get(key) {
|
match self.parameters.get(key) {
|
||||||
Some(parameter) => Ok(parameter.get_possible_values()),
|
Some(parameter) => Ok(parameter.get_possible_values()),
|
||||||
|
|
@ -82,7 +82,7 @@ impl DeviceConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// # Получение значения параметра соответствующего key
|
/// ## Получение значения параметра соответствующего `key`
|
||||||
pub fn get_parameter_value(&self, key: &str) -> Result<String, String> {
|
pub fn get_parameter_value(&self, key: &str) -> Result<String, String> {
|
||||||
match self.parameters.get(key) {
|
match self.parameters.get(key) {
|
||||||
Some(parameter) => Ok(parameter.get_value()),
|
Some(parameter) => Ok(parameter.get_value()),
|
||||||
|
|
@ -90,7 +90,7 @@ impl DeviceConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// # Установка значения параметра соответствующего key
|
/// ## Установка значения параметра соответствующего `key`
|
||||||
pub fn set_parameter_value(&mut self, key: &str, value: String) -> Result<(), String> {
|
pub fn set_parameter_value(&mut self, key: &str, value: String) -> Result<(), String> {
|
||||||
match self.parameters.get_mut(key) {
|
match self.parameters.get_mut(key) {
|
||||||
Some(parameter) => parameter.set_value(value),
|
Some(parameter) => parameter.set_value(value),
|
||||||
|
|
@ -99,7 +99,7 @@ impl DeviceConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ## Получение числового индекса соответствующего текущему значению параметра
|
/// ## Получение числового индекса соответствующего текущему значению параметра
|
||||||
/// Индекс соответствует положению текущего значения в списке возможных значений possible_values
|
/// Индекс соответствует положению текущего значения в списке возможных значений `possible_values`
|
||||||
pub fn get_parameter_index_using_value(&self, key: &str) -> Result<u8, String> {
|
pub fn get_parameter_index_using_value(&self, key: &str) -> Result<u8, String> {
|
||||||
let parameters_list = self.get_parameters_names()?;
|
let parameters_list = self.get_parameters_names()?;
|
||||||
if parameters_list.contains(&key.to_owned()) {
|
if parameters_list.contains(&key.to_owned()) {
|
||||||
|
|
@ -116,8 +116,8 @@ impl DeviceConfig {
|
||||||
Err("Unable to get parameter value using index".to_string())
|
Err("Unable to get parameter value using index".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ## Присваивание параметру key значения, соответствующего index
|
/// ## Присваивание параметру `key` значения, соответствующего `index`
|
||||||
/// Индекс соответствует положению значения в списке возможных значений possible_values
|
/// Индекс соответствует положению значения в списке возможных значений `possible_values`
|
||||||
pub fn set_parameter_value_using_index(&mut self, key: &str, index: u8) -> Result<(), String> {
|
pub fn set_parameter_value_using_index(&mut self, key: &str, index: u8) -> Result<(), String> {
|
||||||
let parameters_list = self.get_parameters_names()?;
|
let parameters_list = self.get_parameters_names()?;
|
||||||
if parameters_list.contains(&key.to_owned()) {
|
if parameters_list.contains(&key.to_owned()) {
|
||||||
|
|
|
||||||
BIN
menu_tui/.DS_Store
vendored
Normal file
BIN
menu_tui/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -1,16 +1,14 @@
|
||||||
[package]
|
[package]
|
||||||
name = "menu_tui"
|
name = "menu_tui"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
config_lib = { path = "../config_lib" }
|
config_lib = { path = "../config_lib" }
|
||||||
inquire = { workspace = true }
|
cursive = {version = "0.21.1", features = ["toml"]}
|
||||||
enigo = "0.5.0"
|
|
||||||
colored = "3.0.0"
|
|
||||||
terminal-menu = "3.0.0"
|
|
||||||
crossterm = "0.25.0"
|
crossterm = "0.25.0"
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
env_logger = { workspace = true }
|
env_logger = { workspace = true }
|
||||||
|
|
|
||||||
9
menu_tui/TODO.md
Normal file
9
menu_tui/TODO.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Рекомендации по улучшению
|
||||||
|
|
||||||
|
1.Рефакторинг обработки ошибок: Заменить все `unwrap()` / `expect()` на proper error handling с кастомным error enum
|
||||||
|
2.Добавить логирование: Интегрировать `log` / `defmt` с выводом на UART для embedded-отладки
|
||||||
|
3.Типобезопасные идентификаторы: Использовать `enum` вместо строк для имен `view`
|
||||||
|
4.Оптимизация памяти: Рассмотреть `Rc<RefCell<...>>` для state management
|
||||||
|
5.Unit-тесты: Покрыть тестами критичные модули (`DeviceParameters`, `NavigationManager`)
|
||||||
|
6.Реализовать `update_view`: Добавить функционал обновления прошивки через HTTP/UART
|
||||||
|
7.Документация: Дополнить `rustdoc` комментариями и примерами использования
|
||||||
25
menu_tui/assets/style.toml
Normal file
25
menu_tui/assets/style.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# First come some various options
|
||||||
|
shadow = false # Don't draw shadows around stacked views
|
||||||
|
borders = "simple" # Alternatives are "none" and "outset"
|
||||||
|
|
||||||
|
# Here we define the color palette.
|
||||||
|
[colors]
|
||||||
|
background = "#000000"
|
||||||
|
# If the value is an array, the first valid color will be used.
|
||||||
|
# If the terminal doesn't support custom color,
|
||||||
|
# non-base colors will be skipped.
|
||||||
|
shadow = ["#000000", "black"]
|
||||||
|
view = "#d3d7cf"
|
||||||
|
|
||||||
|
# Array and simple values have the same effect.
|
||||||
|
primary = ["#111111"]
|
||||||
|
secondary = "#EEEEEE"
|
||||||
|
tertiary = "#444444"
|
||||||
|
|
||||||
|
# Hex values can use lower or uppercase.
|
||||||
|
title_primary = "#0000FF"
|
||||||
|
title_secondary = "#ffff55"
|
||||||
|
|
||||||
|
# Lower precision values can use only 3 digits.
|
||||||
|
highlight = "#00F"
|
||||||
|
highlight_inactive = "#5555FF"
|
||||||
|
|
@ -1,27 +1,66 @@
|
||||||
use config_lib::device_config::DeviceConfig;
|
use menu_tui::menu_navigation::NavigationManager;
|
||||||
use menu_tui::menu_rendering::{MainMenuStates, show_main_dialog};
|
use menu_tui::menu_process::DeviceMenu;
|
||||||
|
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Запуск меню в режиме имитации нажатия кнопок
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut device_config =
|
// Создаём меню
|
||||||
DeviceConfig::create_parameter_list("menu_tui/examples/simple_config.toml")?;
|
let mut menu = DeviceMenu::new(
|
||||||
|
"menu_tui/examples/simple_config.toml",
|
||||||
|
"menu_tui/assets/style.toml",
|
||||||
|
);
|
||||||
|
|
||||||
loop {
|
// Получаем менеджер навигации для управления от GPIO
|
||||||
match show_main_dialog(&mut device_config) {
|
let nav_manager = menu.get_navigation_manager();
|
||||||
Ok(MainMenuStates::ConfigurationState) => {
|
|
||||||
println!("Configuration state");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Ok(MainMenuStates::ExitState) => {
|
|
||||||
println!("The user has chosen to exit...");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("Menu error: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
menu.show_main_menu();
|
||||||
|
|
||||||
|
menu.launch_idling_watchdog(5);
|
||||||
|
|
||||||
|
// Симуляция GPIO обработчиков в отдельных потоках
|
||||||
|
simulate_gpio_handlers(nav_manager);
|
||||||
|
|
||||||
|
// Запускаем главный цикл обработки событий
|
||||||
|
menu.run();
|
||||||
|
|
||||||
|
let device_config = menu.get_schema_config()?;
|
||||||
|
|
||||||
|
println!("Итоговая конфигурация:");
|
||||||
|
for parameter in device_config.get_parameters_names()? {
|
||||||
|
let value = device_config.get_parameter_value(¶meter).unwrap();
|
||||||
|
println!("{}: {}", parameter, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Симуляция обработчиков GPIO кнопок
|
||||||
|
fn simulate_gpio_handlers(nav_manager: NavigationManager) {
|
||||||
|
// Кнопка 1 (GPIO): Навигация вниз
|
||||||
|
let nav_down = nav_manager.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(Duration::from_secs(2));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
thread::sleep(Duration::from_millis(1000));
|
||||||
|
|
||||||
|
// Симуляция нажатия кнопки GPIO
|
||||||
|
nav_down.navigate_down();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Кнопка 2 (GPIO): Выбор/подтверждение
|
||||||
|
let nav_select = nav_manager.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(Duration::from_secs(3));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
thread::sleep(Duration::from_millis(3000));
|
||||||
|
|
||||||
|
// Симуляция нажатия кнопки GPIO
|
||||||
|
nav_select.select_item();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,29 @@ possible_values = [
|
||||||
"15",
|
"15",
|
||||||
]
|
]
|
||||||
default = "0"
|
default = "0"
|
||||||
current = "6"
|
current = "4"
|
||||||
|
|
||||||
|
[placement]
|
||||||
|
name = "Размещение"
|
||||||
|
possible_values = [
|
||||||
|
"Кабина",
|
||||||
|
"Этаж",
|
||||||
|
"Универсальное",
|
||||||
|
]
|
||||||
|
default = "Кабина"
|
||||||
|
current = "Универсальное"
|
||||||
|
|
||||||
|
|
||||||
|
[station]
|
||||||
|
name = "Станция управления"
|
||||||
|
possible_values = [
|
||||||
|
"Smart",
|
||||||
|
"ШК6000",
|
||||||
|
"НКУ-CAN",
|
||||||
|
"НКУ-SD7",
|
||||||
|
]
|
||||||
|
default = "Smart"
|
||||||
|
current = "Smart"
|
||||||
|
|
||||||
[soundvolume]
|
[soundvolume]
|
||||||
name = "Громкость звука"
|
name = "Громкость звука"
|
||||||
|
|
@ -38,7 +60,7 @@ current = "50%"
|
||||||
name = "Громкость музыки"
|
name = "Громкость музыки"
|
||||||
possible_values = ["0%", "25%", "50%", "75%", "100%"]
|
possible_values = ["0%", "25%", "50%", "75%", "100%"]
|
||||||
default = "0%"
|
default = "0%"
|
||||||
current = "100%"
|
current = "50%"
|
||||||
|
|
||||||
[loadcapacity]
|
[loadcapacity]
|
||||||
name = "Грузоподъемность"
|
name = "Грузоподъемность"
|
||||||
|
|
@ -61,4 +83,4 @@ possible_values = [
|
||||||
"1600кг 21чел.",
|
"1600кг 21чел.",
|
||||||
]
|
]
|
||||||
default = "СКРЫТО"
|
default = "СКРЫТО"
|
||||||
current = "240кг 3чел."
|
current = "525кг 7чел."
|
||||||
|
|
|
||||||
BIN
menu_tui/src/.DS_Store
vendored
Normal file
BIN
menu_tui/src/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -1 +1,4 @@
|
||||||
pub mod menu_rendering;
|
pub mod menu_navigation;
|
||||||
|
pub mod menu_process;
|
||||||
|
mod user_parameters;
|
||||||
|
mod views;
|
||||||
|
|
|
||||||
165
menu_tui/src/menu_navigation.rs
Normal file
165
menu_tui/src/menu_navigation.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
//! # Модуль навигации по меню
|
||||||
|
//!
|
||||||
|
//! > Позволяет имитировать нажатия определенных клавиш в процессе работы с меню.
|
||||||
|
//! Используется если в системе отсутствует клавиатура.
|
||||||
|
//!
|
||||||
|
//!
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use cursive::event::{Event, EventResult, Key};
|
||||||
|
use cursive::views::{CircularFocus, ListView, SelectView};
|
||||||
|
use cursive::{CbSink, Cursive, View};
|
||||||
|
|
||||||
|
// TODO: Перенести в отдельный модуль
|
||||||
|
enum ActiveMenuView {
|
||||||
|
MainView,
|
||||||
|
ConfigView,
|
||||||
|
ExitView,
|
||||||
|
UpdateView,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for ActiveMenuView {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
match value {
|
||||||
|
"main_menu" => ActiveMenuView::MainView,
|
||||||
|
"config_menu" => ActiveMenuView::ConfigView,
|
||||||
|
"exit_menu" => ActiveMenuView::ExitView,
|
||||||
|
"update_menu" => ActiveMenuView::UpdateView,
|
||||||
|
_ => ActiveMenuView::MainView,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Менеджер навигации для управления меню c вызывающей стороны
|
||||||
|
pub struct NavigationManager {
|
||||||
|
// CbSink — это thread-safe канал, позволяющий отправлять callback-функции из любого потока.
|
||||||
|
// Cursive автоматически обрабатывает эти callback’и в своем event loop, обеспечивая потокобезопасность
|
||||||
|
cb_sink: CbSink,
|
||||||
|
current_view: Arc<Mutex<String>>,
|
||||||
|
last_activity: Arc<Mutex<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NavigationManager {
|
||||||
|
/// Создать новый менеджер навигации
|
||||||
|
pub fn new(cb_sink: CbSink) -> Self {
|
||||||
|
NavigationManager {
|
||||||
|
cb_sink,
|
||||||
|
current_view: Arc::new(Mutex::new("main_menu".to_string())),
|
||||||
|
last_activity: Arc::new(Mutex::new(Instant::now())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить время последнего нажатия клавиши
|
||||||
|
pub fn get_last_activity(&self) -> Instant {
|
||||||
|
self.last_activity.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Установить время последнего нажатия клавиши
|
||||||
|
pub fn set_last_activity(&self, instant: Instant) {
|
||||||
|
let mut last_time = self.last_activity.lock().unwrap();
|
||||||
|
*last_time = instant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Установить текущий активный View (Cursive)
|
||||||
|
pub(crate) fn set_current_view(&self, view_name: &str) {
|
||||||
|
let mut current = self.current_view.lock().unwrap();
|
||||||
|
*current = view_name.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить имя текущего View (Cursive)
|
||||||
|
pub(crate) fn get_current_view(&self) -> String {
|
||||||
|
self.current_view.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Имитация нажатия клавиши вниз
|
||||||
|
pub fn navigate_down(&self) {
|
||||||
|
self.set_last_activity(Instant::now());
|
||||||
|
let view_name = self.get_current_view();
|
||||||
|
self.send_event(&view_name, Event::Key(Key::Down));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Имитация нажатия клавиши вверх
|
||||||
|
pub fn navigate_up(&self) {
|
||||||
|
self.set_last_activity(Instant::now());
|
||||||
|
let view_name = self.get_current_view();
|
||||||
|
self.send_event(&view_name, Event::Key(Key::Up));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Имитация нажатия клавиши Enter
|
||||||
|
pub fn select_item(&self) {
|
||||||
|
let mut last_time = self.last_activity.lock().unwrap();
|
||||||
|
*last_time = Instant::now();
|
||||||
|
let view_name = self.get_current_view();
|
||||||
|
self.send_event(&view_name, Event::Key(Key::Enter));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ## Отправка события `event` текущему рантайму `Cursive` для активного `View`
|
||||||
|
/// Отправка по каналу с помощью `cb_sink: Sender`
|
||||||
|
fn send_event(&self, view_name: &str, event: Event) {
|
||||||
|
let view_name = view_name.to_string();
|
||||||
|
let event_clone = event.clone();
|
||||||
|
|
||||||
|
self.cb_sink
|
||||||
|
.send(Box::new(move |s: &mut Cursive| {
|
||||||
|
Self::dispatch_event_globally(s, &view_name, event_clone);
|
||||||
|
}))
|
||||||
|
.ok(); // Используем .ok() чтобы не паниковать при закрытии
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка события `event` в разные типы view
|
||||||
|
fn dispatch_event_globally(siv: &mut cursive::Cursive, view_name: &str, event: Event) {
|
||||||
|
// Сначала пытаемся отправить событие в активный слой (верхний)
|
||||||
|
// Это позволит обработать popup окна
|
||||||
|
let result = siv.screen_mut().on_event(event.clone());
|
||||||
|
|
||||||
|
match result {
|
||||||
|
EventResult::Consumed(maybe_callback) => {
|
||||||
|
// Событие обработано, выполняем callback если есть
|
||||||
|
if let Some(callback) = maybe_callback {
|
||||||
|
callback(siv);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EventResult::Ignored => {
|
||||||
|
|
||||||
|
// Событие проигнорировано верхним слоем
|
||||||
|
// Пытаемся отправить в именованный view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: отправка в конкретный именованный view
|
||||||
|
// Для CircularFocus<SelectView<i32>> - главное меню
|
||||||
|
if let Some(result) = siv
|
||||||
|
.call_on_name(view_name, |view: &mut CircularFocus<SelectView<i32>>| {
|
||||||
|
view.on_event(event.clone())
|
||||||
|
})
|
||||||
|
{
|
||||||
|
if let EventResult::Consumed(Some(callback)) = result {
|
||||||
|
callback(siv);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для CircularFocus<ListView> - меню параметров устройства
|
||||||
|
if let Some(result) = siv.call_on_name(view_name, |view: &mut CircularFocus<ListView>| {
|
||||||
|
view.on_event(event.clone())
|
||||||
|
}) {
|
||||||
|
if let EventResult::Consumed(Some(callback)) = result {
|
||||||
|
callback(siv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Реализация клонирования для использования менеджера в разных потоках
|
||||||
|
impl Clone for NavigationManager {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
NavigationManager {
|
||||||
|
cb_sink: self.cb_sink.clone(),
|
||||||
|
current_view: Arc::clone(&self.current_view),
|
||||||
|
last_activity: Arc::clone(&self.last_activity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
menu_tui/src/menu_process.rs
Normal file
146
menu_tui/src/menu_process.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
//! # Модуль отображения меню
|
||||||
|
//!
|
||||||
|
//! ## Навигация
|
||||||
|
//! > Для навигации по меню используется клавиши стрелка вверх/вниз
|
||||||
|
//! Выбор элемента в меню происходит клавишей Enter
|
||||||
|
//! `В случае отсутствия данных клавиш в системе, можно эмулировать их с помощью модуля menu_navigation
|
||||||
|
//!
|
||||||
|
//! ## Параметры меню
|
||||||
|
//! > Параметры меню подтягиваются из файла-схемы TOML с помощью крейта config_lib
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use config_lib::device_config::DeviceConfig;
|
||||||
|
use cursive::{
|
||||||
|
Cursive, CursiveExt,
|
||||||
|
event::{Event, EventResult, EventTrigger},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
menu_navigation::NavigationManager, user_parameters::DeviceParameters,
|
||||||
|
views::main_view::show_main_view,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct DeviceMenu {
|
||||||
|
siv: Cursive,
|
||||||
|
nav_manager: NavigationManager,
|
||||||
|
scheme_config: DeviceConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct MenuAppState {
|
||||||
|
pub navigation_manager: NavigationManager,
|
||||||
|
pub inner_config: DeviceParameters,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceMenu {
|
||||||
|
pub fn new(path_to_scheme: &str, theme_path: &str) -> Self {
|
||||||
|
// Получение конфигурации устройства
|
||||||
|
let device_config = DeviceConfig::create_parameter_list(path_to_scheme)
|
||||||
|
.expect("Ошибка загрузки config_scheme");
|
||||||
|
|
||||||
|
let mut siv = Cursive::default();
|
||||||
|
|
||||||
|
siv.set_global_callback('q', |siv: &mut Cursive| {
|
||||||
|
siv.quit();
|
||||||
|
});
|
||||||
|
// TODO: обработка ошибок
|
||||||
|
let mut device_parameters: DeviceParameters = DeviceParameters::new();
|
||||||
|
|
||||||
|
// Заполнение списка пользовательских параметров данными из `DeviceConfig`
|
||||||
|
device_parameters
|
||||||
|
.load_user_config(&device_config)
|
||||||
|
.expect("Ошибка загрузки пользовательских параметров!");
|
||||||
|
|
||||||
|
let nav_manager = NavigationManager::new(siv.cb_sink().clone());
|
||||||
|
|
||||||
|
// TODO: Cursive logs example
|
||||||
|
if let Err(_) = siv.load_theme_file(theme_path) {
|
||||||
|
println!("{}", format!("Не удалось загрузить тему: {theme_path}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем и NavigationManager и конфигурацию
|
||||||
|
let app_state = MenuAppState {
|
||||||
|
navigation_manager: nav_manager.clone(),
|
||||||
|
inner_config: device_parameters,
|
||||||
|
};
|
||||||
|
|
||||||
|
// При работе в меню с исподьзованием обычной клавиатуры, обновляем last_activity (активность пользователя)
|
||||||
|
siv.set_on_pre_event_inner(EventTrigger::from_fn(|e| matches!(e, Event::Key(_))), {
|
||||||
|
let nav_manager_clone = nav_manager.clone();
|
||||||
|
move |_event: &Event| -> Option<EventResult> {
|
||||||
|
nav_manager_clone.set_last_activity(Instant::now());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
siv.set_user_data(app_state);
|
||||||
|
|
||||||
|
DeviceMenu {
|
||||||
|
siv,
|
||||||
|
nav_manager,
|
||||||
|
scheme_config: device_config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Показать главное меню
|
||||||
|
pub fn show_main_menu(&mut self) {
|
||||||
|
self.nav_manager.set_current_view("main_menu");
|
||||||
|
show_main_view(&mut self.siv);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить менеджер навигации для управления извне
|
||||||
|
pub fn get_navigation_manager(&self) -> NavigationManager {
|
||||||
|
self.nav_manager.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запустить главный цикл обработки событий
|
||||||
|
pub fn run(&mut self) {
|
||||||
|
self.siv.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ## Запуск таймера, который проверяет активность пользователя
|
||||||
|
/// > Если пользователь не активен в течение `timeout_seconds`, то приложение закрывается.
|
||||||
|
/// > Каждый раз, когда пользователь нажимает одну из кнопок навигации, сбрасывается таймер.
|
||||||
|
pub fn launch_idling_watchdog(&mut self, timeout_seconds: u64) {
|
||||||
|
let nav_manager = self.get_navigation_manager();
|
||||||
|
let cb_sink = self.siv.cb_sink().clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
|
||||||
|
// Проверяем активность
|
||||||
|
let is_active = {
|
||||||
|
let last = nav_manager.get_last_activity();
|
||||||
|
last.elapsed() < Duration::from_secs(timeout_seconds)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !is_active {
|
||||||
|
cb_sink
|
||||||
|
.send(Box::new(|s: &mut Cursive| {
|
||||||
|
s.quit();
|
||||||
|
}))
|
||||||
|
.ok();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Закрытие приложения
|
||||||
|
pub fn quit(&mut self) {
|
||||||
|
self.siv.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить текущую конфигурацию
|
||||||
|
pub fn get_schema_config(&mut self) -> Result<DeviceConfig, String> {
|
||||||
|
// TODO: обработка ошибок
|
||||||
|
if let Some(app_state) = self.siv.take_user_data::<MenuAppState>() {
|
||||||
|
app_state
|
||||||
|
.inner_config
|
||||||
|
.update_user_config(&mut self.scheme_config)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(self.scheme_config.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
//! # Модуль отображения меню
|
|
||||||
//!
|
|
||||||
//! ## Навигация
|
|
||||||
//! > Для навигации по меню используется клавиши стрелка вверх/вниз
|
|
||||||
//! Выбор эелемента в меню происходит клавишей Enter
|
|
||||||
//! `В случае отсутствия данных клавиш в системе, можно эмулировать их с помощью библиотеки enigo`
|
|
||||||
//!
|
|
||||||
//! ## Параметры меню
|
|
||||||
//! > Параметры меню подтягиваются из файла-схемы TOML с помощью крейта config_lib
|
|
||||||
use config_lib::device_config::DeviceConfig;
|
|
||||||
use crossterm::style::Color;
|
|
||||||
use inquire::Select;
|
|
||||||
use terminal_menu::{TerminalMenuItem, button, label, menu, mut_menu, run};
|
|
||||||
|
|
||||||
pub enum MainMenuStates {
|
|
||||||
ConfigurationState,
|
|
||||||
ExitState,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Отображение главного меню
|
|
||||||
pub fn show_main_dialog(config: &mut DeviceConfig) -> Result<MainMenuStates, String> {
|
|
||||||
// Header
|
|
||||||
let mut menu_structure = generate_menu_header("Меню настроек индикатора");
|
|
||||||
|
|
||||||
// Текущие значения
|
|
||||||
menu_structure.extend(generate_current_values(config)?);
|
|
||||||
|
|
||||||
// Элементы меню
|
|
||||||
menu_structure.extend(generate_menu_items(config)?);
|
|
||||||
|
|
||||||
let main_menu = menu(menu_structure);
|
|
||||||
|
|
||||||
// Отрисовка и навигация по меню
|
|
||||||
run(&main_menu);
|
|
||||||
|
|
||||||
// Получение названия выбранного пункта меню
|
|
||||||
let mut_menu_instance = mut_menu(&main_menu);
|
|
||||||
let selected_menu_item = mut_menu_instance.selected_item_name();
|
|
||||||
|
|
||||||
// Получение ключа параметра в HashMap, соответствующего названию пункта меню
|
|
||||||
let selected_parameter_name = config
|
|
||||||
.get_parameters_names()?
|
|
||||||
.into_iter()
|
|
||||||
.find_map(|par_name| {
|
|
||||||
// Получение ключа параметра в HashMap, в случае если описание параметра совпадает с названием пункта меню
|
|
||||||
if let Ok(par_desc) = config.get_parameter_description(&par_name) {
|
|
||||||
return if par_desc == selected_menu_item {
|
|
||||||
Some(par_name)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
}
|
|
||||||
None
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(key) = selected_parameter_name {
|
|
||||||
show_configuration_dialog(config, &key)?;
|
|
||||||
return Ok(MainMenuStates::ConfigurationState);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохранение настроек в TOML-файл схему
|
|
||||||
config.save_parameters_values()?;
|
|
||||||
|
|
||||||
return Ok(MainMenuStates::ExitState);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Отображение диалога для выбора значения параметра
|
|
||||||
fn show_configuration_dialog(config: &mut DeviceConfig, par_name: &str) -> Result<(), String> {
|
|
||||||
let possible_values = config.get_parameter_possible_values(par_name)?;
|
|
||||||
let answer = Select::new("Выберите значение параметра", possible_values.clone()).prompt();
|
|
||||||
|
|
||||||
match answer {
|
|
||||||
Ok(selection) => {
|
|
||||||
config.set_parameter_value(par_name, selection)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Генерация структуры меню: Титул
|
|
||||||
fn generate_menu_header(title: &str) -> Vec<TerminalMenuItem> {
|
|
||||||
vec![
|
|
||||||
label("----------------------").colorize(Color::DarkGreen),
|
|
||||||
label(title).colorize(Color::DarkGreen),
|
|
||||||
label(format!(
|
|
||||||
"{} v{}",
|
|
||||||
env!("CARGO_CRATE_NAME"),
|
|
||||||
env!("CARGO_PKG_VERSION")
|
|
||||||
))
|
|
||||||
.colorize(Color::DarkGreen),
|
|
||||||
label(format!("{}", env!("CARGO_PKG_AUTHORS"))).colorize(Color::DarkGreen),
|
|
||||||
label("-----------------------").colorize(Color::DarkGreen),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Генерация структуры меню: Текущие значения настраиваемых параметров
|
|
||||||
fn generate_current_values(config: &DeviceConfig) -> Result<Vec<TerminalMenuItem>, String> {
|
|
||||||
let parameter_names = config.get_parameters_names()?;
|
|
||||||
|
|
||||||
let mut menu_structure = vec![];
|
|
||||||
|
|
||||||
for parameter_name in ¶meter_names {
|
|
||||||
let parameter_value = config.get_parameter_value(¶meter_name)?;
|
|
||||||
let parameter_description = config.get_parameter_description(¶meter_name)?;
|
|
||||||
menu_structure.push(
|
|
||||||
label(format!("{}: {}", parameter_description, parameter_value))
|
|
||||||
.colorize(Color::DarkYellow),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
menu_structure.push(label("-----------------------").colorize(Color::DarkYellow));
|
|
||||||
|
|
||||||
Ok(menu_structure)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Генерация структуры меню: Элементы меню
|
|
||||||
fn generate_menu_items(config: &DeviceConfig) -> Result<Vec<TerminalMenuItem>, String> {
|
|
||||||
let parameter_names = config.get_parameters_names()?;
|
|
||||||
|
|
||||||
let mut menu_structure = vec![];
|
|
||||||
|
|
||||||
menu_structure.push(label("-----------------------").colorize(Color::DarkGrey));
|
|
||||||
|
|
||||||
// Элементы меню; имя каждого параметра соответствует полю description
|
|
||||||
let mut menu_structure = menu_structure;
|
|
||||||
for parameter_name in ¶meter_names {
|
|
||||||
let parameter_description = config.get_parameter_description(¶meter_name)?;
|
|
||||||
menu_structure.push(button(parameter_description).colorize(Color::White));
|
|
||||||
}
|
|
||||||
menu_structure.push(button("Выход").colorize(Color::White));
|
|
||||||
|
|
||||||
menu_structure.push(label("-----------------------").colorize(Color::DarkGrey));
|
|
||||||
|
|
||||||
Ok(menu_structure)
|
|
||||||
}
|
|
||||||
89
menu_tui/src/user_parameters.rs
Normal file
89
menu_tui/src/user_parameters.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
use config_lib::device_config::DeviceConfig;
|
||||||
|
|
||||||
|
/// ### Структура для хранения пользовательских параметров
|
||||||
|
/// Дублирует данные из экземпляра `DeviceConfig` (пара ключ-значение из `HashMap<String, Parameter>`)
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Parameter {
|
||||||
|
/// Имя параметра (совпадает с ключом в `HashMap<String, Parameter>`)
|
||||||
|
pub key: String,
|
||||||
|
/// Описание параметра (используется для отображения в интерфейсе)
|
||||||
|
pub description: String,
|
||||||
|
/// Возможные значения параметра
|
||||||
|
pub options: Vec<String>,
|
||||||
|
/// Текущее значение параметра
|
||||||
|
pub selected_value: String,
|
||||||
|
}
|
||||||
|
impl Parameter {
|
||||||
|
fn new(key: String, description: String, options: Vec<String>, selected: String) -> Self {
|
||||||
|
Parameter {
|
||||||
|
key,
|
||||||
|
description,
|
||||||
|
options,
|
||||||
|
selected_value: selected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Конфигурация устройства
|
||||||
|
/// Представляет собой набор `Parameter`, дублирует данные из экземпляра `DeviceConfig`
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DeviceParameters {
|
||||||
|
pub parameters: Vec<Parameter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DeviceParameters {
|
||||||
|
fn default() -> Self {
|
||||||
|
DeviceParameters::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceParameters {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
DeviceParameters {
|
||||||
|
parameters: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Заполнение списка пользовательских параметров данными из `DeviceConfig`
|
||||||
|
pub fn load_user_config(&mut self, parameters_schema: &DeviceConfig) -> Result<(), String> {
|
||||||
|
for parameter_key in parameters_schema.get_parameters_names()? {
|
||||||
|
let parameter_values = parameters_schema
|
||||||
|
.get_parameter_possible_values(¶meter_key)?
|
||||||
|
.clone();
|
||||||
|
let parameter_desc = parameters_schema.get_parameter_description(¶meter_key)?;
|
||||||
|
let parameter_value = parameters_schema
|
||||||
|
.get_parameter_value(¶meter_key)?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
self.add_parameter(Parameter::new(
|
||||||
|
parameter_key,
|
||||||
|
parameter_desc,
|
||||||
|
parameter_values,
|
||||||
|
parameter_value,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обновление конфигурации `DeviceConfig` по данным из `DeviceParameters`
|
||||||
|
pub fn update_user_config(&self, parameters_schema: &mut DeviceConfig) -> Result<(), String> {
|
||||||
|
for parameter in self.parameters.iter() {
|
||||||
|
let value = parameter.selected_value.clone();
|
||||||
|
parameters_schema.set_parameter_value(¶meter.key, value)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавление нового параметра
|
||||||
|
fn add_parameter(&mut self, param: Parameter) {
|
||||||
|
self.parameters.push(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обновление значения параметра
|
||||||
|
pub fn update_parameter(&mut self, key: &str, value: String) {
|
||||||
|
if let Some(param) = self.parameters.iter_mut().find(|p| p.key == key) {
|
||||||
|
param.selected_value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
menu_tui/src/views/config_view.rs
Normal file
89
menu_tui/src/views/config_view.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
use crate::menu_process::MenuAppState;
|
||||||
|
use cursive::{
|
||||||
|
Cursive,
|
||||||
|
align::HAlign,
|
||||||
|
view::{Nameable, Resizable},
|
||||||
|
views::{Button, CircularFocus, Dialog, ListView, SelectView},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Отображение диалога выбора значения параметров устройства
|
||||||
|
pub(crate) fn show_config_view(siv: &mut Cursive) {
|
||||||
|
// Получение текущего состояния приложения
|
||||||
|
let app_state: MenuAppState = siv
|
||||||
|
.take_user_data()
|
||||||
|
.expect("Не удалось выполнить take_user_data");
|
||||||
|
|
||||||
|
// Обновляем current_view
|
||||||
|
app_state.navigation_manager.set_current_view("params_menu");
|
||||||
|
|
||||||
|
let mut parameter_list = ListView::new();
|
||||||
|
|
||||||
|
// Разделитель
|
||||||
|
parameter_list.add_delimiter();
|
||||||
|
|
||||||
|
// Создание списка параметров c выпадающими списками возможных значений
|
||||||
|
for parameter in app_state.inner_config.parameters.iter() {
|
||||||
|
let key = parameter.key.clone();
|
||||||
|
let current_value = parameter.selected_value.clone();
|
||||||
|
|
||||||
|
let mut select_view = SelectView::new().popup().h_align(HAlign::Left);
|
||||||
|
|
||||||
|
let mut selected_index = 0;
|
||||||
|
for (index, option) in parameter.options.iter().enumerate() {
|
||||||
|
select_view.add_item(option, option.clone());
|
||||||
|
if option == ¤t_value {
|
||||||
|
selected_index = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Установка курсора на текущем значении параметра
|
||||||
|
select_view.set_selection(selected_index);
|
||||||
|
|
||||||
|
// Добавление коллбэка для обновления выбранного значения
|
||||||
|
let key_for_callback = key.clone();
|
||||||
|
|
||||||
|
select_view.set_on_submit(move |s, selected_value: &String| {
|
||||||
|
let mut state: MenuAppState = s
|
||||||
|
.take_user_data()
|
||||||
|
.expect("Не удалось выполнить take_user_data");
|
||||||
|
|
||||||
|
state
|
||||||
|
.inner_config
|
||||||
|
.update_parameter(&key_for_callback, selected_value.clone());
|
||||||
|
s.set_user_data(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
let parameter_view = select_view.with_name(key);
|
||||||
|
|
||||||
|
parameter_list.add_child(¶meter.description, parameter_view);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разделитель
|
||||||
|
parameter_list.add_delimiter();
|
||||||
|
|
||||||
|
// Кнопка возврата в главное меню
|
||||||
|
parameter_list.add_child(
|
||||||
|
"",
|
||||||
|
Button::new("Назад в главное меню", |s| {
|
||||||
|
let state: MenuAppState = s
|
||||||
|
.take_user_data()
|
||||||
|
.expect("Не удалось выполнить take_user_data");
|
||||||
|
state.navigation_manager.set_current_view("main_menu");
|
||||||
|
s.set_user_data(state);
|
||||||
|
s.pop_layer();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обёртка для циклической навигации с помощью одной кнопки
|
||||||
|
let circular_list = CircularFocus::new(parameter_list)
|
||||||
|
.with_wrap_arrows(true)
|
||||||
|
.with_name("params_menu");
|
||||||
|
|
||||||
|
let parameter_dialog = Dialog::around(circular_list)
|
||||||
|
.title("⚙ Параметры устройства")
|
||||||
|
.fixed_width(50);
|
||||||
|
|
||||||
|
// Обновление текущих значений параметров
|
||||||
|
siv.set_user_data(app_state);
|
||||||
|
|
||||||
|
siv.add_layer(parameter_dialog);
|
||||||
|
}
|
||||||
6
menu_tui/src/views/exit_view.rs
Normal file
6
menu_tui/src/views/exit_view.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
use cursive::Cursive;
|
||||||
|
|
||||||
|
/// Закрытие приложения с сохранением параметров
|
||||||
|
pub(crate) fn show_exit_view(siv: &mut Cursive) {
|
||||||
|
siv.quit();
|
||||||
|
}
|
||||||
45
menu_tui/src/views/main_view.rs
Normal file
45
menu_tui/src/views/main_view.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
use crate::views::config_view::show_config_view;
|
||||||
|
use crate::views::exit_view::show_exit_view;
|
||||||
|
use crate::views::update_view::show_update_view;
|
||||||
|
use cursive::{
|
||||||
|
Cursive,
|
||||||
|
view::{Nameable, Resizable},
|
||||||
|
views::{CircularFocus, Dialog, LinearLayout, SelectView},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Отображение главного меню
|
||||||
|
pub(crate) fn show_main_view(siv: &mut Cursive) {
|
||||||
|
siv.pop_layer();
|
||||||
|
|
||||||
|
let main_menu = SelectView::new()
|
||||||
|
.item("Параметры устройства", 1)
|
||||||
|
.item("Обновление прошивки", 2)
|
||||||
|
.item("Сохранение и выход", 3)
|
||||||
|
.h_align(cursive::align::HAlign::Center)
|
||||||
|
.on_submit(|s: &mut Cursive, menu_item_index| menu_item_selected(s, *menu_item_index));
|
||||||
|
|
||||||
|
// Обёртка для циклической навигации с помощью одной кнопки
|
||||||
|
let circular_menu = CircularFocus::new(main_menu).with_wrap_arrows(true);
|
||||||
|
|
||||||
|
let layout = LinearLayout::vertical().child(
|
||||||
|
Dialog::around(circular_menu.with_name("main_menu"))
|
||||||
|
.title(format!(
|
||||||
|
"{} {}",
|
||||||
|
env!("CARGO_PKG_NAME"),
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
))
|
||||||
|
.fixed_size((50, 5)),
|
||||||
|
);
|
||||||
|
|
||||||
|
siv.add_layer(layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка выбора определенного пункта в главном меню
|
||||||
|
fn menu_item_selected(siv: &mut Cursive, menu_item_index: i32) {
|
||||||
|
match menu_item_index {
|
||||||
|
1 => show_config_view(siv),
|
||||||
|
2 => show_update_view(siv),
|
||||||
|
3 => show_exit_view(siv),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
menu_tui/src/views/mod.rs
Normal file
4
menu_tui/src/views/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod config_view;
|
||||||
|
pub mod exit_view;
|
||||||
|
pub mod main_view;
|
||||||
|
pub mod update_view;
|
||||||
6
menu_tui/src/views/update_view.rs
Normal file
6
menu_tui/src/views/update_view.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
use cursive::{Cursive, views::Dialog};
|
||||||
|
|
||||||
|
/// Отображение диалога обновления прошивки
|
||||||
|
pub(crate) fn show_update_view(siv: &mut Cursive) {
|
||||||
|
siv.add_layer(Dialog::info("Обновление прошивки пока не реализовано"));
|
||||||
|
}
|
||||||
BIN
rk_nku_menu/.DS_Store
vendored
Normal file
BIN
rk_nku_menu/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -1,12 +1,13 @@
|
||||||
[package]
|
[package]
|
||||||
name = "rk_nku_menu"
|
name = "rk_smart_menu"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
enigo = "0.5.0"
|
thiserror = "2.0"
|
||||||
sysfs_gpio = { version = "0.6.2" }
|
gpiocdev-embedded-hal = "0.1.2"
|
||||||
|
gpiocdev = "0.7.3"
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
env_logger = { workspace = true }
|
env_logger = { workspace = true }
|
||||||
config_lib = { path = "../config_lib" }
|
config_lib = { path = "../config_lib" }
|
||||||
|
|
|
||||||
25
rk_nku_menu/assets/style.toml
Normal file
25
rk_nku_menu/assets/style.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# First come some various options
|
||||||
|
shadow = false # Don't draw shadows around stacked views
|
||||||
|
borders = "simple" # Alternatives are "none" and "outset"
|
||||||
|
|
||||||
|
# Here we define the color palette.
|
||||||
|
[colors]
|
||||||
|
background = "#000000"
|
||||||
|
# If the value is an array, the first valid color will be used.
|
||||||
|
# If the terminal doesn't support custom color,
|
||||||
|
# non-base colors will be skipped.
|
||||||
|
shadow = ["#000000", "black"]
|
||||||
|
view = "#d3d7cf"
|
||||||
|
|
||||||
|
# Array and simple values have the same effect.
|
||||||
|
primary = ["#111111"]
|
||||||
|
secondary = "#EEEEEE"
|
||||||
|
tertiary = "#444444"
|
||||||
|
|
||||||
|
# Hex values can use lower or uppercase.
|
||||||
|
title_primary = "#0000FF"
|
||||||
|
title_secondary = "#ffff55"
|
||||||
|
|
||||||
|
# Lower precision values can use only 3 digits.
|
||||||
|
highlight = "#00F"
|
||||||
|
highlight_inactive = "#5555FF"
|
||||||
|
|
@ -1,133 +1,113 @@
|
||||||
use config_lib::device_config::DeviceConfig;
|
use gpiocdev::Request;
|
||||||
use enigo::{Direction::Click, Enigo, Key, Keyboard, Settings};
|
use log::debug;
|
||||||
use log::{debug, error};
|
use menu_tui::menu_navigation::NavigationManager;
|
||||||
use menu_tui::menu_rendering::{MainMenuStates, show_main_dialog};
|
use menu_tui::menu_process::DeviceMenu;
|
||||||
use std::sync::atomic::AtomicBool;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use sysfs_gpio::{Direction, Edge, Pin};
|
|
||||||
|
|
||||||
/// ### Путь к файлу-схемы параметров устройства
|
/// ### Путь к файлу-схемы параметров устройства
|
||||||
const NKU_DEVICE_CONFIG_PATH: &str = "rk_nku_configs/nku_scheme.toml";
|
const NKU_DEVICE_CONFIG_PATH: &str = "rk_smart_configs/smart_scheme.toml";
|
||||||
|
const TUI_APP_CONFIG_PATH: &str = "rk_smart_configs/menu_style.toml";
|
||||||
|
|
||||||
/// Расчёт номера пина в порядке SYSFS
|
/// Максимальное время бездействия [c], после достижения которого происходит выход из меню
|
||||||
|
const IDLE_TIMEOUT_SEC: u64 = 30;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
// Создаём меню
|
||||||
|
let mut menu = DeviceMenu::new(NKU_DEVICE_CONFIG_PATH, TUI_APP_CONFIG_PATH);
|
||||||
|
|
||||||
|
let nav_manager = menu.get_navigation_manager();
|
||||||
|
|
||||||
|
menu.show_main_menu();
|
||||||
|
|
||||||
|
// Навигация по меню с помощью кнопок, подключенных к GPIO
|
||||||
|
gpio_navigation_handlers(nav_manager);
|
||||||
|
|
||||||
|
menu.launch_idling_watchdog(IDLE_TIMEOUT_SEC);
|
||||||
|
|
||||||
|
menu.run();
|
||||||
|
|
||||||
|
menu.quit();
|
||||||
|
|
||||||
|
let device_config = menu.get_schema_config()?;
|
||||||
|
|
||||||
|
debug!("Итоговая конфигурация:");
|
||||||
|
for parameter in device_config.get_parameters_names()? {
|
||||||
|
let value = device_config.get_parameter_value(¶meter).unwrap();
|
||||||
|
debug!("{}: {}", parameter, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
device_config.save_parameters_values()?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ligpiod номер порта кнопки ввода
|
||||||
|
const S1_BTN: (&str, u8) = ("/dev/gpiochip3", 16);
|
||||||
|
/// ligpiod номер порта кнопки выбора
|
||||||
|
const S2_BTN: (&str, u8) = ("/dev/gpiochip2", 1);
|
||||||
|
/// ## Симуляция обработчиков GPIO кнопок
|
||||||
|
/// ### Расчёт номера пина в порядке libgpiod
|
||||||
/// ```
|
/// ```
|
||||||
/// bank = 0; // GPIO0_B5 => 0, bank ∈ [0,4]
|
/// bank = 0; // GPIO0_B5 => 0, bank ∈ [0,4]
|
||||||
/// group = 1; // GPIO0_B5 => 1, group ∈ {(A=0), (B=1), (C=2), (D=3)}
|
/// group = 1; // GPIO0_B5 => 1, group ∈ {(A=0), (B=1), (C=2), (D=3)}
|
||||||
/// X = 5; // GPIO0_B5 => 5, X ∈ [0,7]
|
/// X = 5; // GPIO0_B5 => 5, X ∈ [0,7]
|
||||||
/// number = group * 8 + X = 1 * 8 + 5 = 13;
|
/// number = group * 8 + X = 1 * 8 + 5 = 13;
|
||||||
/// pin = bank * 32 + number = 0 * 32 + 13 = 13;
|
/// pin = bank * 32 + number = 0 * 32 + 13 = 13;
|
||||||
|
///
|
||||||
|
/// gpiochip_number = pin_number / 32; // Номер банка
|
||||||
|
/// line_offset = pin_number % 32; // Номер пина в банке
|
||||||
/// ```
|
/// ```
|
||||||
|
///
|
||||||
/// SYSFS номер порта кнопки ввода
|
fn gpio_navigation_handlers(nav_manager: NavigationManager) {
|
||||||
const S1_BTN: u8 = 124; //124
|
// Кнопка 1 (GPIO): Навигация вниз
|
||||||
/// SYSFS номер порта кнопки выбора
|
let nav_down = nav_manager.clone();
|
||||||
const S2_BTN: u8 = 125; //125
|
|
||||||
/// Максимальное время бездействия [c], после достижения которого происходит выход из меню
|
|
||||||
const IDLE_TIMEOUT_SEC: u32 = 30;
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
env_logger::init();
|
|
||||||
let mut device_config = DeviceConfig::create_parameter_list(NKU_DEVICE_CONFIG_PATH)?;
|
|
||||||
|
|
||||||
static BTN_PRESSED: AtomicBool = AtomicBool::new(false);
|
|
||||||
static STOP_FLAG: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
// Открытие потоков для обслуживания EXT прерываний
|
|
||||||
let down_irq_thread = std::thread::spawn(move || {
|
|
||||||
let input = Pin::new(S2_BTN as u64);
|
|
||||||
debug!("down_irq_thread launched!");
|
|
||||||
input.with_exported(|| {
|
|
||||||
input.set_direction(Direction::In)?;
|
|
||||||
input.set_edge(Edge::RisingEdge)?;
|
|
||||||
let mut poller = input.get_poller()?;
|
|
||||||
|
|
||||||
while !STOP_FLAG.load(std::sync::atomic::Ordering::SeqCst) {
|
|
||||||
if let Some(pin_value) = poller.poll(1000)? {
|
|
||||||
debug!("{}", format!("Pin value: {}", pin_value));
|
|
||||||
if pin_value == 1 {
|
|
||||||
let mut enigo = Enigo::new(&Settings::default()).unwrap();
|
|
||||||
enigo.key(Key::DownArrow, Click).unwrap();
|
|
||||||
BTN_PRESSED.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
|
||||||
debug!("DownArrow pressed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let return_irq_thread = std::thread::spawn(move || {
|
|
||||||
let input = Pin::new(S1_BTN as u64);
|
|
||||||
debug!("return_irq_thread launched!");
|
|
||||||
input.with_exported(|| {
|
|
||||||
input.set_direction(Direction::In)?;
|
|
||||||
input.set_edge(Edge::RisingEdge)?;
|
|
||||||
let mut poller = input.get_poller()?;
|
|
||||||
|
|
||||||
while !STOP_FLAG.load(std::sync::atomic::Ordering::SeqCst) {
|
|
||||||
if let Some(pin_value) = poller.poll(1000)? {
|
|
||||||
debug!("{}", format!("Pin value: {}", pin_value));
|
|
||||||
if pin_value == 1 {
|
|
||||||
let mut enigo = Enigo::new(&Settings::default()).unwrap();
|
|
||||||
enigo.key(Key::Return, Click).unwrap();
|
|
||||||
BTN_PRESSED.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
|
||||||
debug!("Return pressed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// Поток TUI меню
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
let down_btn_req = Request::builder()
|
||||||
|
.on_chip(S1_BTN.0)
|
||||||
|
.with_line(S1_BTN.1 as u32)
|
||||||
|
.as_input()
|
||||||
|
.with_edge_detection(gpiocdev::line::EdgeDetection::RisingEdge)
|
||||||
|
.request()
|
||||||
|
.unwrap();
|
||||||
|
debug!("down_irq_thread launched!");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match show_main_dialog(&mut device_config) {
|
if down_btn_req.has_edge_event().unwrap() {
|
||||||
Ok(MainMenuStates::ConfigurationState) => {
|
if let Ok(edge) = down_btn_req.read_edge_event() {
|
||||||
debug!("Configuration state");
|
if edge.kind == gpiocdev::line::EdgeKind::Rising {
|
||||||
continue;
|
debug!("DownArrow pressed");
|
||||||
}
|
// Симуляция нажатия кнопки DOWN
|
||||||
Ok(MainMenuStates::ExitState) => {
|
nav_down.navigate_down();
|
||||||
debug!("The user has chosen to exit...");
|
std::thread::sleep(Duration::from_millis(400));
|
||||||
break;
|
}
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Menu error: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
STOP_FLAG.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
debug!("The user has chosen to exit...");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Главный поток - проверка бездействия
|
// Кнопка 2 (GPIO): Выбор/подтверждение
|
||||||
let mut idle_counter: u32 = 0;
|
let nav_select = nav_manager.clone();
|
||||||
while !STOP_FLAG.load(std::sync::atomic::Ordering::SeqCst) {
|
std::thread::spawn(move || {
|
||||||
if BTN_PRESSED.load(std::sync::atomic::Ordering::SeqCst) {
|
let return_btn_req = Request::builder()
|
||||||
idle_counter = 0;
|
.on_chip(S2_BTN.0)
|
||||||
BTN_PRESSED.store(false, std::sync::atomic::Ordering::SeqCst);
|
.with_line(S2_BTN.1 as u32)
|
||||||
} else {
|
.as_input()
|
||||||
idle_counter += 1;
|
.with_edge_detection(gpiocdev::line::EdgeDetection::RisingEdge)
|
||||||
if idle_counter > IDLE_TIMEOUT_SEC * 10 {
|
.request()
|
||||||
STOP_FLAG.store(true, std::sync::atomic::Ordering::SeqCst);
|
.unwrap();
|
||||||
debug!("Idle timeout has been reached, exiting...");
|
debug!("return_irq_thread launched!");
|
||||||
|
loop {
|
||||||
|
if return_btn_req.has_edge_event().unwrap() {
|
||||||
|
if let Ok(edge) = return_btn_req.read_edge_event() {
|
||||||
|
if edge.kind == gpiocdev::line::EdgeKind::Rising {
|
||||||
|
debug!("Return pressed");
|
||||||
|
// Симуляция нажатия кнопки ENTER
|
||||||
|
nav_select.select_item();
|
||||||
|
std::thread::sleep(Duration::from_millis(400));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(100));
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if down_irq_thread.join().is_err() {
|
|
||||||
debug!("Unable to stop down_irq_thread");
|
|
||||||
}
|
|
||||||
|
|
||||||
if return_irq_thread.join().is_err() {
|
|
||||||
debug!("Unable to stop return_irq_thread");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue