Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin: Add support for settings and use them in issue selection
Merged did:key:z6MkswQE...2C1V opened 1 year ago
9 files changed +252 -14 eb00c21e ba1ef1cc
modified CHANGELOG.md
@@ -7,6 +7,8 @@
**Binary features:**

- Issue preview widgets in `issue select`
+
- Basic theming support with light and dark theme bundles
+
- Support for application settings

**Library features:**

modified Cargo.lock
@@ -365,6 +365,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"

[[package]]
+
name = "cfg_aliases"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
+

+
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -436,6 +442,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"

[[package]]
+
name = "coolor"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "37e93977247fb916abeee1ff8c6594c9b421fd9c26c9b720a3944acb2a7de27b"
+

+
[[package]]
name = "core-foundation-sys"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -479,6 +491,31 @@ dependencies = [
]

[[package]]
+
name = "crossterm"
+
version = "0.27.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
+
dependencies = [
+
 "bitflags 2.4.1",
+
 "crossterm_winapi",
+
 "libc",
+
 "mio",
+
 "parking_lot",
+
 "signal-hook",
+
 "signal-hook-mio",
+
 "winapi",
+
]
+

+
[[package]]
+
name = "crossterm_winapi"
+
version = "0.9.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+
dependencies = [
+
 "winapi",
+
]
+

+
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1040,9 +1077,9 @@ dependencies = [

[[package]]
name = "lazy_static"
-
version = "1.4.0"
+
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
 "spin",
]
@@ -1168,6 +1205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09"
dependencies = [
 "libc",
+
 "log",
 "wasi 0.11.0+wasi-snapshot-preview1",
 "windows-sys 0.48.0",
]
@@ -1193,6 +1231,18 @@ dependencies = [
]

[[package]]
+
name = "nix"
+
version = "0.28.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
+
dependencies = [
+
 "bitflags 2.4.1",
+
 "cfg-if",
+
 "cfg_aliases",
+
 "libc",
+
]
+

