use crate::config::{ClipboardMode, Config, ConfigRow, ConfigSource, SidebarOrientation, SidebarPosition};
use crate::tui::keymap::{Action, KeyStroke, Keymap};
use crate::tui::modal_keymap::{ModalAction, ModalKeymap};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SettingsTab {
#[default]
Theme,
Worktree,
Tui,
Keys,
All,
}
impl SettingsTab {
pub const ALL: [SettingsTab; 5] = [
SettingsTab::Theme,
SettingsTab::Worktree,
SettingsTab::Tui,
SettingsTab::Keys,
SettingsTab::All,
];
pub fn label(self) -> &'static str {
match self {
SettingsTab::Theme => "Theme",
SettingsTab::Worktree => "Worktree",
SettingsTab::Tui => "TUI",
SettingsTab::Keys => "Keys",
SettingsTab::All => "All",
}
}
pub fn fields(self) -> &'static [SettingField] {
match self {
SettingsTab::Theme => &[SettingField::ThemePreset],
SettingsTab::Worktree => &[
SettingField::WorktreeBase,
SettingField::WorktreePathPattern,
SettingField::WorktreeBranchPattern,
],
SettingsTab::Tui => &[
SettingField::SidebarPosition,
SettingField::SidebarOrientation,
SettingField::Clipboard,
SettingField::OpenMode,
SettingField::ConfirmCountdown,
SettingField::AutoRefreshSecs,
SettingField::OpenShellCmd,
SettingField::OpenEditorCmd,
],
SettingsTab::Keys | SettingsTab::All => &[],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyTarget {
Global(Action),
Modal(ModalAction),
}
impl KeyTarget {
pub fn config_key(self) -> String {
match self {
KeyTarget::Global(a) => format!("tui.keys.{}", a.slug()),
KeyTarget::Modal(m) => format!("tui.keys.modal.{}.{}", m.context().config_path(), m.verb()),
}
}
pub fn single_only(self) -> bool {
matches!(self, KeyTarget::Modal(_))
}
pub fn compat_alias_keys(self) -> Vec<String> {
match self {
KeyTarget::Global(a) => a.compat_alias_slugs().map(|s| format!("tui.keys.{s}")).collect(),
KeyTarget::Modal(_) => Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyRow {
pub target: KeyTarget,
pub scope: String,
pub label: String,
pub keys: String,
pub source: ConfigSource,
}
pub fn build_key_rows(keymap: &Keymap, modal: &ModalKeymap, source_of: impl Fn(&str) -> ConfigSource) -> Vec<KeyRow> {
let mut rows = Vec::new();
for action in Action::all() {
let target = KeyTarget::Global(action);
rows.push(KeyRow {
target,
scope: "global".to_string(),
label: action.slug().to_string(),
keys: keymap.keys_display(action),
source: source_of(&target.config_key()),
});
}
for action in ModalAction::all() {
let target = KeyTarget::Modal(action);
rows.push(KeyRow {
target,
scope: format!("modal.{}", action.context().config_path()),
label: action.verb().to_string(),
keys: modal.keys_display(action),
source: source_of(&target.config_key()),
});
}
rows
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyCapture {
pub row: usize,
pub single_only: bool,
pub pending: Vec<KeyStroke>,
}
impl KeyCapture {
pub fn as_config_items(&self) -> Vec<String> {
if self.pending.is_empty() {
return Vec::new();
}
vec![self.pending.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" ")]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SettingsLayer {
#[default]
Project,
Global,
}
impl SettingsLayer {
pub fn label(self) -> &'static str {
match self {
SettingsLayer::Project => "project (.gwm.toml)",
SettingsLayer::Global => "global (~/.config/gwm)",
}
}
pub fn source(self) -> ConfigSource {
match self {
SettingsLayer::Project => ConfigSource::Repo,
SettingsLayer::Global => ConfigSource::User,
}
}
pub fn toggled(self) -> Self {
match self {
SettingsLayer::Project => SettingsLayer::Global,
SettingsLayer::Global => SettingsLayer::Project,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldKind {
Choice,
Uint,
Text,
}
const SIDEBAR_CHOICES: &[&str] = &[SidebarPosition::Right.label(), SidebarPosition::Left.label()];
const SIDEBAR_ORIENTATION_CHOICES: &[&str] = &[
SidebarOrientation::Stacked.label(),
SidebarOrientation::SideBySide.label(),
SidebarOrientation::Auto.label(),
];
const OPEN_MODE_CHOICES: &[&str] = &["shell", "editor", "finder"];
const CLIPBOARD_CHOICES: &[&str] = &[
ClipboardMode::Auto.label(),
ClipboardMode::Osc52.label(),
ClipboardMode::Tools.label(),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingField {
ThemePreset,
WorktreeBase,
WorktreePathPattern,
WorktreeBranchPattern,
SidebarPosition,
SidebarOrientation,
Clipboard,
OpenMode,
ConfirmCountdown,
AutoRefreshSecs,
OpenShellCmd,
OpenEditorCmd,
}
impl SettingField {
pub fn label(self) -> &'static str {
match self {
SettingField::ThemePreset => "theme preset",
SettingField::WorktreeBase => "base directory",
SettingField::WorktreePathPattern => "path pattern",
SettingField::WorktreeBranchPattern => "branch pattern",
SettingField::SidebarPosition => "sidebar position",
SettingField::SidebarOrientation => "sidebar layout",
SettingField::Clipboard => "clipboard",
SettingField::OpenMode => "open mode",
SettingField::ConfirmCountdown => "confirm countdown (s)",
SettingField::AutoRefreshSecs => "auto refresh (s)",
SettingField::OpenShellCmd => "open shell cmd",
SettingField::OpenEditorCmd => "open editor cmd",
}
}
pub fn key_path(self) -> &'static str {
match self {
SettingField::ThemePreset => "theme.preset",
SettingField::WorktreeBase => "worktree.base",
SettingField::WorktreePathPattern => "worktree.path_pattern",
SettingField::WorktreeBranchPattern => "worktree.branch_pattern",
SettingField::SidebarPosition => "tui.sidebar_position",
SettingField::SidebarOrientation => "tui.sidebar_orientation",
SettingField::Clipboard => "tui.clipboard",
SettingField::OpenMode => "tui.open.mode",
SettingField::ConfirmCountdown => "tui.confirm_countdown_secs",
SettingField::AutoRefreshSecs => "tui.auto_refresh_secs",
SettingField::OpenShellCmd => "tui.open.shell_cmd",
SettingField::OpenEditorCmd => "tui.open.editor_cmd",
}
}
pub fn kind(self) -> FieldKind {
match self {
SettingField::ThemePreset
| SettingField::SidebarPosition
| SettingField::SidebarOrientation
| SettingField::Clipboard
| SettingField::OpenMode => FieldKind::Choice,
SettingField::ConfirmCountdown | SettingField::AutoRefreshSecs => FieldKind::Uint,
SettingField::WorktreeBase
| SettingField::WorktreePathPattern
| SettingField::WorktreeBranchPattern
| SettingField::OpenShellCmd
| SettingField::OpenEditorCmd => FieldKind::Text,
}
}
fn edit_char_limit(self) -> usize {
match self {
SettingField::AutoRefreshSecs => 20,
SettingField::ConfirmCountdown => 3,
_ => 256,
}
}
pub fn choices(self) -> &'static [&'static str] {
match self {
SettingField::ThemePreset => crate::tui::theme::preset_names(),
SettingField::SidebarPosition => SIDEBAR_CHOICES,
SettingField::SidebarOrientation => SIDEBAR_ORIENTATION_CHOICES,
SettingField::Clipboard => CLIPBOARD_CHOICES,
SettingField::OpenMode => OPEN_MODE_CHOICES,
_ => &[],
}
}
pub fn current(self, cfg: &Config) -> String {
match self {
SettingField::ThemePreset => cfg.theme.preset.clone().unwrap_or_else(|| "default".into()),
SettingField::WorktreeBase => cfg.worktree.base.clone(),
SettingField::WorktreePathPattern => cfg.worktree.path_pattern.clone(),
SettingField::WorktreeBranchPattern => cfg.worktree.branch_pattern.clone(),
SettingField::SidebarPosition => cfg.tui.sidebar_position.label().into(),
SettingField::SidebarOrientation => cfg.tui.sidebar_orientation.label().into(),
SettingField::Clipboard => cfg.tui.clipboard.label().into(),
SettingField::OpenMode => match cfg.tui.open.mode {
crate::config::TuiOpenMode::Shell => "shell".into(),
crate::config::TuiOpenMode::Editor => "editor".into(),
crate::config::TuiOpenMode::Finder => "finder".into(),
},
SettingField::ConfirmCountdown => cfg.tui.confirm_countdown_secs.to_string(),
SettingField::AutoRefreshSecs => cfg.tui.auto_refresh_secs.to_string(),
SettingField::OpenShellCmd => cfg.tui.open.shell_cmd.clone().unwrap_or_default(),
SettingField::OpenEditorCmd => cfg.tui.open.editor_cmd.clone().unwrap_or_default(),
}
}
pub fn next_choice(self, cfg: &Config) -> Option<String> {
let choices = self.choices();
if choices.is_empty() {
return None;
}
let current = self.current(cfg);
let idx = choices.iter().position(|c| *c == current);
let next = match idx {
Some(i) => choices[(i + 1) % choices.len()],
None => choices[0],
};
Some(next.to_string())
}
}
#[derive(Debug, Default)]
pub struct ConfigPanel {
pub rows: Vec<ConfigRow>,
pub tab: SettingsTab,
pub layer: SettingsLayer,
pub selected: usize,
pub editing: Option<String>,
pub key_rows: Vec<KeyRow>,
pub capture: Option<KeyCapture>,
pub scroll: u16,
pub max_scroll: u16,
pub x_scroll: u16,
pub max_x_scroll: u16,
}
impl ConfigPanel {
pub fn new() -> Self {
Self::default()
}
pub fn fields(&self) -> &'static [SettingField] {
self.tab.fields()
}
pub fn selected_field(&self) -> Option<SettingField> {
self.fields().get(self.selected).copied()
}
pub fn selected_key_row(&self) -> Option<&KeyRow> {
if self.tab == SettingsTab::Keys {
self.key_rows.get(self.selected)
} else {
None
}
}
fn selectable_count(&self) -> usize {
if self.tab == SettingsTab::Keys {
self.key_rows.len()
} else {
self.fields().len()
}
}
pub fn next_tab(&mut self) {
let idx = SettingsTab::ALL.iter().position(|t| *t == self.tab).unwrap_or(0);
self.tab = SettingsTab::ALL[(idx + 1) % SettingsTab::ALL.len()];
self.selected = 0;
self.editing = None;
self.capture = None;
self.scroll = 0;
}
pub fn prev_tab(&mut self) {
let idx = SettingsTab::ALL.iter().position(|t| *t == self.tab).unwrap_or(0);
let len = SettingsTab::ALL.len();
self.tab = SettingsTab::ALL[(idx + len - 1) % len];
self.selected = 0;
self.editing = None;
self.capture = None;
self.scroll = 0;
}
pub fn toggle_layer(&mut self) {
self.layer = self.layer.toggled();
}
pub fn select_prev(&mut self) {
if self.editing.is_some() || self.capture.is_some() {
return;
}
self.selected = self.selected.saturating_sub(1);
}
pub fn select_next(&mut self) {
if self.editing.is_some() || self.capture.is_some() {
return;
}
let count = self.selectable_count();
if count > 0 {
self.selected = (self.selected + 1).min(count - 1);
}
}
pub fn begin_edit(&mut self, current: &str) {
if matches!(
self.selected_field().map(SettingField::kind),
Some(FieldKind::Uint | FieldKind::Text)
) {
self.editing = Some(current.to_string());
}
}
pub fn push_edit_char(&mut self, c: char) {
let field = self.selected_field();
let uint = matches!(field.map(SettingField::kind), Some(FieldKind::Uint));
let limit = field.map(SettingField::edit_char_limit).unwrap_or(256);
if let Some(buf) = self.editing.as_mut() {
if uint {
if c.is_ascii_digit() && buf.len() < limit {
buf.push(c);
}
} else if !c.is_control() && buf.len() < limit {
buf.push(c);
}
}
}
pub fn pop_edit_char(&mut self) {
if let Some(buf) = self.editing.as_mut() {
buf.pop();
}
}
pub fn cancel_edit(&mut self) {
self.editing = None;
}
pub fn take_edit(&mut self) -> Option<String> {
self.editing.take()
}
pub fn field_source(&self, field: SettingField) -> Option<ConfigSource> {
self.rows.iter().find(|r| r.key == field.key_path()).map(|r| r.source)
}
pub fn begin_capture(&mut self) {
if self.tab != SettingsTab::Keys {
return;
}
if let Some(row) = self.key_rows.get(self.selected) {
self.capture = Some(KeyCapture {
row: self.selected,
single_only: row.target.single_only(),
pending: Vec::new(),
});
}
}
pub fn capture_push(&mut self, stroke: KeyStroke) {
if let Some(cap) = self.capture.as_mut() {
cap.pending.push(stroke);
}
}
pub fn capture_pop(&mut self) {
if let Some(cap) = self.capture.as_mut() {
cap.pending.pop();
}
}
pub fn cancel_capture(&mut self) {
self.capture = None;
}
pub fn take_capture(&mut self) -> Option<KeyCapture> {
self.capture.take()
}
pub fn scroll_down(&mut self) {
self.scroll = (self.scroll + 1).min(self.max_scroll);
}
pub fn scroll_up(&mut self) {
self.scroll = self.scroll.saturating_sub(1);
}
pub fn scroll_right(&mut self) {
self.x_scroll = (self.x_scroll + 1).min(self.max_x_scroll);
}
pub fn scroll_left(&mut self) {
self.x_scroll = self.x_scroll.saturating_sub(1);
}
pub fn scroll_to_top(&mut self) {
self.scroll = 0;
}
pub fn scroll_to_bottom(&mut self) {
self.scroll = self.max_scroll;
}
pub fn reset(&mut self) {
self.scroll = 0;
self.x_scroll = 0;
self.selected = 0;
self.editing = None;
self.capture = None;
}
}