Repository: tarkah/tickrs (language: rust)

Issue:
Custom theming and keybinds
Add config file with same settings as command line. Would also be nice to add support for custom theme colors and key bindings

As suggested by @rafi here: https://github.com/tarkah/tickrs/issues/62#issuecomment-778013840


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 96a290c..a877be8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,11 @@ and `Removed`.
 
 - Summary pane can be scrolled with Up / Down arrows if more tickers are present
   than are able to be shown in the terminal
+- A config file can now be used to change program behavior. A default file will
+  be created / can be updated at the following locations:
+  - Linux: `$HOME/.config/tickrs/config.yml`
+  - macOS: `$HOME/Library/Application Support/tickrs/config.yml`
+  - Windows: `%APPDATA%\tickrs\config.yml`
 
 ## [0.10.2] - 2021-02-10
 
diff --git a/Cargo.lock b/Cargo.lock
index 69d8954..086f305 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -454,6 +454,27 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if 1.0.0",
+ "dirs-sys-next",
+]
+
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
 [[package]]
 name = "dtoa"
 version = "0.4.7"
@@ -613,6 +634,17 @@ dependencies = [
  "slab",
 ]
 
+[[package]]
+name = "getrandom"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8"
+dependencies = [
+ "cfg-if 1.0.0",
+ "libc",
+ "wasi",
+]
+
 [[package]]
 name = "gimli"
 version = "0.23.0"
@@ -779,6 +811,12 @@ dependencies = [
  "vcpkg",
 ]
 
+[[package]]
+name = "linked-hash-map"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
+
 [[package]]
 name = "lock_api"
 version = "0.3.4"
@@ -963,7 +1001,7 @@ dependencies = [
  "cfg-if 0.1.10",
  "cloudabi",
  "libc",
- "redox_syscall",
+ "redox_syscall 0.1.57",
  "smallvec",
  "winapi",
 ]
@@ -1074,6 +1112,25 @@ version = "0.1.57"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce"
 
+[[package]]
+name = "redox_syscall"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05ec8ca9416c5ea37062b502703cd7fcb207736bc294f6e0cf367ac6fc234570"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64"
+dependencies = [
+ "getrandom",
+ "redox_syscall 0.2.4",
+]
+
 [[package]]
 name = "regex"
 version = "1.4.3"
@@ -1160,6 +1217,18 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "serde_yaml"
+version = "0.8.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15654ed4ab61726bf918a39cb8d98a2e2995b002387807fa6ba58fdf7f59bb23"
+dependencies = [
+ "dtoa",
+ "linked-hash-map",
+ "serde",
+ "yaml-rust",
+]
+
 [[package]]
 name = "signal-hook"
 version = "0.1.17"
