use std::cell::Cell;
use std::io::Read;
use std::path::PathBuf;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph, Wrap},
};
use crate::prelude::*;
use input::{Key, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use operation::{Event, ExtensionConfigOp, Operation};
use settings::Settings;
use crate::theme::Theme;
use views::View;
use crate::interraction::button::hit_test;
const STATUS_LABELS: &[&str] = &["Global", "Project", "Auto", "Disabled"];
const STATUS_COLORS: &[Color] = &[Color::Cyan, Color::Green, Color::Yellow, Color::Red];
#[derive(Debug)]
pub struct ExtensionConfigView {
extensions: Vec<ExtensionInfo>,
selected_index: usize,
selected_status: usize,
pub pending_save: bool,
button_rects: [Cell<Rect>; 4],
list_rect: Cell<Rect>,
}
#[derive(Debug, Clone)]
struct ExtensionInfo {
name: String,
version: String,
readme: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtensionStatus {
Global,
Project,
Auto,
Disabled,
}
impl ExtensionConfigView {
pub fn new(settings: &Settings) -> Self {
let extensions = load_extensions_info(settings);
let mut view = Self {
extensions,
selected_index: 0,
selected_status: 2, pending_save: false,
button_rects: [
Cell::new(Rect::default()),
Cell::new(Rect::default()),
Cell::new(Rect::default()),
Cell::new(Rect::default()),
],
list_rect: Cell::new(Rect::default()),
};
if !view.extensions.is_empty() {
view.select_extension(0, settings);
}
view
}
pub fn selected_extension_name(&self) -> Option<String> {
self.extensions.get(self.selected_index).map(|e| e.name.clone())
}
fn select_extension(&mut self, index: usize, settings: &Settings) {
if index >= self.extensions.len() {
return;
}
self.selected_index = index;
let name = &self.extensions[index].name;
let mut status_idx = 3;
let project_path = settings.project_config_dir().join("extension.yaml");
if project_path.exists()
&& let Ok(data) = std::fs::read_to_string(&project_path)
&& let Ok(map) = serde_saphyr::from_str::<std::collections::HashMap<String, String>>(&data)
&& let Some(val) = map.get(name) {
match val.as_str() {
"enabled" => status_idx = 1, "auto" => status_idx = 2,
"disabled" => status_idx = 3,
_ => status_idx = 3,
}
self.selected_status = status_idx;
return;
}
let global_path = settings.global_config_dir().join("extension.yaml");
if global_path.exists()
&& let Ok(data) = std::fs::read_to_string(&global_path)
&& let Ok(map) = serde_saphyr::from_str::<std::collections::HashMap<String, String>>(&data)
&& let Some(val) = map.get(name) {
match val.as_str() {
"enabled" => status_idx = 0, "auto" => status_idx = 2,
"disabled" => status_idx = 3,
_ => status_idx = 3,
}
}
self.selected_status = status_idx;
}
fn set_status(&mut self, status: ExtensionStatus) {
self.selected_status = match status {
ExtensionStatus::Global => 0,
ExtensionStatus::Project => 1,
ExtensionStatus::Auto => 2,
ExtensionStatus::Disabled => 3,
};
if self.selected_index < self.extensions.len() {
let ext_name = self.extensions[self.selected_index].name.clone();
self.pending_save = true;
log::info!("Setting extension '{}' status to '{:?}'", ext_name, status);
}
}
fn render_content(&self, f: &mut Frame, area: Rect, theme: &Theme) {
let [left_area, right_area] = Layout::horizontal([
Constraint::Percentage(30),
Constraint::Percentage(70),
]).areas(area);
self.render_extension_list(f, left_area, theme);
self.render_right_panel(f, right_area, theme);
}
fn render_extension_list(&self, f: &mut Frame, area: Rect, theme: &Theme) {
let left_bg = theme.bg_inactive();
f.render_widget(Block::default().style(Style::new().bg(left_bg)), area);
let title_area = Rect::new(area.x, area.y, area.width, 1);
f.render_widget(
Paragraph::new(Span::styled(
" Extensions",
Style::new().fg(theme.fg_dim()).bg(left_bg).add_modifier(Modifier::BOLD),
)),
title_area,
);
let list_area = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
self.list_rect.set(list_area);
if self.extensions.is_empty() {
f.render_widget(
Paragraph::new(Span::styled(
" No extensions found",
Style::new().fg(theme.fg_dim()).bg(left_bg),
)),
list_area,
);
return;
}
for (i, ext) in self.extensions.iter().enumerate() {
let y = list_area.y + i as u16;
if y >= list_area.y + list_area.height {
break;
}
let (fg, bg) = if i == self.selected_index {
(theme.selection_fg(), theme.selection_bg())
} else {
(theme.fg_active(), left_bg)
};
f.render_widget(
Paragraph::new(Span::styled(
format!(" {} v{}", ext.name, ext.version),
Style::new().fg(fg).bg(bg),
)),
Rect::new(list_area.x, y, list_area.width, 1),
);
}
}
fn render_right_panel(&self, f: &mut Frame, area: Rect, theme: &Theme) {
let right_bg = theme.bg_active();
f.render_widget(Block::default().style(Style::new().bg(right_bg)), area);
let [header_area, readme_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
]).areas(area);
self.render_status_header(f, header_area, theme, right_bg);
self.render_readme(f, readme_area, right_bg);
}
fn render_status_header(&self, f: &mut Frame, area: Rect, theme: &Theme, bg: Color) {
let ext_name = self.extensions.get(self.selected_index)
.map(|e| e.name.as_str())
.unwrap_or("");
let mut spans = vec![Span::styled(
format!(" {} ", ext_name),
Style::new().fg(theme.accent()).bg(bg).add_modifier(Modifier::BOLD),
)];
let mut x = area.x + 1 + ext_name.len() as u16 + 2;
for (i, (label, color)) in STATUS_LABELS.iter().zip(STATUS_COLORS.iter()).enumerate() {
let is_selected = i == self.selected_status;
let width = (label.len() + 2) as u16;
self.button_rects[i].set(Rect::new(x, area.y, width, 1));
if is_selected {
spans.push(Span::styled(
format!(" {} ", label),
Style::new().fg(Color::Black).bg(*color).add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
format!(" {} ", label),
Style::new().fg(*color).bg(bg).add_modifier(Modifier::DIM),
));
}
spans.push(Span::styled(" ", Style::new().bg(bg)));
x += width + 1;
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_readme(&self, f: &mut Frame, area: Rect, bg: Color) {
let content = self.extensions.get(self.selected_index)
.map(|e| e.readme.as_str())
.unwrap_or("Select an extension to view its README");
f.render_widget(
Paragraph::new(content)
.style(Style::new().bg(bg))
.wrap(Wrap { trim: false }),
area,
);
}
fn hit_button(&self, col: u16, row: u16) -> Option<usize> {
for (i, cell) in self.button_rects.iter().enumerate() {
let rect = cell.get();
if rect.width > 0 && hit_test((col, row), rect) {
return Some(i);
}
}
None
}
}
fn load_extensions_info(settings: &Settings) -> Vec<ExtensionInfo> {
let mut extensions = Vec::new();
let ext_dir = get_extensions_dir(settings);
if ext_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&ext_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("cyix") {
continue;
}
if let Some(ext) = load_extension_from_cyix(&path) {
extensions.push(ext);
}
}
}
} else {
log::debug!("extensions dir {:?} does not exist", ext_dir);
}
extensions.sort_by(|a, b| a.name.cmp(&b.name));
extensions
}
fn get_extensions_dir(settings: &Settings) -> PathBuf {
let configured = settings.get::<String>("extensions.extensions_dir");
if !configured.is_empty() {
return PathBuf::from(configured);
}
directories::ProjectDirs::from("com", "cyloncore", "oo")
.map(|d| d.data_dir().join("extensions"))
.unwrap_or_else(|| PathBuf::from("extensions"))
}
fn load_extension_from_cyix(path: &std::path::Path) -> Option<ExtensionInfo> {
use crate::extension::loader::load_cyix;
let pkg = load_cyix(path)
.map_err(|e| log::warn!("failed to read {:?}: {}", path, e))
.ok()?;
let readme = read_readme_from_cyix(path)
.unwrap_or_else(|| pkg.manifest.description.clone());
Some(ExtensionInfo {
name: pkg.manifest.name,
version: pkg.manifest.version,
readme,
})
}
fn read_readme_from_cyix(path: &std::path::Path) -> Option<String> {
let file = std::fs::File::open(path).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
for i in 0..archive.len() {
if let Ok(mut entry) = archive.by_index(i) {
let name = entry.name().to_owned();
if name.eq_ignore_ascii_case("readme.md") || name.eq_ignore_ascii_case("readme.txt") {
let mut s = String::new();
if entry.read_to_string(&mut s).is_ok() {
return Some(s);
}
}
}
}
None
}
impl View for ExtensionConfigView {
const KIND: crate::views::ViewKind = crate::views::ViewKind::Modal;
fn save_state(&mut self, app: &mut crate::app_state::AppState) {
crate::views::save_state::extension_config_pre_save(self, app);
if self.pending_save {
let mut map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for ext in &self.extensions {
map.insert(ext.name.clone(), match self.selected_status {
0 => "enabled".to_string(),
1 => "enabled".to_string(),
2 => "auto".to_string(),
3 => "disabled".to_string(),
_ => "disabled".to_string(),
});
}
if let Ok(y) = serde_saphyr::to_string(&map) {
let path = app.settings.project_config_dir().join("extension.yaml");
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, y);
}
self.pending_save = false;
}
}
fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
match (key.modifiers, key.key) {
(_, Key::ArrowUp) | (_, Key::Char('k')) => {
vec![Operation::ExtensionConfig(ExtensionConfigOp::MoveUp)]
}
(_, Key::ArrowDown) | (_, Key::Char('j')) => {
vec![Operation::ExtensionConfig(ExtensionConfigOp::MoveDown)]
}
(_, Key::ArrowLeft) => {
let prev = if self.selected_status == 0 { 3 } else { self.selected_status - 1 };
vec![Operation::ExtensionConfig(ExtensionConfigOp::SetStatus(prev))]
}
(_, Key::ArrowRight) | (_, Key::Enter) => {
let next = (self.selected_status + 1) % 4;
vec![Operation::ExtensionConfig(ExtensionConfigOp::SetStatus(next))]
}
_ => vec![],
}
}
fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
if mouse.kind == MouseEventKind::Down(MouseButton::Left) {
if let Some(btn_idx) = self.hit_button(mouse.column, mouse.row) {
return vec![Operation::ExtensionConfig(ExtensionConfigOp::SetStatus(btn_idx))];
}
let rect = self.list_rect.get();
if rect.width > 0 && hit_test((mouse.column, mouse.row), rect) {
let idx = (mouse.row - rect.y) as usize;
return vec![Operation::ExtensionConfig(ExtensionConfigOp::Select(idx))];
}
}
vec![]
}
fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
match op {
Operation::ExtensionConfig(ext_op) => {
match ext_op {
ExtensionConfigOp::MoveUp => {
if self.selected_index > 0 {
self.select_extension(self.selected_index - 1, _settings);
}
Some(Event::applied("extension_config", op.clone()))
}
ExtensionConfigOp::MoveDown => {
if self.selected_index < self.extensions.len().saturating_sub(1) {
self.select_extension(self.selected_index + 1, _settings);
}
Some(Event::applied("extension_config", op.clone()))
}
ExtensionConfigOp::Select(idx) => {
if *idx < self.extensions.len() {
self.select_extension(*idx, _settings);
}
Some(Event::applied("extension_config", op.clone()))
}
ExtensionConfigOp::SetStatus(idx) => {
if *idx < 4 {
let status = match idx {
0 => ExtensionStatus::Global,
1 => ExtensionStatus::Project,
2 => ExtensionStatus::Auto,
_ => ExtensionStatus::Disabled,
};
self.set_status(status);
}
Some(Event::applied("extension_config", op.clone()))
}
}
}
_ => None,
}
}
fn render(&self, f: &mut Frame, area: Rect, theme: &Theme) {
self.render_content(f, area, theme);
}
}