# tui_menu 0.2.0

1. Переход на Cursive
2. Менеджер навигации по меню: вызывающая сторона может осуществлять навигацию по меню вне Cursive event loop, например, в потоке обработчика внешнего прерывания GPIO
This commit is contained in:
Dmitry Akimov 2025-10-31 18:28:04 +03:00
parent 4880b822df
commit 0922c81c0a
25 changed files with 1247 additions and 680 deletions

BIN
.DS_Store vendored

Binary file not shown.

825
Cargo.lock generated

File diff suppressed because it is too large Load diff

BIN
config_lib/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -4,17 +4,17 @@ use toml_edit::value;
use crate::toml_parser::*;
/// Структура, содержащая набор параметров Parameter
/// Структура, содержащая набор параметров` Parameter`
#[derive(Debug, Clone)]
pub struct DeviceConfig {
scheme: TomlScheme,
/// Набор параметров
/// key - имя параметра, value - структура, описывающая параметр
/// `key` - имя параметра, `value` - структура, описывающая параметр
parameters: HashMap<String, Parameter>,
}
impl DeviceConfig {
/// ## Заполнение струтуры DeviceConfig
/// ## Заполнение струтуры `DeviceConfig`
/// * `path_to_scheme` - путь к файлу-cхеме
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())?;
@ -43,7 +43,7 @@ impl DeviceConfig {
Ok(device_config)
}
/// # Сохранение текущих значений параметров в TOML-файл self.schema_path
/// ## Сохранение текущих значений параметров в TOML-файл `self.schema_path`
pub fn save_parameters_values(&self) -> Result<(), String> {
let mut toml_config =
TomlScheme::new(&self.scheme.get_path_to_scheme_file()).map_err(|e| e.to_string())?;
@ -57,7 +57,7 @@ impl DeviceConfig {
Ok(())
}
/// # Получение списка ключей - имен параметров
/// ## Получение списка ключей - имен параметров
pub fn get_parameters_names(&self) -> Result<Vec<String>, String> {
if self.parameters.is_empty() {
return Err("Parameters list is empty".to_string());
@ -66,7 +66,7 @@ impl DeviceConfig {
return Ok(self.parameters.keys().map(|key| key.clone()).collect());
}
/// # Получение описания параметра соответствующего key
/// ## Получение описания параметра соответствующего `key`
pub fn get_parameter_description(&self, key: &str) -> Result<String, String> {
match self.parameters.get(key) {
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> {
match self.parameters.get(key) {
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> {
match self.parameters.get(key) {
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> {
match self.parameters.get_mut(key) {
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> {
let parameters_list = self.get_parameters_names()?;
if parameters_list.contains(&key.to_owned()) {
@ -116,8 +116,8 @@ impl DeviceConfig {
Err("Unable to get parameter value using index".to_string())
}
/// ## Присваивание параметру key значения, соответствующего index
/// Индекс соответствует положению значения в списке возможных значений possible_values
/// ## Присваивание параметру `key` значения, соответствующего `index`
/// Индекс соответствует положению значения в списке возможных значений `possible_values`
pub fn set_parameter_value_using_index(&mut self, key: &str, index: u8) -> Result<(), String> {
let parameters_list = self.get_parameters_names()?;
if parameters_list.contains(&key.to_owned()) {

BIN
menu_tui/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -1,16 +1,14 @@
[package]
name = "menu_tui"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
authors.workspace = true
[dependencies]
clap = { workspace = true }
config_lib = { path = "../config_lib" }
inquire = { workspace = true }
enigo = "0.5.0"
colored = "3.0.0"
terminal-menu = "3.0.0"
cursive = {version = "0.21.1", features = ["toml"]}
crossterm = "0.25.0"
log = { workspace = true }
env_logger = { workspace = true }

9
menu_tui/TODO.md Normal file
View 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` комментариями и примерами использования

View 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"

View file

@ -1,27 +1,66 @@
use config_lib::device_config::DeviceConfig;
use menu_tui::menu_rendering::{MainMenuStates, show_main_dialog};
use menu_tui::menu_navigation::NavigationManager;
use menu_tui::menu_process::DeviceMenu;
use std::thread;
use std::time::Duration;
/// Запуск меню в режиме имитации нажатия кнопок
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 {
match show_main_dialog(&mut device_config) {
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;
}
}
// Получаем менеджер навигации для управления от GPIO
let nav_manager = menu.get_navigation_manager();
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(&parameter).unwrap();
println!("{}: {}", parameter, value);
}
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();
}
});
}

View file

@ -26,7 +26,29 @@ possible_values = [
"15",
]
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]
name = "Громкость звука"
@ -38,7 +60,7 @@ current = "50%"
name = "Громкость музыки"
possible_values = ["0%", "25%", "50%", "75%", "100%"]
default = "0%"
current = "100%"
current = "50%"
[loadcapacity]
name = "Грузоподъемность"
@ -61,4 +83,4 @@ possible_values = [
"1600кг 21чел.",
]
default = "СКРЫТО"
current = "240кг 3чел."
current = "525кг 7чел."

BIN
menu_tui/src/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -1 +1,4 @@
pub mod menu_rendering;
pub mod menu_navigation;
pub mod menu_process;
mod user_parameters;
mod views;

View 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),
}
}
}

View 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())
}
}

View file

@ -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 &parameter_names {
let parameter_value = config.get_parameter_value(&parameter_name)?;
let parameter_description = config.get_parameter_description(&parameter_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 &parameter_names {
let parameter_description = config.get_parameter_description(&parameter_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)
}

View 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(&parameter_key)?
.clone();
let parameter_desc = parameters_schema.get_parameter_description(&parameter_key)?;
let parameter_value = parameters_schema
.get_parameter_value(&parameter_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(&parameter.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;
}
}
}

View 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 == &current_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(&parameter.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);
}

View file

@ -0,0 +1,6 @@
use cursive::Cursive;
/// Закрытие приложения с сохранением параметров
pub(crate) fn show_exit_view(siv: &mut Cursive) {
siv.quit();
}

View 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),
_ => {}
}
}

View file

@ -0,0 +1,4 @@
pub mod config_view;
pub mod exit_view;
pub mod main_view;
pub mod update_view;

View 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

Binary file not shown.

View file

@ -1,12 +1,13 @@
[package]
name = "rk_nku_menu"
name = "rk_smart_menu"
version = "0.1.0"
edition = "2024"
authors.workspace = true
[dependencies]
enigo = "0.5.0"
sysfs_gpio = { version = "0.6.2" }
thiserror = "2.0"
gpiocdev-embedded-hal = "0.1.2"
gpiocdev = "0.7.3"
log = { workspace = true }
env_logger = { workspace = true }
config_lib = { path = "../config_lib" }

View 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"

View file

@ -1,133 +1,113 @@
use config_lib::device_config::DeviceConfig;
use enigo::{Direction::Click, Enigo, Key, Keyboard, Settings};
use log::{debug, error};
use menu_tui::menu_rendering::{MainMenuStates, show_main_dialog};
use std::sync::atomic::AtomicBool;
use gpiocdev::Request;
use log::debug;
use menu_tui::menu_navigation::NavigationManager;
use menu_tui::menu_process::DeviceMenu;
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(&parameter).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]
/// group = 1; // GPIO0_B5 => 1, group ∈ {(A=0), (B=1), (C=2), (D=3)}
/// X = 5; // GPIO0_B5 => 5, X ∈ [0,7]
/// number = group * 8 + X = 1 * 8 + 5 = 13;
/// pin = bank * 32 + number = 0 * 32 + 13 = 13;
///
/// gpiochip_number = pin_number / 32; // Номер банка
/// line_offset = pin_number % 32; // Номер пина в банке
/// ```
/// SYSFS номер порта кнопки ввода
const S1_BTN: u8 = 124; //124
/// SYSFS номер порта кнопки выбора
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 меню
///
fn gpio_navigation_handlers(nav_manager: NavigationManager) {
// Кнопка 1 (GPIO): Навигация вниз
let nav_down = nav_manager.clone();
std::thread::spawn(move || {
loop {
match show_main_dialog(&mut device_config) {
Ok(MainMenuStates::ConfigurationState) => {
debug!("Configuration state");
continue;
}
Ok(MainMenuStates::ExitState) => {
debug!("The user has chosen to exit...");
break;
}
Err(e) => {
error!("Menu error: {}", e);
break;
}
}
}
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!");
STOP_FLAG.store(true, std::sync::atomic::Ordering::SeqCst);
debug!("The user has chosen to exit...");
loop {
if down_btn_req.has_edge_event().unwrap() {
if let Ok(edge) = down_btn_req.read_edge_event() {
if edge.kind == gpiocdev::line::EdgeKind::Rising {
debug!("DownArrow pressed");
// Симуляция нажатия кнопки DOWN
nav_down.navigate_down();
std::thread::sleep(Duration::from_millis(400));
}
}
}
}
});
// Главный поток - проверка бездействия
let mut idle_counter: u32 = 0;
while !STOP_FLAG.load(std::sync::atomic::Ordering::SeqCst) {
if BTN_PRESSED.load(std::sync::atomic::Ordering::SeqCst) {
idle_counter = 0;
BTN_PRESSED.store(false, std::sync::atomic::Ordering::SeqCst);
} else {
idle_counter += 1;
if idle_counter > IDLE_TIMEOUT_SEC * 10 {
STOP_FLAG.store(true, std::sync::atomic::Ordering::SeqCst);
debug!("Idle timeout has been reached, exiting...");
// Кнопка 2 (GPIO): Выбор/подтверждение
let nav_select = nav_manager.clone();
std::thread::spawn(move || {
let return_btn_req = Request::builder()
.on_chip(S2_BTN.0)
.with_line(S2_BTN.1 as u32)
.as_input()
.with_edge_detection(gpiocdev::line::EdgeDetection::RisingEdge)
.request()
.unwrap();
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(());
});
}