Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover?

19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,5 @@ strum = { version = "0.26.3", features = ["strum_macros"] }
tinyvec = "1.10.0"
smashquote = "0.1.2"
imara-diff = { version = "0.2.0", default-features = false }
fuzzy-matcher = "0.3.7"
url = "2.5.7"
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ Configuration is also loaded from:
- Windows: `%USERPROFILE%\AppData\Roaming\gitu\config.toml`

, refer to the [default configuration](src/default_config.toml).

#### Picker Style Customization

You can customize the appearance of the interactive picker by adding the following to your config:

```toml
[style.picker]
prompt = { fg = "cyan" } # Prompt text color
info = { mods = "DIM" } # Status line style (e.g., "3/10 matches")
selection_line = { mods = "BOLD" } # Selected item style
matched = { fg = "yellow", mods = "BOLD" } # Fuzzy-matched characters highlight
```

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this doesn't belong in the README, it's a bit too specific. There's documentation about configuration inside of default_config.toml.
Having the code comments inside of default_config.toml seems like the better approach.

### Installing Gitu
Follow the install instructions: [Installing Gitu](docs/installing.md)\
Or install from your package manager:
Expand Down
97 changes: 96 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ use crate::item_data::RefKind;
use crate::menu::Menu;
use crate::menu::PendingMenu;
use crate::ops::Op;
use crate::picker::PickerData;
use crate::picker::PickerState;
use crate::prompt;
use crate::screen;
use crate::screen::Screen;
Expand All @@ -52,6 +54,7 @@ pub(crate) struct State {
enable_async_cmds: bool,
pub current_cmd_log: CmdLog,
pub prompt: prompt::Prompt,
pub picker: Option<PickerState>,
pub clipboard: Option<Clipboard>,
needs_redraw: bool,
file_watcher: Option<FileWatcher>,
Expand Down Expand Up @@ -103,6 +106,7 @@ impl App {
pending_menu,
current_cmd_log: CmdLog::new(),
prompt: prompt::Prompt::new(),
picker: None,
clipboard,
file_watcher: None,
needs_redraw: true,
Expand Down Expand Up @@ -213,7 +217,9 @@ impl App {
self.state.current_cmd_log.clear();
}

if self.state.prompt.state.is_focused() {
if self.state.picker.is_some() {
self.handle_picker_input(key);
} else if self.state.prompt.state.is_focused() {
self.state.prompt.state.handle_key_event(key);
} else {
self.handle_key_input(term, key)?;
Expand Down Expand Up @@ -618,6 +624,95 @@ impl App {
self.redraw_now(term)?;
}
}

/// Show a picker and wait for user to select an item or cancel.
///
/// Returns:
/// - `Ok(Some(data))` - User selected an item
/// - `Ok(None)` - User cancelled (Esc or Ctrl-C)
/// - `Err(e)` - An error occurred
///
/// # Example
/// ```ignore
/// let items = vec![
/// PickerItem::new("main", PickerData::Revision("main".to_string())),
/// PickerItem::new("develop", PickerData::Revision("develop".to_string())),
/// ];
/// let picker = PickerState::new("Select branch", items, false);
///
/// match app.picker(term, picker)? {
/// Some(PickerData::Revision(name)) => {
/// // User selected a branch
/// println!("Selected: {}", name);
/// }
/// Some(PickerData::CustomInput(_)) => {
/// // Should not happen when allow_custom_input is false
/// }
/// None => {
/// // User cancelled
/// println!("Cancelled");
/// }
/// }
/// ```
#[allow(dead_code)]
pub fn picker(
&mut self,
term: &mut Term,
picker_state: PickerState,
) -> Res<Option<PickerData>> {
self.state.picker = Some(picker_state);
let result = self.handle_picker(term);

self.state.picker = None;

result
}

fn handle_picker(&mut self, term: &mut Term) -> Res<Option<PickerData>> {
self.redraw_now(term)?;

loop {
let event = term.backend_mut().read_event()?;
self.handle_event(term, event)?;

if let Some(ref picker) = self.state.picker {
if picker.is_done() {
// User selected an item
return Ok(picker.selected().map(|item| item.data.clone()));
} else if picker.is_cancelled() {
// User cancelled - this is not an error
return Ok(None);
}
}

self.redraw_now(term)?;
}
}

fn handle_picker_input(&mut self, key: event::KeyEvent) {
if let Some(ref mut picker) = self.state.picker {
// The character received in the KeyEvent changes as shift is pressed,
// e.g. '/' becomes '?' on a US keyboard. So just ignore SHIFT.
let mods_without_shift = key.modifiers.difference(KeyModifiers::SHIFT);
let key_combo = vec![(mods_without_shift, key.code)];

let bindings = &self.state.config.picker_bindings;

if bindings.next.iter().any(|b| b == &key_combo) {
picker.next();
} else if bindings.previous.iter().any(|b| b == &key_combo) {
picker.previous();
} else if bindings.done.iter().any(|b| b == &key_combo) {
picker.done();
} else if bindings.cancel.iter().any(|b| b == &key_combo) {
picker.cancel();
} else {
// Text input - delegate to text state
picker.input_state.handle_key_event(key);
picker.update_filter();
}
}
}
}

fn get_prompt_result(params: &PromptParams, app: &mut App) -> Res<String> {
Expand Down
101 changes: 95 additions & 6 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::{collections::BTreeMap, path::PathBuf};

use crate::{Bindings, Res, error::Error, menu::Menu, ops::Op};
use crate::{Bindings, Res, error::Error, key_parser, menu::Menu, ops::Op};
use crossterm::event::{KeyCode, KeyModifiers};
use etcetera::{BaseStrategy, choose_base_strategy};
use figment::{
Figment,
Expand All @@ -15,6 +16,27 @@ pub struct Config {
pub general: GeneralConfig,
pub style: StyleConfig,
pub bindings: Bindings,
pub picker_bindings: PickerBindings,
}

#[derive(Default, Deserialize)]
pub(crate) struct PickerBindingsConfig {
#[serde(default)]
pub next: Vec<String>,
#[serde(default)]
pub previous: Vec<String>,
#[serde(default)]
pub done: Vec<String>,
#[serde(default)]
pub cancel: Vec<String>,
}

#[derive(Default, Deserialize)]
pub(crate) struct BindingsConfig {
#[serde(flatten)]
pub menus: BTreeMap<Menu, BTreeMap<Op, Vec<String>>>,
#[serde(default)]
pub picker: PickerBindingsConfig,
}

#[derive(Default, Deserialize)]
Expand All @@ -23,7 +45,7 @@ pub struct Config {
pub(crate) struct FigmentConfig {
pub general: GeneralConfig,
pub style: StyleConfig,
pub bindings: BTreeMap<Menu, BTreeMap<Op, Vec<String>>>,
pub bindings: BindingsConfig,
}

#[derive(Default, Debug, Deserialize)]
Expand Down Expand Up @@ -78,6 +100,9 @@ pub struct StyleConfig {
#[serde(default)]
pub syntax_highlight: SyntaxHighlightConfig,

#[serde(default)]
pub picker: PickerStyleConfig,

pub cursor: SymbolStyleConfigEntry,
pub selection_bar: SymbolStyleConfigEntry,
pub selection_line: StyleConfigEntry,
Expand Down Expand Up @@ -170,6 +195,18 @@ pub struct SyntaxHighlightConfig {
pub variable_parameter: StyleConfigEntry,
}

#[derive(Default, Debug, Deserialize)]
pub struct PickerStyleConfig {
#[serde(default)]
pub prompt: StyleConfigEntry,
#[serde(default)]
pub info: StyleConfigEntry,
#[serde(default)]
pub selection_line: StyleConfigEntry,
#[serde(default)]
pub matched: StyleConfigEntry,
}

#[derive(Default, Debug, Deserialize)]
pub struct StyleConfigEntry {
#[serde(default)]
Expand Down Expand Up @@ -216,6 +253,55 @@ impl From<&SymbolStyleConfigEntry> for Style {
}
}

pub struct PickerBindings {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize this was indeed not as smooth to add, due to how the existing bindings always relate to a menu.
Could clean it up in the future, think this is good enough for now!

pub next: Vec<Vec<(KeyModifiers, KeyCode)>>,
pub previous: Vec<Vec<(KeyModifiers, KeyCode)>>,
pub done: Vec<Vec<(KeyModifiers, KeyCode)>>,
pub cancel: Vec<Vec<(KeyModifiers, KeyCode)>>,
}

impl TryFrom<PickerBindingsConfig> for PickerBindings {
type Error = crate::error::Error;

fn try_from(config: PickerBindingsConfig) -> Result<Self, Self::Error> {
let mut bad_bindings = Vec::new();

let next = parse_picker_keys(&config.next, "picker.next", &mut bad_bindings);
let previous = parse_picker_keys(&config.previous, "picker.previous", &mut bad_bindings);
let done = parse_picker_keys(&config.done, "picker.done", &mut bad_bindings);
let cancel = parse_picker_keys(&config.cancel, "picker.cancel", &mut bad_bindings);

if !bad_bindings.is_empty() {
return Err(Error::Bindings { bad_key_bindings: bad_bindings });
}

Ok(Self {
next,
previous,
done,
cancel,
})
}
}

fn parse_picker_keys(
raw_keys: &[String],
action_name: &str,
bad_bindings: &mut Vec<String>,
) -> Vec<Vec<(KeyModifiers, KeyCode)>> {
raw_keys
.iter()
.filter_map(|keys| {
if let Ok(("", parsed)) = key_parser::parse_config_keys(keys) {
Some(parsed)
} else {
bad_bindings.push(format!("- {} = {}", action_name, keys));
None
}
})
.collect()
}

pub fn init_config(path: Option<PathBuf>) -> Res<Config> {
let config_path = path.unwrap_or_else(config_path);

Expand All @@ -228,19 +314,21 @@ pub fn init_config(path: Option<PathBuf>) -> Res<Config> {
let FigmentConfig {
general,
style,
bindings: raw_bindings,
bindings: bindings_config,
} = Figment::new()
.merge(Toml::string(DEFAULT_CONFIG))
.merge(Toml::file(config_path))
.extract()
.map_err(Box::new)
.map_err(Error::Config)?;
let bindings = Bindings::try_from(raw_bindings)?;
let bindings = Bindings::try_from(bindings_config.menus)?;
let picker_bindings = PickerBindings::try_from(bindings_config.picker)?;

Ok(Config {
general,
style,
bindings,
picker_bindings,
})
}

Expand All @@ -256,7 +344,7 @@ pub(crate) fn init_test_config() -> Res<Config> {
let FigmentConfig {
mut general,
style,
bindings: raw_bindings,
bindings: bindings_config,
} = Figment::new()
.merge(Toml::string(DEFAULT_CONFIG))
.extract()
Expand All @@ -269,7 +357,8 @@ pub(crate) fn init_test_config() -> Res<Config> {
Ok(Config {
general,
style,
bindings: Bindings::try_from(raw_bindings).unwrap(),
bindings: Bindings::try_from(bindings_config.menus).unwrap(),
picker_bindings: PickerBindings::try_from(bindings_config.picker).unwrap(),
})
}

Expand Down
10 changes: 10 additions & 0 deletions src/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ syntax_highlight.type_builtin = { fg = "yellow" }
syntax_highlight.variable_builtin = {}
syntax_highlight.variable_parameter = {}

picker.prompt = { fg = "cyan" }
picker.info = { mods = "DIM" }
picker.selection_line = { mods = "BOLD" }
picker.matched = { fg = "yellow", mods = "BOLD" }

cursor = { symbol = "▌", fg = "blue" }
selection_bar = { symbol = "▌", fg = "blue", mods = "DIM" }
selection_line = { mods = "BOLD" }
Expand Down Expand Up @@ -227,3 +232,8 @@ stash_menu.stash_pop = ["p"]
stash_menu.stash_apply = ["a"]
stash_menu.stash_drop = ["k"]
stash_menu.quit = ["q", "esc"]

picker.next = ["down", "ctrl+n"]
picker.previous = ["up", "ctrl+p"]
Comment on lines +236 to +237
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the tab bindings would be like this.

Suggested change
picker.next = ["down", "ctrl+n"]
picker.previous = ["up", "ctrl+p"]
picker.next = ["down", "ctrl+n", "tab"]
picker.previous = ["up", "ctrl+p", "shift+tab"]

picker.done = ["enter"]
picker.cancel = ["esc", "ctrl+c"]
Loading
Loading