+
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1819,6 +1869,7 @@ dependencies = [
 "anyhow",
 "fuzzy-matcher",
 "inquire",
+
 "lazy_static",
 "lexopt",
 "libc",
 "log",
@@ -1832,6 +1883,7 @@ dependencies = [
 "serde_json",
 "signal-hook",
 "simple-logging",
+
 "terminal-light",
 "termion 3.0.0",
 "textwrap",
 "thiserror",
@@ -2112,6 +2164,17 @@ dependencies = [
]

[[package]]
+
name = "signal-hook-mio"
+
version = "0.2.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af"
+
dependencies = [
+
 "libc",
+
 "mio",
+
 "signal-hook",
+
]
+

+
[[package]]
name = "signal-hook-registry"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2221,9 +2284,9 @@ dependencies = [

[[package]]
name = "spin"
-
version = "0.5.2"
+
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
+
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"

[[package]]
name = "spki"
@@ -2405,6 +2468,18 @@ dependencies = [
]

[[package]]
+
name = "terminal-light"
+
version = "1.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a2ecb7b6ab8a3eeff2b61770d313d1e971f184e29321785c62ef523b132437b7"
+
dependencies = [
+
 "coolor",
+
 "crossterm",
+
 "thiserror",
+
 "xterm-query",
+
]
+

+
[[package]]
name = "termion"
version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3072,6 +3147,16 @@ dependencies = [
]

[[package]]
+
name = "xterm-query"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2f29504d0a2ca8c1714781c1395a8a660d2557b2cf9c9669433153fc903e9bfc"
+
dependencies = [
+
 "nix",
+
 "thiserror",
+
]
+

+
[[package]]
name = "yansi"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -26,6 +26,7 @@ anyhow = { version = "1" }
inquire = { version = "0.7.4", default-features = false, features = ["termion", "editor"] }
lexopt = { version = "0.3.0" }
fuzzy-matcher = "0.3.7"
+
lazy_static = { version = "1.5.0" }
libc = { version = "^0.2" }
log = { version = "0.4.19" }
nom = { version = "^7.1.0" }
@@ -40,6 +41,7 @@ serde_json = { version = "1.0" }
signal-hook = { version = "0.3.17" }
timeago = { version = "0.4.1" }
termion = { version = "3" }
+
terminal-light = { version = "1.4.0" }
textwrap = { version = "0.16.0" }
thiserror = { version = "1" }
tokio = { version = "1.32.0", features = ["full"] }
modified bin/commands/issue.rs
@@ -7,6 +7,8 @@ use std::ffi::OsString;

use anyhow::anyhow;

+
use lazy_static::lazy_static;
+

use radicle::identity::RepoId;
use radicle::issue;

@@ -18,6 +20,13 @@ use radicle_tui as tui;
use tui::log;

use crate::cob;
+
use crate::ui::TerminalInfo;
+

+
lazy_static! {
+
    static ref TERMINAL_INFO: TerminalInfo = TerminalInfo {
+
        luma: Some(terminal_light::luma().unwrap_or_default())
+
    };
+
}

pub const HELP: Help = Help {
    name: "issue",
@@ -143,6 +152,8 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
    let (_, rid) = radicle::rad::cwd()
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;

+
    let terminal_info = TERMINAL_INFO.clone();
+

    match options.op {
        Operation::Select { opts } => {
            let profile = ctx.profile()?;
@@ -157,7 +168,8 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
                mode: opts.mode,
                filter: opts.filter.clone(),
            };
-
            let output = select::App::new(context).run().await?;
+

+
            let output = select::App::new(context, terminal_info).run().await?;

            let output = output
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
modified bin/commands/issue/select.rs
@@ -23,9 +23,10 @@ use radicle_tui as tui;
use tui::store;
use tui::store::StateValue;
use tui::ui::span;
+
use tui::ui::theme::Theme;
use tui::ui::widget::container::{
-
    Column, Container, Footer, FooterProps, Header, HeaderProps, SectionGroup, SectionGroupProps,
-
    SplitContainer, SplitContainerFocus, SplitContainerProps,
+
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
+
    SectionGroupProps, SplitContainer, SplitContainerFocus, SplitContainerProps,
};
use tui::ui::widget::input::{TextView, TextViewProps};
use tui::ui::widget::list::{Tree, TreeProps};
@@ -34,8 +35,10 @@ use tui::ui::widget::{PredefinedLayout, ToWidget, Widget};
use tui::{BoxedAny, Channel, Exit, PageStack};

use crate::cob::issue;
+
use crate::settings::{self, ThemeBundle, ThemeMode};
use crate::ui::items::{CommentItem, Filter, IssueItem, IssueItemFilter};
use crate::ui::widget::{IssueDetails, IssueDetailsProps};
+
use crate::ui::TerminalInfo;

use self::ui::{Browser, BrowserProps};

@@ -52,6 +55,7 @@ pub struct Context {

pub struct App {
    context: Context,
+
    terminal_info: TerminalInfo,
}

#[derive(Clone, Debug, Eq, PartialEq, Hash)]
@@ -230,16 +234,34 @@ pub struct State {
    preview: PreviewState,
    section: Option<Section>,
    help: HelpState,
+
    theme: Theme,
}

-
impl TryFrom<&Context> for State {
+
impl TryFrom<(&Context, &TerminalInfo)> for State {
    type Error = anyhow::Error;

-
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
    fn try_from(value: (&Context, &TerminalInfo)) -> Result<Self, Self::Error> {
+
        let (context, terminal_info) = value;
+
        let settings = settings::Settings::default();
+

        let issues = issue::all(&context.profile, &context.repository)?;
        let search = StateValue::new(context.filter.to_string());
        let filter = IssueItemFilter::from_str(&search.read()).unwrap_or_default();

+
        let default_bundle = ThemeBundle::default();
+
        let theme_bundle = settings.theme.active_bundle().unwrap_or(&default_bundle);
+
        let theme = match settings.theme.mode() {
+
            ThemeMode::Auto => {
+
                if terminal_info.is_dark() {
+
                    theme_bundle.dark.clone()
+
                } else {
+
                    theme_bundle.light.clone()
+
                }
+
            }
+
            ThemeMode::Light => theme_bundle.light.clone(),
+
            ThemeMode::Dark => theme_bundle.dark.clone(),
+
        };
+

        // Convert into UI items
        let mut items = vec![];
        for issue in issues {
@@ -282,6 +304,7 @@ impl TryFrom<&Context> for State {
                scroll: 0,
                cursor: (0, 0),
            },
+
            theme,
        })
    }
}
@@ -426,13 +449,16 @@ impl store::State<Selection> for State {
}

impl App {
-
    pub fn new(context: Context) -> Self {
-
        Self { context }
+
    pub fn new(context: Context, terminal_info: TerminalInfo) -> Self {
+
        Self {
+
            context,
+
            terminal_info,
+
        }
    }

    pub async fn run(&self) -> Result<Option<Selection>> {
        let channel = Channel::default();
-
        let state = State::try_from(&self.context)?;
+
        let state = State::try_from((&self.context, &self.terminal_info))?;
        let tx = channel.tx.clone();

        let window = Window::default()
@@ -471,6 +497,8 @@ fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {

            ShortcutsProps::default()
                .shortcuts(&shortcuts)
+
                .shortcuts_keys_style(state.theme.shortcuts_keys_style)
+
                .shortcuts_action_style(state.theme.shortcuts_action_style)
                .to_boxed_any()
                .into()
        });
@@ -548,9 +576,11 @@ fn issue(channel: &Channel<Message>) -> Widget<State, Message> {
        .top(issue_details(channel))
        .bottom(comment_tree(channel))
        .to_widget(tx.clone())
-
        .on_update(|_| {
+
        .on_update(|state| {
            SplitContainerProps::default()
                .heights([Constraint::Length(5), Constraint::Min(1)])
+
                .border_color(state.theme.border_color)
+
                .focus_border_color(state.theme.focus_border_color)
                .split_focus(SplitContainerFocus::Bottom)
                .to_boxed_any()
                .into()
@@ -641,6 +671,13 @@ fn comment(channel: &Channel<Message>) -> Widget<State, Message> {
                }),
        )
        .to_widget(tx.clone())
+
        .on_update(|state| {
+
            ContainerProps::default()
+
                .border_color(state.theme.border_color)
+
                .focus_border_color(state.theme.focus_border_color)
+
                .to_boxed_any()
+
                .into()
+
        })
}

fn help_page(channel: &Channel<Message>) -> Widget<State, Message> {
@@ -691,7 +728,14 @@ fn help_page(channel: &Channel<Message>) -> Widget<State, Message> {
                        .into()
                }),
        )
-
        .to_widget(tx.clone());
+
        .to_widget(tx.clone())
+
        .on_update(|state| {
+
            ContainerProps::default()
+
                .border_color(state.theme.border_color)
+
                .focus_border_color(state.theme.focus_border_color)
+
                .to_boxed_any()
+
                .into()
+
        });

    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
        ShortcutsProps::default()
modified bin/commands/issue/select/ui.rs
@@ -124,8 +124,11 @@ impl Browser {
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
                    // TODO: remove and use state directly
                    let props = BrowserProps::from(state);
+

                    HeaderProps::default()
                        .columns(props.header.clone())
+
                        .border_color(state.theme.border_color)
+
                        .focus_border_color(state.theme.focus_border_color)
                        .to_boxed_any()
                        .into()
                }))
@@ -155,12 +158,16 @@ impl Browser {

                    FooterProps::default()
                        .columns(browse_footer(&props))
+
                        .border_color(state.theme.border_color)
+
                        .focus_border_color(state.theme.focus_border_color)
                        .to_boxed_any()
                        .into()
                }))
                .to_widget(tx.clone())
                .on_update(|state| {
                    ContainerProps::default()
+
                        .border_color(state.theme.border_color)
+
                        .focus_border_color(state.theme.focus_border_color)
                        .hide_footer(BrowserProps::from(state).show_search)
                        .to_boxed_any()
                        .into()
modified bin/main.rs
@@ -2,6 +2,7 @@ mod cob;
mod commands;
mod git;
mod ui;
+
mod settings;

use std::ffi::OsString;
use std::io;
added bin/settings.rs
@@ -0,0 +1,74 @@
+
use std::collections::HashMap;
+

+
use radicle_tui as tui;
+
use tui::ui::theme::Theme;
+

+
static THEME_RADICLE: &str = "Radicle";
+

+
pub type ThemeBundleId = String;
+

+
/// `ThemeMode` defines which theme is selected from a `ThemeBundle`. It can
+
/// be either `light``, `dark`` or `auto``, which sets the mode depending on
+
/// the terminal background luma.
+
#[allow(dead_code)]
+
#[derive(Debug, PartialEq, Eq, Hash)]
+
pub enum ThemeMode {
+
    Auto,
+
    Light,
+
    Dark,
+
}
+

+
/// A `ThemeBundle` defines a tuple of themes, that should be adapted to light or
+
/// dark terminal colors.
+
#[derive(Debug)]
+
pub struct ThemeBundle {
+
    pub light: Theme,
+
    pub dark: Theme,
+
}
+

+
impl Default for ThemeBundle {
+
    fn default() -> Self {
+
        Self {
+
            light: Theme::default_light(),
+
            dark: Theme::default_dark(),
+
        }
+
    }
+
}
+

+
#[derive(Debug)]
+
pub struct ThemeSettings {
+
    /// Set light or dark mode, or detect terminal background luma and
+
    /// switch automatically.
+
    mode: ThemeMode,
+
    /// Theme bundle identifier.
+
    active_bundle: ThemeBundleId,
+
    /// All theme bundles.
+
    bundles: HashMap<ThemeBundleId, ThemeBundle>,
+
}
+

+
impl ThemeSettings {
+
    pub fn mode(&self) -> &ThemeMode {
+
        &self.mode
+
    }
+

+
    pub fn active_bundle(&self) -> Option<&ThemeBundle> {
+
        self.bundles.get(&self.active_bundle)
+
    }
+
}
+

+
#[derive(Debug)]
+
pub struct Settings {
+
    pub theme: ThemeSettings,
+
}
+

+
impl Default for Settings {
+
    fn default() -> Self {
+
        Self {
+
            theme: ThemeSettings {
+
                mode: ThemeMode::Auto,
+
                active_bundle: THEME_RADICLE.into(),
+
                bundles: HashMap::from([(THEME_RADICLE.to_string(), ThemeBundle::default())]),
+
            },
+
        }
+
    }
+
}
modified bin/ui.rs
@@ -1,3 +1,14 @@
pub mod format;
pub mod items;
pub mod widget;
+

+
#[derive(Clone, Debug)]
+
pub struct TerminalInfo {
+
    pub luma: Option<f32>,
+
}
+

+
impl TerminalInfo {
+
    pub fn is_dark(&self) -> bool {
+
        self.luma.unwrap_or_default() <= 0.6
+
    }
+
}