diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5340c1b..4ffc056 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ '*' ] + branches: ["dev", "master"] pull_request: - branches: [ master ] + branches: [master] env: CARGO_TERM_COLOR: always @@ -15,183 +15,122 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - rust: [nightly, stable, '1.70'] + rust: [stable, "1.70"] runs-on: ${{ matrix.os }} - continue-on-error: ${{ matrix.rust == 'nightly' }} - steps: - - uses: actions/checkout@v2 - - - name: Restore cargo cache - uses: actions/cache@v2 - env: - cache-name: ci - with: - path: | - ~/.cargo/registry - ~/.cargo/git - ~/.cargo/bin - target - key: ${{ matrix.os }}-${{ env.cache-name }}-${{ matrix.rust }}-${{ hashFiles('Cargo.lock') }} - - - name: MacOS Workaround - if: matrix.os == 'macos-latest' - run: cargo clean -p serde_derive -p thiserror - - - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: ${{ matrix.rust }} - default: true - override: true - profile: minimal - components: clippy - - - name: Build Debug - run: | - cargo build - - - name: Run tests - run: cargo test - - - name: Run clippy - run: | - cargo clippy --workspace --all-features - - - name: Build Release - run: cargo build --release - - - name: Test Install - run: cargo install --path "." --force - - - name: Binary Size (unix) - if: matrix.os != 'windows-latest' - run: | - ls -l ./target/release/leetui - - - name: Binary Size (win) - if: matrix.os == 'windows-latest' - run: | - ls -l ./target/release/leetui.exe - - - name: Binary dependencies (mac) - if: matrix.os == 'macos-latest' - run: | - otool -L ./target/release/leetui - - - name: Build MSI (windows) - if: matrix.os == 'windows-latest' - run: | - cargo install cargo-wix --version 0.3.3 - cargo wix --version - cargo wix -p leetui --no-build --nocapture --output ./target/wix/leetui.msi - ls -l ./target/wix/leetui.msi - - build-linux-musl: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - rust: [nightly, stable, '1.70'] - continue-on-error: ${{ matrix.rust == 'nightly' }} - steps: - - uses: actions/checkout@master - - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: ${{ matrix.rust }} - profile: minimal - default: true - override: true - target: x86_64-unknown-linux-musl - - - name: Setup MUSL - run: | - sudo apt-get -qq install musl-tools && sudo apt-get -y install libssl-dev && sudo apt-get -y install pkg-config - - name: Build Debug - run: | - cargo build --target=x86_64-unknown-linux-musl - ./target/x86_64-unknown-linux-musl/debug/leetui --version - - name: Build Release - run: | - cargo build --release --target=x86_64-unknown-linux-musl - ./target/x86_64-unknown-linux-musl/release/leetui --version - ls -l ./target/x86_64-unknown-linux-musl/release/leetui - - name: Test - run: | - cargo test --workspace --target=x86_64-unknown-linux-musl - - name: Test Install - run: cargo install --path "." --force + - uses: actions/checkout@v3 + + - name: Restore cargo cache + uses: actions/cache@v3 + env: + cache-name: ci + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cargo/bin + target + key: ${{ matrix.os }}-${{ env.cache-name }}-${{ matrix.rust }}-${{ hashFiles('Cargo.lock') }} + + - name: MacOS Workaround + if: matrix.os == 'macos-latest' + run: cargo clean -p serde_derive -p thiserror + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.rust }} + default: true + override: true + profile: minimal + components: clippy + + - name: Build Debug + run: | + cargo build + + - name: Run tests + run: cargo test + + - name: Run clippy + run: | + cargo clippy --workspace --all-features + + - name: Build Release + run: cargo build --release + + - name: Test Install + run: cargo install --path "." --force + + - name: Binary Size (unix) + if: matrix.os != 'windows-latest' + run: | + ls -l ./target/release/leetui + + - name: Binary Size (win) + if: matrix.os == 'windows-latest' + run: | + ls -l ./target/release/leetui.exe + + - name: Binary dependencies (mac) + if: matrix.os == 'macos-latest' + run: | + otool -L ./target/release/leetui linting: name: Lints runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - components: rustfmt + - uses: actions/checkout@master + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + override: true + components: rustfmt - - run: cargo fmt -- --check + - run: cargo fmt -- --check - - name: cargo-sort - run: | - cargo install cargo-sort --force - cargo sort -c -w + - name: cargo-sort + run: | + cargo install cargo-sort --force + cargo sort -c -w - - name: cargo-deny install - run: | - cargo install --locked cargo-deny + - name: cargo-deny install + run: | + cargo install --locked cargo-deny - # - name: cargo-deny licenses - # run: | - # cargo deny check licenses - - - name: cargo-deny bans - run: | - cargo deny check bans + - name: cargo-deny bans + run: | + cargo deny check bans udeps: name: udeps runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly + override: true + + - name: cargo-udeps + run: | + cargo install --git https://github.com/est31/cargo-udeps --locked + cargo +nightly udeps --all-targets + + log-test: + name: Changelog Test + runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - name: Install Rust - uses: actions-rs/toolchain@v1 + - name: Extract release notes + id: extract_release_notes + uses: ffurrer2/extract-release-notes@v1 + with: + release_notes_file: ./release-notes.txt + - uses: actions/upload-artifact@v1 with: - toolchain: nightly - override: true - - - name: cargo-udeps - run: | - # cargo install --locked cargo-udeps - cargo install --git https://github.com/est31/cargo-udeps --locked - cargo +nightly udeps --all-targets - - - # sec: - # name: Security audit - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v2 - # - uses: actions-rs/audit-check@v1 - # with: - # token: ${{ secrets.GITHUB_TOKEN }} - - # log-test: - # name: Changelog Test - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@master - # - name: Extract release notes - # id: extract_release_notes - # uses: ffurrer2/extract-release-notes@v1 - # with: - # release_notes_file: ./release-notes.txt - # - uses: actions/upload-artifact@v1 - # with: - # name: release-notes.txt - # path: ./release-notes.txt + name: release-notes.txt + path: ./release-notes.txt diff --git a/.gitignore b/.gitignore index 5c3c098..4975813 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ leetcode.sqlite config.toml src/.vscode -src/app_ui/components -src/app_ui/widgets +temp/ +gif_demo +.ssh/vhs_ed25519 +.ssh/vhs_ed25519.pub diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8ef7611 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-added-large-files + +- repo: https://github.com/doublify/pre-commit-rust + rev: v1.0 + hooks: + - id: fmt + - id: cargo-check + - id: clippy diff --git a/CHANGELOG.md b/CHANGELOG.md index c880847..db0b079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,28 +4,46 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Fix config directories setup for windows - -- Take input directly from the user lc session +- Sort questions by: + - likes dislikes ratio. -- Sort tags/questions by least accepted. +- Filter -- Sort by likes dislikes ratio. +- Search feature. -- Color question by accepted. +- Scroll bar visible list -- Search feature. +- Take input directly from the user lc session - Select multiple tags. -- Submit directly. - -- Summary of the question - - Submission stats +- Summary of the question - View more question details - Invalidate questions cache through `userSessionProgress` +## [0.2.0] - 2023-07-30 + +### Added + +- Read question view is scrollable using up and down keys. +- Question line is colored by easy => green, medium => yellow, hard => red. +- Show helps at the bottom/top. +- Open file in the editor to solve by pressing the key e. +- Create solution file in the preferred language +- Run/test the solution against test cases + - show test case submission stats in the popup +- Submit solution file +- Update table question if solution accepted +- Loading spinner at the top. +- Fix config directories setup for windows +- Submission stats upon successful submit +- Added gif demo using [vhs tape](https://github.com/charmbracelet/vhs) +- Vim like keybinding to jump to a problem (number followed by G (123G) in topic tag "all" questions) + +### Fixed + +- Failing build windows ## [0.1.0] - 2023-07-19 @@ -45,6 +63,4 @@ All notable changes to this project will be documented in this file. - Scrollable View of questions corresponding to the tags. -- Read question in the popup using `Enter` key on the selected question. - - +- Read question in the popup using `Enter` key on the selected question. diff --git a/Cargo.lock b/Cargo.lock index e133d33..85681c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1069,7 +1069,7 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "leetcode-tui-rs" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "crossbeam", @@ -1077,13 +1077,17 @@ dependencies = [ "futures", "futures-timer", "html2text", + "indexmap 2.0.0", "kdam", + "lru", + "rand", "ratatui", "reqwest", "sea-orm", "sea-orm-migration", "serde", "serde_json", + "strum", "thiserror", "tokio", "toml 0.7.6", @@ -1131,6 +1135,15 @@ version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" +[[package]] +name = "lru" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eedb2bdbad7e0634f83989bf596f497b070130daaa398ab22d84c39e266deec5" +dependencies = [ + "hashbrown 0.14.0", +] + [[package]] name = "mac" version = "0.1.1" @@ -2357,6 +2370,28 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6069ca09d878a33f883cc06aaa9718ede171841d3832450354410b718b097232" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.25", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/Cargo.toml b/Cargo.toml index 92b6237..e8c7873 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "leetcode-tui-rs" -version = "0.1.0" +version = "0.2.0" edition = "2021" authors = ["Akarsh "] description = "Leetcode terminal UI. Helps you browse leetcode stats and manage your leetcode from terminal." @@ -10,6 +10,7 @@ homepage = "https://github.com/akarsh1995/leetcode-tui" license = "MIT" keywords = ["tui", "leetcode", "terminal", "algorithms", "cli"] categories = ["algorithms", "command-line-utilities"] +exclude = [".github", "vhs_tapes"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] @@ -19,12 +20,16 @@ crossterm = { version = "0.26.1", features = ["event-stream"] } futures = "0.3.28" futures-timer = "3.0.2" html2text = "0.6.0" +indexmap = "2.0.0" kdam = "0.3.0" +lru = "0.11.0" +rand = "0.8.5" ratatui = { version = "0.22.0", features = ["all-widgets"] } reqwest = { version = "0.11.18", features = ["json", "cookie_crate", "cookie_store", "cookies", ] } sea-orm = { version = "^0", features = [ "sqlx-sqlite", "runtime-tokio-native-tls", "macros", "with-json"] } serde = { version = "1.0.171", features = ["derive"] } serde_json = "1.0.102" +strum = { version = "0.25", features = ["derive"] } thiserror = "1.0.43" tokio ={ version = "1.29.1", features = ["macros", "rt-multi-thread"] } toml = "0.7.6" @@ -37,10 +42,14 @@ path = "src/main.rs" [dependencies.sea-orm-migration] version = "^0" features = [ - "runtime-tokio-native-tls", - "sqlx-sqlite", + "runtime-tokio-native-tls", + "sqlx-sqlite", ] [dev-dependencies] tracing = "0.1.37" tracing-subscriber = "0.3.17" + +[profile.release] +lto = true +opt-level = "z" # Optimize for size. diff --git a/README.md b/README.md index 93636f5..0aa4a69 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Use Leetcode in your terminal. -![Demo](demo.gif) +![Demo](https://vhs.charm.sh/vhs-7mc1SjatwAFIfEpRjylgaO.gif) > **Warning** @@ -23,6 +23,17 @@ leetui # Get the Cookies from the browser `LEETCODE_SESSION` and `csrftoken` and paste it in `~/.config/leetcode_tui/config.toml` -# run the command again +# run the command again to populate db leetui ``` + +## Features + +- Question grouped by categories +- Read Question +- Jump to question using vim like keybinding (123G). +- Open question in `EDITOR` +- Solve question in multiple languages +- Submit and run solution in multiple languages +- Read Stats of your performance +- Solved questions are marked with ✔️ diff --git a/demo.gif b/demo.gif deleted file mode 100644 index b973ef1..0000000 Binary files a/demo.gif and /dev/null differ diff --git a/src/app_ui/app.rs b/src/app_ui/app.rs index cba3def..d9497bd 100644 --- a/src/app_ui/app.rs +++ b/src/app_ui/app.rs @@ -1,169 +1,217 @@ -use ratatui::widgets::ListState; - -use crate::entities::topic_tag::Model as TopicTagModel; -use crate::{entities::question::Model as QuestionModel, errors::AppResult}; -use std::collections::{HashMap, HashSet}; - -use super::{ - channel::{ChannelRequestSender, ChannelResponseReceiver, TaskResponse}, - list::StatefulList, -}; - -/// Application result type. - -pub type SS = (TopicTagModel, Vec); - -pub type TTReciever = crossbeam::channel::Receiver; -pub type TTSender = crossbeam::channel::Sender; - -#[derive(Debug)] -pub enum Widget<'a> { - QuestionList(&'a mut StatefulList), - TopicTagList(&'a mut StatefulList), -} +use std::collections::VecDeque; +use std::rc::Rc; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use super::async_task_channel::{ChannelRequestSender, ChannelResponseReceiver}; +use super::event::VimPingSender; +use super::widgets::help_bar::HelpBar; +use super::widgets::notification::{Notification, WidgetName, WidgetVariant}; +use super::widgets::popup::Popup; +use super::widgets::question_list::QuestionListWidget; +use super::widgets::stats::Stats; +use super::widgets::topic_list::TopicTagListWidget; +use super::widgets::Widget; +use crate::config::Config; +use crate::errors::AppResult; +use indexmap::IndexMap; /// Application. #[derive(Debug)] -pub struct App<'a> { +pub struct App { /// Is the application running? pub running: bool, - pub widgets: &'a mut Vec>, + pub(crate) widget_map: indexmap::IndexMap, - pub questions_list: Option>>, + selected_wid_idx: i32, - pub widget_switcher: i32, + pub task_request_sender: ChannelRequestSender, - pub last_response: Option, + pub task_response_recv: ChannelResponseReceiver, - pub show_popup: bool, + pub pending_notifications: VecDeque>, - pub task_request_sender: ChannelRequestSender, + pub(crate) popup_stack: Vec, - pub task_response_recv: ChannelResponseReceiver, + pub vim_tx: VimPingSender, + + pub vim_running: Arc, + + pub config: Rc, } -impl<'a> App<'a> { +impl App { /// Constructs a new instance of [`App`]. pub fn new( - wid: &'a mut Vec>, task_request_sender: ChannelRequestSender, task_response_recv: ChannelResponseReceiver, + vim_tx: VimPingSender, + vim_running: Arc, + config: Rc, ) -> AppResult { - task_request_sender.send(super::channel::TaskRequest::GetAllQuestionsMap)?; - task_request_sender.send(super::channel::TaskRequest::GetAllTopicTags)?; + let w0 = WidgetVariant::TopicList(TopicTagListWidget::new( + WidgetName::TopicList, + task_request_sender.clone(), + )); + let w1 = WidgetVariant::QuestionList(QuestionListWidget::new( + WidgetName::QuestionList, + task_request_sender.clone(), + vim_tx.clone(), + vim_running.clone(), + config.clone(), + )); + + let w2 = WidgetVariant::Stats(Stats::new(WidgetName::Stats, task_request_sender.clone())); + + let w3 = WidgetVariant::HelpLine(HelpBar::new( + WidgetName::HelpLine, + task_request_sender.clone(), + )); + + let order = [ + (WidgetName::TopicList, w0), + (WidgetName::QuestionList, w1), + (WidgetName::Stats, w2), + (WidgetName::HelpLine, w3), + ]; let mut app = Self { + config, running: true, - questions_list: None, - widgets: wid, - widget_switcher: 0, + widget_map: IndexMap::from(order), + selected_wid_idx: 0, task_request_sender, task_response_recv, - last_response: None, - show_popup: false, + pending_notifications: vec![].into(), + popup_stack: vec![], + vim_running, + vim_tx, }; - app.update_question_list(); + app.setup()?; Ok(app) } - pub fn next_widget(&mut self) { - let a = self.widget_switcher + 1; - let b = self.widgets.len() as i32; - self.widget_switcher = ((a % b) + b) % b; + pub fn total_widgets_count(&self) -> usize { + self.widget_map.len() + } + + pub fn navigate(&mut self, val: i32) -> AppResult> { + if self.get_current_popup().is_some() { + return Ok(None); + } + self.get_current_widget_mut().set_inactive(); + let a = self.selected_wid_idx + val; + let b = self.total_widgets_count() as i32; + self.selected_wid_idx = ((a % b) + b) % b; + let maybe_notif = self.get_current_widget_mut().set_active()?; + self.push_notif(maybe_notif); + if !self.get_current_widget().is_navigable() { + self.navigate(val)?; + } + Ok(None) } - pub fn prev_widget(&mut self) { - let a = self.widget_switcher - 1; - let b = self.widgets.len() as i32; - self.widget_switcher = ((a % b) + b) % b; + pub fn next_widget(&mut self) -> AppResult> { + self.navigate(1) } - pub fn get_current_widget(&self) -> &Widget { - &self.widgets[self.widget_switcher as usize] + pub fn prev_widget(&mut self) -> AppResult> { + self.navigate(-1) } - pub fn update_question_in_popup(&self) -> AppResult<()> { - if self.show_popup { - if let Widget::QuestionList(s) = self.get_current_widget() { - if let Some(selected_item) = s.get_selected_item() { - if let Some(slug) = &selected_item.title_slug { - self.task_request_sender.send( - super::channel::TaskRequest::QuestionDetail { slug: slug.clone() }, - )?; - } - } - } + pub(crate) fn get_current_widget(&self) -> &WidgetVariant { + let (_, v) = self + .widget_map + .get_index(self.selected_wid_idx as usize) + .unwrap(); + v + } + + pub(crate) fn get_current_widget_mut(&mut self) -> &mut WidgetVariant { + let (_, v) = self + .widget_map + .get_index_mut(self.selected_wid_idx as usize) + .unwrap(); + v + } + + pub(crate) fn get_current_popup(&self) -> Option<&Popup> { + self.popup_stack.last() + } + + pub(crate) fn get_current_popup_mut(&mut self) -> Option<&mut Popup> { + self.popup_stack.last_mut() + } + + pub fn setup(&mut self) -> AppResult<()> { + let maybe_notif = self.get_current_widget_mut().set_active()?; + self.push_notif(maybe_notif); + for (_, widget) in self.widget_map.iter_mut() { + widget.setup()?; } Ok(()) } - pub fn update_question_list(&mut self) { - let mut tt_model: Option = None; + pub fn push_notif(&mut self, value: Option) { + self.pending_notifications.push_back(value) + } - if let Widget::TopicTagList(ttl) = self.get_current_widget() { - if let Some(selected) = ttl.get_selected_item() { - tt_model = Some(selected.clone()) - } + /// Handles the tick event of the terminal. + pub fn tick(&mut self) -> AppResult<()> { + if let Some(popup) = self.get_current_popup_mut() { + if !popup.is_active() { + self.popup_stack.pop(); + let maybe_notif; + if let Some(popup) = self.get_current_popup_mut() { + maybe_notif = popup.set_active()?; + } else { + maybe_notif = self.get_current_widget_mut().set_active()?; + } + self.push_notif(maybe_notif); + }; } - for w in self.widgets.iter_mut() { - if let Widget::QuestionList(ql) = w { - if let Some(selected_tt_model) = &tt_model { - let mut items; - if let Some(tt_ql_map) = &mut self.questions_list { - if selected_tt_model.id.as_str() == "all" { - let set = tt_ql_map - .values() - .flat_map(|q| q.clone()) - .collect::>(); - items = set.into_iter().collect::>(); - } else { - items = tt_ql_map.get(selected_tt_model).unwrap().clone(); - } - items.sort(); - ql.items = items; - ql.state = ListState::default(); - } - } + for wid in self.widget_map.values_mut() { + while let Some(notif) = wid.get_notification_queue().pop_front() { + self.pending_notifications.push_back(Some(notif)); } } - } - - // pub fn find_widget(&mut self, wid_type: Widget) -> &mut Widget { - // for wid in self.widgets { - // if wid == Widget:: - // } - // } + self.check_for_task()?; + self.process_pending_notification()?; + Ok(()) + } - pub fn toggle_popup(&mut self) { - self.show_popup = !self.show_popup; + fn check_for_task(&mut self) -> AppResult<()> { + if let Ok(task_result) = self.task_response_recv.try_recv() { + self.widget_map + .get_mut(&task_result.get_widget_name()) + .unwrap() + .process_task_response(task_result)?; + } + Ok(()) } - /// Handles the tick event of the terminal. - pub fn tick(&mut self) { - if let Ok(response) = self.task_response_recv.try_recv() { - match response { - TaskResponse::GetAllQuestionsMap(map) => { - if let Some(ql) = &mut self.questions_list { - ql.extend(map) - } else { - self.questions_list = Some(map); - } - self.update_question_list() + pub fn process_pending_notification(&mut self) -> AppResult<()> { + while let Some(elem) = self.pending_notifications.pop_front() { + if let Some(notif) = elem { + let wid_name = notif.get_wid_name(); + if let WidgetName::Popup = wid_name { + let mut popup_instance = + Popup::new(wid_name.clone(), self.task_request_sender.clone()); + let maybe_notif = popup_instance.process_notification(notif)?; + self.push_notif(popup_instance.set_active()?); + self.pending_notifications.push_back(maybe_notif); + self.popup_stack.push(popup_instance); + } else { + let widget_var = self.widget_map.get_mut(wid_name).unwrap(); + let more_notif = widget_var.process_notification(notif)?; + self.pending_notifications.push_back(more_notif); } - TaskResponse::AllTopicTags(tts) => { - for w in self.widgets.iter_mut() { - if let Widget::TopicTagList(tt_list) = w { - tt_list.items.extend(tts); - break; - } - } - } - response => self.last_response = Some(response), } } + Ok(()) } /// Set running to false to quit the application. diff --git a/src/app_ui/async_task_channel.rs b/src/app_ui/async_task_channel.rs new file mode 100644 index 0000000..c6d0e24 --- /dev/null +++ b/src/app_ui/async_task_channel.rs @@ -0,0 +1,118 @@ +use std::collections::HashMap; + +use crate::app_ui::helpers::tasks::*; +use crate::graphql::RunOrSubmitCode; +use crate::{ + deserializers, + entities::{QuestionModel, TopicTagModel}, +}; + +#[derive(Debug)] +pub struct Request { + pub(crate) request_id: String, + pub(crate) content: T, + pub(crate) widget_name: WidgetName, +} +pub enum TaskRequest { + QuestionDetail(Request), + GetAllQuestionsMap(Request<()>), + GetAllTopicTags(Request<()>), + GetQuestionEditorData(Request), + CodeRunRequest(Request), + DbUpdateQuestion(Request), +} + +impl TaskRequest { + pub async fn execute( + self, + client: &reqwest::Client, + conn: &DatabaseConnection, + ) -> TaskResponse { + match self { + TaskRequest::QuestionDetail(Request { + content: slug, + widget_name, + request_id, + }) => get_question_details(request_id, widget_name, slug, client).await, + TaskRequest::GetAllQuestionsMap(Request { + widget_name, + request_id, + .. + }) => get_all_questions(request_id, widget_name, conn).await, + TaskRequest::GetAllTopicTags(Request { + widget_name, + request_id, + .. + }) => get_all_topic_tags(request_id, widget_name, conn).await, + TaskRequest::GetQuestionEditorData(Request { + request_id, + content, + widget_name, + }) => get_editor_data(request_id, widget_name, content, client).await, + TaskRequest::CodeRunRequest(Request { + request_id, + content, + widget_name, + }) => run_or_submit_question(request_id, widget_name, content, client).await, + TaskRequest::DbUpdateQuestion(Request { + request_id, + content, + widget_name, + }) => update_status_to_accepted(request_id, widget_name, content, conn).await, + } + } +} + +#[derive(Debug)] +pub struct Response { + pub(crate) request_id: String, + pub(crate) content: T, + pub(crate) widget_name: WidgetName, +} + +#[derive(Debug)] +pub enum TaskResponse { + QuestionDetail(Response), + GetAllQuestionsMap(Response>>), + AllTopicTags(Response>), + QuestionEditorData(Response), + RunResponseData(Response), + DbUpdateStatus(Response<()>), + Error(Response), +} + +impl TaskResponse { + pub fn get_widget_name(&self) -> WidgetName { + match self { + TaskResponse::QuestionDetail(Response { widget_name, .. }) => widget_name, + TaskResponse::GetAllQuestionsMap(Response { widget_name, .. }) => widget_name, + TaskResponse::AllTopicTags(Response { widget_name, .. }) => widget_name, + TaskResponse::Error(Response { widget_name, .. }) => widget_name, + TaskResponse::QuestionEditorData(Response { widget_name, .. }) => widget_name, + TaskResponse::RunResponseData(Response { widget_name, .. }) => widget_name, + TaskResponse::DbUpdateStatus(Response { widget_name, .. }) => widget_name, + } + .clone() + } +} + +pub type ChannelRequestSender = tokio::sync::mpsc::UnboundedSender; +pub type ChannelRequestReceiver = tokio::sync::mpsc::UnboundedReceiver; + +use sea_orm::DatabaseConnection; +pub use tokio::sync::mpsc::unbounded_channel as request_channel; + +pub type ChannelResponseSender = crossbeam::channel::Sender; +pub type ChannelResponseReceiver = crossbeam::channel::Receiver; + +pub use crossbeam::channel::unbounded as response_channel; + +use super::widgets::notification::WidgetName; + +pub type RequestSendError = tokio::sync::mpsc::error::SendError; +pub type RequestRecvError = tokio::sync::mpsc::error::TryRecvError; + +pub type ResponseSendError = crossbeam::channel::SendError; +pub type ResponseReceiveError = crossbeam::channel::RecvError; + +// pub type diff --git a/src/app_ui/channel.rs b/src/app_ui/channel.rs deleted file mode 100644 index d3dcd8b..0000000 --- a/src/app_ui/channel.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::collections::HashMap; - -use crate::app_ui::helpers::tasks::*; -use crate::{ - deserializers, - entities::{QuestionModel, TopicTagModel}, -}; - -#[derive(Debug)] -pub enum TaskRequest { - QuestionDetail { slug: String }, - GetAllQuestionsMap, - GetAllTopicTags, -} - -impl TaskRequest { - pub async fn execute( - self, - client: &reqwest::Client, - conn: &DatabaseConnection, - ) -> TaskResponse { - match self { - TaskRequest::QuestionDetail { slug } => get_question_details(slug, client).await, - TaskRequest::GetAllQuestionsMap => get_all_questions(conn).await, - TaskRequest::GetAllTopicTags => get_all_topic_tags(conn).await, - } - } -} - -#[derive(Debug)] -pub enum TaskResponse { - QuestionDetail(deserializers::question_content::QuestionContent), - GetAllQuestionsMap(HashMap>), - AllTopicTags(Vec), - Error(String), -} - -pub type ChannelRequestSender = tokio::sync::mpsc::UnboundedSender; -pub type ChannelRequestReceiver = tokio::sync::mpsc::UnboundedReceiver; - -use sea_orm::DatabaseConnection; -pub use tokio::sync::mpsc::unbounded_channel as request_channel; - -pub type ChannelResponseSender = crossbeam::channel::Sender; -pub type ChannelResponseReceiver = crossbeam::channel::Receiver; - -pub use crossbeam::channel::unbounded as response_channel; - -pub type RequestSendError = tokio::sync::mpsc::error::SendError; -pub type RequestRecvError = tokio::sync::mpsc::error::TryRecvError; - -pub type ResponseSendError = crossbeam::channel::SendError; -pub type ResponseReceiveError = crossbeam::channel::RecvError; - -// pub type diff --git a/src/app_ui/components/color.rs b/src/app_ui/components/color.rs new file mode 100644 index 0000000..12d307f --- /dev/null +++ b/src/app_ui/components/color.rs @@ -0,0 +1,80 @@ +use ratatui::prelude::*; +pub struct Colour { + r: u8, + g: u8, + b: u8, +} + +impl From for Style { + /// sets fg color and returns the style + fn from(val: Colour) -> Self { + let pair = val; + let Colour { r, g, b } = pair; + Style::default().fg(style::Color::Rgb(r, g, b)) + } +} + +pub struct Pair { + pub fg: Colour, + pub bg: Colour, +} + +pub const CHECK_MARK: &str = "✔️"; + +pub enum Callout { + Success, + Info, + Warning, + Error, + Disabled, +} + +impl Callout { + // Method to get the corresponding Pair for each ColorCombination variant + pub fn get_pair(&self) -> Pair { + match self { + Callout::Success => Pair { + fg: Colour { r: 0, g: 255, b: 0 }, // Green foreground + bg: Colour { r: 0, g: 0, b: 0 }, // Black background + }, + Callout::Info => Pair { + fg: Colour { + r: 0, + g: 255, + b: 255, + }, // Cyan foreground + bg: Colour { r: 0, g: 0, b: 0 }, // Black background + }, + Callout::Warning => Pair { + fg: Colour { + r: 255, + g: 255, + b: 0, + }, // Yellow foreground + bg: Colour { r: 0, g: 0, b: 0 }, // Black background + }, + Callout::Error => Pair { + fg: Colour { r: 255, g: 0, b: 0 }, // Red foreground + bg: Colour { r: 0, g: 0, b: 0 }, // Black background + }, + Callout::Disabled => Pair { + fg: Colour { + r: 128, + g: 128, + b: 128, + }, // Gray foreground (disabled) + bg: Colour { r: 0, g: 0, b: 0 }, // Black background + }, + } + } +} + +impl From for Style { + /// gets you the style object directly. sets bg and fg + fn from(val: Callout) -> Self { + let pair = val.get_pair(); + let style: Style = pair.fg.into(); + let Colour { r, g, b } = pair.bg; + style.bg(style::Color::Rgb(r, g, b)) + } +} diff --git a/src/app_ui/components/help_text.rs b/src/app_ui/components/help_text.rs new file mode 100644 index 0000000..e08288b --- /dev/null +++ b/src/app_ui/components/help_text.rs @@ -0,0 +1,126 @@ +use std::hash::Hash; + +use crossterm::event::{KeyCode, ModifierKeyCode}; + +#[derive(Debug, Clone)] +pub struct HelpText { + button: Vec, + title: String, +} + +impl Eq for HelpText {} + +impl PartialEq for HelpText { + fn eq(&self, other: &Self) -> bool { + self.title == other.title + } +} + +/// char('s') -> solve, char('s') -> show_solution is not possible. +/// Hence hashing only button values so that multiple actions cannot point to single key +impl Hash for HelpText { + fn hash(&self, state: &mut H) { + self.button.hash(state); + } +} + +impl HelpText { + pub fn get_keys(&self) -> std::slice::Iter { + self.button.iter() + } +} + +impl HelpText { + pub fn new(title: String, button: Vec) -> Self { + Self { button, title } + } + fn get_symbol_by_keycode(k: &KeyCode) -> String { + match k { + KeyCode::Backspace => "⌫ ".to_string(), + KeyCode::Enter => "⏎".to_string(), + KeyCode::Left => "←".to_string(), + KeyCode::Right => "→".to_string(), + KeyCode::Up => "↑".to_string(), + KeyCode::Down => "↓".to_string(), + KeyCode::Home => "⤒".to_string(), + KeyCode::End => "⤓".to_string(), + KeyCode::PageUp => "⇞".to_string(), + KeyCode::PageDown => "⇟".to_string(), + KeyCode::Tab => "⇥".to_string(), + KeyCode::BackTab => "⇤".to_string(), + KeyCode::Delete => "⌦ ".to_string(), + KeyCode::Insert => "⎀".to_string(), + KeyCode::F(n) => format!("F{}", n), + KeyCode::Char(c) => c.to_string(), + KeyCode::Null => "∅".to_string(), + KeyCode::Esc => "⎋".to_string(), + KeyCode::CapsLock => "⇪".to_string(), + KeyCode::ScrollLock => "⤓".to_string(), + KeyCode::NumLock => "⇭".to_string(), + KeyCode::PrintScreen => "⎙".to_string(), + KeyCode::Pause => "⏸".to_string(), + KeyCode::Menu => "☰".to_string(), + KeyCode::KeypadBegin => "⎆".to_string(), + KeyCode::Media(_) => "☊".to_string(), + KeyCode::Modifier(modifier) => match modifier { + ModifierKeyCode::LeftShift => "⇧".to_string(), + ModifierKeyCode::LeftControl => "⌃".to_string(), + ModifierKeyCode::LeftAlt => "⌥".to_string(), + ModifierKeyCode::LeftSuper => "⌘".to_string(), + ModifierKeyCode::LeftHyper => "⎇".to_string(), + ModifierKeyCode::LeftMeta => "⌘".to_string(), + ModifierKeyCode::RightShift => "⇧".to_string(), + ModifierKeyCode::RightControl => "⌃".to_string(), + ModifierKeyCode::RightAlt => "⌥".to_string(), + ModifierKeyCode::RightSuper => "⌘".to_string(), + ModifierKeyCode::RightHyper => "⎇".to_string(), + ModifierKeyCode::RightMeta => "⌘".to_string(), + ModifierKeyCode::IsoLevel3Shift => "ISO Level3 Shift".to_string(), + ModifierKeyCode::IsoLevel5Shift => "ISO Level5 Shift".to_string(), + }, + } + } +} + +impl From<&HelpText> for String { + fn from(value: &HelpText) -> Self { + let symbols = value + .button + .iter() + .map(HelpText::get_symbol_by_keycode) + .collect::(); + format!("{}[{}]", value.title, symbols,) + } +} + +pub(crate) enum CommonHelpText { + ScrollUp, + ScrollDown, + SwitchPane, + Edit, + ReadContent, + Submit, + Run, + Close, + Select, +} + +impl From for HelpText { + fn from(value: CommonHelpText) -> Self { + let (k, t) = match value { + CommonHelpText::ScrollUp => (vec![KeyCode::Up], "Up"), + CommonHelpText::ScrollDown => (vec![KeyCode::Down], "Down"), + CommonHelpText::SwitchPane => (vec![KeyCode::Left, KeyCode::Right], "Switch Pane"), + CommonHelpText::Edit => (vec![KeyCode::Char('E'), KeyCode::Char('e')], "Edit"), + CommonHelpText::ReadContent => (vec![KeyCode::Enter], "Read Content"), + CommonHelpText::Close => (vec![KeyCode::Esc], "Close"), + CommonHelpText::Select => (vec![KeyCode::Enter], "Select"), + CommonHelpText::Submit => (vec![KeyCode::Char('s')], "Submit"), + CommonHelpText::Run => (vec![KeyCode::Char('R'), KeyCode::Char('r')], "Run"), + }; + HelpText { + button: k, + title: t.to_string(), + } + } +} diff --git a/src/app_ui/list.rs b/src/app_ui/components/list.rs similarity index 73% rename from src/app_ui/list.rs rename to src/app_ui/components/list.rs index f27ef25..ca6acf6 100644 --- a/src/app_ui/list.rs +++ b/src/app_ui/components/list.rs @@ -1,17 +1,30 @@ use ratatui::widgets::ListState; +use std::rc::Rc; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct StatefulList { pub state: ListState, - pub items: Vec, + pub items: Vec>, +} + +impl Default for StatefulList { + fn default() -> Self { + Self { + state: ListState::default(), + items: vec![], + } + } } impl StatefulList { pub fn add_item(&mut self, item: T) { - self.items.push(item) + if self.items.is_empty() { + self.state.select(Some(0)) + } + self.items.push(Rc::new(item)) } - pub fn get_selected_item(&self) -> Option<&T> { + pub fn get_selected_item(&self) -> Option<&Rc> { match self.state.selected() { Some(i) => Some(&self.items[i]), None => None, @@ -25,7 +38,7 @@ impl StatefulList { } StatefulList { state: list_state, - items, + items: items.into_iter().map(|item| Rc::new(item)).collect(), } } diff --git a/src/app_ui/components/mod.rs b/src/app_ui/components/mod.rs new file mode 100644 index 0000000..b578d70 --- /dev/null +++ b/src/app_ui/components/mod.rs @@ -0,0 +1,5 @@ +pub mod color; +pub mod help_text; +pub mod list; +pub mod popups; +pub mod rect; diff --git a/src/app_ui/components/popups/mod.rs b/src/app_ui/components/popups/mod.rs new file mode 100644 index 0000000..958cc51 --- /dev/null +++ b/src/app_ui/components/popups/mod.rs @@ -0,0 +1,42 @@ +pub mod paragraph; +pub mod selection_list; + +use std::io::Stderr; + +use crossterm::event::KeyEvent; +use indexmap::IndexSet; +use ratatui::prelude::*; + +use super::help_text::HelpText; + +pub type CrossTermStderr = CrosstermBackend; +pub type TermBackend = Terminal; +pub type FrameBackend<'a> = Frame<'a, CrossTermStderr>; + +pub(crate) trait Component { + fn event_handler(&mut self, event: KeyEvent) -> Option; + fn render(&mut self, f: &mut Frame>, render_area: Rect); + fn get_common_state_mut(&mut self) -> &mut CommonState; + fn get_common_state(&self) -> &CommonState; + fn get_key_set(&self) -> IndexSet { + self.get_common_state().help_text.clone() + } + fn set_show(&mut self) { + self.get_common_state_mut().show = true + } + + fn hide(&mut self) { + self.get_common_state_mut().show = false + } + + fn is_showing(&self) -> bool { + self.get_common_state().show + } +} + +#[derive(Clone, Debug)] +pub(crate) struct CommonState { + help_text: IndexSet, + title: String, + show: bool, +} diff --git a/src/app_ui/components/popups/paragraph.rs b/src/app_ui/components/popups/paragraph.rs new file mode 100644 index 0000000..4f2b3d4 --- /dev/null +++ b/src/app_ui/components/popups/paragraph.rs @@ -0,0 +1,79 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use indexmap::IndexSet; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, +}; + +use crate::app_ui::components::help_text::CommonHelpText; + +use super::{CommonState, Component, FrameBackend}; + +#[derive(Debug, Clone)] +pub(crate) struct ParagraphPopup { + common_state: CommonState, + content: String, + pub scroll_x: u16, + pub scroll_y: u16, +} + +impl ParagraphPopup { + pub fn new(title: String, content: String) -> Self { + Self { + common_state: CommonState { + help_text: IndexSet::from_iter([ + CommonHelpText::Close.into(), + CommonHelpText::ScrollUp.into(), + CommonHelpText::ScrollDown.into(), + ]), + title, + show: true, + }, + content, + scroll_x: 0, + scroll_y: 0, + } + } +} + +impl Component for ParagraphPopup { + fn event_handler(&mut self, event: KeyEvent) -> Option { + match event.code { + KeyCode::Up => self.scroll_y = self.scroll_y.saturating_sub(1), + KeyCode::Down => self.scroll_y += 1, + KeyCode::Esc => self.hide(), + KeyCode::Enter => { + self.hide(); + return Some(event); + } + _ => return Some(event), + } + None + } + + fn render(&mut self, f: &mut FrameBackend, render_area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .style(Style::default().fg(Color::Gray)) + .title(Span::styled( + self.common_state.title.clone(), + Style::default().add_modifier(Modifier::BOLD), + )); + + let content = Paragraph::new(self.content.as_str()) + .wrap(Wrap { trim: true }) + .scroll((self.scroll_y, self.scroll_x)) + .block(block); + + f.render_widget(Clear, render_area); + f.render_widget(content, render_area); // frame.render_widget(block, area); + } + + fn get_common_state_mut(&mut self) -> &mut CommonState { + &mut self.common_state + } + + fn get_common_state(&self) -> &CommonState { + &self.common_state + } +} diff --git a/src/app_ui/components/popups/selection_list.rs b/src/app_ui/components/popups/selection_list.rs new file mode 100644 index 0000000..b4bb89d --- /dev/null +++ b/src/app_ui/components/popups/selection_list.rs @@ -0,0 +1,92 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use indexmap::IndexSet; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Clear, List, ListItem}, +}; + +use crate::app_ui::components::{help_text::CommonHelpText, list::StatefulList}; + +use super::{CommonState, Component, FrameBackend}; + +#[derive(Debug, Clone)] +pub(crate) struct SelectionListPopup { + common_state: CommonState, + list: StatefulList, +} + +impl SelectionListPopup { + pub fn new(title: String, list_items: Vec) -> Self { + Self { + common_state: CommonState { + help_text: IndexSet::from_iter([ + CommonHelpText::Close.into(), + CommonHelpText::ScrollUp.into(), + CommonHelpText::ScrollDown.into(), + CommonHelpText::Select.into(), + ]), + title, + show: true, + }, + list: StatefulList::with_items(list_items), + } + } + + pub fn get_selected_index(&self) -> usize { + self.list.state.selected().unwrap() + } +} + +impl Component for SelectionListPopup { + // selection list specific events + fn event_handler(&mut self, event: KeyEvent) -> Option { + match event.code { + KeyCode::Esc => self.hide(), + KeyCode::Up => self.list.previous(), + KeyCode::Down => self.list.next(), + // only escape key get passed to the parent event + KeyCode::Enter => { + self.hide(); + return Some(event); + } + _ => return Some(event), + } + None + } + + fn render(&mut self, f: &mut FrameBackend, render_area: Rect) { + let lines = self + .list + .items + .iter() + .map(|item| { + let line_text = item.as_ref(); + ListItem::new(Span::styled(line_text, Style::default())) + }) + .collect::>(); + + let border_style = Style::default().fg(Color::Cyan); + let items = List::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(self.common_state.title.as_ref()) + .border_style(border_style), + ) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 0)) + .add_modifier(Modifier::BOLD), + ); + f.render_widget(Clear, render_area); + f.render_stateful_widget(items, render_area, &mut self.list.state); + } + + fn get_common_state_mut(&mut self) -> &mut CommonState { + &mut self.common_state + } + + fn get_common_state(&self) -> &CommonState { + &self.common_state + } +} diff --git a/src/app_ui/components/rect.rs b/src/app_ui/components/rect.rs new file mode 100644 index 0000000..4167303 --- /dev/null +++ b/src/app_ui/components/rect.rs @@ -0,0 +1,27 @@ +use ratatui::prelude::*; + +pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ] + .as_ref(), + ) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ] + .as_ref(), + ) + .split(popup_layout[1])[1] +} diff --git a/src/app_ui/event.rs b/src/app_ui/event.rs index 76a02d6..9d9416f 100644 --- a/src/app_ui/event.rs +++ b/src/app_ui/event.rs @@ -1,5 +1,8 @@ use crossterm::event::{Event as CrosstermEvent, KeyEvent, MouseEvent}; -use std::time::Duration; +use std::{ + sync::{atomic::AtomicBool, Arc}, + time::Duration, +}; use futures::{future::FutureExt, StreamExt}; use futures_timer::Delay; @@ -19,6 +22,9 @@ pub enum Event { Mouse(MouseEvent), /// Terminal resize. Resize(u16, u16), + + /// redraws the terminal + Redraw, } /// Terminal event handler. @@ -31,10 +37,16 @@ pub struct EventHandler { pub receiver: std::sync::mpsc::Receiver, } +pub use tokio::sync::mpsc::channel as vim_ping_channel; +pub type VimPingSender = tokio::sync::mpsc::Sender; +pub type VimPingReceiver = tokio::sync::mpsc::Receiver; + // should be in the main thread to funtion pub async fn look_for_events( tick_rate: u64, sender: std::sync::mpsc::Sender, + vim_running_loop_ref: Arc, + mut vim_rx: VimPingReceiver, ) -> AppResult<()> { let tick_rate = Duration::from_millis(tick_rate); @@ -52,7 +64,14 @@ pub async fn look_for_events( match maybe_event { Some(event) => { match event? { - CrosstermEvent::Key(e) => sender.send(Event::Key(e))?, + CrosstermEvent::Key(e) => { + if vim_running_loop_ref.load(std::sync::atomic::Ordering::Relaxed) { + vim_rx.recv().await.unwrap(); + sender.send(Event::Redraw)? + } else { + sender.send(Event::Key(e))? + } + }, CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e))?, CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h))?, _ => unimplemented!() diff --git a/src/app_ui/handler.rs b/src/app_ui/handler.rs index 1a659fe..cf12bb3 100644 --- a/src/app_ui/handler.rs +++ b/src/app_ui/handler.rs @@ -1,60 +1,29 @@ -use super::app::{App, Widget}; +use super::{ + app::App, + widgets::{notification::Notification, Widget}, +}; use crate::errors::AppResult; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; /// Handles the key events and updates the state of [`App`]. -pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> { - let curr_widget = &mut app.widgets[app.widget_switcher as usize]; - match key_event.code { - // Exit application on `ESC` or `q` - KeyCode::Char('q') => { - app.quit(); - } - KeyCode::Esc => { - if app.show_popup { - app.toggle_popup(); - } - } +pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult> { + // if ui has active popups then send only events registered with popup + if let Some(popup) = app.get_current_popup_mut() { + return popup.handler(key_event); + } - KeyCode::Enter => { - if let Widget::QuestionList(_) = app.get_current_widget() { - app.toggle_popup(); - app.update_question_in_popup()?; - } - } - // Exit application on `Ctrl-C` + match key_event.code { + KeyCode::Left => return app.next_widget(), + KeyCode::Right => return app.prev_widget(), + KeyCode::Char('q') | KeyCode::Char('Q') => app.running = false, KeyCode::Char('c') | KeyCode::Char('C') => { if key_event.modifiers == KeyModifiers::CONTROL { - app.quit(); + app.running = false; } } + _ => return app.get_current_widget_mut().handler(key_event), + }; - KeyCode::Char('p') | KeyCode::Char('P') => { - app.toggle_popup(); - } - // Counter handlers - KeyCode::Up => match curr_widget { - super::app::Widget::QuestionList(ql) => { - ql.previous(); - app.update_question_in_popup()?; - } - super::app::Widget::TopicTagList(tt) => tt.previous(), - }, - KeyCode::Down => match curr_widget { - super::app::Widget::QuestionList(ql) => { - ql.next(); - app.update_question_in_popup()?; - } - super::app::Widget::TopicTagList(tt) => tt.next(), - }, - KeyCode::Left => app.prev_widget(), - KeyCode::Right => app.next_widget(), - // Other handlers you could add here. - _ => {} - } - app.update_question_list(); - - // post key event update the question list - Ok(()) + Ok(None) } diff --git a/src/app_ui/helpers/mod.rs b/src/app_ui/helpers/mod.rs index 969d6fb..3855507 100644 --- a/src/app_ui/helpers/mod.rs +++ b/src/app_ui/helpers/mod.rs @@ -1,2 +1,3 @@ pub mod question; pub mod tasks; +pub mod utils; diff --git a/src/app_ui/helpers/question.rs b/src/app_ui/helpers/question.rs index ed3a4e0..35118ba 100644 --- a/src/app_ui/helpers/question.rs +++ b/src/app_ui/helpers/question.rs @@ -1,7 +1,24 @@ +use std::{cell::RefCell, rc::Rc}; + use crate::entities::QuestionModel; +use std::hash::Hash; + +#[derive(PartialEq, Eq, Debug, Ord, PartialOrd)] +pub struct QuestionModelContainer { + pub question: RefCell, +} + +// RefCell keys are mutable and should not be used in types where hashing +// is required. This implementation is valid until question_frontend_id change. +// For more refer https://rust-lang.github.io/rust-clippy/master/index.html#/mutable_key_type +impl Hash for QuestionModelContainer { + fn hash(&self, state: &mut H) { + self.question.borrow().hash(state) + } +} pub struct Stats<'a> { - pub qm: &'a Vec, + pub qm: &'a Vec>, } impl<'a> Stats<'a> { @@ -49,8 +66,8 @@ impl<'a> Stats<'a> { self.qm .iter() .filter(|q| { - if let Some(st) = &q.status { - if let Some(at) = &q.difficulty { + if let Some(st) = &q.question.borrow().status { + if let Some(at) = &q.question.borrow().difficulty { st.as_str() == status && difficulty == at.as_str() } else { false @@ -66,7 +83,7 @@ impl<'a> Stats<'a> { self.qm .iter() .filter(|q| { - if let Some(st) = &q.status { + if let Some(st) = &q.question.borrow().status { st.as_str() == status } else { false @@ -79,7 +96,7 @@ impl<'a> Stats<'a> { self.qm .iter() .filter(|q| { - if let Some(diff) = &q.difficulty { + if let Some(diff) = &q.question.borrow().difficulty { diff.as_str() == difficulty } else { false diff --git a/src/app_ui/helpers/tasks.rs b/src/app_ui/helpers/tasks.rs index 7f1dc83..f5acaf3 100644 --- a/src/app_ui/helpers/tasks.rs +++ b/src/app_ui/helpers/tasks.rs @@ -1,30 +1,155 @@ -use sea_orm::DatabaseConnection; +use sea_orm::{prelude::*, DatabaseConnection, IntoActiveModel, Set}; -use crate::app_ui::channel::TaskResponse; -use crate::entities::TopicTagEntity; +use crate::app_ui::async_task_channel::{Response, TaskResponse}; +use crate::app_ui::widgets::notification::WidgetName; +use crate::entities::{QuestionModel, TopicTagEntity}; +use crate::graphql::editor_data::Query as QuestionEditorDataQuery; use crate::graphql::question_content::Query as QuestionGQLQuery; -use crate::graphql::GQLLeetcodeQuery; +use crate::graphql::run_code::RunCode; +use crate::graphql::{self, GQLLeetcodeQuery, RunOrSubmitCode}; -pub async fn get_question_details(slug: String, client: &reqwest::Client) -> TaskResponse { +pub async fn get_question_details( + request_id: String, + widget_name: WidgetName, + slug: String, + client: &reqwest::Client, +) -> TaskResponse { match QuestionGQLQuery::new(slug).post(client).await { Ok(resp) => { let query_response = resp; - TaskResponse::QuestionDetail(query_response.data.question) + TaskResponse::QuestionDetail(Response { + request_id, + content: query_response.data.question, + widget_name, + }) } - Err(e) => TaskResponse::Error(e.to_string()), + Err(e) => TaskResponse::Error(Response { + request_id, + content: e.to_string(), + widget_name, + }), } } -pub async fn get_all_questions(conn: &DatabaseConnection) -> TaskResponse { +pub async fn get_editor_data( + request_id: String, + widget_name: WidgetName, + slug: String, + client: &reqwest::Client, +) -> TaskResponse { + match QuestionEditorDataQuery::new(slug).post(client).await { + Ok(data) => TaskResponse::QuestionEditorData(Response { + request_id, + content: data.data.question, + widget_name, + }), + Err(e) => TaskResponse::Error(Response { + request_id, + content: e.to_string(), + widget_name, + }), + } +} + +pub async fn get_all_questions( + request_id: String, + widget_name: WidgetName, + conn: &DatabaseConnection, +) -> TaskResponse { match TopicTagEntity::get_all_topic_questions_map(conn).await { - Ok(map) => TaskResponse::GetAllQuestionsMap(map), - Err(e) => TaskResponse::Error(e.to_string()), + Ok(map) => TaskResponse::GetAllQuestionsMap(Response { + request_id, + content: map, + widget_name, + }), + Err(e) => TaskResponse::Error(Response { + request_id, + content: e.to_string(), + widget_name, + }), } } -pub async fn get_all_topic_tags(conn: &DatabaseConnection) -> TaskResponse { +pub async fn get_all_topic_tags( + request_id: String, + widget_name: WidgetName, + conn: &DatabaseConnection, +) -> TaskResponse { match TopicTagEntity::get_all_topics(conn).await { - Ok(t_tags) => TaskResponse::AllTopicTags(t_tags), - Err(e) => TaskResponse::Error(e.to_string()), + Ok(t_tags) => TaskResponse::AllTopicTags(Response { + request_id, + content: t_tags, + widget_name, + }), + Err(e) => TaskResponse::Error(Response { + request_id, + content: e.to_string(), + widget_name, + }), + } +} + +pub async fn run_or_submit_question( + request_id: String, + widget_name: WidgetName, + mut run_or_submit_code: graphql::RunOrSubmitCode, + client: &reqwest::Client, +) -> TaskResponse { + if let RunOrSubmitCode::Run(RunCode { + test_cases_stdin, + slug, + .. + }) = &mut run_or_submit_code + { + match graphql::console_panel_config::Query::new(slug.clone()) + .post(client) + .await + { + Ok(resp) => { + *test_cases_stdin = Some(resp.data.question.example_testcase_list.join("\n")); + } + Err(e) => { + return TaskResponse::Error(Response { + request_id, + content: e.to_string(), + widget_name, + }) + } + } + } + + match run_or_submit_code.post(client).await { + Ok(run_response_body) => TaskResponse::RunResponseData(Response { + request_id, + content: run_response_body, + widget_name, + }), + Err(e) => TaskResponse::Error(Response { + request_id, + content: e.to_string(), + widget_name, + }), + } +} + +pub async fn update_status_to_accepted( + request_id: String, + widget_name: WidgetName, + question: QuestionModel, + db: &DatabaseConnection, +) -> TaskResponse { + let mut am = question.into_active_model(); + am.status = Set(Some("ac".to_string())); + match am.update(db).await { + Ok(_) => TaskResponse::DbUpdateStatus(Response { + request_id, + content: (), + widget_name, + }), + Err(e) => TaskResponse::Error(Response { + request_id, + content: e.to_string(), + widget_name, + }), } } diff --git a/src/app_ui/helpers/utils.rs b/src/app_ui/helpers/utils.rs new file mode 100644 index 0000000..54acd1c --- /dev/null +++ b/src/app_ui/helpers/utils.rs @@ -0,0 +1,168 @@ +use std::hash::Hash; +use std::path::{Path, PathBuf}; + +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; + +use std::fs::{read_to_string, File}; +use std::io::Write; + +use crate::errors::AppResult; +use crate::graphql::Language; + +pub(crate) fn generate_random_string(length: usize) -> String { + let rng = thread_rng(); + let random_string: String = rng + .sample_iter(&Alphanumeric) + .take(length) + .map(char::from) + .collect(); + + random_string +} + +fn write_to_file(filename: &PathBuf, content: &str) -> Result<(), std::io::Error> { + let mut file = File::create(filename)?; + file.write_all(content.as_bytes())?; + Ok(()) +} + +#[derive(Debug)] +pub(crate) struct SolutionFile { + slug: String, + pub lang: Language, + description: Option, + editor_data: Option, + pub question_id: String, +} + +impl Eq for SolutionFile {} + +impl PartialEq for SolutionFile { + fn eq(&self, other: &Self) -> bool { + self.slug == other.slug && self.lang == other.lang + } +} + +impl Hash for SolutionFile { + fn hash(&self, state: &mut H) { + self.slug.hash(state); + self.lang.hash(state); + } +} + +impl SolutionFile { + pub(crate) fn new( + slug: String, + lang: Language, + description: Option, + editor_data: Option, + question_id: String, + ) -> Self { + Self { + slug, + lang, + description, + editor_data, + question_id, + } + } + + pub fn from_file(value: PathBuf) -> Option { + if value.is_file() { + let file_name = value + .file_name() + .expect("cannot get file name") + .to_str() + .expect("cannot convert filename to string"); + if file_name.starts_with("s_") { + let mut splitted = file_name.split('_'); + splitted.next(); + let id = splitted.next().unwrap(); + let slug = splitted.next().unwrap(); + let lang_id = splitted.next().unwrap().split('.').next().unwrap(); + let lang = Language::from_id(lang_id.parse().unwrap()); + return Some(Self { + slug: slug.to_string(), + lang, + description: None, + editor_data: None, + question_id: id.to_string(), + }); + } + } + None + } + + pub(crate) fn create_if_not_exists(&self, path: &Path) -> AppResult<()> { + let save_path = &self.get_save_path(path); + if !save_path.exists() { + if let Some(contents) = self.get_file_contents() { + write_to_file(save_path, contents.as_str())?; + } + } + Ok(()) + } + + pub(crate) fn get_save_path(&self, directory: &Path) -> PathBuf { + directory.join(self.get_file_name()) + } + + fn get_file_name(&self) -> String { + format!( + "s_{0:0>3}_{1}_{2}.{3}", + self.question_id, + self.slug, + self.lang.to_id(), + self.lang.get_extension() + ) + } + + pub fn read_file_contents(&self, directory: &Path) -> String { + let file_path = self.get_save_path(directory); + read_to_string(file_path).unwrap() + } + + fn get_file_contents(&self) -> Option { + if let Some(description) = self.get_commented_description() { + if let Some(editor_data) = &self.editor_data { + return Some(format!("{}\n\n{}", description, editor_data)); + } + } + None + } + + fn get_commented_description(&self) -> Option { + if let Some(d) = &self.description { + return Some(self.lang.comment_text(d.as_str())); + } + None + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_solution() { + let sf = SolutionFile { + slug: "two-sum".to_string(), + lang: Language::Python3, + description: Some("Helloworld".to_string()), + editor_data: Some("def hello(): print('hello')".to_string()), + question_id: "1".to_string(), + }; + + assert_eq!("s_001_two-sum_11.py".to_string(), sf.get_file_name()); + assert_eq!( + Some("'''\nHelloworld\n'''".to_string()), + sf.get_commented_description() + ); + assert_eq!( + Some("'''\nHelloworld\n'''\n\ndef hello(): print('hello')".to_string()), + sf.get_file_contents() + ); + } +} diff --git a/src/app_ui/mod.rs b/src/app_ui/mod.rs index f3f8975..debe381 100644 --- a/src/app_ui/mod.rs +++ b/src/app_ui/mod.rs @@ -13,7 +13,7 @@ pub mod tui; /// Event handler. pub mod handler; -pub mod list; - -pub mod channel; +pub mod async_task_channel; +pub mod components; pub mod helpers; +pub mod widgets; diff --git a/src/app_ui/tui.rs b/src/app_ui/tui.rs index e4607aa..e61776e 100644 --- a/src/app_ui/tui.rs +++ b/src/app_ui/tui.rs @@ -4,25 +4,25 @@ use super::ui; use crate::errors::{AppResult, LcAppError}; use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; -use ratatui::backend::Backend; +use ratatui::prelude::CrosstermBackend; use ratatui::Terminal; -use std::io; +use std::io::{self, Stderr}; /// Representation of a terminal user interface. /// /// It is responsible for setting up the terminal, /// initializing the interface and handling the draw events. -#[derive(Debug)] -pub struct Tui { + +pub struct Tui { /// Interface to the Terminal. - terminal: Terminal, + terminal: Terminal>, /// Terminal event handler. pub events: EventHandler, } -impl Tui { +impl Tui { /// Constructs a new instance of [`Tui`]. - pub fn new(terminal: Terminal, events: EventHandler) -> Self { + pub fn new(terminal: Terminal>, events: EventHandler) -> Self { Self { terminal, events } } @@ -58,10 +58,16 @@ impl Tui { Ok(()) } + pub fn reinit(&mut self) -> AppResult<()> { + self.exit()?; + self.init() + } + /// Exits the terminal interface. /// /// It disables the raw mode and reverts back the terminal properties. - pub fn exit(mut self) -> AppResult<()> { + pub fn exit(&mut self) -> AppResult<()> { + self.terminal.resize(self.terminal.size()?)?; terminal::disable_raw_mode().map_err(|e| { LcAppError::CrossTermError(format!("Error while disabling raw mode. {e}")) })?; @@ -71,7 +77,6 @@ impl Tui { self.terminal .show_cursor() .map_err(|e| LcAppError::CrossTermError(format!("Error while show cursor. {e}")))?; - drop(self.events.receiver); Ok(()) } } diff --git a/src/app_ui/ui.rs b/src/app_ui/ui.rs index b892821..ba250d9 100644 --- a/src/app_ui/ui.rs +++ b/src/app_ui/ui.rs @@ -1,31 +1,34 @@ +use std::collections::HashMap; + use ratatui::{ - backend::Backend, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style, Stylize}, - text::{Line, Span}, - widgets::{Block, BorderType, Borders, Clear, Gauge, List, ListItem, Paragraph, Wrap}, - Frame, + layout::{Alignment, Constraint, Direction, Layout}, + widgets::{Block, BorderType, Borders}, }; -use super::{app::App, helpers::question::Stats}; +use super::{ + app::App, + widgets::{notification::WidgetName, CrosstermStderr, Widget}, +}; /// Renders the user interface widgets. -pub fn render<'a, B: Backend>(app: &'a mut App, f: &mut Frame<'_, B>) { +pub fn render(app: &mut App, f: &mut CrosstermStderr) { // Create two chunks with equal horizontal screen space let size = f.size(); - let block = Block::default() + let terminal_main_block = Block::default() .borders(Borders::ALL) - .title("Main block with round corners") + .title("Leetcode TUI") .title_alignment(Alignment::Center) .border_type(BorderType::Rounded); - f.render_widget(block, size); + let inner_size = terminal_main_block.inner(f.size()); + + f.render_widget(terminal_main_block, size); let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) - .split(f.size()); + .split(inner_size); let left_chunks = Layout::default() .direction(Direction::Vertical) @@ -37,270 +40,19 @@ pub fn render<'a, B: Backend>(app: &'a mut App, f: &mut Frame<'_, B>) { .constraints([Constraint::Percentage(100)]) .split(chunks[1]); - // Iterate through all elements in the `items` app and append some debug text to it. - for (i, w) in app.widgets.iter_mut().enumerate() { - let is_widget_active = app.widget_switcher as usize == i; - let mut border_style = Style::default(); - if is_widget_active { - border_style = border_style.fg(Color::Cyan); - } - match w { - super::app::Widget::TopicTagList(ttl) => { - let items: Vec = ttl - .items - .iter() - .map(|tt_model| { - if let Some(name) = &tt_model.name { - let lines = vec![Line::from(name.as_str())]; - ListItem::new(lines) - } else { - ListItem::new(vec![Line::from("")]) - } - }) - .collect(); - - // Create a List from all list items and highlight the currently selected one - let items = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title("Tags") - .border_style(border_style), - ) - .highlight_style( - Style::default() - .bg(Color::White) - .fg(Color::Black) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); - - // We can now render the item list - f.render_stateful_widget(items, left_chunks[0], &mut ttl.state); - } - super::app::Widget::QuestionList(ql) => { - let questions: Vec = ql - .items - .iter() - .map(|question| { - let mut lines = vec![]; - if let Some(title) = &question.title { - lines.push(Line::from(format!( - "{:0>4}: {}", - question.frontend_question_id, title, - ))); - } - ListItem::new(lines) - }) - .collect(); - - let items = List::new(questions) - .block( - Block::default() - .borders(Borders::ALL) - .title("Questions") - .border_style(border_style), - ) - .highlight_style( - Style::default() - .bg(Color::White) - .fg(Color::Black) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); - f.render_stateful_widget(items, right_chunk[0], &mut ql.state); - - let create_block = |title| { - Block::default() - .borders(Borders::ALL) - .style(Style::default().fg(Color::Gray)) - .title(Span::styled( - title, - Style::default().add_modifier(Modifier::BOLD), - )) - }; - - let block = create_block("Stats"); - let inner_area = block.inner(left_chunks[1]); - - f.render_widget(block, left_chunks[1]); - - let stats = Stats { qm: &ql.items }; - - let guage = |title: &'a str, val: usize, total: usize| { - let block_title = format!("{}: {}/{}", title, val, total); - let percentage = if total != 0 { - (val as f32 / total as f32) * 100_f32 - } else { - 0 as f32 - }; - let label = Span::styled( - format!("{:.2}%", percentage), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::ITALIC | Modifier::BOLD), - ); - - Gauge::default() - .block(Block::default().title(block_title).borders(Borders::ALL)) - .gauge_style(Style::default().fg(Color::Green).bg(Color::Black)) - .percent(percentage as u16) - .label(label) - }; - - let horizontal_partition = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(inner_area); - - let left_partition = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(horizontal_partition[0]); - - let right_partition = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(33), - Constraint::Percentage(33), - Constraint::Percentage(33), - ]) - .split(horizontal_partition[1]); - - f.render_widget( - guage( - "Attempted", - stats.get_total_question() - stats.get_not_attempted(), - stats.get_total_question(), - ), - left_partition[0], - ); - f.render_widget( - guage("Accepted", stats.get_accepted(), stats.get_total_question()), - left_partition[1], - ); - - f.render_widget( - guage("Easy", stats.get_easy_accepted(), stats.get_easy_count()), - right_partition[0], - ); + let layout_map = HashMap::from([ + (WidgetName::TopicList, left_chunks[0]), // tags + (WidgetName::Stats, left_chunks[1]), // stats + (WidgetName::QuestionList, right_chunk[0]), // question + (WidgetName::HelpLine, size), + ]); - f.render_widget( - guage( - "Medium", - stats.get_medium_accepted(), - stats.get_medium_count(), - ), - right_partition[1], - ); - - f.render_widget( - guage("Hard", stats.get_hard_accepted(), stats.get_hard_count()), - right_partition[2], - ); - } - } + for (name, wid) in app.widget_map.iter_mut() { + let rect = layout_map.get(name).unwrap(); + wid.render(*rect, f); } - if app.show_popup { - let mut popup_title = "".to_string(); - let mut popup_content = "".to_string(); - - if let Some(response) = &app.last_response { - match response { - super::channel::TaskResponse::QuestionDetail(qd) => { - if let super::app::Widget::QuestionList(ql) = app.get_current_widget() { - popup_title = ql - .get_selected_item() - .as_ref() - .unwrap() - .title - .as_ref() - .unwrap() - .as_str() - .to_owned(); - popup_content = qd.html_to_text(); - }; - } - super::channel::TaskResponse::Error(e) => { - popup_title = "Error".to_string(); - popup_content = e.to_owned(); - } - _ => {} - } - } - handle_popup(app, f, popup_content.as_str(), popup_title.as_str()) + if let Some(popup) = app.get_current_popup_mut() { + popup.render(size, f) } } - -pub fn handle_popup( - app: &mut App, - f: &mut Frame<'_, B>, - popup_msg: &str, - question_title: &str, -) { - let size = f.size(); - - let text = if app.show_popup { - "Press esc to close the question info" - } else { - "Press ↵ to show the question info" - }; - - // top message press p to close - let paragraph = Paragraph::new(text.slow_blink()) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - f.render_widget(paragraph, size); - - if app.show_popup { - let create_block = |title| { - Block::default() - .borders(Borders::ALL) - .style(Style::default().fg(Color::Gray)) - .title(Span::styled( - title, - Style::default().add_modifier(Modifier::BOLD), - )) - }; - - let block = create_block(question_title); - let area = centered_rect(60, 100, size); - let inner = block.inner(area); - f.render_widget(Clear, area); //this clears out the background - // f.render_widget(block.clone(), area); - - let content = Paragraph::new(popup_msg) - .wrap(Wrap { trim: true }) - .block(block); - - f.render_widget(content, inner); - } -} - -/// helper function to create a centered rect using up certain percentage of the available rect `r` -fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ] - .as_ref(), - ) - .split(r); - - Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ] - .as_ref(), - ) - .split(popup_layout[1])[1] -} diff --git a/src/app_ui/widgets/help_bar.rs b/src/app_ui/widgets/help_bar.rs new file mode 100644 index 0000000..2471f4b --- /dev/null +++ b/src/app_ui/widgets/help_bar.rs @@ -0,0 +1,106 @@ +use std::time::{Duration, Instant}; + +use crate::app_ui::async_task_channel::ChannelRequestSender; + +use crate::errors::AppResult; + +use ratatui::widgets::block::Position; +use ratatui::{prelude::*, widgets::Block}; + +use super::notification::{NotifContent, Notification, WidgetName}; +use super::{CommonState, CrosstermStderr}; + +// Loading animation characters +const LOADING_CHARS: [char; 8] = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']; + +#[derive(Debug)] +pub struct HelpBar { + pub common_state: CommonState, + loading_state: usize, + show_loading: bool, + instant: Instant, +} + +impl HelpBar { + pub fn new(widget_name: WidgetName, task_sender: ChannelRequestSender) -> Self { + let mut cs = CommonState::new(widget_name, task_sender, vec![]); + cs.is_navigable = false; + Self { + common_state: cs, + loading_state: 0, + show_loading: false, + instant: Instant::now(), + } + } +} + +impl super::Widget for HelpBar { + fn render(&mut self, rect: Rect, frame: &mut CrosstermStderr) { + let mut spans = vec![]; + for (i, ht) in self.get_help_texts().iter().enumerate() { + let help_string: String = ht.into(); + spans.push(Span::from(help_string).white().on_cyan()); + if i < self.get_help_texts().len() - 1 { + spans.push(Span::from(" ")) + } + } + + if self.show_loading { + let elapsed = std::time::Instant::now() - self.instant; + + if elapsed > Duration::from_millis(80) { + self.loading_state = (self.loading_state + 1) % LOADING_CHARS.len(); + self.instant = std::time::Instant::now(); + } + + frame.render_widget( + Block::default() + .title(vec![Span::from( + LOADING_CHARS[self.loading_state].to_string(), + )]) + .title_position(Position::Top) + .title_alignment(Alignment::Right), + rect, + ); + } + + if !spans.is_empty() { + let b = Block::default() + .title(spans) + .title_position(Position::Bottom) + .title_alignment(Alignment::Right); + + frame.render_widget(b, rect); + } + } + + fn process_notification( + &mut self, + notification: Notification, + ) -> AppResult> { + match notification { + Notification::HelpText(NotifContent { + src_wid: _, + dest_wid: _, + content, + }) => { + *self.get_help_texts_mut() = content; + } + Notification::Loading(NotifContent { content, .. }) => self.show_loading = content, + _ => (), + } + Ok(None) + } + + fn get_common_state(&self) -> &CommonState { + &self.common_state + } + + fn get_common_state_mut(&mut self) -> &mut CommonState { + &mut self.common_state + } + + fn get_notification_queue(&mut self) -> &mut std::collections::VecDeque { + &mut self.common_state.notification_queue + } +} diff --git a/src/app_ui/widgets/mod.rs b/src/app_ui/widgets/mod.rs new file mode 100644 index 0000000..82f81ad --- /dev/null +++ b/src/app_ui/widgets/mod.rs @@ -0,0 +1,224 @@ +pub(crate) mod help_bar; +pub(crate) mod notification; +pub(crate) mod popup; +pub mod question_list; +pub mod stats; +pub mod topic_list; + +use std::{ + collections::{HashMap, VecDeque}, + fmt::Debug, + io::Stderr, +}; + +use crossterm::event::{KeyCode, KeyEvent}; +use indexmap::IndexSet; +use ratatui::{prelude::Rect, prelude::*, Frame}; + +use crate::errors::AppResult; + +use self::notification::{NotifContent, Notification, WidgetName, WidgetVariant}; + +use super::{ + async_task_channel::{ChannelRequestSender, TaskResponse}, + components::help_text::HelpText, +}; + +// pub fn loading_notification(src_wid: WidgetName, show: bool) -> Notification { +// } + +#[derive(Debug)] +pub struct CommonState { + pub widget_name: WidgetName, + active: bool, + pub task_sender: ChannelRequestSender, + pub is_navigable: bool, + help_texts: IndexSet, + pub notification_queue: VecDeque, +} + +impl CommonState { + pub(crate) fn new( + id: WidgetName, + task_sender: ChannelRequestSender, + help_texts: Vec, + ) -> Self { + Self { + widget_name: id, + active: false, + task_sender, + is_navigable: true, + help_texts: IndexSet::from_iter(help_texts), + notification_queue: Default::default(), + } + } + + pub(crate) fn get_key_set(&self) -> IndexSet<&KeyCode> { + self.help_texts + .iter() + .flat_map(|ht| ht.get_keys()) + .collect::>() + } +} + +pub trait Widget: Debug { + fn get_help_text_notif(&self) -> AppResult> { + Ok(Some(Notification::HelpText(NotifContent { + src_wid: self.get_common_state().widget_name.clone(), + dest_wid: WidgetName::HelpLine, + content: self.get_help_texts().clone(), + }))) + } + + fn can_handle_key_set(&self) -> IndexSet<&KeyCode> { + self.get_common_state().get_key_set() + } + + fn set_active(&mut self) -> AppResult> { + self.get_common_state_mut().active = true; + self.get_help_text_notif() + } + fn is_active(&self) -> bool { + self.get_common_state().active + } + + fn get_help_texts(&self) -> &IndexSet { + &self.get_common_state().help_texts + } + + fn get_help_texts_mut(&mut self) -> &mut IndexSet { + &mut self.get_common_state_mut().help_texts + } + + fn is_navigable(&self) -> bool { + self.get_common_state().is_navigable + } + + fn set_inactive(&mut self) { + self.get_common_state_mut().active = false; + } + + fn get_widget_name(&self) -> WidgetName { + self.get_common_state().widget_name.clone() + } + + fn get_task_sender(&self) -> &ChannelRequestSender { + &self.get_common_state().task_sender + } + + fn show_spinner(&mut self) -> AppResult<()> { + self.spinner_notif(true) + } + + fn hide_spinner(&mut self) -> AppResult<()> { + self.spinner_notif(false) + } + + fn spinner_notif(&mut self, show: bool) -> AppResult<()> { + let src_wid = self.get_widget_name(); + self.get_notification_queue() + .push_back(Notification::Loading(NotifContent { + src_wid, + dest_wid: WidgetName::HelpLine, + content: show, + })); + Ok(()) + } + + fn get_common_state_mut(&mut self) -> &mut CommonState; + + fn get_common_state(&self) -> &CommonState; + + fn get_notification_queue(&mut self) -> &mut VecDeque; + + fn render(&mut self, rect: Rect, frame: &mut Frame>); + + fn handler(&mut self, _event: KeyEvent) -> AppResult> { + Ok(None) + } + + fn process_task_response(&mut self, _response: TaskResponse) -> AppResult<()> { + Ok(()) + } + + fn setup(&mut self) -> AppResult<()> { + Ok(()) + } + + fn process_notification( + &mut self, + _notification: Notification, + ) -> AppResult> { + Ok(None) + } +} + +macro_rules! gen_methods { +( + $( + ($fn_name:ident, ($(($arg:ident, $par_type:ty)),*), $res:ty) + ),* +) => { + $( + pub fn $fn_name(&mut self, $($arg: $par_type),*) -> $res { + match self { + WidgetVariant::QuestionList(v) => v.$fn_name($($arg),*), + WidgetVariant::TopicList(v) => v.$fn_name($($arg),*), + WidgetVariant::Stats(v) => v.$fn_name($($arg),*), + WidgetVariant::HelpLine(v) => v.$fn_name($($arg),*), + } + } + )* + }; + +( + $( + ($fn_name:ident, $_:ident, ($(($arg:ident, $par_type:ty)),*), $res:ty) + ),* +) => { + $( + pub fn $fn_name(&self, $($arg: $par_type),*) -> $res { + match self { + WidgetVariant::QuestionList(v) => v.$fn_name($($arg),*), + WidgetVariant::TopicList(v) => v.$fn_name($($arg),*), + WidgetVariant::Stats(v) => v.$fn_name($($arg),*), + WidgetVariant::HelpLine(v) => v.$fn_name($($arg),*), + } + } + )* + }; +} + +impl WidgetVariant { + gen_methods!((is_navigable, nm, (), bool)); + gen_methods!((get_notification_queue, (), &mut VecDeque)); + gen_methods!( + (set_active, (), AppResult>), + (set_inactive, (), ()), + (setup, (), AppResult<()>), + ( + process_task_response, + ((response, TaskResponse)), + AppResult<()> + ), + ( + handler, + ((event, KeyEvent)), + AppResult> + ), + ( + process_notification, + ((notification, Notification)), + AppResult> + ), + ( + render, + ((rect, Rect), (frame, &mut Frame>)), + () + ) + ); +} + +pub type WidgetList = Vec>; +pub type NameWidgetMap = HashMap>; +pub type CrosstermStderr<'a> = Frame<'a, CrosstermBackend>; diff --git a/src/app_ui/widgets/notification.rs b/src/app_ui/widgets/notification.rs new file mode 100644 index 0000000..6bec47e --- /dev/null +++ b/src/app_ui/widgets/notification.rs @@ -0,0 +1,104 @@ +use std::rc::Rc; + +use crate::app_ui::helpers::question::QuestionModelContainer; +use crate::{ + app_ui::components::{ + help_text::HelpText, + popups::{paragraph::ParagraphPopup, selection_list::SelectionListPopup}, + }, + entities::TopicTagModel, +}; + +#[derive(Debug, Clone)] +pub(crate) enum PopupType { + Paragraph(ParagraphPopup), + List { + popup: SelectionListPopup, + // to catch the reference back to the parent widget + key: String, + }, +} + +#[derive(Debug, Clone)] +pub struct PopupMessage { + pub(crate) help_texts: IndexSet, + pub(crate) popup: PopupType, +} + +#[derive(Debug, Hash, Eq, Clone, PartialEq)] +pub enum WidgetName { + QuestionList, + TopicList, + Stats, + Popup, + HelpLine, +} + +#[derive(Debug, Clone)] +pub struct NotifContent { + pub src_wid: WidgetName, + pub dest_wid: WidgetName, + pub content: T, +} + +impl NotifContent { + pub fn new(src_wid: WidgetName, dest_wid: WidgetName, content: T) -> Self { + Self { + src_wid, + dest_wid, + content, + } + } +} + +#[derive(Debug, Clone)] +pub enum Notification { + Questions(NotifContent>), + Stats(NotifContent>>), + Popup(NotifContent), + HelpText(NotifContent>), + Event(NotifContent), + SelectedItem(NotifContent<(String, usize)>), + Loading(NotifContent), +} + +macro_rules! dest_widname { + ($($variant:ident),*) => { + pub fn get_wid_name(&self) -> &WidgetName { + match self { + $( + Notification::$variant(NotifContent { dest_wid, .. }) => dest_wid, + )* + } + } + }; +} + +impl Notification { + dest_widname!( + Questions, + Stats, + Popup, + HelpText, + Event, + SelectedItem, + Loading + ); +} + +#[derive(Debug)] +pub(crate) enum WidgetVariant { + QuestionList(QuestionListWidget), + TopicList(TopicTagListWidget), + Stats(Stats), + HelpLine(HelpBar), +} + +pub use crossbeam::channel::unbounded as notification_channel; +use crossterm::event::KeyEvent; +use indexmap::IndexSet; + +use super::{ + help_bar::HelpBar, question_list::QuestionListWidget, stats::Stats, + topic_list::TopicTagListWidget, +}; diff --git a/src/app_ui/widgets/popup.rs b/src/app_ui/widgets/popup.rs new file mode 100644 index 0000000..65dd9e9 --- /dev/null +++ b/src/app_ui/widgets/popup.rs @@ -0,0 +1,126 @@ +use crate::{ + app_ui::{ + async_task_channel::ChannelRequestSender, + components::{popups::Component, rect::centered_rect}, + }, + errors::AppResult, +}; + +use crossterm::event::{KeyCode, KeyEvent}; + +use ratatui::prelude::*; + +use super::{ + notification::{NotifContent, Notification, PopupType, WidgetName}, + CommonState, CrosstermStderr, Widget, +}; + +#[derive(Debug)] +pub(crate) struct Popup { + pub common_state: CommonState, + pub callee_wid: Option, + pub popup_type: Option, +} + +impl Popup { + pub fn new(widget_name: WidgetName, task_sender: ChannelRequestSender) -> Self { + Self { + common_state: CommonState::new(widget_name, task_sender, vec![]), + popup_type: None, + callee_wid: None, + } + } +} + +impl Widget for Popup { + fn render(&mut self, rect: Rect, frame: &mut CrosstermStderr) { + if self.is_active() { + let size = rect; + let size = centered_rect(60, 50, size); + if let Some(pt) = &mut self.popup_type { + match pt { + PopupType::Paragraph(p) => p.render(frame, size), + PopupType::List { popup: l, .. } => l.render(frame, size), + } + } + } + } + + fn handler(&mut self, event: KeyEvent) -> AppResult> { + let src_wid = self.get_widget_name(); + if let Some(popup) = &mut self.popup_type { + match popup { + PopupType::Paragraph(para_popup) => { + let notif: Option = para_popup.event_handler(event); + + if !para_popup.is_showing() { + self.set_inactive(); + } + + if let Some(n) = notif { + if self.can_handle_key_set().contains(&n.code) { + return Ok(Some(Notification::Event(NotifContent { + src_wid, + dest_wid: self.callee_wid.as_ref().unwrap().clone(), + content: n, + }))); + } + } + } + PopupType::List { popup, key } => { + let mut notif = None; + if let Some(key_event) = popup.event_handler(event) { + if let KeyCode::Enter = key_event.code { + let i = popup.get_selected_index(); + notif = Some(Notification::SelectedItem(NotifContent { + src_wid, + dest_wid: self.callee_wid.as_ref().unwrap().clone(), + content: (key.to_string(), i), + })); + } + } + if !popup.is_showing() { + self.set_inactive(); + } + return Ok(notif); + } + } + } + Ok(None) + } + + fn process_notification( + &mut self, + notification: Notification, + ) -> AppResult> { + if let Notification::Popup(NotifContent { + src_wid, + dest_wid: _, + content, + }) = notification + { + self.callee_wid = Some(src_wid); + let extended_help = match &content.popup { + PopupType::Paragraph(p) => p.get_key_set(), + PopupType::List { popup: l, .. } => l.get_key_set(), + }; + self.popup_type = Some(content.popup); + self.get_help_texts_mut().extend(content.help_texts); + // extend help specific to the popup recieved + self.get_help_texts_mut().extend(extended_help); + } + Ok(None) + } + + fn get_common_state(&self) -> &CommonState { + &self.common_state + } + + fn get_common_state_mut(&mut self) -> &mut CommonState { + &mut self.common_state + } + + fn get_notification_queue(&mut self) -> &mut std::collections::VecDeque { + &mut self.common_state.notification_queue + } +} diff --git a/src/app_ui/widgets/question_list.rs b/src/app_ui/widgets/question_list.rs new file mode 100644 index 0000000..8b5fd4e --- /dev/null +++ b/src/app_ui/widgets/question_list.rs @@ -0,0 +1,1025 @@ +use crate::app_ui::helpers::question::QuestionModelContainer; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::rc::Rc; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use crate::app_ui::async_task_channel::{Request, Response, TaskResponse}; +use crate::app_ui::components::help_text::{CommonHelpText, HelpText}; +use crate::app_ui::components::popups::paragraph::ParagraphPopup; +use crate::app_ui::components::popups::selection_list::SelectionListPopup; +use crate::app_ui::event::VimPingSender; +use crate::app_ui::helpers::utils::{generate_random_string, SolutionFile}; +use crate::app_ui::{async_task_channel::ChannelRequestSender, components::list::StatefulList}; +use crate::config::Config; +use crate::deserializers; +use crate::deserializers::editor_data::CodeSnippet; +use crate::deserializers::run_submit::{ParsedResponse, Success}; +use crate::entities::TopicTagModel; +use crate::errors::{AppResult, LcAppError}; +use crate::graphql::run_code::RunCode; +use crate::graphql::submit_code::SubmitCode; +use crate::graphql::{Language, RunOrSubmitCode}; + +use crossterm::event::{KeyCode, KeyEvent}; +use indexmap::IndexSet; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, List, ListItem}, +}; + +use super::notification::{NotifContent, Notification, PopupMessage, PopupType, WidgetName}; +use super::{CommonState, CrosstermStderr, Widget}; +use crate::app_ui::components::color::{Callout, CHECK_MARK}; +use lru; +use std::num::NonZeroUsize; + +#[derive(Debug, Default)] +struct CachedQuestion { + editor_data: Option, + qd: Option, +} + +impl CachedQuestion { + fn question_data_received(&self) -> bool { + self.qd.is_some() + } + + fn editor_data_received(&self) -> bool { + self.editor_data.is_some() + } + + fn get_code_snippets(&self) -> Option<&Vec> { + if let Some(ed) = &self.editor_data { + return Some(&ed.code_snippets); + } + None + } + + fn get_list_of_languages(&self) -> Option> { + if let Some(cs) = self.get_code_snippets() { + return Some(cs.iter().map(|s| s.lang.clone()).collect()); + } + None + } + + fn get_question_content(&self) -> Option { + if let Some(content) = &self.qd { + return Some(content.html_to_text()); + } + None + } +} + +#[derive(Debug)] +enum TaskType { + Run, + Edit, + Read, + Submit, +} + +#[derive(Debug)] +enum State { + JumpingTo, + Normal, +} + +type Question = Rc; + +#[derive(Debug)] +pub struct QuestionListWidget { + pub common_state: CommonState, + pub questions: StatefulList, + pub all_questions: HashMap, Vec>, + vim_tx: VimPingSender, + vim_running: Arc, + cache: lru::LruCache, + task_map: HashMap, + pending_event_actions: IndexSet<(KeyEvent, Question)>, + config: Rc, + files: HashMap>, + jump_to: usize, + state: State, + selected_topic_all: bool, +} + +impl QuestionListWidget { + pub fn new( + id: WidgetName, + task_sender: ChannelRequestSender, + vim_tx: VimPingSender, + vim_running: Arc, + config: Rc, + ) -> Self { + let mut files: HashMap> = HashMap::new(); + for file in config + .solutions_dir + .read_dir() + .expect("Cannot read the solutions directory") + .flatten() + { + if file.path().is_file() { + if let Some(sf) = SolutionFile::from_file(file.path()) { + let qid = sf + .question_id + .clone() + .parse::() + .expect("frontend_question_id is not a number"); + files.entry(qid).or_default().insert(sf); + } + } + } + Self { + common_state: CommonState::new( + id, + task_sender, + vec![ + CommonHelpText::SwitchPane.into(), + CommonHelpText::ScrollUp.into(), + CommonHelpText::ScrollDown.into(), + CommonHelpText::Edit.into(), + CommonHelpText::ReadContent.into(), + CommonHelpText::Run.into(), + CommonHelpText::Submit.into(), + ], + ), + all_questions: HashMap::new(), + questions: Default::default(), + vim_tx, + vim_running, + cache: lru::LruCache::new(NonZeroUsize::new(10).unwrap()), + task_map: HashMap::new(), + pending_event_actions: Default::default(), + config, + files, + jump_to: 0, + state: State::Normal, + selected_topic_all: false, + } + } +} + +impl QuestionListWidget { + fn send_fetch_question_editor_details(&mut self, question: Question) -> AppResult<()> { + if let Some(cached_q) = self.cache.peek(&question) { + if !cached_q.question_data_received() { + self.send_fetch_question_details(question.clone())?; + } + } + self.show_spinner()?; + let random_key = generate_random_string(10); + self.task_map + .insert(random_key.clone(), (question.clone(), TaskType::Edit)); + self.get_task_sender() + .send( + crate::app_ui::async_task_channel::TaskRequest::GetQuestionEditorData(Request { + widget_name: self.get_widget_name(), + request_id: random_key, + content: question + .question + .borrow() + .title_slug + .as_ref() + .unwrap() + .clone(), + }), + ) + .map_err(Box::new)?; + Ok(()) + } + + fn send_fetch_solution_run_details( + &mut self, + question: Question, + lang: Language, + typed_code: String, + is_submit: bool, + ) -> AppResult<()> { + self.show_spinner()?; + let random_key = generate_random_string(10); + self.task_map + .insert(random_key.clone(), (question.clone(), TaskType::Run)); + + let content = if is_submit { + let submit_code = SubmitCode { + lang, + question_id: question.question.borrow().frontend_question_id.clone(), + typed_code, + slug: question + .question + .borrow() + .title_slug + .as_ref() + .unwrap() + .clone(), + }; + + RunOrSubmitCode::Submit(submit_code) + } else { + let run_code = RunCode { + lang, + question_id: question.question.borrow().frontend_question_id.clone(), + typed_code, + test_cases_stdin: None, // automatically fetches sample test cases from the server + slug: question + .question + .borrow() + .title_slug + .as_ref() + .unwrap() + .clone(), + }; + + RunOrSubmitCode::Run(run_code) + }; + + self.get_task_sender() + .send( + crate::app_ui::async_task_channel::TaskRequest::CodeRunRequest(Request { + widget_name: self.get_widget_name(), + request_id: random_key, + content, + }), + ) + .map_err(Box::new)?; + Ok(()) + } + + fn solution_file_popup_action( + &mut self, + question: Question, + task_type: TaskType, + index: usize, + ) -> AppResult<()> { + self.show_spinner()?; + let solution_files = self + .files + .get( + &question + .question + .borrow() + .frontend_question_id + .clone() + .parse() + .unwrap(), + ) + .expect("Question id does not exist in the solutions mapping"); + let solution_file = solution_files.iter().nth(index).unwrap(); + let typed_code = solution_file.read_file_contents(&self.config.solutions_dir); + let is_submit = match task_type { + TaskType::Run => false, + TaskType::Submit => true, + _ => unimplemented!(), + }; + self.send_fetch_solution_run_details( + question, + solution_file.lang.clone(), + typed_code, + is_submit, + ) + } + + fn send_fetch_question_details(&mut self, question: Question) -> AppResult<()> { + self.show_spinner()?; + let random_key = generate_random_string(10); + self.task_map + .insert(random_key.clone(), (question.clone(), TaskType::Read)); + self.get_task_sender() + .send( + crate::app_ui::async_task_channel::TaskRequest::QuestionDetail(Request { + widget_name: self.get_widget_name(), + request_id: random_key, + content: question + .question + .borrow() + .title_slug + .as_ref() + .unwrap() + .clone(), + }), + ) + .map_err(Box::new)?; + Ok(()) + } + + fn sync_db_solution_submit_status(&mut self, question: Question) -> AppResult<()> { + self.show_spinner()?; + self.get_task_sender() + .send( + crate::app_ui::async_task_channel::TaskRequest::DbUpdateQuestion(Request { + widget_name: self.get_widget_name(), + request_id: "".to_string(), + content: question.question.borrow().to_owned(), + }), + ) + .map_err(Box::new)?; + Ok(()) + } + + fn is_notif_pending(&self, key: &(KeyEvent, Question)) -> bool { + self.pending_event_actions.contains(key) + } + + fn open_vim_like_editor(&mut self, file_name: &Path, editor: &str) -> AppResult<()> { + let mut output = std::process::Command::new("sh") + .arg("-c") + .arg(&format!("{} {}", editor, file_name.display())) + .spawn() + .map_err(|e| LcAppError::EditorOpen(format!("Can't spawn {} editor: {e}", editor)))?; + self.vim_running + .store(true, std::sync::atomic::Ordering::Relaxed); + let vim_cmd_result = output + .wait() + .map_err(|e| LcAppError::EditorOpen(format!("Editor Error: {e}")))?; + self.vim_running + .store(false, std::sync::atomic::Ordering::Relaxed); + self.vim_tx.blocking_send(1).unwrap(); + if !vim_cmd_result.success() { + return Err(LcAppError::EditorOpen( + "Cannot open editor, Reason: Unknown".to_string(), + )); + } + Ok(()) + } + + fn open_editor(&mut self, file_name: &Path) -> AppResult<()> { + if let Ok(editor) = std::env::var("EDITOR") { + if editor.contains("vim") || editor.contains("nano") { + self.open_vim_like_editor(file_name, editor.as_str())?; + } else { + std::process::Command::new("sh") + .arg("-c") + .arg(&format!("{} {}", editor, file_name.display())) + .spawn()? + .wait()?; + } + } else { + // try open vim + self.open_vim_like_editor(file_name, "vim")?; + } + Ok(()) + } + + fn popup_list_notification( + &mut self, + popup_content: Vec, + question_title: String, + popup_key: String, + help_texts: IndexSet, + ) -> Notification { + Notification::Popup(NotifContent { + src_wid: self.get_widget_name(), + dest_wid: WidgetName::Popup, + content: PopupMessage { + help_texts, + popup: PopupType::List { + popup: SelectionListPopup::new(question_title, popup_content), + key: popup_key, + }, + }, + }) + } + + fn popup_paragraph_notification( + &self, + popup_content: String, + popup_title: String, + help_texts: IndexSet, + ) -> Notification { + Notification::Popup(NotifContent { + src_wid: self.get_widget_name(), + dest_wid: WidgetName::Popup, + content: PopupMessage { + help_texts, + popup: PopupType::Paragraph(ParagraphPopup::new(popup_title, popup_content)), + }, + }) + } + + fn get_item(question: &Rc) -> ListItem { + let number = question.question.borrow().frontend_question_id.clone(); + let title = question + .question + .borrow() + .title + .as_ref() + .unwrap_or(&"No title".to_string()) + .to_string(); + + let is_accepted = question + .question + .borrow() + .status + .as_ref() + .map_or(false, |v| v.as_str() == "ac"); + + let line_text = format!( + "{} {:0>3}: {}", + { + if is_accepted { + CHECK_MARK + } else { + " " + } + }, + number, + title + ); + + let qs_diff = question + .question + .borrow() + .difficulty + .as_ref() + .unwrap_or(&"Disabled".to_string()) + .to_string(); + + let combination: Style = match qs_diff.as_str() { + "Easy" => Callout::Success.get_pair().fg, + "Medium" => Callout::Warning.get_pair().fg, + "Hard" => Callout::Error.get_pair().fg, + "Disabled" => Callout::Disabled.get_pair().fg, + _ => unimplemented!(), + } + .into(); + + let styled_title = Span::styled(line_text, combination); + ListItem::new(styled_title) + } + + fn add_event_to_event_queue(&mut self, data: (KeyEvent, Question)) -> bool { + self.pending_event_actions.insert(data) + } + + fn process_pending_events(&mut self) { + let mut to_process_again = vec![]; + while let Some((pending_event, qm)) = self.pending_event_actions.pop() { + let ques_in_cache = self + .cache + .get_or_insert_mut(qm.clone(), CachedQuestion::default); + match pending_event.code { + KeyCode::Enter => { + if let Some(cache_ques) = &ques_in_cache.qd { + let content = cache_ques.html_to_text(); + let title = qm.question.borrow().title.as_ref().unwrap().to_string(); + let notif = self.popup_paragraph_notification( + content, + title, + IndexSet::from_iter([CommonHelpText::Edit.into()]), + ); + self.get_notification_queue().push_back(notif); + } else { + to_process_again.push((pending_event, qm)); + } + } + KeyCode::Char('E') | KeyCode::Char('e') => { + let question_data_in_cache = ques_in_cache.question_data_received(); + let question_editor_data_in_cache = ques_in_cache.editor_data_received(); + + if question_data_in_cache && question_editor_data_in_cache { + let content = ques_in_cache.get_list_of_languages().unwrap(); + let title = "Select Language".to_string(); + let popup_key = generate_random_string(10); + self.task_map + .insert(popup_key.clone(), (qm.clone(), TaskType::Edit)); + let notif = self.popup_list_notification( + content, + title, + popup_key, + IndexSet::new(), + ); + self.get_notification_queue().push_back(notif); + } else { + to_process_again.push((pending_event, qm)); + } + } + _ => continue, + } + } + + for i in to_process_again { + self.add_event_to_event_queue(i); + } + } + + fn get_selected_question_from_cache(&mut self) -> (&mut CachedQuestion, Question) { + let selected_question = self.questions.get_selected_item(); + let sel = selected_question.expect("no question selected"); + let model = sel.clone(); + let k = self + .cache + .get_or_insert_mut(model.clone(), CachedQuestion::default); + (k, model) + } + + fn run_or_submit_code_event_handler( + &mut self, + task_type: TaskType, + ) -> AppResult> { + let selected_question = self + .questions + .get_selected_item() + .expect("no question selected"); + let id: i32 = selected_question + .question + .borrow() + .frontend_question_id + .parse() + .unwrap(); + if let Some(files) = self.files.get(&id) { + let langs = files + .iter() + .map(|f| f.lang.clone().to_string()) + .collect::>(); + let key = generate_random_string(10); + self.task_map + .insert(key.clone(), (selected_question.clone(), task_type)); + return Ok(Some(self.popup_list_notification( + langs, + "Select Language".to_string(), + key, + IndexSet::new(), + ))); + } + Ok(Some(self.popup_paragraph_notification( + "Kindly press key 'e' to create the solution file first.".to_string(), + "Help".to_string(), + IndexSet::new(), + ))) + } +} + +impl super::Widget for QuestionListWidget { + fn render(&mut self, rect: Rect, frame: &mut CrosstermStderr) { + let lines = self + .questions + .items + .iter() + .map(Self::get_item) + .collect::>(); + + let mut border_style = Style::default(); + if self.is_active() { + border_style = border_style.fg(Color::Cyan); + } + + let items = List::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Questions") + .border_style(border_style), + ) + .highlight_style( + Style::default() + .bg(Color::Rgb(0, 0, 0)) + .add_modifier(Modifier::BOLD), + ); + frame.render_stateful_widget(items, rect, &mut self.questions.state); + } + + fn handler(&mut self, event: KeyEvent) -> AppResult> { + match event.code { + crossterm::event::KeyCode::Up => self.questions.previous(), + crossterm::event::KeyCode::Down => self.questions.next(), + crossterm::event::KeyCode::Enter => { + let (cache, model) = self.get_selected_question_from_cache(); + let question_data_in_cache = cache.question_data_received(); + + if question_data_in_cache { + let content = cache.get_question_content().unwrap(); + let title = model.question.borrow().title.as_ref().unwrap().clone(); + return Ok(Some(self.popup_paragraph_notification( + content, + title, + IndexSet::from_iter([CommonHelpText::Edit.into()]), + ))); + } + + if self.is_notif_pending(&(event, model.clone())) { + self.process_pending_events(); + return Ok(None); + } + + // before sending the task request set the key as the request id and value + // as question model so that we can obtain the question model once we get the response + self.send_fetch_question_details(model.clone())?; + self.add_event_to_event_queue((event, model)); + } + + KeyCode::Char('e') | KeyCode::Char('E') => { + let (cache, model) = self.get_selected_question_from_cache(); + let question_data_in_cache = cache.question_data_received(); + let question_editor_data_in_cache = cache.editor_data_received(); + + if question_data_in_cache && question_editor_data_in_cache { + let content = cache.get_list_of_languages().unwrap(); + let title = "Select Language".to_string(); + let popup_key = generate_random_string(10); + self.task_map + .insert(popup_key.clone(), (model, TaskType::Edit)); + let notif = + self.popup_list_notification(content, title, popup_key, IndexSet::new()); + return Ok(Some(notif)); + } + + if self.is_notif_pending(&(event, model.clone())) { + self.process_pending_events(); + return Ok(None); + } + + // before sending the task request set the key as the request id and value + // as question model so that we can obtain the question model once we get the response + self.send_fetch_question_editor_details(model.clone())?; + self.add_event_to_event_queue((event, model)); + } + + KeyCode::Char('r') | KeyCode::Char('R') => { + return self.run_or_submit_code_event_handler(TaskType::Run); + } + KeyCode::Char('s') => { + return self.run_or_submit_code_event_handler(TaskType::Submit); + } + KeyCode::Char(c) => match self.state { + State::Normal => { + if c.is_numeric() { + self.state = State::JumpingTo; + self.jump_to = 0; + let digit = c.to_digit(10).unwrap() as usize; + self.jump_to *= 10; + self.jump_to += digit; + } + } + State::JumpingTo => { + if c.is_numeric() { + let digit = c.to_digit(10).unwrap() as usize; + self.jump_to *= 10; + self.jump_to += digit; + } else if c == 'G' { + if !self.selected_topic_all { + return Ok(Some(self.popup_paragraph_notification( + "Can only use jump to in all topic section".to_string(), + "Jump Info".to_string(), + IndexSet::new(), + ))); + } + let mut failed_notif_msg = None; + if self.jump_to > self.questions.items.len() { + failed_notif_msg = Some(format!( + "Max range {}. You entered {}.", + self.questions.items.len(), + self.jump_to + )); + } else if self.jump_to != 0 { + self.questions.state.select(Some(self.jump_to - 1)); + } else if self.jump_to == 0 { + failed_notif_msg = Some("No Question with id = 0".to_string()); + } + self.jump_to = 0; + return Ok(failed_notif_msg.map(|msg| { + self.popup_paragraph_notification( + msg, + "Jump failed".to_string(), + IndexSet::new(), + ) + })); + } else { + self.state = State::Normal; + self.jump_to = 0; + } + } + }, + _ => {} + }; + Ok(None) + } + + fn setup(&mut self) -> AppResult<()> { + self.get_task_sender() + .send( + crate::app_ui::async_task_channel::TaskRequest::GetAllQuestionsMap(Request { + widget_name: self.get_widget_name(), + request_id: "".to_string(), + content: (), + }), + ) + .map_err(Box::new)?; + Ok(()) + } + + fn process_task_response( + &mut self, + response: crate::app_ui::async_task_channel::TaskResponse, + ) -> AppResult<()> { + match response { + crate::app_ui::async_task_channel::TaskResponse::GetAllQuestionsMap(Response { + content, + .. + }) => { + // creating rc cloned question as one question can appear in multiple topics + let question_set = content + .iter() + .flat_map(|x| { + x.1.iter().map(|x| { + ( + x.frontend_question_id.clone(), + Rc::new(QuestionModelContainer { + question: RefCell::new(x.clone()), + }), + ) + }) + }) + .collect::>(); + + let map_iter = content.into_iter().map(|v| { + ( + Rc::new(v.0), + (v.1.into_iter() + .map(|x| question_set.get(&x.frontend_question_id).unwrap().clone())) + .collect::>(), + ) + }); + + self.all_questions.extend(map_iter); + for ql in &mut self.all_questions.values_mut() { + ql.sort_unstable() + } + self.get_notification_queue() + .push_back(Notification::Questions(NotifContent::new( + WidgetName::QuestionList, + super::notification::WidgetName::QuestionList, + vec![TopicTagModel { + name: Some("All".to_owned()), + id: "all".to_owned(), + slug: Some("all".to_owned()), + }], + ))); + } + crate::app_ui::async_task_channel::TaskResponse::QuestionDetail(qd) => { + let cached_q = self.cache.get_or_insert_mut( + self.task_map + .remove(&qd.request_id) + .expect("sent task is not found in the task list.") + .0, + CachedQuestion::default, + ); + cached_q.qd = Some(qd.content); + } + TaskResponse::QuestionEditorData(ed) => { + let cached_q = self.cache.get_or_insert_mut( + self.task_map + .remove(&ed.request_id) + .expect("sent task is not found in the task list.") + .0, + CachedQuestion::default, + ); + cached_q.editor_data = Some(ed.content); + } + TaskResponse::RunResponseData(run_res) => { + let mut is_submit = false; + let k = match run_res.content { + ParsedResponse::Pending => "Pending".to_string(), + ParsedResponse::CompileError(_) => "Compile Error".to_string(), + ParsedResponse::RuntimeError(_) => { + // } => format!("{status_msg}:\n\n{runtime_error}\n\n{full_runtime_error}"), + "Runtime Error".to_string() + } + ParsedResponse::MemoryLimitExceeded(_) => "Memory Limit Exceeded".to_string(), + ParsedResponse::OutputLimitExceed(_) => "Output Limit Exceeded".to_string(), + ParsedResponse::TimeLimitExceeded(_) => "Time Limit Exceeded".to_string(), + ParsedResponse::InternalError(_) => "Internal Error".to_string(), + ParsedResponse::TimeOut(_) => "Timout".to_string(), + ParsedResponse::Success(Success::Run { + status_runtime, + code_answer, + expected_code_answer, + correct_answer, + total_correct, + total_testcases, + status_memory, + .. + }) => { + let is_accepted_symbol = if correct_answer { "✅" } else { "❌" }; + let mut ans_compare = String::new(); + for (output, expected_output) in + code_answer.into_iter().zip(expected_code_answer) + { + let emoji = if output == expected_output { + "✅" + } else { + "❌" + }; + let compare = format!( + "{emoji}\nOuput: {}\nExpected: {}\n\n", + output, expected_output + ); + ans_compare.push_str(compare.as_str()) + } + let result_string = vec![ + format!("Accepted: {}", is_accepted_symbol), + if let Some(correct) = total_correct { + let mut x = format!("Correct: {correct}"); + if let Some(total) = total_testcases { + x = format!("{x}/{}", total); + } + x + } else { + String::new() + }, + format!("Memory Used: {status_memory}"), + format!("Status Runtime: {status_runtime}"), + ans_compare, + ]; + result_string.join("\n") + } + ParsedResponse::Success(Success::Submit { + status_runtime, + total_correct, + total_testcases, + status_memory, + .. + }) => { + // upon successful submit of the question update the question accepted status + // also update the db + { + let question_model_container = self + .task_map + .get(&run_res.request_id) + .expect( + "Cannot get the question model container from the sent task map.", + ); + question_model_container.0.question.borrow_mut().status = + Some("ac".to_string()); + self.sync_db_solution_submit_status( + question_model_container.0.clone(), + )?; + } + is_submit = true; + let is_accepted_symbol = "✅"; + let result_string = vec![ + format!("Accepted: {}", is_accepted_symbol), + if let Some(correct) = total_correct { + let mut x = format!("Correct: {correct}"); + if let Some(total) = total_testcases { + x = format!("{x}/{}", total); + } + x + } else { + String::new() + }, + format!("Memory Used: {status_memory}"), + format!("Status Runtime: {status_runtime}"), + ]; + result_string.join("\n") + } + ParsedResponse::Unknown(_) => "Unknown Error".to_string(), + }; + let notification = self.popup_paragraph_notification( + k, + format!("{} Status", (if is_submit { "Submit" } else { "Run" })), + IndexSet::new(), + ); + // post submit remove the reference_task_key from task_map + self.task_map.remove(&run_res.request_id).unwrap(); + self.get_notification_queue().push_back(notification); + } + TaskResponse::Error(e) => { + let src_wid = self.get_widget_name(); + self.get_notification_queue() + .push_back(Notification::Popup(NotifContent { + src_wid, + dest_wid: WidgetName::Popup, + content: PopupMessage { + help_texts: IndexSet::new(), + popup: PopupType::Paragraph(ParagraphPopup::new( + "Error Encountered".into(), + e.content, + )), + }, + })); + } + _ => {} + } + self.hide_spinner()?; + self.process_pending_events(); + Ok(()) + } + + fn process_notification( + &mut self, + notification: Notification, + ) -> AppResult> { + match notification { + Notification::Questions(NotifContent { content: tags, .. }) => { + self.questions.items = vec![]; + if let Some(tag) = tags.into_iter().next() { + // if any topic change notification is received set jump to state to 0 + if tag.id == "all" { + let mut unique_question_map = HashMap::new(); + for val in self.all_questions.values().flatten() { + unique_question_map.insert( + val.question.borrow().frontend_question_id.clone(), + val.clone(), + ); + } + let unique_questions = unique_question_map + .drain() + .map(|(_, v)| v) + .collect::>(); + let notif = Notification::Stats(NotifContent::new( + WidgetName::QuestionList, + WidgetName::Stats, + unique_questions.clone(), + )); + self.questions.items.extend(unique_questions); + self.questions.items.sort_unstable(); + self.selected_topic_all = true; + self.jump_to = 0; + return Ok(Some(notif)); + } else { + let values = self.all_questions.get(&tag).unwrap(); + let notif = Notification::Stats(NotifContent::new( + WidgetName::QuestionList, + WidgetName::Stats, + values.to_vec(), + )); + self.questions.items.extend(values.iter().cloned()); + self.selected_topic_all = false; + return Ok(Some(notif)); + }; + } + } + Notification::SelectedItem(NotifContent { content, .. }) => { + let (lookup_key, index) = content; + match self.task_map.remove(&lookup_key).unwrap() { + (question, TaskType::Edit) => { + let question_id = question.question.borrow().frontend_question_id.clone(); + let cached_question = self.cache.get(&question).unwrap(); + let editor_data = cached_question + .editor_data + .as_ref() + .expect("no editor data found"); + let question_data = + cached_question.qd.as_ref().expect("no question data found"); + let description = question_data.html_to_text(); + let slug = question_data.title_slug.as_str().to_string(); + + let snippets = &editor_data.code_snippets; + let selected_snippet = snippets[index].code.as_str().to_string(); + let selected_lang = snippets[index].lang_slug.clone(); + let dir = self.config.solutions_dir.clone(); + + let sf = SolutionFile::new( + slug, + selected_lang, + Some(description), + Some(selected_snippet), + question_id, + ); + let save_path = sf.get_save_path(&dir); + sf.create_if_not_exists(&dir)?; + self.files + .entry(sf.question_id.parse().unwrap()) + .or_default() + .insert(sf); + + if let Err(e) = self.open_editor(&save_path) { + return Ok(Some(self.popup_paragraph_notification( + e.to_string(), + "Error opening editor".to_string(), + IndexSet::new(), + ))); + }; + } + (question, tt) => { + self.solution_file_popup_action(question, tt, index)?; + } + } + } + + Notification::Event(NotifContent { + src_wid: _, + dest_wid: _, + content: event, + }) => { + return self.handler(event); + } + _ => {} + } + Ok(None) + } + + fn get_common_state(&self) -> &CommonState { + &self.common_state + } + + fn get_common_state_mut(&mut self) -> &mut CommonState { + &mut self.common_state + } + fn get_notification_queue(&mut self) -> &mut std::collections::VecDeque { + &mut self.common_state.notification_queue + } +} diff --git a/src/app_ui/widgets/stats.rs b/src/app_ui/widgets/stats.rs new file mode 100644 index 0000000..2254ad1 --- /dev/null +++ b/src/app_ui/widgets/stats.rs @@ -0,0 +1,193 @@ +use super::{notification::NotifContent, *}; +use crate::app_ui::components::color::Callout; +use crate::app_ui::{async_task_channel::ChannelRequestSender, helpers::question}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Gauge}, +}; + +#[derive(Debug)] +pub struct Stats { + common_state: CommonState, + stat_state: Option, +} + +impl Stats { + pub(crate) fn new(id: WidgetName, task_sender: ChannelRequestSender) -> Self { + let mut cs = CommonState::new(id, task_sender, vec![]); + cs.is_navigable = false; + Self { + stat_state: None, + common_state: cs, + } + } +} + +impl Stats { + fn create_block(title: &str) -> Block { + Block::default() + .borders(Borders::ALL) + .style(Style::default().fg(Color::Gray)) + .title(Span::styled( + title, + Style::default().add_modifier(Modifier::BOLD), + )) + } +} + +impl Widget for Stats { + fn render(&mut self, rect: Rect, frame: &mut Frame>) { + let block = Self::create_block("Stats"); + let inner_area = block.inner(rect); + frame.render_widget(block, rect); + + let horizontal_partition = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner_area); + + let left_partition = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(horizontal_partition[0]); + + let right_partition = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(33), + Constraint::Percentage(33), + Constraint::Percentage(33), + ]) + .split(horizontal_partition[1]); + if let Some(stat_state) = &self.stat_state { + let gauges: Vec = stat_state.into(); + for (part, gauge) in [ + left_partition[0], + left_partition[1], + right_partition[0], + right_partition[1], + right_partition[2], + ] + .into_iter() + .zip(gauges) + { + frame.render_widget(gauge, part) + } + } + } + + fn process_notification( + &mut self, + notification: Notification, + ) -> AppResult> { + if let Notification::Stats(NotifContent { + src_wid: _, + dest_wid: _, + content: questions, + }) = notification + { + let stats = crate::app_ui::helpers::question::Stats { qm: &questions }; + self.stat_state = Some(stats.into()); + } + Ok(None) + } + + fn get_common_state(&self) -> &CommonState { + &self.common_state + } + + fn get_common_state_mut(&mut self) -> &mut CommonState { + &mut self.common_state + } + fn get_notification_queue(&mut self) -> &mut std::collections::VecDeque { + &mut self.common_state.notification_queue + } +} + +impl<'a> From> for StatState { + fn from(val: question::Stats<'a>) -> Self { + StatState { + accepted: val.get_accepted(), + total: val.get_total_question(), + not_attempted: val.get_not_attempted(), + easy: val.get_easy_count(), + medium: val.get_medium_count(), + hard: val.get_hard_count(), + easy_accepted: val.get_easy_accepted(), + medium_accepted: val.get_medium_accepted(), + hard_accepted: val.get_hard_accepted(), + } + } +} + +#[derive(Debug)] +struct StatState { + pub accepted: usize, + pub total: usize, + pub not_attempted: usize, + pub easy: usize, + pub medium: usize, + pub hard: usize, + pub easy_accepted: usize, + pub medium_accepted: usize, + pub hard_accepted: usize, +} + +impl StatState { + fn get_gauge(title: &str, val: usize, total: usize, comination: Callout) -> Gauge { + let block_title = format!("{}: {}/{}", title, val, total); + let percentage = if total != 0 { + (val as f32 / total as f32) * 100_f32 + } else { + 0 as f32 + }; + let style: Style = comination.get_pair().fg.into(); + let label = Span::styled( + format!("{:.2}%", percentage), + style.add_modifier(Modifier::ITALIC | Modifier::BOLD), + ); + + Gauge::default() + .block(Block::default().title(block_title).borders(Borders::ALL)) + .gauge_style(Style::default().fg(Color::Green).bg(Color::Black)) + .percent(percentage as u16) + .label(label) + } +} + +impl<'a> From<&StatState> for Vec> { + fn from(value: &StatState) -> Self { + [ + ("Total Accepted", value.accepted, value.total, Callout::Info), + ( + "Total Attempted", + value.total - value.not_attempted, + value.total, + Callout::Info, + ), + ( + "Easy Accepted", + value.easy_accepted, + value.easy, + Callout::Success, + ), + ( + "Medium Accepted", + value.medium_accepted, + value.medium, + Callout::Warning, + ), + ( + "Hard Accepted", + value.hard_accepted, + value.hard, + Callout::Error, + ), + ] + .into_iter() + .map(|(title, val, total, color_combo)| { + StatState::get_gauge(title, val, total, color_combo) + }) + .collect() + } +} diff --git a/src/app_ui/widgets/topic_list.rs b/src/app_ui/widgets/topic_list.rs new file mode 100644 index 0000000..8476ff0 --- /dev/null +++ b/src/app_ui/widgets/topic_list.rs @@ -0,0 +1,161 @@ +use crate::{ + app_ui::{ + async_task_channel::{ + ChannelRequestSender, Request as TaskRequestFormat, Response, TaskRequest, TaskResponse, + }, + components::{help_text::CommonHelpText, list::StatefulList}, + }, + entities::TopicTagModel, + errors::AppResult, +}; + +use crossterm::event::KeyEvent; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, List, ListItem}, +}; + +use super::{ + notification::{ + NotifContent, Notification, + WidgetName::{self, QuestionList}, + }, + CommonState, CrosstermStderr, Widget, +}; +use crate::app_ui::components::color::Callout; + +#[derive(Debug)] +pub struct TopicTagListWidget { + common_state: CommonState, + pub topics: StatefulList, +} + +impl TopicTagListWidget { + pub fn new(id: WidgetName, task_sender: ChannelRequestSender) -> Self { + Self { + common_state: CommonState::new( + id, + task_sender, + vec![ + CommonHelpText::ScrollUp.into(), + CommonHelpText::ScrollDown.into(), + CommonHelpText::SwitchPane.into(), + ], + ), + topics: Default::default(), + } + } +} + +impl TopicTagListWidget { + fn get_item(ttm: &TopicTagModel) -> ListItem { + ListItem::new(Text::styled( + ttm.name + .as_ref() + .map_or("Not a Valid Tag".to_string(), |name| name.to_owned()), + Style::default(), + )) + } + + fn update_questions(&mut self) -> AppResult> { + if let Some(sel) = self.topics.get_selected_item() { + let questions = vec![sel.as_ref().clone()]; + let notif = Notification::Questions(NotifContent::new( + WidgetName::TopicList, + QuestionList, + questions, + )); + return Ok(Some(notif)); + } + Ok(None) + } +} + +impl Widget for TopicTagListWidget { + fn set_active(&mut self) -> AppResult> { + self.common_state.active = true; + Ok(Some(Notification::HelpText(NotifContent::new( + WidgetName::TopicList, + WidgetName::HelpLine, + self.get_help_texts().clone(), + )))) + } + fn render(&mut self, rect: Rect, frame: &mut CrosstermStderr) { + let lines = self + .topics + .items + .iter() + .map(|tt| Self::get_item(tt)) + .collect::>(); + + let mut border_style = Style::default(); + + if self.is_active() { + border_style = border_style.fg(Color::Cyan); + } + + let hstyle: Style = Callout::Info.into(); + let items = List::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Topics") + .border_style(border_style), + ) + .highlight_style(hstyle.add_modifier(Modifier::BOLD)); + frame.render_stateful_widget(items, rect, &mut self.topics.state); + } + + fn handler(&mut self, event: KeyEvent) -> AppResult> { + match event.code { + crossterm::event::KeyCode::Up => { + self.topics.previous(); + return self.update_questions(); + } + crossterm::event::KeyCode::Down => { + self.topics.next(); + return self.update_questions(); + } + _ => {} + }; + Ok(None) + } + + fn process_task_response(&mut self, response: TaskResponse) -> AppResult<()> { + if let TaskResponse::AllTopicTags(Response { content, .. }) = response { + self.topics.add_item(TopicTagModel { + name: Some("All".to_owned()), + id: "all".to_owned(), + slug: Some("all".to_owned()), + }); + for tt in content { + self.topics.add_item(tt) + } + } + self.update_questions()?; + Ok(()) + } + + fn setup(&mut self) -> AppResult<()> { + self.get_task_sender() + .send(TaskRequest::GetAllTopicTags(TaskRequestFormat { + widget_name: self.get_widget_name(), + request_id: "".to_string(), + content: (), + })) + .map_err(Box::new)?; + Ok(()) + } + + fn get_common_state(&self) -> &CommonState { + &self.common_state + } + + fn get_common_state_mut(&mut self) -> &mut CommonState { + &mut self.common_state + } + + fn get_notification_queue(&mut self) -> &mut std::collections::VecDeque { + &mut self.common_state.notification_queue + } +} diff --git a/src/config.rs b/src/config.rs index 06fe1e2..3d567ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,16 @@ use std::path::PathBuf; +use tokio::fs::create_dir_all; use tokio::io::AsyncWriteExt; use tokio::{fs::File, io::AsyncReadExt}; use serde::{self, Deserialize, Serialize}; use toml; -use xdg::{self, BaseDirectories}; + +#[cfg(target_family = "windows")] +use std::env; + +#[cfg(target_family = "unix")] +use xdg; use crate::errors::AppResult; @@ -14,20 +20,74 @@ pub async fn write_file(path: PathBuf, contents: &str) -> AppResult<()> { Ok(()) } -#[derive(Deserialize, Serialize, Default)] +#[cfg(target_family = "windows")] +fn get_home_directory() -> String { + env::var("USERPROFILE") + .ok() + .expect("Cannot find the env var USERPROFILE") +} + +#[derive(Deserialize, Serialize, Debug)] pub struct Config { pub db: Db, pub leetcode: Leetcode, + pub solutions_dir: PathBuf, +} + +impl Default for Config { + fn default() -> Self { + let solutions_dir = Self::get_default_solutions_dir().expect("Cannot config base dir"); + Self { + db: Default::default(), + leetcode: Default::default(), + solutions_dir, + } + } } impl Config { - pub fn get_base_directory() -> AppResult { - Ok(xdg::BaseDirectories::with_prefix("leetcode_tui")?) + #[cfg(target_family = "windows")] + pub fn get_config_base_directory() -> AppResult { + let mut home = PathBuf::new(); + home.push(get_home_directory()); + home.push(Self::get_base_name()); + Ok(home) + } + + #[cfg(target_family = "windows")] + pub fn get_data_base_directory() -> AppResult { + Self::get_config_base_directory() + } + + #[cfg(target_family = "unix")] + pub fn get_config_base_directory() -> AppResult { + Ok(xdg::BaseDirectories::with_prefix(Self::get_base_name())?.get_config_home()) + } + + #[cfg(target_family = "unix")] + pub fn get_data_base_directory() -> AppResult { + Ok(xdg::BaseDirectories::with_prefix(Self::get_base_name())?.get_data_home()) + } + + pub fn get_base_name() -> &'static str { + "leetcode_tui" + } + + pub fn get_default_solutions_dir() -> AppResult { + let mut path = Self::get_config_base_directory()?; + path.push("solutions"); + Ok(path) + } + + pub async fn create_solutions_dir() -> AppResult<()> { + let default = Self::get_default_solutions_dir()?; + Ok(create_dir_all(default).await?) } - pub fn get_base_config() -> AppResult { - let config_path = Self::get_base_directory()?.place_config_file("config.toml")?; - Ok(config_path) + pub fn get_config_base_file() -> AppResult { + let mut base_config_dir = Self::get_config_base_directory()?; + base_config_dir.push("config.toml"); + Ok(base_config_dir) } pub async fn read_config(path: PathBuf) -> AppResult { @@ -38,25 +98,36 @@ impl Config { } pub async fn write_config(&self, path: PathBuf) -> AppResult<()> { + create_dir_all( + path.parent() + .unwrap_or_else(|| panic!("Cannot get parent dir of: {}", path.display())), + ) + .await?; write_file(path, toml::to_string(self)?.as_str()).await?; Ok(()) } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Debug)] pub struct Db { pub url: String, } impl Db { pub fn get_base_sqlite_data_path() -> AppResult { - let base_dirs = Config::get_base_directory()?; - let data_file_path = base_dirs.place_data_file("data.sqlite")?; - Ok(data_file_path) + let mut db_path = Config::get_data_base_directory()?; + db_path.push("data.sqlite"); + Ok(db_path) } pub async fn touch_default_db() -> AppResult<()> { let path = Self::get_base_sqlite_data_path()?; + create_dir_all( + path.clone() + .parent() + .expect("cannot get the parent directory"), + ) + .await?; write_file(path, "").await?; Ok(()) } @@ -75,7 +146,7 @@ impl Default for Db { } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Debug)] pub struct Leetcode { #[serde(rename = "LEETCODE_SESSION")] pub leetcode_session: String, @@ -98,6 +169,7 @@ mod tests { #[test] fn test() { let sample_config = [ + "solutions_dir = '/some/xyz/path'", "[db]", "url = 'sqlite://leetcode.sqlite'", "[leetcode]", @@ -106,7 +178,13 @@ mod tests { ] .join("\n"); + let mut pathbuf = PathBuf::new(); + pathbuf.push("/"); + pathbuf.push("some"); + pathbuf.push("xyz"); + pathbuf.push("path"); let config: Config = toml::from_str(sample_config.as_str()).unwrap(); + assert_eq!(config.solutions_dir, pathbuf); assert_eq!(config.leetcode.csrftoken, "ctoken".to_string()); assert_eq!(config.leetcode.leetcode_session, "lsession".to_string()); diff --git a/src/db_ops.rs b/src/db_ops/mod.rs similarity index 100% rename from src/db_ops.rs rename to src/db_ops/mod.rs diff --git a/src/db_ops/question.rs b/src/db_ops/question.rs index ee7533f..8bbc88e 100644 --- a/src/db_ops/question.rs +++ b/src/db_ops/question.rs @@ -6,7 +6,7 @@ use crate::entities::{ }; use crate::errors::AppResult; use crate::{ - deserializers::question::{Question, TopicTag}, + deserializers::problemset_question_list::{Question, TopicTag}, entities::{ prelude::QuestionTopicTag, prelude::TopicTag as TopicTagEntity, diff --git a/src/deserializers.rs b/src/deserializers.rs deleted file mode 100644 index db2d41d..0000000 --- a/src/deserializers.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod custom_serde; -pub mod question; -pub mod question_content; -pub mod topic_tag; diff --git a/src/deserializers/console_panel_config.rs b/src/deserializers/console_panel_config.rs new file mode 100644 index 0000000..f7d681f --- /dev/null +++ b/src/deserializers/console_panel_config.rs @@ -0,0 +1,65 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Question { + pub question_frontend_id: String, + pub question_title: String, + pub example_testcase_list: Vec, + // pub meta_data: String, +} + +#[derive(Debug, Deserialize, PartialEq)] +pub struct Data { + pub question: Question, +} + +#[derive(Debug, Deserialize, PartialEq)] +pub struct Root { + pub data: Data, +} + +#[test] +fn test_parse_json() { + fn parse_json(json_str: &str) -> Root { + serde_json::from_str(json_str).unwrap() + } + let json_str = r#" + { + "data": { + "question": { + "questionFrontendId": "1", + "questionTitle": "Two Sum", + "exampleTestcaseList": [ + "[2,7,11,15]\n9", + "[3,2,4]\n6", + "[3,3]\n6" + ] + } + } + } +"#; + + let expected_question = Question { + question_frontend_id: "1".to_string(), + question_title: "Two Sum".to_string(), + example_testcase_list: vec![ + "[2,7,11,15]\n9".to_string(), + "[3,2,4]\n6".to_string(), + "[3,3]\n6".to_string(), + ], + // meta_data: "{\n \"name\": \"twoSum\",\n \"params\": [\n {\n \"name\": \"nums\",\n \"type\": \"integer[]\"\n },\n {\n \"name\": \"target\",\n \"type\": \"integer\"\n }\n ],\n \"return\": {\n \"type\": \"integer[]\",\n \"size\": 2\n },\n \"manual\": false\n}" + // .to_string(), + }; + + let expected_data = Data { + question: expected_question, + }; + + let expected_root = Root { + data: expected_data, + }; + + let root = parse_json(json_str); + assert_eq!(root, expected_root); +} diff --git a/src/deserializers/custom_serde.rs b/src/deserializers/custom_serde.rs index ab68ace..90aeb54 100644 --- a/src/deserializers/custom_serde.rs +++ b/src/deserializers/custom_serde.rs @@ -2,6 +2,8 @@ use serde; use serde::de::{Deserialize, Deserializer}; +use crate::deserializers::run_submit::StatusMessage; + pub(crate) fn int_from_bool<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -11,3 +13,10 @@ where true => Ok(Some(1)), } } + +pub(crate) fn status_from_id<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.map(StatusMessage::from_status_code)) +} diff --git a/src/deserializers/editor_data.rs b/src/deserializers/editor_data.rs new file mode 100644 index 0000000..d4ca726 --- /dev/null +++ b/src/deserializers/editor_data.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +use crate::graphql::Language; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeSnippet { + pub lang: String, + pub lang_slug: Language, + pub code: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Question { + pub question_id: String, + pub question_frontend_id: String, + pub code_snippets: Vec, + pub title_slug: String, + pub enable_run_code: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct QuestionEditorData { + pub question: Question, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct QuestionData { + pub data: QuestionEditorData, +} diff --git a/src/deserializers/mod.rs b/src/deserializers/mod.rs new file mode 100644 index 0000000..8c9f34d --- /dev/null +++ b/src/deserializers/mod.rs @@ -0,0 +1,6 @@ +pub mod console_panel_config; +pub mod custom_serde; +pub mod editor_data; +pub mod problemset_question_list; +pub mod question_content; +pub mod run_submit; diff --git a/src/deserializers/question.rs b/src/deserializers/problemset_question_list.rs similarity index 95% rename from src/deserializers/question.rs rename to src/deserializers/problemset_question_list.rs index 20615bf..ebbe5b2 100644 --- a/src/deserializers/question.rs +++ b/src/deserializers/problemset_question_list.rs @@ -41,11 +41,11 @@ pub struct Data { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ProblemSetQuestionListQuery { +pub struct Root { data: Data, } -impl ProblemSetQuestionListQuery { +impl Root { pub fn get_questions(self) -> Vec { self.data.problemset_question_list.questions } @@ -58,7 +58,7 @@ impl ProblemSetQuestionListQuery { #[cfg(test)] mod tests { - use super::ProblemSetQuestionListQuery; + use super::Root; use serde_json; #[test] @@ -93,7 +93,7 @@ mod tests { } }"#; - let root: ProblemSetQuestionListQuery = serde_json::from_str(json).unwrap(); + let root: Root = serde_json::from_str(json).unwrap(); // Validate the deserialized struct fields assert_eq!(root.data.problemset_question_list.total, 2777); diff --git a/src/deserializers/question_content.rs b/src/deserializers/question_content.rs index 8a0db76..9acab7a 100644 --- a/src/deserializers/question_content.rs +++ b/src/deserializers/question_content.rs @@ -5,8 +5,10 @@ pub struct QueryQuestionContent { } #[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub struct QuestionContent { pub content: String, + pub title_slug: String, } #[derive(Debug, serde::Deserialize)] diff --git a/src/deserializers/run_submit.rs b/src/deserializers/run_submit.rs new file mode 100644 index 0000000..c2656bb --- /dev/null +++ b/src/deserializers/run_submit.rs @@ -0,0 +1,215 @@ +use crate::graphql::Language; +use crate::{deserializers::custom_serde::status_from_id, errors::AppResult}; +use serde::{Deserialize, Serialize}; +use serde_json::from_value; +use strum::Display; + +#[derive(Debug, Deserialize, Serialize, Display)] +#[serde(rename_all = "UPPERCASE")] +pub enum State { + Pending, + Success, + Started, +} + +#[derive(Debug, Display)] +pub enum StatusMessage { + Accepted, + WrongAnswer, + MemoryLimitExceeded, + OutputLimitExceeded, + TimeLimitExceeded, + RuntimeError, + InternalError, + CompileError, + Timeout, + Unknown, +} + +impl StatusMessage { + pub fn to_status_code(&self) -> u32 { + match self { + StatusMessage::Accepted => 10, + StatusMessage::WrongAnswer => 11, + StatusMessage::MemoryLimitExceeded => 12, + StatusMessage::OutputLimitExceeded => 13, + StatusMessage::TimeLimitExceeded => 14, + StatusMessage::RuntimeError => 15, + StatusMessage::InternalError => 16, + StatusMessage::CompileError => 20, + StatusMessage::Timeout => 30, + StatusMessage::Unknown => 0, + } + } + + pub fn from_status_code(status_code: u32) -> StatusMessage { + match status_code { + 10 => StatusMessage::Accepted, + 11 => StatusMessage::WrongAnswer, + 12 => StatusMessage::MemoryLimitExceeded, + 13 => StatusMessage::OutputLimitExceeded, + 14 => StatusMessage::TimeLimitExceeded, + 15 => StatusMessage::RuntimeError, + 16 => StatusMessage::InternalError, + 20 => StatusMessage::CompileError, + 30 => StatusMessage::Timeout, + _ => StatusMessage::Unknown, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct RunResponse(pub serde_json::Value); + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum IntermediateParsed { + Response { + #[serde(deserialize_with = "status_from_id")] + status_code: Option, + }, + Pending { + state: State, + }, +} + +impl RunResponse { + pub fn to_parsed_response(&self) -> AppResult { + let value = self.0.clone(); + let value_copy = value.clone(); + let intermediate_parsed: IntermediateParsed = from_value(value)?; + let k = match intermediate_parsed { + IntermediateParsed::Pending { .. } => ParsedResponse::Pending, + IntermediateParsed::Response { + status_code: status_message, + .. + } => { + let status_message = status_message.unwrap(); + let status_code = status_message.to_status_code(); + match status_message { + StatusMessage::Accepted | StatusMessage::WrongAnswer => { + ParsedResponse::Success(from_value(value_copy)?) + } + StatusMessage::MemoryLimitExceeded => { + ParsedResponse::MemoryLimitExceeded(from_value(value_copy)?) + } + StatusMessage::OutputLimitExceeded => { + ParsedResponse::OutputLimitExceed(from_value(value_copy)?) + } + StatusMessage::TimeLimitExceeded => { + ParsedResponse::TimeLimitExceeded(from_value(value_copy)?) + } + StatusMessage::RuntimeError => { + ParsedResponse::RuntimeError(from_value(value_copy)?) + } + StatusMessage::InternalError => { + ParsedResponse::InternalError(InternalError { status_code }) + } + StatusMessage::CompileError => { + ParsedResponse::CompileError(from_value(value_copy)?) + } + StatusMessage::Timeout => ParsedResponse::TimeOut(Timeout { status_code }), + StatusMessage::Unknown => ParsedResponse::Unknown(status_code), + } + } + }; + Ok(k) + } +} + +#[derive(Deserialize, Debug)] +pub enum ParsedResponse { + Pending, + CompileError(CompileError), + RuntimeError(RuntimeError), + MemoryLimitExceeded(MemoryLimitExceeded), + OutputLimitExceed(OutputLimitExceed), + TimeLimitExceeded(TimeLimitExceeded), + InternalError(InternalError), + Unknown(u32), + TimeOut(Timeout), + Success(Success), +} + +#[derive(Deserialize, Debug)] +pub struct Timeout { + pub status_code: u32, +} + +#[derive(Deserialize, Debug)] +pub struct CompileError { + pub lang: Language, + pub compile_error: String, + pub full_compile_error: String, +} + +#[derive(Deserialize, Debug)] +pub struct RuntimeError { + pub lang: Language, + pub runtime_error: String, + pub full_runtime_error: String, + pub memory: u32, + pub elapsed_time: u32, +} + +#[derive(Deserialize, Debug)] +pub struct MemoryLimitExceeded { + pub memory: u32, +} + +#[derive(Deserialize, Debug)] +pub struct InternalError { + pub status_code: u32, +} + +#[derive(Deserialize, Debug)] +pub struct TimeLimitExceeded { + pub elapsed_time: u32, +} + +#[derive(Deserialize, Debug)] +pub struct OutputLimitExceed { + pub memory: u32, + pub question_id: String, + pub compare_result: String, + pub std_output: String, + pub last_testcase: String, + pub expected_output: String, + pub finished: bool, + pub total_correct: i32, + pub total_testcases: i32, + pub submission_id: String, +} + +#[derive(Deserialize, Debug)] +#[serde(untagged)] +pub enum Success { + Run { + status_runtime: String, + memory: u32, + question_id: Option, + elapsed_time: u32, + code_answer: Vec, + std_output_list: Vec, + expected_code_answer: Vec, + correct_answer: bool, + total_correct: Option, + total_testcases: Option, + runtime_percentile: Option, + status_memory: String, + memory_percentile: Option, + }, + Submit { + status_runtime: String, + memory: u32, + question_id: Option, + elapsed_time: u32, + std_output: String, + expected_output: String, + total_correct: Option, + total_testcases: Option, + runtime_percentile: Option, + status_memory: String, + memory_percentile: Option, + }, +} diff --git a/src/deserializers/topic_tag.rs b/src/deserializers/topic_tag.rs deleted file mode 100644 index 8a42724..0000000 --- a/src/deserializers/topic_tag.rs +++ /dev/null @@ -1,73 +0,0 @@ -use serde; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Question { - pub topic_tags: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProblemSetQuestionList { - questions: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct Data { - problemset_question_list: ProblemSetQuestionList, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProblemSetQuestionListRoot { - data: Data, -} - -impl ProblemSetQuestionListRoot { - pub fn get_questions_with_topics(&mut self) -> &mut Vec { - &mut self.data.problemset_question_list.questions - } -} - -#[cfg(test)] -mod tests { - - use super::*; - use serde_json; - - #[test] - fn test_json_deserialization() { - let json = r#"{ - "data": { - "problemsetQuestionList": { - "total": 2777, - "questions": [ - { - "topicTags": [ - { - "name": "String", - "id": "VG9waWNUYWdOb2RlOjEw", - "slug": "string" - } - ] - } - ] - } - } - }"#; - - let root: ProblemSetQuestionListRoot = serde_json::from_str(json).unwrap(); - - // Validate the deserialized struct fields - let question = &root.data.problemset_question_list.questions[0]; - - assert_eq!(question.topic_tags.len(), 1); - - let topic_tag = &question.topic_tags[0]; - assert_eq!(topic_tag.id, "VG9waWNUYWdOb2RlOjEw"); - assert_eq!(topic_tag.name, Some("String".into())); - assert_eq!(topic_tag.slug, Some("string".into())); - } -} diff --git a/src/errors.rs b/src/errors.rs index bdbaf53..198341e 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -2,7 +2,7 @@ use sea_orm::error::DbErr; use thiserror::Error; -use crate::app_ui::channel::*; +use crate::app_ui::async_task_channel::*; use crate::app_ui::event::Event; #[derive(Error, Debug)] @@ -14,13 +14,13 @@ pub enum LcAppError { SyncReceiveError(#[from] std::sync::mpsc::RecvError), #[error("Task request send error sync to async context: {0}")] - RequestSendError(#[from] RequestSendError), + RequestSendError(#[from] Box), #[error("Task request receive error sync to async context: {0}")] RequestRecvError(#[from] RequestRecvError), #[error("Task response send error async to sync context: {0}")] - ResponseSendError(#[from] ResponseSendError), + ResponseSendError(#[from] Box), #[error("Task response receive error async to sync context: {0}")] ResponseReceiveError(#[from] ResponseReceiveError), @@ -40,6 +40,7 @@ pub enum LcAppError { #[error("Database Error encountered {0}")] DatabaseError(#[from] DbErr), + #[cfg(target_family = "unix")] #[error("Maybe could not find xdg dirs {0}")] XDGError(#[from] xdg::BaseDirectoriesError), @@ -55,13 +56,12 @@ pub enum LcAppError { #[error("Tokio join handle error")] TokioThreadJoinError(#[from] tokio::task::JoinError), - // #[error("Crossterm Error")] - // CrossTermError(#[from] crossterm::ErrorKind), + #[error("Key Combination already exists")] + KeyCombiExist(String), + + #[error("Editor open error: {0}")] + EditorOpen(String), - // #[error("the data for key `{0}` is not available")] - // Redaction(String), - // #[error("invalid header (expected {expected:?}, found {found:?})")] - // InvalidHeader { expected: String, found: String }, #[error("unknown lc app error")] Unknown, } diff --git a/src/graphql.rs b/src/graphql.rs deleted file mode 100644 index 3c5abc9..0000000 --- a/src/graphql.rs +++ /dev/null @@ -1,34 +0,0 @@ -use async_trait::async_trait; -use serde::{de::DeserializeOwned, Serialize}; -use serde_json::{json, Value}; -pub mod problemset_question_list; -pub mod question_content; -use crate::errors::AppResult; - -pub type QuestionContentQuery = question_content::Query; - -const LEETCODE_GRAPHQL_ENDPOINT: &str = "https://leetcode.com/graphql"; - -#[async_trait] -pub trait GQLLeetcodeQuery: Serialize { - type T: DeserializeOwned; - - fn get_body(&self) -> Value { - json!(self) - } - - fn get_endpoint(&self) -> &'static str { - LEETCODE_GRAPHQL_ENDPOINT - } - - async fn post(&self, client: &reqwest::Client) -> AppResult { - Ok(client - .post(self.get_endpoint()) - .header("Content-Type", "application/json") - .json(&self.get_body()) - .send() - .await? - .json() - .await?) - } -} diff --git a/src/graphql/console_panel_config.rs b/src/graphql/console_panel_config.rs new file mode 100644 index 0000000..d7fdce0 --- /dev/null +++ b/src/graphql/console_panel_config.rs @@ -0,0 +1,39 @@ +use super::GQLLeetcodeQuery; +use serde::Serialize; + +const QUERY: &str = r#" +query consolePanelConfig($titleSlug: String!) { + question(titleSlug: $titleSlug) { + questionFrontendId + questionTitle + exampleTestcaseList + # metaData + } +} +"#; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Variables { + title_slug: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Query { + query: &'static str, + variables: Variables, +} + +impl Query { + pub fn new(title_slug: String) -> Self { + Self { + query: QUERY, + variables: Variables { title_slug }, + } + } +} + +impl GQLLeetcodeQuery for Query { + type T = crate::deserializers::console_panel_config::Root; +} diff --git a/src/graphql/editor_data.rs b/src/graphql/editor_data.rs new file mode 100644 index 0000000..e23a81f --- /dev/null +++ b/src/graphql/editor_data.rs @@ -0,0 +1,45 @@ +use super::GQLLeetcodeQuery; +use serde::Serialize; + +const QUERY: &str = r#" +query questionEditorData($titleSlug: String!) { + question(titleSlug: $titleSlug) { + questionId + titleSlug + questionFrontendId + codeSnippets { + lang + langSlug + code + } + envInfo + enableRunCode + } +} +"#; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Variables { + title_slug: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Query { + query: &'static str, + variables: Variables, +} + +impl Query { + pub fn new(title_slug: String) -> Self { + Self { + query: QUERY, + variables: Variables { title_slug }, + } + } +} + +impl GQLLeetcodeQuery for Query { + type T = crate::deserializers::editor_data::QuestionData; +} diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs new file mode 100644 index 0000000..5ae59a3 --- /dev/null +++ b/src/graphql/mod.rs @@ -0,0 +1,456 @@ +use std::fmt::Display; + +use async_trait::async_trait; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Value}; +pub mod console_panel_config; +pub mod editor_data; +pub mod problemset_question_list; +pub mod question_content; +pub mod run_code; +pub mod submit_code; +use crate::errors::AppResult; + +pub type QuestionContentQuery = question_content::Query; + +#[async_trait] +pub trait GQLLeetcodeQuery: Serialize + Sync { + type T: DeserializeOwned; + + fn get_body(&self) -> Value { + json!(self) + } + + fn is_post(&self) -> bool { + true + } + + /// Default graphql endpoint + fn get_endpoint(&self) -> String { + "https://leetcode.com/graphql".to_string() + } + + async fn post(&self, client: &reqwest::Client) -> AppResult { + let request = if self.is_post() { + client.post(self.get_endpoint()).json(&self.get_body()) + } else { + client.get(self.get_endpoint()) + }; + Ok(request + .header("Content-Type", "application/json") + .send() + .await? + .json() + .await?) + } +} + +#[derive(Debug)] +pub enum RunOrSubmitCode { + Run(RunCode), + Submit(SubmitCode), +} + +impl RunOrSubmitCode { + pub async fn post(&self, client: &reqwest::Client) -> AppResult { + match self { + RunOrSubmitCode::Run(run) => self.poll_check_response(client, run).await, + RunOrSubmitCode::Submit(submit) => self.poll_check_response(client, submit).await, + } + } + + pub async fn poll_check_response>( + &self, + client: &reqwest::Client, + body: &impl GQLLeetcodeQuery, + ) -> AppResult { + let run_response: T = body.post(client).await?; + loop { + let status_check = run_response.post(client).await?; + let parsed_response = status_check.to_parsed_response()?; + match parsed_response { + ParsedResponse::Pending => {} + _ => return Ok(parsed_response), + } + } + } +} + +use serde::Deserialize; + +use self::{run_code::RunCode, submit_code::SubmitCode}; +use crate::deserializers::run_submit::{ParsedResponse, RunResponse}; + +#[derive(Debug, Deserialize, Serialize)] +struct LanguageInfo { + id: u32, + name: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct Data { + language_list: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct Languages { + data: Data, +} + +// Generate the enum for languages +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Hash)] +#[serde(rename_all = "lowercase")] +pub enum Language { + Cpp, + Java, + Python, + Python3, + Mysql, + Mssql, + Oraclesql, + C, + Csharp, + Javascript, + Ruby, + Bash, + Swift, + Golang, + Scala, + Html, + Pythonml, + Kotlin, + Rust, + Php, + Typescript, + Racket, + Erlang, + Elixir, + Dart, + Pythondata, + React, + Unknown(u32), +} + +impl Language { + pub fn from_id(id: u32) -> Language { + match id { + 0 => Language::Cpp, + 1 => Language::Java, + 2 => Language::Python, + 3 => Language::Mysql, + 4 => Language::C, + 5 => Language::Csharp, + 6 => Language::Javascript, + 7 => Language::Ruby, + 8 => Language::Bash, + 9 => Language::Swift, + 10 => Language::Golang, + 11 => Language::Python3, + 12 => Language::Scala, + 13 => Language::Kotlin, + 14 => Language::Mssql, + 15 => Language::Oraclesql, + 16 => Language::Html, + 17 => Language::Pythonml, + 18 => Language::Rust, + 19 => Language::Php, + 20 => Language::Typescript, + 21 => Language::Racket, + 22 => Language::Erlang, + 23 => Language::Elixir, + 24 => Language::Dart, + 25 => Language::Pythondata, + 26 => Language::React, + _ => Language::Unknown(id), + } + } + + pub fn to_id(&self) -> u32 { + match self { + Language::Cpp => 0, + Language::Java => 1, + Language::Python => 2, + Language::Mysql => 3, + Language::C => 4, + Language::Csharp => 5, + Language::Javascript => 6, + Language::Ruby => 7, + Language::Bash => 8, + Language::Swift => 9, + Language::Golang => 10, + Language::Python3 => 11, + Language::Scala => 12, + Language::Kotlin => 13, + Language::Mssql => 14, + Language::Oraclesql => 15, + Language::Html => 16, + Language::Pythonml => 17, + Language::Rust => 18, + Language::Php => 19, + Language::Typescript => 20, + Language::Racket => 21, + Language::Erlang => 22, + Language::Elixir => 23, + Language::Dart => 24, + Language::Pythondata => 25, + Language::React => 26, + Language::Unknown(id) => *id, + } + } + + pub fn comment_text(&self, input_text: &str) -> String { + let (comment_start, comment_end) = match self { + Language::Cpp + | Language::C + | Language::Scala + | Language::Java + | Language::Javascript + | Language::Swift + | Language::Golang + | Language::Rust + | Language::Kotlin => ("/*\n", "\n*/"), + Language::Python | Language::Python3 => ("'''\n", "\n'''"), + Language::Mysql | Language::Mssql | Language::Oraclesql => ("-- ", ""), + Language::Csharp => ("// ", ""), + Language::Ruby => ("=begin\n", "\n=end"), + Language::Bash => ("# ", ""), + Language::Html => (""), + Language::Pythonml => ("# ", ""), + // => ("// ", ""), + Language::Php => ("// ", ""), + Language::Typescript => ("// ", ""), + Language::Racket => ("; ", ""), + Language::Erlang => ("% ", ""), + Language::Elixir => ("# ", ""), + Language::Dart => ("// ", ""), + Language::Pythondata => ("# ", ""), + Language::React => ("// ", ""), + Language::Unknown(_) => ("", ""), + }; + + match self { + Language::C + | Language::Html + | Language::Cpp + | Language::Python + | Language::Python3 + | Language::Ruby + | Language::Javascript + | Language::Scala + | Language::Java + | Language::Swift + | Language::Golang + | Language::Kotlin + | Language::Rust => { + format!("{}{}{}", comment_start, input_text, comment_end) + } + _ => { + let commented_lines: Vec = input_text + .lines() + .map(|line| format!("{}{}", comment_start, line)) + .collect(); + + commented_lines.join("\n") + } + } + } + + pub fn get_extension(&self) -> &str { + match self { + Language::Cpp => "cpp", + Language::Java => "java", + Language::Python => "py", + Language::Python3 => "py", + Language::Mysql => "sql", + Language::Mssql => "sql", + Language::Oraclesql => "sql", + Language::C => "c", + Language::Csharp => "cs", + Language::Javascript => "js", + Language::Ruby => "rb", + Language::Bash => "sh", + Language::Swift => "swift", + Language::Golang => "go", + Language::Scala => "scala", + Language::Html => "html", + Language::Pythonml => "py", + Language::Kotlin => "kt", + Language::Rust => "rs", + Language::Php => "php", + Language::Typescript => "ts", + Language::Racket => "rkt", + Language::Erlang => "erl", + Language::Elixir => "ex", + Language::Dart => "dart", + Language::Pythondata => "py", + Language::React => "jsx", + Language::Unknown(_) => "", + } + } +} + +impl Display for Language { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let k = match self { + Language::Cpp => "cpp".to_string(), + Language::Java => "java".to_string(), + Language::Python => "python".to_string(), + Language::Python3 => "python3".to_string(), + Language::Mysql => "mysql".to_string(), + Language::Mssql => "mssql".to_string(), + Language::Oraclesql => "oraclesql".to_string(), + Language::C => "c".to_string(), + Language::Csharp => "csharp".to_string(), + Language::Javascript => "javascript".to_string(), + Language::Ruby => "ruby".to_string(), + Language::Bash => "bash".to_string(), + Language::Swift => "swift".to_string(), + Language::Golang => "golang".to_string(), + Language::Scala => "scala".to_string(), + Language::Html => "html".to_string(), + Language::Pythonml => "pythonml".to_string(), + Language::Kotlin => "kotlin".to_string(), + Language::Rust => "rust".to_string(), + Language::Php => "php".to_string(), + Language::Typescript => "typescript".to_string(), + Language::Racket => "racket".to_string(), + Language::Erlang => "erlang".to_string(), + Language::Elixir => "elixir".to_string(), + Language::Dart => "dart".to_string(), + Language::Pythondata => "pythondata".to_string(), + Language::React => "react".to_string(), + Language::Unknown(id) => format!("{}", id), + }; + f.write_str(k.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_comment_text() { + let test_cases = [ + // Test for C++ + ( + Language::Cpp, + "This is a single-line comment.", + "/*\nThis is a single-line comment.\n*/", + ), + ( + Language::Cpp, + "This is a multi-line text.\nIt can have multiple lines.", + "/*\nThis is a multi-line text.\nIt can have multiple lines.\n*/", + ), + ( + Language::Python, + "This is a single-line comment.", + "'''\nThis is a single-line comment.\n'''", + ), + ( + Language::Python, + "This is a multi-line text.\nIt can have multiple lines.", + "'''\nThis is a multi-line text.\nIt can have multiple lines.\n'''", + ), + // Test for C + ( + Language::C, + "This is a single-line comment.", + "/*\nThis is a single-line comment.\n*/", + ), + ( + Language::C, + "This is a multi-line text.\nIt can have multiple lines.", + "/*\nThis is a multi-line text.\nIt can have multiple lines.\n*/", + ), + // Test for HTML + ( + Language::Html, + "This is a single-line comment.", + "", + ), + ( + Language::Html, + "This is a multi-line text.\nIt can have multiple lines.", + "", + ), + // Test for Unknown language + ( + Language::Unknown(999), + "This is a single-line comment.", + "This is a single-line comment.", + ), + ( + Language::Unknown(999), + "This is a multi-line text.\nIt can have multiple lines.", + "This is a multi-line text.\nIt can have multiple lines.", + ), + ]; + + for (language, input_text, expected_output) in &test_cases { + assert_eq!(language.comment_text(input_text), *expected_output); + } + } + + use std::collections::HashMap; + #[test] + fn test() { + // JSON data as a string + let json_data = r#" + { + "data": { + "languageList": [ + { "id": 0, "name": "cpp" }, + { "id": 1, "name": "java" }, + { "id": 2, "name": "python" }, + { "id": 11, "name": "python3" }, + { "id": 3, "name": "mysql" }, + { "id": 14, "name": "mssql" }, + { "id": 15, "name": "oraclesql" }, + { "id": 4, "name": "c" }, + { "id": 5, "name": "csharp" }, + { "id": 6, "name": "javascript" }, + { "id": 7, "name": "ruby" }, + { "id": 8, "name": "bash" }, + { "id": 9, "name": "swift" }, + { "id": 10, "name": "golang" }, + { "id": 12, "name": "scala" }, + { "id": 16, "name": "html" }, + { "id": 17, "name": "pythonml" }, + { "id": 13, "name": "kotlin" }, + { "id": 18, "name": "rust" }, + { "id": 19, "name": "php" }, + { "id": 20, "name": "typescript" }, + { "id": 21, "name": "racket" }, + { "id": 22, "name": "erlang" }, + { "id": 23, "name": "elixir" }, + { "id": 24, "name": "dart" }, + { "id": 25, "name": "pythondata" }, + { "id": 26, "name": "react" } + ] + } + } + "#; + + // Parse JSON data into the Languages struct + let languages: Languages = serde_json::from_str(json_data).unwrap(); + + // Extract the languageList + let language_list = languages.data.language_list; + + // Create a HashMap to store Language enums by id + let mut language_map: HashMap = HashMap::new(); + for lang_info in language_list { + language_map.insert(lang_info.id, Language::from_id(lang_info.id)); + } + + // Example: Accessing the language by id + let id_to_find = 2; // Example: "python" + if let Some(lang) = language_map.get(&id_to_find) { + println!("Language with id {}: {:?}", id_to_find, lang); + } + } +} diff --git a/src/graphql/problemset_question_list.rs b/src/graphql/problemset_question_list.rs index f77ef9a..96946af 100644 --- a/src/graphql/problemset_question_list.rs +++ b/src/graphql/problemset_question_list.rs @@ -91,8 +91,8 @@ impl Default for Query { } } } -use crate::deserializers::question::ProblemSetQuestionListQuery; +use crate::deserializers::problemset_question_list::Root; impl GQLLeetcodeQuery for Query { - type T = ProblemSetQuestionListQuery; + type T = Root; } diff --git a/src/graphql/question_content.rs b/src/graphql/question_content.rs index b1e800d..ae01ed2 100644 --- a/src/graphql/question_content.rs +++ b/src/graphql/question_content.rs @@ -5,6 +5,7 @@ const QUERY: &str = r#" query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content + titleSlug } } "#; diff --git a/src/graphql/run_code.rs b/src/graphql/run_code.rs new file mode 100644 index 0000000..c876e61 --- /dev/null +++ b/src/graphql/run_code.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; + +use super::{GQLLeetcodeQuery, Language}; +use crate::deserializers::run_submit::RunResponse; + +#[derive(Debug, Deserialize, Serialize)] +pub struct RunCode { + pub lang: Language, + pub question_id: String, + pub typed_code: String, + #[serde(rename = "data_input")] + pub test_cases_stdin: Option, + #[serde(skip_serializing, skip_deserializing)] + pub slug: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct RunCodeResponse { + interpret_id: String, + test_case: String, +} + +impl GQLLeetcodeQuery for RunCode { + type T = RunCodeResponse; + + fn get_endpoint(&self) -> String { + let slug = self.slug.as_str(); + format!("https://leetcode.com/problems/{slug}/interpret_solution/") + } +} + +impl GQLLeetcodeQuery for RunCodeResponse { + type T = RunResponse; + fn is_post(&self) -> bool { + false + } + + fn get_endpoint(&self) -> String { + let interpret_id = self.interpret_id.as_str(); + format!("https://leetcode.com/submissions/detail/{interpret_id}/check/") + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::RunCode; + + #[test] + fn test() { + let s = RunCode { + lang: crate::graphql::Language::Python3, + question_id: "1".to_string(), + typed_code: "class Solution:\n def twoSum(self, nums: List[int], target: int) -> List[int]: return [4]".to_string(), + test_cases_stdin: Some("[2,7,11,15]\n9\n[3,2,4]\n6\n[3,3]\n6".to_string()), + slug: "".to_string(), + }; + + let from_struct = json!(s); + let from_raw_json = json!( + { + "lang": "python3", + "question_id": "1", + "typed_code": "class Solution:\n def twoSum(self, nums: List[int], target: int) -> List[int]: return [4]", + "data_input": "[2,7,11,15]\n9\n[3,2,4]\n6\n[3,3]\n6" + } + ); + assert_eq!(from_struct, from_raw_json); + } +} diff --git a/src/graphql/submit_code.rs b/src/graphql/submit_code.rs new file mode 100644 index 0000000..50fc0a9 --- /dev/null +++ b/src/graphql/submit_code.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; + +use super::{GQLLeetcodeQuery, Language}; +use crate::deserializers::run_submit::RunResponse; + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +pub struct SubmitCode { + pub lang: Language, + pub question_id: String, + pub typed_code: String, + #[serde(skip_serializing, skip_deserializing)] + pub slug: String, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +pub struct SubmitCodeResponse { + submission_id: u32, +} + +impl GQLLeetcodeQuery for SubmitCode { + type T = SubmitCodeResponse; + + fn get_endpoint(&self) -> String { + let slug = self.slug.as_str(); + format!("https://leetcode.com/problems/{slug}/submit/") + } +} + +/// It may take indefinite time to run the solution on leetcode. +/// Hence polling is done to retrieve the run status from the server. +impl GQLLeetcodeQuery for SubmitCodeResponse { + type T = RunResponse; + fn is_post(&self) -> bool { + false + } + + fn get_endpoint(&self) -> String { + let submission_id = self.submission_id; + format!("https://leetcode.com/submissions/detail/{submission_id}/check/") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test() { + // Request JSON data as a string + let request_json_data = r#" + { + "lang": "python3", + "question_id": "1", + "typed_code": "class Solution:\n def twoSum(self, nums: List[int], target: int) -> List[int]: return [1]" + } + "#; + + // Parse Request JSON data into the RequestBody struct + let request_body: SubmitCode = serde_json::from_str(request_json_data).unwrap(); + println!("Request Body: {:?}", request_body); + + // Define expected request body + let expected_request_body = SubmitCode { + lang: super::super::Language::Python3, + question_id: "1".to_string(), + typed_code: "class Solution:\n def twoSum(self, nums: List[int], target: int) -> List[int]: return [1]".to_string(), + slug: "".to_string() + }; + + // Test if the parsed request body matches the expected request body + assert_eq!(request_body, expected_request_body); + + // Response JSON data as a string + let response_json_data = r#" + { + "submission_id": 1001727658 + } + "#; + + // Parse Response JSON data into the ResponseBody struct + let response_body: SubmitCodeResponse = serde_json::from_str(response_json_data).unwrap(); + println!("Response Body: {:?}", response_body); + + // Define expected response body + let expected_response_body = SubmitCodeResponse { + submission_id: 1001727658, + }; + + // Test if the parsed response body matches the expected response body + assert_eq!(response_body, expected_response_body); + } +} diff --git a/src/main.rs b/src/main.rs index 98f4b4c..8ed1087 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,27 @@ -use leetcode_tui_rs::app_ui::channel::{request_channel, response_channel}; -use leetcode_tui_rs::app_ui::channel::{ChannelRequestSender, ChannelResponseReceiver}; -use leetcode_tui_rs::app_ui::list::StatefulList; +use leetcode_tui_rs::app_ui::async_task_channel::{request_channel, response_channel}; +use leetcode_tui_rs::app_ui::async_task_channel::{ChannelRequestSender, ChannelResponseReceiver}; use leetcode_tui_rs::app_ui::tui::Tui; use leetcode_tui_rs::config::Config; -use leetcode_tui_rs::entities::{QuestionEntity, QuestionModel}; +use leetcode_tui_rs::entities::QuestionEntity; use leetcode_tui_rs::errors::AppResult; use sea_orm::Database; use tokio::task::JoinHandle; -use leetcode_tui_rs::app_ui::app::{App, Widget}; -use leetcode_tui_rs::app_ui::event::{look_for_events, Event, EventHandler}; +use leetcode_tui_rs::app_ui::app::App; +use leetcode_tui_rs::app_ui::event::{ + look_for_events, vim_ping_channel, Event, EventHandler, VimPingSender, +}; use leetcode_tui_rs::app_ui::handler::handle_key_events; -use leetcode_tui_rs::entities::topic_tag::Model as TopicTagModel; + use leetcode_tui_rs::utils::{ - do_migrations, get_config, get_reqwest_client, tasks_executor, update_database_questions, + async_tasks_executor, do_migrations, get_config, get_reqwest_client, update_database_questions, }; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; -use std::io::{self, Stderr}; +use std::io; +use std::rc::Rc; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; #[tokio::main] async fn main() -> AppResult<()> { @@ -49,7 +53,7 @@ async fn main() -> AppResult<()> { let client = client.clone(); let task_receiver_from_app: JoinHandle> = tokio::spawn(async move { - tasks_executor(rx_request, tx_response, &client, &database_client).await?; + async_tasks_executor(rx_request, tx_response, &client, &database_client).await?; Ok(()) }); @@ -58,7 +62,7 @@ async fn main() -> AppResult<()> { let (ev_sender, ev_receiver) = std::sync::mpsc::channel(); - let mut tui = Tui::new( + let tui = Tui::new( terminal, EventHandler { sender: ev_sender.clone(), @@ -66,11 +70,16 @@ async fn main() -> AppResult<()> { }, ); - tui.init()?; - tokio::task::spawn_blocking(move || run_app(tx_request, rx_response, tui).unwrap()); + let vim_running = Arc::new(AtomicBool::new(false)); + let vim_running_loop_ref = vim_running.clone(); + let (vim_tx, vim_rx) = vim_ping_channel(10); + + tokio::task::spawn_blocking(move || { + run_app(tx_request, rx_response, tui, vim_tx, vim_running, config).unwrap() + }); // blog post does not work in separate thread - match look_for_events(100, ev_sender).await { + match look_for_events(100, ev_sender, vim_running_loop_ref, vim_rx).await { Ok(_) => Ok(()), Err(e) => match e { leetcode_tui_rs::errors::LcAppError::SyncSendError(_) => Ok(()), @@ -86,23 +95,14 @@ async fn main() -> AppResult<()> { fn run_app( tx_request: ChannelRequestSender, rx_response: ChannelResponseReceiver, - mut tui: Tui>, + mut tui: Tui, + vim_tx: VimPingSender, + vim_running: Arc, + config: Config, ) -> AppResult<()> { - let topic_tags: Vec = vec![TopicTagModel { - name: Some("All".to_string()), - id: "all".to_string(), - slug: Some("all".to_string()), - }]; - - let questions = vec![]; - - let mut qm: StatefulList = StatefulList::with_items(questions); - let mut ttm: StatefulList = StatefulList::with_items(topic_tags); - let question_stateful = Widget::QuestionList(&mut qm); - let topic_tag_stateful = Widget::TopicTagList(&mut ttm); - let mut vw = vec![topic_tag_stateful, question_stateful]; - - let mut app = App::new(&mut vw, tx_request, rx_response)?; + let config = Rc::new(config); + tui.init()?; + let mut app = App::new(tx_request, rx_response, vim_tx, vim_running, config)?; // Start the main loop. while app.running { @@ -110,10 +110,15 @@ fn run_app( tui.draw(&mut app)?; // Handle events. match tui.events.next()? { - Event::Tick => app.tick(), - Event::Key(key_event) => handle_key_events(key_event, &mut app)?, + Event::Tick => app.tick()?, + Event::Key(key_event) => { + let notif = handle_key_events(key_event, &mut app)?; + app.pending_notifications.push_back(notif); + app.process_pending_notification()?; + } Event::Mouse(_) => {} Event::Resize(_, _) => {} + Event::Redraw => tui.reinit()?, } } diff --git a/src/utils.rs b/src/utils.rs index 39fe689..692dd50 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,4 @@ -use crate::deserializers::question::Question; +use crate::deserializers::problemset_question_list::Question; use crate::errors::AppResult; use crate::graphql::problemset_question_list::Query as QuestionDbQuery; use crate::graphql::GQLLeetcodeQuery; @@ -73,18 +73,21 @@ pub async fn get_reqwest_client(config: &Config) -> AppResult { use crate::config::Db; pub async fn get_config() -> AppResult> { - let config_path = Config::get_base_config()?; + let config_path = Config::get_config_base_file()?; let config: Config; if !config_path.exists() { config = Config::default(); - config.write_config(Config::get_base_config()?).await?; + config.write_config(config_path.clone()).await?; println!("\nConfig is created at config_path {}.\n\nKindly set LEETCODE_SESSION and csrftoken in the config file. These can be obained from leetcode cookies in the browser.", config_path.display()); let db_data_path = Db::get_base_sqlite_data_path()?; if !db_data_path.exists() { Db::touch_default_db().await?; println!("\nDatabase resides in {}", db_data_path.display()); } + if !Config::get_default_solutions_dir()?.exists() { + Config::create_solutions_dir().await?; + } Ok(None) } else { println!("Config file found @ {}", &config_path.display()); @@ -93,9 +96,9 @@ pub async fn get_config() -> AppResult> { } } -use crate::app_ui::channel::{ChannelRequestReceiver, ChannelResponseSender}; +use crate::app_ui::async_task_channel::{ChannelRequestReceiver, ChannelResponseSender}; -pub async fn tasks_executor( +pub async fn async_tasks_executor( mut rx_request: ChannelRequestReceiver, tx_response: ChannelResponseSender, client: &reqwest::Client, @@ -103,7 +106,7 @@ pub async fn tasks_executor( ) -> AppResult<()> { while let Some(task) = rx_request.recv().await { let response = task.execute(client, conn).await; - tx_response.send(response)?; + tx_response.send(response).map_err(Box::new)?; } Ok(()) } diff --git a/tests/test_editor_data.json b/tests/test_editor_data.json new file mode 100644 index 0000000..b5c9f5e --- /dev/null +++ b/tests/test_editor_data.json @@ -0,0 +1,107 @@ +{ + "data": { + "question": { + "questionId": "1", + "questionFrontendId": "1", + "codeSnippets": [ + { + "lang": "C++", + "langSlug": "cpp", + "code": "class Solution {\npublic:\n vector twoSum(vector& nums, int target) {\n \n }\n};" + }, + { + "lang": "Java", + "langSlug": "java", + "code": "class Solution {\n public int[] twoSum(int[] nums, int target) {\n \n }\n}" + }, + { + "lang": "Python", + "langSlug": "python", + "code": "class Solution(object):\n def twoSum(self, nums, target):\n \"\"\"\n :type nums: List[int]\n :type target: int\n :rtype: List[int]\n \"\"\"\n " + }, + { + "lang": "Python3", + "langSlug": "python3", + "code": "class Solution:\n def twoSum(self, nums: List[int], target: int) -> List[int]:\n " + }, + { + "lang": "C", + "langSlug": "c", + "code": "/**\n * Note: The returned array must be malloced, assume caller calls free().\n */\nint* twoSum(int* nums, int numsSize, int target, int* returnSize){\n\n}" + }, + { + "lang": "C#", + "langSlug": "csharp", + "code": "public class Solution {\n public int[] TwoSum(int[] nums, int target) {\n \n }\n}" + }, + { + "lang": "JavaScript", + "langSlug": "javascript", + "code": "/**\n * @param {number[]} nums\n * @param {number} target\n * @return {number[]}\n */\nvar twoSum = function(nums, target) {\n \n};" + }, + { + "lang": "Ruby", + "langSlug": "ruby", + "code": "# @param {Integer[]} nums\n# @param {Integer} target\n# @return {Integer[]}\ndef two_sum(nums, target)\n \nend" + }, + { + "lang": "Swift", + "langSlug": "swift", + "code": "class Solution {\n func twoSum(_ nums: [Int], _ target: Int) -> [Int] {\n \n }\n}" + }, + { + "lang": "Go", + "langSlug": "golang", + "code": "func twoSum(nums []int, target int) []int {\n \n}" + }, + { + "lang": "Scala", + "langSlug": "scala", + "code": "object Solution {\n def twoSum(nums: Array[Int], target: Int): Array[Int] = {\n \n }\n}" + }, + { + "lang": "Kotlin", + "langSlug": "kotlin", + "code": "class Solution {\n fun twoSum(nums: IntArray, target: Int): IntArray {\n \n }\n}" + }, + { + "lang": "Rust", + "langSlug": "rust", + "code": "impl Solution {\n pub fn two_sum(nums: Vec, target: i32) -> Vec {\n \n }\n}" + }, + { + "lang": "PHP", + "langSlug": "php", + "code": "class Solution {\n\n /**\n * @param Integer[] $nums\n * @param Integer $target\n * @return Integer[]\n */\n function twoSum($nums, $target) {\n \n }\n}" + }, + { + "lang": "TypeScript", + "langSlug": "typescript", + "code": "function twoSum(nums: number[], target: number): number[] {\n\n};" + }, + { + "lang": "Racket", + "langSlug": "racket", + "code": "(define/contract (two-sum nums target)\n (-> (listof exact-integer?) exact-integer? (listof exact-integer?))\n\n )" + }, + { + "lang": "Erlang", + "langSlug": "erlang", + "code": "-spec two_sum(Nums :: [integer()], Target :: integer()) -> [integer()].\ntwo_sum(Nums, Target) ->\n ." + }, + { + "lang": "Elixir", + "langSlug": "elixir", + "code": "defmodule Solution do\n @spec two_sum(nums :: [integer], target :: integer) :: [integer]\n def two_sum(nums, target) do\n\n end\nend" + }, + { + "lang": "Dart", + "langSlug": "dart", + "code": "class Solution {\n List twoSum(List nums, int target) {\n\n }\n}" + } + ], + "enableRunCode": true, + "titleSlug": "two-sum" + } + } +} diff --git a/tests/test_editor_data.rs b/tests/test_editor_data.rs new file mode 100644 index 0000000..9a29278 --- /dev/null +++ b/tests/test_editor_data.rs @@ -0,0 +1,8 @@ +use leetcode_tui_rs::deserializers::editor_data::QuestionData; + +#[test] +fn test_parse_editor_data() { + let qdata: QuestionData = + serde_json::from_str(include_str!("./test_editor_data.json")).unwrap(); + dbg!(qdata); +} diff --git a/tests/test_submit.json b/tests/test_submit.json new file mode 100644 index 0000000..529e8a8 --- /dev/null +++ b/tests/test_submit.json @@ -0,0 +1,212 @@ +{ + "output_limit": { + "status_code": 13, + "lang": "python3", + "run_success": false, + "status_runtime": "N/A", + "memory": 16412000, + "question_id": "567", + "elapsed_time": 110, + "compare_result": "111111111111111111111111111111111111111111111111111111111111111111111111111111000000000000000000000000000000", + "code_output": "", + "std_output": "some_long_string", + "last_testcase": "maybe long testcase", + "expected_output": "true", + "task_finish_time": 1690195580348, + "task_name": "judger.judgetask.Judge", + "finished": true, + "total_correct": 78, + "total_testcases": 108, + "runtime_percentile": null, + "status_memory": "N/A", + "memory_percentile": null, + "pretty_lang": "Python3", + "submission_id": "1002544072", + "status_msg": "Output Limit Exceeded", + "state": "SUCCESS" + }, + "compile_error": { + "status_code": 20, + "lang": "rust", + "run_success": false, + "compile_error": "Line 89, Char 2: this file contains an unclosed delimiter (solution.rs)", + "full_compile_error": "Line 89, Char 2: this file contains an unclosed delimiter (solution.rs)\n |\n1 | fn first_index(haystack: &str, needle: &str) -> i32 {\n | - unclosed delimiter\n...\n31 | if flag {\n | - this delimiter might not be properly closed...\n...\n34 | }\n | - ...as it matches this but it has different indentation\n...\n89 | }\n | ^\nLine 40, Char 56: expected `;`, found keyword `while` (solution.rs)\n |\n40 | first_index(haystack.as_str(), needle.as_str())\n | ^ help: add `;` here\n41 | while true {\n | ----- unexpected token\nLine 54, Char 1: cannot declare a non-inline module inside a block unless it has a path attribute (solution.rs)\n |\n54 | mod list_node;\n | ^^^^^^^^^^^^^^\n |\nnote: maybe `use` the module `list_node` instead of redeclaring it\n --> src/main.rs:55:1\n |\n54 | mod list_node;\n | ^^^^^^^^^^^^^^\nLine 55, Char 1: cannot declare a non-inline module inside a block unless it has a path attribute (solution.rs)\n |\n55 | mod tree_node;\n | ^^^^^^^^^^^^^^\n |\nnote: maybe `use` the module `tree_node` instead of redeclaring it\n --> src/main.rs:56:1\n |\n55 | mod tree_node;\n | ^^^^^^^^^^^^^^\nLine 56, Char 1: cannot declare a non-inline module inside a block unless it has a path attribute (solution.rs)\n |\n56 | mod nested_integer;\n | ^^^^^^^^^^^^^^^^^^^\n |\nnote: maybe `use` the module `nested_integer` instead of redeclaring it\n --> src/main.rs:57:1\n |\n56 | mod nested_integer;\n | ^^^^^^^^^^^^^^^^^^^\nLine 58, Char 1: cannot declare a non-inline module inside a block unless it has a path attribute (solution.rs)\n |\n58 | mod __serializer__;\n | ^^^^^^^^^^^^^^^^^^^\n |\nnote: maybe `use` the module `__serializer__` instead of redeclaring it\n --> src/main.rs:59:1\n |\n58 | mod __serializer__;\n | ^^^^^^^^^^^^^^^^^^^\nLine 59, Char 1: cannot declare a non-inline module inside a block unless it has a path attribute (solution.rs)\n |\n59 | mod __deserializer__;\n | ^^^^^^^^^^^^^^^^^^^^^\n |\nnote: maybe `use` the module `__deserializer__` instead of redeclaring it\n --> src/main.rs:60:1\n |\n59 | mod __deserializer__;\n | ^^^^^^^^^^^^^^^^^^^^^\nLine 50, Char 1: an `extern crate` loading macros must be at the crate root (solution.rs)\n |\n50 | extern crate lazy_static;\n | ^^^^^^^^^^^^^^^^^^^^^^^^^\nLine 61, Char 11: unresolved import `self::__serializer__` (solution.rs)\n |\n61 | use self::__serializer__::*;\n | ^^^^^^^^^^^^^^ could not find `__serializer__` in the crate root\nLine 62, Char 11: unresolved import `self::__deserializer__` (solution.rs)\n |\n62 | use self::__deserializer__::*;\n | ^^^^^^^^^^^^^^^^ could not find `__deserializer__` in the crate root\nLine 63, Char 11: unresolved import `self::list_node` (solution.rs)\n |\n63 | use self::list_node::*;\n | ^^^^^^^^^ could not find `list_node` in the crate root\nLine 64, Char 11: unresolved import `self::tree_node` (solution.rs)\n |\n64 | use self::tree_node::*;\n | ^^^^^^^^^ could not find `tree_node` in the crate root\nLine 65, Char 11: unresolved import `self::nested_integer` (solution.rs)\n |\n65 | use self::nested_integer::*;\n | ^^^^^^^^^^^^^^ could not find `nested_integer` in the crate root\nLine 81, Char 23: failed to resolve: use of undeclared type `Deserializer` (solution.rs)\n |\n81 | let param_1 = Deserializer::to_string(line.unwrap().as_str()).unwrap();\n | ^^^^^^^^^^^^ not found in this scope\n |\nhelp: consider importing this struct\n |\n1 | use serde_json::Deserializer;\n |\nLine 83, Char 23: failed to resolve: use of undeclared type `Deserializer` (solution.rs)\n |\n83 | let param_2 = Deserializer::to_string(line.unwrap().as_str()).unwrap();\n | ^^^^^^^^^^^^ not found in this scope\n |\nhelp: consider importing this struct\n |\n1 | use serde_json::Deserializer;\n |\nwarning: denote infinite loops with `loop { ... }`\n --> src/main.rs:42:9\n |\n41 | while true {\n | ^^^^^^^^^^ help: use `loop`\n |\n = note: `#[warn(while_true)]` on by default\nLine 40, Char 9: mismatched types (solution.rs)\n |\n40 | first_index(haystack.as_str(), needle.as_str())\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^- help: consider using a semicolon here\n | |\n | expected `()`, found `i32`\nLine 85, Char 23: no method named `serialize` found for type `i32` in the current scope (solution.rs)\n |\n85 | let out = ret.serialize();\n | ^^^^^^^^^ method not found in `i32`\n |\n ::: /usr/local/cargo/registry/src/github.com-1ecc6299db9ec823/serde-1.0.80/src/ser/mod.rs:246:8\n |\n245 | fn serialize(&self, serializer: S) -> Result\n | --------- the method is available for `i32` here\n |\n = help: items from traits can only be used if the trait is in scope\nhelp: the following trait is implemented but not in scope; perhaps add a `use` for it:\n |\n1 | use serde::ser::Serialize;\n |\nSome errors have detailed explanations: E0308, E0432, E0433, E0468, E0599.\nFor more information about an error, try `rustc --explain E0308`.\nwarning: `prog` (bin \"prog\") generated 1 warning\nerror: could not compile `prog` due to 17 previous errors; 1 warning emitted\nmv: cannot stat '/leetcode/rust_compile/target/release/prog': No such file or directory", + "status_runtime": "N/A", + "memory": 0, + "code_answer": [], + "code_output": [], + "std_output_list": [ + "" + ], + "task_finish_time": 1690111655862, + "task_name": "judger.runcodetask.RunCode", + "total_correct": null, + "total_testcases": null, + "runtime_percentile": null, + "status_memory": "N/A", + "memory_percentile": null, + "pretty_lang": "Rust", + "submission_id": "runcode_1690111652.7634475_TUGkFGAvCs", + "status_msg": "Compile Error", + "state": "SUCCESS" + }, + "runtime_error": { + "status_code": 15, + "lang": "python3", + "run_success": false, + "runtime_error": "Line 3: IndentationError: expected an indented block after function definition on line 52", + "full_runtime_error": "IndentationError: expected an indented block after function definition on line 52\n import sys\nLine 3 (Solution.py)", + "status_runtime": "N/A", + "memory": 9028000, + "code_answer": [], + "code_output": [], + "std_output_list": [ + "" + ], + "elapsed_time": 29, + "task_finish_time": 1690110685327, + "task_name": "judger.runcodetask.RunCode", + "total_correct": null, + "total_testcases": null, + "runtime_percentile": null, + "status_memory": "N/A", + "memory_percentile": null, + "pretty_lang": "Python3", + "submission_id": "runcode_1690110683.056359_eI1ADXx4EQ", + "status_msg": "Runtime Error", + "state": "SUCCESS" + }, + "success": { + "status_code": 10, + "lang": "rust", + "run_success": true, + "status_runtime": "0 ms", + "memory": 2000000, + "code_answer": [ + "0", + "-1" + ], + "code_output": [], + "std_output_list": [ + "", + "", + "" + ], + "elapsed_time": 20, + "task_finish_time": 1690111018529, + "task_name": "judger.runcodetask.RunCode", + "expected_status_code": 10, + "expected_lang": "cpp", + "expected_run_success": true, + "expected_status_runtime": "2", + "expected_memory": 5944000, + "expected_code_answer": [ + "0", + "-1" + ], + "expected_code_output": [], + "expected_std_output_list": [ + "", + "", + "" + ], + "expected_elapsed_time": 22, + "expected_task_finish_time": 1690109912057, + "expected_task_name": "judger.interprettask.Interpret", + "correct_answer": true, + "compare_result": "11", + "total_correct": 2, + "total_testcases": 2, + "runtime_percentile": null, + "status_memory": "2 MB", + "memory_percentile": null, + "pretty_lang": "Rust", + "submission_id": "runcode_1690111013.83172_QguZOxVXV0", + "status_msg": "Accepted", + "state": "SUCCESS" + }, + "wrong_answer": { + "status_code": 11, + "lang": "rust", + "run_success": true, + "status_runtime": "N/A", + "memory": 2320000, + "question_id": "28", + "elapsed_time": 14, + "compare_result": "00000000000000100000000000000001000000000000100000000000000000000000000000000000", + "code_output": "4", + "std_output": "", + "last_testcase": "\"sadbutsad\"\n\"sad\"", + "expected_output": "0", + "task_finish_time": 1690113635048, + "task_name": "judger.judgetask.Judge", + "finished": true, + "total_correct": 3, + "total_testcases": 80, + "runtime_percentile": null, + "status_memory": "N/A", + "memory_percentile": null, + "pretty_lang": "Rust", + "submission_id": "1001782379", + "input_formatted": "\"sadbutsad\", \"sad\"", + "input": "\"sadbutsad\"\n\"sad\"", + "status_msg": "Wrong Answer", + "state": "SUCCESS" + }, + "memory_limit_exceeded": { + "status_code": 12, + "lang": "python3", + "run_success": false, + "status_runtime": "N/A", + "memory": 976692000, + "code_answer": [], + "code_output": [], + "std_output_list": [ + "" + ], + "elapsed_time": 1638, + "task_finish_time": 1690182098359, + "task_name": "judger.runcodetask.RunCode", + "total_correct": null, + "total_testcases": null, + "runtime_percentile": null, + "status_memory": "N/A", + "memory_percentile": null, + "pretty_lang": "Python3", + "submission_id": "runcode_1690182094.4995568_1dCAthLRtZ", + "status_msg": "Memory Limit Exceeded", + "state": "SUCCESS" + }, + "pending": { + "state": "PENDING" + }, + "started": { + "state": "STARTED" + }, + "submit_successful": { + "status_code": 10, + "lang": "rust", + "run_success": true, + "status_runtime": "2 ms", + "memory": 2348000, + "question_id": "1", + "elapsed_time": 16, + "compare_result": "111111111111111111111111111111111111111111111111111111111", + "code_output": "", + "std_output": "", + "last_testcase": "", + "expected_output": "", + "task_finish_time": 1690563958315, + "task_name": "judger.judgetask.Judge", + "finished": true, + "total_correct": 57, + "total_testcases": 57, + "runtime_percentile": 83.9281, + "status_memory": "2.3 MB", + "memory_percentile": 39.723299999999995, + "pretty_lang": "Rust", + "submission_id": "1006307118", + "status_msg": "Accepted", + "state": "SUCCESS" + } +} diff --git a/tests/test_submit.rs b/tests/test_submit.rs new file mode 100644 index 0000000..9cd6cac --- /dev/null +++ b/tests/test_submit.rs @@ -0,0 +1,62 @@ +use leetcode_tui_rs::deserializers::run_submit::{ParsedResponse, RunResponse}; +use serde_json::{self, Value}; + +const JSONS_STR: &str = include_str!("./test_submit.json"); + +#[test] +fn test_run_status_parsing() { + let run_responses: Value = serde_json::from_str(JSONS_STR).unwrap(); + let compile_error = &run_responses[&"compile_error"]; + let runtime_error = &run_responses[&"runtime_error"]; + let run_success = &run_responses[&"success"]; + let pending = &run_responses[&"pending"]; + let started = &run_responses[&"started"]; + let mem_limit = &run_responses[&"memory_limit_exceeded"]; + let out_limit = &run_responses[&"output_limit"]; + let submit_success = &run_responses[&"submit_successful"]; + let re: RunResponse = serde_json::from_value(runtime_error.to_owned()).unwrap(); + let ce: RunResponse = serde_json::from_value(compile_error.to_owned()).unwrap(); + let rs: RunResponse = serde_json::from_value(run_success.to_owned()).unwrap(); + let pending: RunResponse = serde_json::from_value(pending.to_owned()).unwrap(); + let started: RunResponse = serde_json::from_value(started.to_owned()).unwrap(); + let mem_limit: RunResponse = serde_json::from_value(mem_limit.to_owned()).unwrap(); + let out_limit: RunResponse = serde_json::from_value(out_limit.to_owned()).unwrap(); + let submit_success: RunResponse = serde_json::from_value(submit_success.to_owned()).unwrap(); + + let re = re.to_parsed_response().unwrap(); + let ce = ce.to_parsed_response().unwrap(); + let rs = rs.to_parsed_response().unwrap(); + + let pending = pending.to_parsed_response().unwrap(); + let started = started.to_parsed_response().unwrap(); + let mem_limit = mem_limit.to_parsed_response().unwrap(); + let out_limit = out_limit.to_parsed_response().unwrap(); + let submit_success = submit_success.to_parsed_response().unwrap(); + + match ( + re, + ce, + rs, + pending, + started, + mem_limit, + out_limit, + submit_success, + ) { + ( + ParsedResponse::RuntimeError(_), + ParsedResponse::CompileError(_), + ParsedResponse::Success(_), + ParsedResponse::Pending, + ParsedResponse::Pending, + ParsedResponse::MemoryLimitExceeded(_), + ParsedResponse::OutputLimitExceed(_), + ParsedResponse::Success(_), + ) => { + assert!(true) + } + (_, _, _, _, _, _, _, _) => { + assert!(false) + } + } +} diff --git a/vhs_tapes/demo.tape b/vhs_tapes/demo.tape new file mode 100644 index 0000000..5ef8149 --- /dev/null +++ b/vhs_tapes/demo.tape @@ -0,0 +1,59 @@ +Output gif_demo/demo.gif + +Require leetui + +Set Width 1280 +Set Height 720 +Set Shell "fish" +Set FontSize 16 +Set Padding 3 +Type@200ms "leetui" +Enter +Sleep 2s + +# Up down on topic pane +Down@800ms 4 +Sleep 800ms +Up@800ms 4 + +# Switch to questions pane +Right 1 + +# Up down on question pane +Down@800ms 4 +Sleep 2s +Up@800ms 3 + +# Showpopup question detail +Enter +Sleep 3s +Down@500ms 5 +Up@500ms 5 +Sleep 3s +Escape +Escape + +# Edit question in python lang in neovim +Type 'e' +Sleep 3s +Down@500ms 3 +Enter +Sleep 2s +Type 'G' +Sleep 2s +Type@500ms ':wq' +Sleep 1s +Enter +Sleep 1s + +# Run the solution +Type 'r' +Sleep 3s +Enter +Sleep 8s +Enter +Sleep 3s +Escape + +# Exit +Type 'q'