@@ -1284,9 +1353,12 @@ dependencies = [
  "chrono-tz",
  "crossbeam-channel",
  "crossterm",
+ "dirs-next",
  "futures",
  "itertools",
  "lazy_static",
+ "serde",
+ "serde_yaml",
  "structopt",
  "tickrs-api",
  "tui",
@@ -1548,3 +1620,12 @@ name = "winapi-x86_64-pc-windows-gnu"
 version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
diff --git a/Cargo.toml b/Cargo.toml
index e5bbaaa..4087737 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,8 +23,11 @@ anyhow = "1.0"
 crossbeam-channel = "0.4"
 chrono = "0.4"
 chrono-tz = "0.5"
+dirs-next = "2.0.0"
 itertools = "0.9"
 lazy_static = "1.4"
+serde = { version = "1", features = ["derive"] }
+serde_yaml = "0.8"
 structopt = "0.3"
 
 async-std = "1.5"
diff --git a/README.md b/README.md
index 33cddb4..050a113 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,8 @@
 Realtime ticker data in your terminal 📈 Built with Rust. Data sourced from Yahoo! Finance.
 
   - [Installation](#installation)
-  - [Usage](#usage)
+  - [Config File](#config-file)
+  - [CLI Usage](#cli-usage)
     - [Windows](#windows)
   - [Acknowledgments](#acknowledgments)
 
@@ -28,7 +29,11 @@ cargo install tickrs
 yay -S tickrs-git
 ```
 
-## Usage
+## Config File
+
+See [wiki entry](https://github.com/tarkah/tickrs/wiki/Config-file)
+
+## CLI Usage
 
 ```
 tickrs
diff --git a/src/cli.rs b/src/cli.rs
deleted file mode 100644
index 35ade47..0000000
--- a/src/cli.rs
+++ /dev/null
@@ -1,49 +0,0 @@
-use structopt::StructOpt;
-
-use crate::common::TimeFrame;
-
-#[derive(Debug, StructOpt, Clone)]
-#[structopt(
-    name = "tickrs",
-    about = "Realtime ticker data in your terminal 📈",
-    version = env!("CARGO_PKG_VERSION")
-)]
-pub struct Opt {
-    #[structopt(short, long, use_delimiter = true)]
-    /// Comma separated list of ticker symbols to start app with
-    pub symbols: Vec<String>,
-    #[structopt(long)]
-    /// Hide help icon in top right
-    pub hide_help: bool,
-    #[structopt(long)]
-    /// Hide toggle block
-    pub hide_toggle: bool,
-    #[structopt(long)]
-    /// Hide previous close line on 1D chart
-    pub hide_prev_close: bool,
-    #[structopt(long)]
-    /// Start in summary mode
-    pub summary: bool,
-    #[structopt(short = "i", long, default_value = "1")]
-    /// Interval to update data from API (seconds)
-    pub update_interval: u64,
-    #[structopt(short = "t", long, default_value = "1D", possible_values(&["1D", "1W", "1M", "3M", "6M", "1Y", "5Y"]))]
-    /// Use specified time frame when starting program and when new stocks are added
-    pub time_frame: TimeFrame,
-    #[structopt(short = "x", long)]
-    /// Show x-axis labels
-    pub show_x_labels: bool,
-    #[structopt(short = "p", long)]
-    /// Enable pre / post market hours for graphs
-    pub enable_pre_post: bool,
-    #[structopt(long)]
-    /// Truncate pre market graphing to only 30 minutes prior to markets opening
-    pub trunc_pre: bool,
-    #[structopt(long)]
-    /// Show volumes graph
-    pub show_volumes: bool,
-}
-
-pub fn get_opts() -> Opt {
-    Opt::from_args()
-}
diff --git a/src/common.rs b/src/common.rs
index 8bc61bb..dfbfccf 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -4,19 +4,27 @@ use std::time::Duration;
 
 use chrono::{Local, TimeZone, Utc};
 use itertools::izip;
+use serde::Deserialize;
 use tickrs_api::Interval;
 
 use crate::api::model::ChartData;
 use crate::api::Range;
 
-#[derive(Clone, Copy, PartialOrd, Debug, Hash, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialOrd, Debug, Hash, PartialEq, Eq, Deserialize)]
 pub enum TimeFrame {
+    #[serde(alias = "1D")]
     Day1,
+    #[serde(alias = "1W")]
     Week1,
+    #[serde(alias = "1M")]
     Month1,
+    #[serde(alias = "3M")]
     Month3,
+    #[serde(alias = "6M")]
     Month6,
+    #[serde(alias = "1Y")]
     Year1,
+    #[serde(alias = "5Y")]
     Year5,
 }
 
diff --git a/src/main.rs b/src/main.rs
index 38ba71c..87ce714 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18,19 +18,19 @@ use crate::app::DebugInfo;
 use crate::common::TimeFrame;
 
 mod app;
-mod cli;
 mod common;
 mod draw;
 mod event;
+mod opts;
 mod service;
 mod task;
 mod widget;
 
 lazy_static! {
     static ref CLIENT: api::Client = api::Client::new();
-    pub static ref OPTS: cli::Opt = cli::get_opts();
-    pub static ref UPDATE_INTERVAL: u64 = OPTS.update_interval;
-    pub static ref TIME_FRAME: TimeFrame = OPTS.time_frame;
+    pub static ref OPTS: opts::Opts = opts::resolve_opts();
+    pub static ref UPDATE_INTERVAL: u64 = OPTS.update_interval.unwrap_or(1);
+    pub static ref TIME_FRAME: TimeFrame = OPTS.time_frame.unwrap_or(TimeFrame::Day1);
     pub static ref HIDE_TOGGLE: bool = OPTS.hide_toggle;
     pub static ref HIDE_PREV_CLOSE: bool = OPTS.hide_prev_close;
     pub static ref REDRAW_REQUEST: (Sender<()>, Receiver<()>) = bounded(1);
@@ -43,10 +43,10 @@ lazy_static! {
 }
 
 fn main() {
-    let opts = OPTS.clone();
-
     better_panic::install();
 
+    let opts = OPTS.clone();
+
     let backend = CrosstermBackend::new(io::stdout());
     let mut terminal = Terminal::new(backend).unwrap();
 
@@ -59,6 +59,7 @@ fn main() {
 
     let starting_stocks: Vec<_> = opts
         .symbols
+        .unwrap_or_default()
         .into_iter()
         .map(widget::StockState::new)
         .collect();
@@ -95,7 +96,7 @@ fn main() {
         } else {
             app::Mode::DisplayStock
         },
-        summary_time_frame: opts.time_frame,
+        summary_time_frame: opts.time_frame.unwrap_or(TimeFrame::Day1),
         default_timestamp_service,
         summary_scroll_state: Default::default(),
     }));
diff --git a/src/opts.rs b/src/opts.rs
new file mode 100644
index 0000000..d480fa0
--- /dev/null
+++ b/src/opts.rs
@@ -0,0 +1,154 @@
+use std::{fs, process};
+
+use anyhow::{bail, format_err, Error};
+use serde::Deserialize;
+use structopt::StructOpt;
+
+use crate::common::TimeFrame;
+
+pub fn resolve_opts() -> Opts {
+    let mut opts = get_cli_opts();
+
+    if let Ok(config_opts) = get_config_opts() {
+        // Options
+        opts.symbols = opts.symbols.or(config_opts.symbols);
+        opts.time_frame = opts.time_frame.or(config_opts.time_frame);
+        opts.update_interval = opts.update_interval.or(config_opts.update_interval);
+
+        // Flags
+        opts.enable_pre_post = opts.enable_pre_post || config_opts.enable_pre_post;
+        opts.hide_help = opts.hide_help || config_opts.hide_help;
+        opts.hide_prev_close = opts.hide_prev_close || config_opts.hide_prev_close;
+        opts.hide_toggle = opts.hide_toggle || config_opts.hide_toggle;
+        opts.show_volumes = opts.show_volumes || config_opts.show_volumes;
+        opts.show_x_labels = opts.show_x_labels || config_opts.show_x_labels;
+        opts.summary = opts.summary || config_opts.summary;
+        opts.trunc_pre = opts.trunc_pre || config_opts.trunc_pre;
+    }
+
+    opts
+}
+
+fn get_cli_opts() -> Opts {
+    Opts::from_args()
+}
+
+fn get_config_opts() -> Result<Opts, Error> {
+    let config_dir = dirs_next::config_dir()
+        .ok_or_else(|| format_err!("Could not get config directory"))?
+        .join("tickrs");
+
+    if !config_dir.exists() {
+        let _ = fs::create_dir_all(&config_dir);
+    }
+
+    let config_path = config_dir.join("config.yml");
+
+    if !config_path.exists() {
+        let _ = fs::write(&config_path, DEFAULT_CONFIG);
+    }
+
+    let config = fs::read_to_string(&config_path)?;
+
+    let opts = match serde_yaml::from_str::<Option<Opts>>(&config) {
+        Ok(Some(opts)) => opts,
+        Ok(None) => bail!("Empty config file"),
+        Err(e) => {
+            println!(
+                "Error parsing config file, make sure format is valid\n\n  {}",
+                e
+            );
+            process::exit(1);
+        }
+    };
+
+    Ok(opts)
+}
+
+#[derive(Debug, StructOpt, Clone, Deserialize, Default)]
+#[structopt(
+    name = "tickrs",
+    about = "Realtime ticker data in your terminal 📈",
+    version = env!("CARGO_PKG_VERSION")
+)]
+#[serde(default)]
+pub struct Opts {
+    // Options
+    //
+    #[structopt(short, long, use_delimiter = true)]
+    /// Comma separated list of ticker symbols to start app with
+    pub symbols: Option<Vec<String>>,
+    #[structopt(short = "t", long, possible_values(&["1D", "1W", "1M", "3M", "6M", "1Y", "5Y"]))]
+    /// Use specified time frame when starting program and when new stocks are added [default: 1D]
+    pub time_frame: Option<TimeFrame>,
+    #[structopt(short = "i", long)]
+    /// Interval to update data from API (seconds) [default: 1]
+    pub update_interval: Option<u64>,
+
+    // Flags
+    //
+    #[structopt(short = "p", long)]
+    /// Enable pre / post market hours for graphs
+    pub enable_pre_post: bool,
+    #[structopt(long)]
+    /// Hide help icon in top right
+    pub hide_help: bool,
+    #[structopt(long)]
+    /// Hide previous close line on 1D chart
+    pub hide_prev_close: bool,
+    #[structopt(long)]
+    /// Hide toggle block
+    pub hide_toggle: bool,
+    #[structopt(long)]
+    /// Show volumes graph
+    pub show_volumes: bool,
+    #[structopt(short = "x", long)]
+    /// Show x-axis labels
+    pub show_x_labels: bool,
+    #[structopt(long)]
+    /// Start in summary mode
+    pub summary: bool,
+    #[structopt(long)]
+    /// Truncate pre market graphing to only 30 minutes prior to markets opening
+    pub trunc_pre: bool,
+}
+
+const DEFAULT_CONFIG: &str = "---
+# List of ticker symbols to start app with
+#symbols:
+#  - SPY
+#  - AMD
+
+# Use specified time frame when starting program and when new stocks are added
+# Default is 1D
+# Possible values: 1D, 1W, 1M, 3M, 6M, 1Y, 5Y
+#time_frame: 1D
+
+# Interval to update data from API (seconds)
+# Default is 1
+#update_interval: 1
+
+# Enable pre / post market hours for graphs
+#enable_pre_post: true
+
+# Hide help icon in top right
+#hide_help: true
+
+# Hide previous close line on 1D chart
+#hide_prev_close: true
+
+# Hide toggle block
+#hide_toggle: true
+
+# Show volumes graph
+#show_volumes: true
+
+# Show x-axis labels
+#show_x_labels: true
+
+# Start in summary mode
+#summary: true
+
+# Truncate pre market graphing to only 30 minutes prior to markets opening
+#trunc_pre: true
+";


Test patch: