├── src └── cloctui │ ├── py.typed │ ├── __init__.py │ ├── __main__.py │ ├── rawoutput.py │ ├── spinner.py │ ├── styles.tcss │ ├── cli.py │ └── main.py ├── .python-version ├── .github └── CODEOWNERS ├── .gitignore ├── Roadmap.md ├── LICENSE ├── Changelog.md ├── justfile ├── pyproject.toml └── README.md /src/cloctui/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @edward-jazzhands -------------------------------------------------------------------------------- /src/cloctui/__init__.py: -------------------------------------------------------------------------------- 1 | def main() -> None: 2 | print("Hello from cloctui!") 3 | -------------------------------------------------------------------------------- /src/cloctui/__main__.py: -------------------------------------------------------------------------------- 1 | from cloctui.cli import cli 2 | 3 | if __name__ == "__main__": 4 | cli() 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | 10 | .venv 11 | logs 12 | *_cache 13 | **/Thumbs.db 14 | **/thumbs.db -------------------------------------------------------------------------------- /Roadmap.md: -------------------------------------------------------------------------------- 1 | # CLOCTUI Roadmap 2 | 3 | [ ] Add option to filter files by name 4 | [ ] Add unit tests 5 | [X] Add button to quit without clearing screen 6 | [X] Add option to group by language 7 | [X] Add option to group by folder 8 | [X] Add Click CLI 9 | [X] Make header clickable for sorting 10 | [X] Add keyboard shortcuts for sorting 11 | [X] Add checking for CLOC installation and nice error message 12 | [X] Add inline / fullscreen argument to CLI 13 | -------------------------------------------------------------------------------- /src/cloctui/rawoutput.py: -------------------------------------------------------------------------------- 1 | # This is for seeing the raw JSON output in the console 2 | # For testing and debugging purposes 3 | 4 | import json 5 | from cloctui.main import CLOC, ClocJsonResult 6 | 7 | if __name__ == "__main__": 8 | 9 | timeout = 15 10 | working_directory = "./" 11 | dir_to_scan = "src" 12 | 13 | result: ClocJsonResult = json.loads( 14 | CLOC() 15 | .add_flag("--by-file") 16 | .add_flag("--json") 17 | .add_option("--timeout", timeout) 18 | .set_working_directory(working_directory) 19 | .add_argument(dir_to_scan) 20 | .execute() 21 | ) 22 | print(json.dumps(result, indent=4)) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | ClocTUI Copyright (c) 2025 Edward Jazzhands, under MIT License 4 | 5 | --- 6 | 7 | A part of ClocTUI was based on `pycloc` by Stefano Campanella, 8 | which is also licensed under the MIT License. 9 | 10 | Copyright (c) 2025 REVEAL, Stefano Campanella 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 | SOFTWARE. 29 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # CLOCTUI Changelog 2 | 3 | ## 0.2.4 - (2025-07-20) 4 | 5 | - Added check for whether the given path exists and is a directory before running CLOC. 6 | 7 | ## 0.2.3 - (2025-07-19) 8 | 9 | - Refactored app again to push a new screen every time the group mode is changed. This was necessary to fix the issue with the table not resizing properly when switching between group modes. 10 | - Implemented logic in python to dynamically set the maximum height for the table based on the terminal size. 11 | - Set background to transparent and turned ansi_color to True in the app class. 12 | - Changed button style to transparent buttons. 13 | 14 | ## 0.2.2 - (2025-07-19) 15 | 16 | - Made check for CLOC installation run before empty path check. 17 | 18 | ## 0.2.1 - (2025-07-19) 19 | 20 | - Fixed all visual issues with more container magic. 21 | - Implemented proper table resizing with min/max values. 22 | - Fixed sorting logic to work after amount of columns is changed. 23 | - Added quit buttons for both clearing and not clearing the screen. 24 | 25 | ## 0.2.0 - (2025-07-19) 26 | 27 | - Big refactor of the app 28 | - Added keyboard controls 29 | - Added nice check for CLOC and error message if not installed 30 | - Added grouping by language 31 | - Added grouping by file type 32 | - Added option for fullscreen or inline mode in CLI (-f flag) 33 | 34 | ## 0.1.0 - (2025-07-12) 35 | 36 | - Released version 0.1.0 of CLOCTUI. 37 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Install the package 2 | install: 3 | uv sync 4 | 5 | # Run the demo with defined entry command 6 | run path: 7 | uv run cloctui {{path}} 8 | 9 | # Run the demo in dev mode 10 | run-dev path: 11 | uv run textual run --dev src/cloctui/__main__.py {{path}} 12 | 13 | # Run the console 14 | console: 15 | uv run textual console -x EVENT -x SYSTEM 16 | 17 | # Runs ruff, exits with 0 if no issues are found 18 | lint: 19 | uv run ruff check src || (echo "Ruff found issues. Please address them." && exit 1) 20 | 21 | # Runs mypy, exits with 0 if no issues are found 22 | typecheck: 23 | uv run mypy src || (echo "Mypy found issues. Please address them." && exit 1) 24 | 25 | # Runs black 26 | format: 27 | uv run black src 28 | 29 | # Runs ruff, mypy, and black 30 | all-checks: lint typecheck format 31 | echo "All pre-commit checks passed. You're good to publish." 32 | 33 | # Build the package, run clean first 34 | build: clean 35 | @echo "Building the package..." 36 | uv build 37 | 38 | # Publish the package, run build first 39 | publish: build 40 | @echo "Publishing the package..." 41 | uv publish 42 | 43 | # Remove the build and dist directories 44 | clean: 45 | rm -rf build dist 46 | find . -name "*.pyc" -delete 47 | 48 | # Remove the virtual environment and lock file 49 | del-env: 50 | rm -rf .venv 51 | rm -rf uv.lock 52 | 53 | reset: clean del-env install 54 | @echo "Resetting the environment..." 55 | #------------------------------------------------------------------------------- -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "cloctui" 3 | version = "0.2.4" 4 | description = "A TUI interface for the CLOC code analysis tool, using the Textual framework." 5 | readme = "README.md" 6 | authors = [ 7 | { name = "Edward Jazzhands", email = "ed.jazzhands@gmail.com" } 8 | ] 9 | license = { text = "MIT" } 10 | keywords = ["python", "textual", "tui", "slidecontainer", "widget"] 11 | classifiers = [ 12 | "Development Status :: 3 - Alpha", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Programming Language :: Python :: 3 :: Only", 21 | ] 22 | requires-python = ">=3.10" 23 | dependencies = [ 24 | "click>=8.1.8", 25 | "textual>=4.0.0", 26 | ] 27 | 28 | [project.scripts] 29 | cloctui = "cloctui.cli:run" 30 | 31 | [build-system] 32 | requires = ["hatchling"] 33 | build-backend = "hatchling.build" 34 | 35 | [project.urls] 36 | Repository = "https://github.com/edward-jazzhands/cloctui" 37 | Changelog = "https://github.com/edward-jazzhands/cloctui/blob/master/Changelog.md" 38 | 39 | [dependency-groups] 40 | dev = [ 41 | "black>=24.8.0", 42 | "mypy>=1.14.1", 43 | "ruff>=0.11.8", 44 | "textual-dev>=1.7.0", 45 | ] 46 | 47 | [tool.black] 48 | line-length = 110 49 | 50 | [tool.mypy] 51 | python_version = "3.10" 52 | pretty = true 53 | strict = true 54 | -------------------------------------------------------------------------------- /src/cloctui/spinner.py: -------------------------------------------------------------------------------- 1 | # python 2 | from typing import Any, Literal # , TYPE_CHECKING 3 | 4 | # if TYPE_CHECKING: 5 | 6 | # Textual 7 | from textual.widgets import Static 8 | from textual.timer import Timer 9 | from rich.spinner import Spinner 10 | from rich.console import RenderableType 11 | 12 | 13 | SPINNER_TYPES = Literal[ 14 | "aesthetic", 15 | "arc", 16 | "arrow", 17 | "bouncingBall", 18 | "bouncingBar", 19 | "boxBounce", 20 | "boxBounce2", 21 | "clock", 22 | "dots", 23 | "dots2", 24 | "dots12", 25 | "earth", 26 | "grenade", 27 | "line", 28 | "material", 29 | "moon", 30 | "noise", 31 | "point", 32 | "pong", 33 | "runner", 34 | "shark", 35 | "simpleDots", 36 | "simpleDotsScrolling", 37 | ] 38 | 39 | 40 | class SpinnerWidget(Static): 41 | """A widget that displays a spinner using the rich library. 42 | See init for details on how to use it.""" 43 | 44 | def __init__( 45 | self, 46 | text: RenderableType = "", 47 | spinner_type: SPINNER_TYPES = "line", 48 | interval: float = 0.02, 49 | mount_running: bool = True, 50 | *args: Any, 51 | **kwargs: Any, 52 | ): 53 | """Initialize the spinner widget. 54 | `python -m rich.spinner` to see all options 55 | 56 | Args: 57 | text (RenderableType): The text to display alongside the spinner. 58 | spinner_type (str): The type of spinner to use. 59 | interval (float, optional): The interval in seconds to update the spinner. 60 | *args: Additional positional arguments. 61 | **kwargs: Additional keyword arguments. 62 | """ 63 | 64 | super().__init__(*args, **kwargs) 65 | 66 | self._spinner = Spinner(spinner_type, text) 67 | self.interval = interval 68 | self.mount_running = mount_running 69 | self.interval_timer: Timer | None = None 70 | 71 | def on_mount(self) -> None: 72 | if self.mount_running: 73 | self.interval_timer = self.set_interval(self.interval, self.update_spinner) 74 | 75 | async def update_spinner(self) -> None: 76 | self.update(self._spinner) 77 | 78 | def pause(self, hide: bool = False) -> None: 79 | if self.interval_timer: 80 | self.interval_timer.pause() 81 | if hide: 82 | self.display = False 83 | 84 | def resume(self, show: bool = True) -> None: 85 | if self.interval_timer: 86 | self.interval_timer.resume() 87 | else: 88 | self.interval_timer = self.set_interval(self.interval, self.update_spinner) 89 | if show: 90 | self.display = True 91 | -------------------------------------------------------------------------------- /src/cloctui/styles.tcss: -------------------------------------------------------------------------------- 1 | /* loading screen */ 2 | #spinner_container { 3 | align: center middle; 4 | &.fullscreen { height: 1fr; } 5 | &.inline { height: auto; } 6 | } 7 | SpinnerWidget { 8 | height: 1; 9 | width: auto; 10 | } 11 | 12 | /* main screen */ 13 | TableScreen { 14 | overflow-x: hidden; 15 | overflow-y: hidden; 16 | # background: transparent; 17 | border-top: dashed $foreground 70% ; 18 | border-bottom: dashed $foreground 70% ; 19 | # border: none; 20 | # min-height: 20; 21 | # max-height: 30; 22 | # height: 100%; 23 | #header_container { 24 | height: 6; 25 | margin: 0 1; 26 | & > HeaderBar { height: auto; } 27 | & > OptionsBar { 28 | height: 3; 29 | width: auto; 30 | align: center middle; 31 | /* NOT USED YET - FUTURE IMPLEMENTATION 32 | #options_input { 33 | width: 1fr; 34 | height: 3; 35 | background: transparent; 36 | border: heavy $primary 30%; 37 | } 38 | */ 39 | } 40 | } 41 | #table_container { 42 | margin: 0 1; 43 | height: auto; 44 | # border: solid red; 45 | # max-height: 28; 46 | & > CustomDataTable { 47 | overflow-x: hidden; 48 | & > .datatable--header { background: $panel; } 49 | & > .datatable--cursor { background: $surface; } 50 | & > .datatable--header-cursor { background: $panel; } 51 | & > .datatable--header-hover { background: $panel-lighten-1; } 52 | & > .datatable--hover { background: $surface; } 53 | } 54 | } 55 | #bottom_container { 56 | margin: 0 1; 57 | height: 5; 58 | & > SummaryBar { 59 | height: 1; 60 | background: $panel-darken-1; 61 | #sum_label, #sum_filler { width: 1fr; } 62 | .sum_cell { padding: 0 1; } 63 | } 64 | & > #controls_bar { 65 | height: 1; 66 | padding: 0 1; 67 | background: $panel; 68 | } 69 | } 70 | } 71 | 72 | /* used in both header and bottom containers */ 73 | .button_container { 74 | width: auto; 75 | align: center middle; 76 | & > Button { 77 | border: round $primary 50%; 78 | background: transparent; 79 | &:focus { 80 | text-style: none; 81 | border: round $primary; 82 | } 83 | &:hover { 84 | border: round $primary; 85 | } 86 | &.-active { 87 | background: transparent; 88 | border: round $accent; 89 | tint: $surface 0%; 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CLOCTUI 2 | 3 | preview 4 | 5 | CLOCTUI is a terminal user interface (TUI) for the [CLOC](https://github.com/AlDanial/cloc) code analysis tool, built using the [Textual](https://github.com/Textualize/textual) framework. 6 | 7 | CLOCTUI runs CLOC under the hood and then displays the results in an interactive table. It makes the results of CLOC much more pleasant to view, especially for large code bases. 8 | 9 | ## Features 10 | 11 | - Group by language, directory, or show all individual files. This works the same as CLOC's different modes, but interactive. 12 | - Sort any column in the table by clicking the header or using keyboard shortcuts. 13 | - You can run in Inline mode (default), or run in fullscreen mode with the `-f` flag. 14 | 15 | ## Requirements 16 | 17 | CLOCTUI requires Python 3.10 or later and the [CLOC](https://github.com/AlDanial/cloc) command line tool to be installed on your system. 18 | 19 | It's also recommended to have a python tool manager such as [UV](https://docs.astral.sh/uv/) or [PipX](https://pipx.pypa.io/stable/) installed to manage the installation of CLOCTUI. 20 | 21 | ## Test without installing 22 | 23 | You can test CLOCTUI without installing it by using [UV](https://docs.astral.sh/uv/) or [PipX](https://pipx.pypa.io/stable/). 24 | 25 | Using the `uvx` or `pipx run` commands: 26 | Enter `uvx cloctui` (or `pipx run`) followed by the path you want to scan as the only argument: 27 | 28 | ```sh 29 | uvx cloctui src 30 | ``` 31 | 32 | ```sh 33 | pipx run cloctui src 34 | ``` 35 | 36 | Both the above commands will run CLOC on the `src` directory and display the results. 37 | 38 | A dot would likewise scan the current directory: 39 | 40 | ```sh 41 | uvx cloctui . 42 | ``` 43 | 44 | ## How to Install 45 | 46 | The recommended way to use CLOCTUI is as a global tool managed with [UV](https://docs.astral.sh/uv/) or [PipX](https://pipx.pypa.io/stable/). 47 | 48 | ```sh 49 | uv tool install cloctui 50 | ``` 51 | 52 | ```sh 53 | pipx install cloctui 54 | ``` 55 | 56 | Once installed, you can use the `cloctui` command anywhere. To scan the `src` folder in your current directory: 57 | 58 | ```sh 59 | cloctui src 60 | ``` 61 | 62 | ## Fullscreen mode 63 | 64 | You can use the `-f` flag to run in Fullscreen mode 65 | 66 | ```sh 67 | cloctui src -f 68 | ``` 69 | 70 | ## Future roadmap 71 | 72 | CLOC is an awesome program, and there's numerous features it has which are not integrated into CLOCTUI. In the future if this tool gets any serious usage, I'd be more than happy to consider adding more cool CLOC feature integrations. 73 | 74 | ## Questions, issues, suggestions? 75 | 76 | Feel free to raise issues and bugs in the issues section, and post any ideas / feature requests on the [Ideas discussion board](https://github.com/edward-jazzhands/cloctui/discussions). 77 | 78 | ## Credits and License 79 | 80 | CLOCTUI is developed by Edward Jazzhands 81 | 82 | A core class for this project was copied from Stefano Stone: 83 | https://github.com/USIREVEAL/pycloc 84 | 85 | It was modified by Edward Jazzhands (added all type hints and improved docstrings). 86 | 87 | Both CLOCTUI and pycloc are licensed under the MIT License. 88 | -------------------------------------------------------------------------------- /src/cloctui/cli.py: -------------------------------------------------------------------------------- 1 | # stndlib 2 | from __future__ import annotations 3 | import shutil 4 | import subprocess 5 | 6 | # libraries 7 | import click 8 | 9 | error_message = """\ 10 | 11 | CLOC is not installed on your system. 12 | Please install CLOC to use this application. 13 | Visit https://github.com/AlDanial/cloc for more information. 14 | 15 | Depending your operating system, one of these installation methods may work for you 16 | (all but the last two entries for Windows require a Perl interpreter): 17 | 18 | npm install -g cloc # https://www.npmjs.com/package/cloc 19 | sudo apt install cloc # Debian, Ubuntu 20 | sudo yum install cloc # Red Hat, Fedora 21 | sudo dnf install cloc # Fedora 22 or later 22 | sudo pacman -S cloc # Arch 23 | yay -S cloc-git # Arch AUR (latest git version) 24 | sudo emerge -av dev-util/cloc # Gentoo https://packages.gentoo.org/packages/dev-util/cloc 25 | sudo apk add cloc # Alpine Linux 26 | doas pkg_add cloc # OpenBSD 27 | sudo pkg install cloc # FreeBSD 28 | sudo port install cloc # macOS with MacPorts 29 | brew install cloc # macOS with Homebrew 30 | winget install AlDanial.Cloc # Windows with winget (might not work, ref https://github.com/AlDanial/cloc/issues/849) 31 | choco install cloc # Windows with Chocolatey 32 | scoop install cloc # Windows with Scoop 33 | """ 34 | 35 | 36 | @click.command(context_settings={"ignore_unknown_options": False}) 37 | @click.argument("path", type=str, default=None, required=False) 38 | @click.option( 39 | "--fullscreen", "-f", is_flag=True, default=False, help="Run in fullscreen / full terminal mode" 40 | ) 41 | def cli(path: str | None, fullscreen: bool = False) -> None: 42 | """ 43 | CLOCTUI - a terminal frontend for CLOC (Count Lines of Code). 44 | 45 | Path must be provided. Use '.' for the current directory or specify a path to a directory. 46 | """ 47 | 48 | # Use shutil.which to check if cloc is in PATH 49 | if shutil.which("cloc") is None: 50 | click.echo(error_message) 51 | return 52 | 53 | # Check if cloc command is available by running it 54 | try: 55 | subprocess.run( 56 | ["cloc", "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True 57 | ) 58 | except (subprocess.CalledProcessError, FileNotFoundError): 59 | click.echo(error_message) 60 | return 61 | 62 | # After confirming the user has CLOC installed, proceed with checking the path 63 | # If no main argument is provided, do nothing 64 | if path is None: 65 | click.echo( 66 | "CLOCTUI - a terminal frontend for CLOC (Count Lines of Code).\n\n" 67 | "Path must be provided. Use '.' for the current directory or specify a path to a directory." 68 | ) 69 | return 70 | 71 | else: 72 | from pathlib import Path 73 | from cloctui.main import ClocTUI 74 | 75 | path_obj = Path(path).expanduser().resolve() 76 | if not path_obj.exists(): 77 | click.echo(f"Path '{path}' does not exist.") 78 | return 79 | 80 | if not path_obj.is_dir(): 81 | click.echo(f"Path '{path}' is not a directory.") 82 | return 83 | 84 | inline = not fullscreen 85 | 86 | if fullscreen: 87 | mode = ClocTUI.AppMode.FULLSCREEN 88 | else: 89 | mode = ClocTUI.AppMode.INLINE 90 | 91 | ClocTUI(path, mode=mode).run(inline=inline, inline_no_clear=True) 92 | 93 | 94 | def run() -> None: 95 | """Entry point for the application.""" 96 | cli() 97 | 98 | 99 | if __name__ == "__main__": 100 | cli() 101 | -------------------------------------------------------------------------------- /src/cloctui/main.py: -------------------------------------------------------------------------------- 1 | """main.py - CLOCTUI - A TUI interface for CLOC 2 | ======================================================== 3 | 4 | # ~ Type Checking (Pyright and MyPy) - Strict Mode 5 | # ~ Linting - Ruff 6 | # ~ Formatting - Black - max 110 characters / line 7 | """ 8 | 9 | # python standard lib 10 | from __future__ import annotations 11 | from typing import TypedDict, Union, cast, Any 12 | import subprocess 13 | import os 14 | import json 15 | from pathlib import Path 16 | from enum import Enum 17 | from functools import partial 18 | 19 | # Textual imports 20 | from textual import on, work, events 21 | 22 | # from textual.worker import Worker 23 | from textual.app import App, ComposeResult 24 | from textual.widgets import Static, DataTable, Button # , Input 25 | from textual.widgets.data_table import ColumnKey, Column 26 | from textual.screen import Screen 27 | from textual.message import Message 28 | from textual.containers import Horizontal, Vertical 29 | from textual.binding import Binding 30 | from rich.text import Text 31 | 32 | # Local imports 33 | from cloctui.spinner import SpinnerWidget 34 | 35 | 36 | class CLOCException(Exception): 37 | 38 | def __init__(self, message: str, code: int): 39 | self.message = message 40 | self.code = code 41 | 42 | 43 | class ClocFileStats(TypedDict): 44 | blank: int 45 | comment: int 46 | code: int 47 | language: str 48 | 49 | 50 | class ClocSummaryStats(TypedDict): 51 | blank: int 52 | comment: int 53 | code: int 54 | nFiles: int 55 | 56 | 57 | class ClocHeader(TypedDict): 58 | cloc_url: str 59 | cloc_version: str 60 | elapsed_seconds: float 61 | n_files: int 62 | n_lines: int 63 | files_per_second: float 64 | lines_per_second: float 65 | 66 | 67 | ClocJsonResult = dict[str, Union[ClocFileStats, ClocSummaryStats, ClocHeader]] 68 | 69 | 70 | class SortableText(Text): 71 | 72 | def __lt__(self, other: object) -> bool: 73 | if isinstance(other, str): 74 | return self.plain < other 75 | if isinstance(other, Text): 76 | return self.plain < other.plain 77 | return NotImplemented 78 | 79 | 80 | # This class courtesey of Stefano Stone 81 | # https://github.com/USIREVEAL/pycloc 82 | # Modified by Edward Jazzhands (added all type hints and improved docstrings) 83 | class CLOC: 84 | def __init__(self) -> None: 85 | self.base_command = "cloc" 86 | self.options: list[str] = [] 87 | self.flags: list[str] = [] 88 | self.arguments: list[str] = [] 89 | self.working_directory = os.getcwd() 90 | 91 | def add_option(self, option: str, value: int) -> CLOC: 92 | """Adds an option with a value (e.g., --output file.txt). 93 | 94 | Args: 95 | option (str): The option name (e.g., --timeout). 96 | value (int): The value for the option (e.g., 30). 97 | """ 98 | self.options.append(f"{option} {value}") 99 | return self 100 | 101 | def add_flag(self, flag: str) -> CLOC: 102 | """Adds a flag (e.g., --verbose, -v). 103 | 104 | Args: 105 | flag (str): The flag to add. 106 | """ 107 | self.flags.append(flag) 108 | return self 109 | 110 | def add_argument(self, argument: str) -> CLOC: 111 | """Adds a positional argument (e.g., filename). 112 | 113 | Args: 114 | argument (str): The argument to add. 115 | """ 116 | self.arguments.append(argument) 117 | return self 118 | 119 | def set_working_directory(self, path: str) -> CLOC: 120 | """Sets the working directory for the command. 121 | 122 | Args: 123 | path (str): The path to set as the working directory. 124 | """ 125 | self.working_directory = path 126 | return self 127 | 128 | def build(self) -> str: 129 | """Constructs the full CLI command string. 130 | 131 | Returns: 132 | str: The complete command string. 133 | """ 134 | parts = [self.base_command] + self.flags + self.options + self.arguments 135 | return " ".join(parts) 136 | 137 | def execute(self) -> str: 138 | """Executes the CLI command, returns raw process result or Exception. 139 | 140 | Returns: 141 | str: The output of the command. 142 | """ 143 | command = self.build() 144 | try: 145 | process = subprocess.run( 146 | command, shell=True, check=True, stdout=subprocess.PIPE, cwd=self.working_directory 147 | ) 148 | return process.stdout.decode("utf-8") 149 | except subprocess.CalledProcessError as error: 150 | match error.returncode: 151 | case 25: 152 | message = "Failed to create tarfile of files from git or not a git repository." 153 | case 126: 154 | message = "Permission denied. Please check the permissions of the working directory." 155 | case 127: 156 | message = "CLOC command not found. Please install CLOC." 157 | case _: 158 | message = "Unknown CLOC error: " + str(error) 159 | 160 | if error.returncode < 0 or error.returncode > 128: 161 | message = "CLOC command was terminated by signal " + str(-error.returncode) 162 | 163 | raise CLOCException(message, error.returncode) 164 | 165 | 166 | class CustomDataTable(DataTable[Any]): 167 | 168 | class UpdateSummarySize(Message): 169 | def __init__(self, size: int, update_mode: CustomDataTable.UpdateMode) -> None: 170 | super().__init__() 171 | self.size = size 172 | "The new size for the summary row's first column." 173 | self.update_mode = update_mode 174 | 175 | class TableInitialized(Message): 176 | pass 177 | 178 | class SortingStatus(Enum): 179 | UNSORTED = 0 # [-] unsorted 180 | ASCENDING = 1 # [↑] ascending (reverse = True) 181 | DESCENDING = 2 # [↓] descending (reverse = False) 182 | 183 | class UpdateMode(Enum): 184 | NO_GROUP = 0 185 | GROUP_BY_LANG = 1 186 | GROUP_BY_DIR = 2 187 | 188 | # ** Source of Truth ** # 189 | COL_SIZES = { 190 | "path": 25, # dynamic column minimum 191 | "language": 14, 192 | "blank": 7, 193 | "comment": 9, 194 | "code": 7, 195 | "total": 9, 196 | } 197 | other_cols_total = sum(COL_SIZES.values()) - COL_SIZES["path"] 198 | 199 | def __init__( 200 | self, 201 | files_data_grouped: dict[str, dict[str, ClocFileStats]], 202 | group_mode: CustomDataTable.UpdateMode, 203 | ) -> None: 204 | super().__init__( 205 | zebra_stripes=True, 206 | show_cursor=False, 207 | # cursor_type="column", 208 | ) 209 | # self.files_data_grouped = files_data_grouped 210 | self.files_data = files_data_grouped["no_group"] 211 | self.files_by_language = files_data_grouped["files_by_lang"] 212 | self.files_by_dir = files_data_grouped["files_by_dir"] 213 | self.initialized = False 214 | 215 | self.group_mode: CustomDataTable.UpdateMode = group_mode 216 | 217 | self.sort_status: dict[str, CustomDataTable.SortingStatus] = { 218 | "path": CustomDataTable.SortingStatus.UNSORTED, 219 | "language": CustomDataTable.SortingStatus.UNSORTED, 220 | "blank": CustomDataTable.SortingStatus.UNSORTED, 221 | "comment": CustomDataTable.SortingStatus.UNSORTED, 222 | "code": CustomDataTable.SortingStatus.UNSORTED, 223 | "total": CustomDataTable.SortingStatus.UNSORTED, 224 | } 225 | self.add_column("path [dark_orange]-[/]", key="path") 226 | self.add_column("language [dark_orange]-[/]", width=self.COL_SIZES["language"], key="language") 227 | self.add_column("blank [dark_orange]-[/]", width=self.COL_SIZES["blank"], key="blank") 228 | self.add_column("comment [dark_orange]-[/]", width=self.COL_SIZES["comment"], key="comment") 229 | self.add_column("code [dark_orange]-[/]", width=self.COL_SIZES["code"], key="code") 230 | self.add_column("total [dark_orange]-[/]", width=self.COL_SIZES["total"], key="total") 231 | 232 | def on_mount(self) -> None: 233 | 234 | if self.group_mode == CustomDataTable.UpdateMode.NO_GROUP: 235 | self.update_table(self.files_data, CustomDataTable.UpdateMode.NO_GROUP) 236 | elif self.group_mode == CustomDataTable.UpdateMode.GROUP_BY_LANG: 237 | self.update_table(self.files_by_language, CustomDataTable.UpdateMode.GROUP_BY_LANG) 238 | elif self.group_mode == CustomDataTable.UpdateMode.GROUP_BY_DIR: 239 | self.update_table(self.files_by_dir, CustomDataTable.UpdateMode.GROUP_BY_DIR) 240 | else: 241 | raise RuntimeError(f"Invalid group mode {self.group_mode}") 242 | 243 | def on_resize(self) -> None: 244 | self.log("on_resize called in table") 245 | if self.initialized and self.group_mode: 246 | self.calculate_first_column_size(self.group_mode) 247 | 248 | def update_table( 249 | self, 250 | file_data: dict[str, ClocFileStats], 251 | update_mode: CustomDataTable.UpdateMode, 252 | ) -> None: 253 | 254 | for key, data in file_data.items(): 255 | 256 | path_obj = Path(key) # path object simplifies the file checking 257 | rich_textized = SortableText(key, overflow="ellipsis") 258 | if path_obj.is_file(): 259 | # This will colorize the file extension in orange, if there is one 260 | ext_index = key.rindex(path_obj.suffix) if path_obj.suffix else None 261 | if ext_index is not None: 262 | rich_textized.stylize(style="dark_orange", start=ext_index) 263 | 264 | self.add_row( 265 | rich_textized, 266 | data["language"], 267 | data["blank"], 268 | data["comment"], 269 | data["code"], 270 | data["blank"] + data["comment"] + data["code"], 271 | ) 272 | 273 | if update_mode == CustomDataTable.UpdateMode.GROUP_BY_LANG: 274 | self.remove_column(ColumnKey("path")) 275 | self.sort_status.pop("path", None) 276 | elif update_mode == CustomDataTable.UpdateMode.GROUP_BY_DIR: 277 | self.remove_column(ColumnKey("language")) 278 | self.sort_status.pop("language", None) 279 | 280 | self.call_after_refresh(self.calculate_first_column_size, update_mode=update_mode) 281 | 282 | def calculate_first_column_size(self, update_mode: CustomDataTable.UpdateMode) -> None: 283 | 284 | # Account for padding on both sides of each column: 285 | total_cell_padding = (self.cell_padding * 2) * len(self.columns) 286 | 287 | # Pretty obvious how this works I think: 288 | first_col_max_width = self.size.width - self.other_cols_total - total_cell_padding 289 | 290 | if ( 291 | update_mode == CustomDataTable.UpdateMode.NO_GROUP 292 | or update_mode == CustomDataTable.UpdateMode.GROUP_BY_DIR 293 | ): 294 | first_col = self.columns[ColumnKey("path")] 295 | else: 296 | assert update_mode == CustomDataTable.UpdateMode.GROUP_BY_LANG 297 | first_col = self.columns[ColumnKey("language")] 298 | 299 | if first_col.content_width > first_col_max_width: 300 | first_col.auto_width = False 301 | first_col.width = first_col_max_width 302 | self.post_message( 303 | CustomDataTable.UpdateSummarySize( 304 | size=first_col_max_width + 2, 305 | update_mode=update_mode, 306 | ) 307 | ) 308 | elif first_col.content_width < self.COL_SIZES["path"]: 309 | first_col.auto_width = False 310 | first_col.width = self.COL_SIZES["path"] 311 | self.post_message( 312 | CustomDataTable.UpdateSummarySize( 313 | size=self.COL_SIZES["path"] + 2, 314 | update_mode=update_mode, 315 | ) 316 | ) 317 | else: 318 | first_col.auto_width = True 319 | self.post_message( 320 | CustomDataTable.UpdateSummarySize( 321 | size=first_col.content_width + 2, 322 | update_mode=update_mode, 323 | ) 324 | ) 325 | 326 | self.call_after_refresh(self.custom_refresh) 327 | self.refresh() 328 | 329 | def custom_refresh(self) -> None: 330 | self.refresh() 331 | if not self.initialized: 332 | self.initialized = True 333 | self.post_message(CustomDataTable.TableInitialized()) 334 | 335 | @on(DataTable.HeaderSelected) 336 | def header_selected(self, event: DataTable.HeaderSelected) -> None: 337 | 338 | column = self.columns[event.column_key] 339 | self.sort_column(column, column.key) 340 | 341 | def sort_column(self, column: Column, column_key: ColumnKey) -> None: 342 | 343 | if column_key.value is None: 344 | raise ValueError("Tried to sort a column with no key.") 345 | if column_key.value not in self.sort_status: 346 | raise ValueError( 347 | f"Unknown column key: {column_key.value}. " 348 | "This should never happen, please report this issue." 349 | ) 350 | value = column_key.value 351 | 352 | # if its currently unsorted, that means the user is switching columns 353 | # to sort. Reset all columns to unsorted. 354 | if self.sort_status[value] == self.SortingStatus.UNSORTED: 355 | for key in self.sort_status: 356 | self.sort_status[key] = self.SortingStatus.UNSORTED 357 | col_index = self.get_column_index(key) 358 | col = self.ordered_columns[col_index] 359 | col.label = Text.from_markup(f"{key} [dark_orange]-[/]") 360 | 361 | # Now set chosen column to ascending: 362 | self.sort_status[value] = self.SortingStatus.ASCENDING 363 | self.sort(column_key, reverse=True) 364 | column.label = Text.from_markup(f"{value} [dark_orange]↑[/]") 365 | 366 | # For the other two conditions, we just toggle ascending/descending 367 | elif self.sort_status[value] == self.SortingStatus.ASCENDING: 368 | self.sort_status[value] = self.SortingStatus.DESCENDING 369 | self.sort(value, reverse=False) 370 | column.label = Text.from_markup(f"{value} [dark_orange]↓[/]") 371 | elif self.sort_status[value] == self.SortingStatus.DESCENDING: 372 | self.sort_status[value] = self.SortingStatus.ASCENDING 373 | self.sort(column_key, reverse=True) 374 | column.label = Text.from_markup(f"{value} [dark_orange]↑[/]") 375 | else: 376 | raise ValueError( 377 | f"Sort status for {value} is '{self.sort_status[value]}', did not meet any expected values." 378 | ) 379 | 380 | 381 | class HeaderBar(Static): 382 | 383 | def __init__(self, header_data: ClocHeader) -> None: 384 | """Initializes the HeaderBar with CLOC version and elapsed time.""" 385 | super().__init__() 386 | self.header_data = header_data 387 | 388 | def on_mount(self) -> None: 389 | self.update_header(self.header_data) 390 | 391 | @work(description="Updating header with CLOC version and elapsed time") 392 | async def update_header(self, header_data: ClocHeader) -> None: 393 | """Updates the header with CLOC version and elapsed time.""" 394 | inline_msg = "" 395 | if self.app.is_inline: 396 | inline_msg = " | Resizing in this mode may cause display issues." 397 | self.update( 398 | f"Running on CLOC v{header_data['cloc_version']}\n" 399 | f"Elapsed time: {header_data['elapsed_seconds']:.2f} sec │ " 400 | f"Files counted: {header_data['n_files']} │ " 401 | f"Lines counted: {header_data['n_lines']}\n" 402 | f"App mode: {'Inline' if self.app.is_inline else 'Fullscreen'}" 403 | f"{inline_msg}" 404 | ) 405 | 406 | 407 | class SummaryBar(Horizontal): 408 | """A horizontal bar to display summary statistics.""" 409 | 410 | def __init__(self, summary_data: ClocSummaryStats) -> None: 411 | """Initializes the SummaryBar with CLOC summary statistics.""" 412 | super().__init__() 413 | self.summary_data = summary_data 414 | 415 | def compose(self) -> ComposeResult: 416 | """Compose the summary bar with static text.""" 417 | yield Static("SUM: ", id="sum_label", classes="sum_cell") 418 | yield Static(id="sum_files", classes="sum_cell") 419 | yield Static(id="sum_blank", classes="sum_cell") 420 | yield Static(id="sum_comment", classes="sum_cell") 421 | yield Static(id="sum_code", classes="sum_cell") 422 | yield Static(id="sum_total", classes="sum_cell") 423 | yield Static(id="sum_filler", classes="sum_cell") 424 | 425 | def on_mount(self) -> None: 426 | # The +2s here are all to account for the padding on both sides of each column. 427 | # I dont set sum_label because it has a width of 1fr in Textual. 428 | 429 | # I didn't use CSS for this because I want the COL_SIZES dictionary to be 430 | # the one source of truth for the column widths. 431 | self.query_one("#sum_files").styles.width = CustomDataTable.COL_SIZES["language"] + 2 432 | self.query_one("#sum_blank").styles.width = CustomDataTable.COL_SIZES["blank"] + 2 433 | self.query_one("#sum_comment").styles.width = CustomDataTable.COL_SIZES["comment"] + 2 434 | self.query_one("#sum_code").styles.width = CustomDataTable.COL_SIZES["code"] + 2 435 | self.query_one("#sum_total").styles.width = CustomDataTable.COL_SIZES["total"] + 2 436 | 437 | self.update_summary(self.summary_data) 438 | 439 | @work(description="Updating summary statistics") 440 | async def update_summary(self, summary_data: ClocSummaryStats) -> None: 441 | 442 | self.query_one("#sum_files", Static).update(f"{summary_data['nFiles']} files") 443 | self.query_one("#sum_blank", Static).update(f"{summary_data['blank']}") 444 | self.query_one("#sum_comment", Static).update(f"{summary_data['comment']}") 445 | self.query_one("#sum_code", Static).update(f"{summary_data['code']}") 446 | self.query_one("#sum_total", Static).update( 447 | f"{summary_data['blank'] + summary_data['comment'] + summary_data['code']}" 448 | ) 449 | 450 | def update_size(self, message: CustomDataTable.UpdateSummarySize) -> None: 451 | mode = message.update_mode 452 | first_col_size = message.size 453 | sum_label = self.query_one("#sum_label", Static) 454 | files_label = self.query_one("#sum_files", Static) 455 | if mode == CustomDataTable.UpdateMode.NO_GROUP: 456 | sum_label.display = True 457 | sum_label.styles.width = first_col_size 458 | files_label.styles.width = CustomDataTable.COL_SIZES["language"] + 2 459 | elif ( 460 | mode == CustomDataTable.UpdateMode.GROUP_BY_LANG 461 | or mode == CustomDataTable.UpdateMode.GROUP_BY_DIR 462 | ): 463 | sum_label.display = False 464 | files_label.styles.width = first_col_size 465 | 466 | 467 | class OptionsBar(Horizontal): 468 | """A horizontal bar to display options or instructions.""" 469 | 470 | class GroupByLang(Message): 471 | pass 472 | 473 | class GroupByDir(Message): 474 | pass 475 | 476 | class NoGroup(Message): 477 | pass 478 | 479 | def __init__(self, **kwargs: Any) -> None: 480 | super().__init__(**kwargs) 481 | 482 | def compose(self) -> ComposeResult: 483 | with Horizontal(id="options_buttons_container", classes="button_container"): 484 | yield Button("Show files", id="button_no_group", compact=True) 485 | yield Button("Group by language", id="button_lang", compact=True) 486 | yield Button("Group by dir", id="button_dir", compact=True) 487 | 488 | # ~ FUTURE VERSION PLAN: 489 | # yield Input(placeholder="Filter by path", id="options_input") 490 | 491 | def on_button_pressed(self, event: Button.Pressed) -> None: 492 | 493 | if event.button.id == "button_lang": 494 | self.post_message(OptionsBar.GroupByLang()) 495 | elif event.button.id == "button_dir": 496 | self.post_message(OptionsBar.GroupByDir()) 497 | elif event.button.id == "button_no_group": 498 | self.post_message(OptionsBar.NoGroup()) 499 | 500 | 501 | class TableScreen(Screen[None]): 502 | 503 | BINDINGS = [ 504 | Binding("1", "sort_column(0)", "Sort Column 1"), 505 | Binding("2", "sort_column(1)", "Sort Column 2"), 506 | Binding("3", "sort_column(2)", "Sort Column 3"), 507 | Binding("4", "sort_column(3)", "Sort Column 4"), 508 | Binding("5", "sort_column(4)", "Sort Column 5"), 509 | Binding("6", "sort_column(5)", "Sort Column 6"), 510 | ] 511 | 512 | def __init__(self, worker_result: ClocTUI.WorkerFinished, group_mode: CustomDataTable.UpdateMode): 513 | """Initializes the TableScreen with the CLOC JSON result.""" 514 | super().__init__() 515 | self.header_data = worker_result.header_data 516 | self.summary_data = worker_result.summary_data 517 | self.files_data_grouped = worker_result.files_data_grouped 518 | self.group_mode = group_mode 519 | self.ctrl_nums = "1-6" if group_mode == CustomDataTable.UpdateMode.NO_GROUP else "1-5" 520 | 521 | def compose(self) -> ComposeResult: 522 | 523 | with Vertical(id="header_container"): 524 | yield HeaderBar(self.header_data) 525 | yield OptionsBar() 526 | with Vertical(id="table_container"): 527 | self.table = CustomDataTable(self.files_data_grouped, self.group_mode) 528 | yield self.table 529 | with Vertical(id="bottom_container"): 530 | self.summary_bar = SummaryBar(self.summary_data) 531 | yield self.summary_bar 532 | yield Static( 533 | "[orange]Tab[/] Cycle focus │ " 534 | f"[orange]{self.ctrl_nums}[/] Sort columns │ " 535 | "[orange]Click[/] Headers to sort │ " 536 | "[orange]Ctrl+q[/] Quit", 537 | id="controls_bar", 538 | ) 539 | with Horizontal(id="quit_buttons_container", classes="button_container"): 540 | yield Button("Quit", id="quit_button", compact=True) 541 | if self.app.is_inline: 542 | yield Button("Quit without clearing screen", id="quit_no_clear", compact=True) 543 | 544 | @on(CustomDataTable.TableInitialized) 545 | async def table_initialized(self) -> None: 546 | 547 | if self.group_mode == CustomDataTable.UpdateMode.NO_GROUP: 548 | await self.run_action("sort_column(5)") 549 | else: 550 | await self.run_action("sort_column(4)") 551 | 552 | # self.app.set_focus(self.query_one(OptionsBar).query_one("#button_no_group")) 553 | 554 | def on_resize(self, event: events.Resize) -> None: 555 | self.log(f"on_resize called in Screen with height: {event.size.height}") 556 | 557 | table_container = self.query_one("#table_container", Vertical) 558 | height = event.size.height 559 | 560 | before_max_height_scalar = table_container.styles.max_height 561 | if before_max_height_scalar is not None: 562 | self.log(f"{before_max_height_scalar.is_cells = }" f" | {before_max_height_scalar.cells = }") 563 | else: 564 | self.log("before_max_height_scalar is not set yet.") 565 | 566 | # the header and bottom containers are 5 and 6 lines tall 567 | # plus 2 for the top/bottom screen borders that for some reason I cannot 568 | # get rid of without messing up the layout. So subtract 13 in total. 569 | 570 | table_container.styles.max_height = height - 13 571 | after_max_height_scalar = table_container.styles.max_height 572 | if after_max_height_scalar is not None: 573 | self.log(f"{after_max_height_scalar.is_cells = }" f" | {after_max_height_scalar.cells = }") 574 | else: 575 | self.log.error("after_max_height_scalar is not set.") 576 | 577 | @on(CustomDataTable.UpdateSummarySize) 578 | def update_summary_size(self, message: CustomDataTable.UpdateSummarySize) -> None: 579 | "Adjust first column of summary row when table is resized." 580 | self.summary_bar.update_size(message) 581 | 582 | def action_sort_column(self, column_index: int) -> None: 583 | 584 | try: 585 | column = self.table.ordered_columns[column_index] 586 | except IndexError: 587 | return 588 | else: 589 | self.table.sort_column(column, column.key) 590 | 591 | @on(Button.Pressed, "#quit_button") 592 | def quit_button_pressed(self) -> None: 593 | self.set_timer(0.3, partial(self.exit_app, clear_screen=True)) 594 | 595 | @on(Button.Pressed, "#quit_no_clear") 596 | def quit_button_no_clear_pressed(self) -> None: 597 | self.set_timer(0.3, partial(self.exit_app, clear_screen=False)) 598 | 599 | def exit_app(self, clear_screen: bool = True) -> None: 600 | if clear_screen: 601 | self.app._exit_renderables.append("") # type: ignore[unused-ignore] 602 | self.app.exit() 603 | 604 | 605 | class ClocTUI(App[None]): 606 | 607 | class AppMode(Enum): 608 | INLINE = 0 609 | FULLSCREEN = 1 610 | 611 | CSS_PATH = "styles.tcss" 612 | 613 | timeout = 15 # seconds 614 | working_directory = "./" #! should this be same as dir_to_scan? 615 | 616 | class WorkerFinished(Message): 617 | def __init__( 618 | self, 619 | header_data: ClocHeader, 620 | summary_data: ClocSummaryStats, 621 | files_data_grouped: dict[str, dict[str, ClocFileStats]], 622 | ) -> None: 623 | super().__init__() 624 | self.header_data = header_data 625 | self.summary_data = summary_data 626 | self.files_data_grouped = files_data_grouped 627 | 628 | def __init__(self, dir_to_scan: str, mode: ClocTUI.AppMode) -> None: 629 | """ 630 | Args: 631 | dir_to_scan (str): The directory to scan for CLOC stats. 632 | """ 633 | self.dir_to_scan = dir_to_scan 634 | self.mode = mode 635 | 636 | if self.mode == ClocTUI.AppMode.INLINE: 637 | ansi_color = True 638 | else: 639 | ansi_color = False 640 | super().__init__(ansi_color=ansi_color) 641 | 642 | def compose(self) -> ComposeResult: 643 | 644 | with Horizontal(id="spinner_container"): 645 | yield SpinnerWidget(text="Counting Lines of Code", spinner_type="line") 646 | yield SpinnerWidget(spinner_type="simpleDotsScrolling") 647 | 648 | def on_mount(self) -> None: 649 | if self.is_inline: 650 | self.query_one("#spinner_container").add_class("inline") 651 | else: 652 | self.query_one("#spinner_container").add_class("fullscreen") 653 | 654 | def on_ready(self) -> None: 655 | self.execute_cloc() 656 | 657 | async def action_quit(self) -> None: 658 | # this forces it to clear when exiting: 659 | self._exit_renderables.append("") 660 | self.exit() 661 | 662 | @work(description="Executing CLOC command", thread=True) 663 | def execute_cloc(self) -> None: 664 | """Executes the CLOC command and returns the parsed JSON result.""" 665 | 666 | cloc = ( 667 | CLOC() 668 | .add_flag("--by-file") 669 | .add_flag("--json") 670 | .add_option("--timeout", self.timeout) 671 | .set_working_directory(self.working_directory) 672 | .add_argument(self.dir_to_scan) 673 | ) 674 | 675 | try: 676 | output = cloc.execute() 677 | except CLOCException as e: 678 | raise e 679 | 680 | result: ClocJsonResult = json.loads(output) 681 | 682 | # The header and summary data will come out ready to use and don't 683 | # require further processing. 684 | header_data: ClocHeader = cast(ClocHeader, result["header"]) 685 | summary_data: ClocSummaryStats = cast(ClocSummaryStats, result["SUM"]) 686 | 687 | # The files data needs to be processed to group by language and directory. 688 | files_data: dict[str, ClocFileStats] = {} 689 | files_by_language: dict[str, ClocFileStats] = {} 690 | files_by_dir: dict[str, ClocFileStats] = {} 691 | 692 | # First separate out the files data from the result. 693 | for key, value in result.items(): 694 | if key in ["header", "SUM"]: 695 | continue 696 | else: 697 | files_data[key] = cast(ClocFileStats, value) 698 | 699 | for key, data in files_data.items(): 700 | # Group by language 701 | if data["language"] not in files_by_language: 702 | files_by_language[data["language"]] = { 703 | "blank": 0, 704 | "comment": 0, 705 | "code": 0, 706 | "language": data["language"], 707 | } 708 | files_by_language[data["language"]]["blank"] += data["blank"] 709 | files_by_language[data["language"]]["comment"] += data["comment"] 710 | files_by_language[data["language"]]["code"] += data["code"] 711 | 712 | # Group by directory 713 | dir_name = os.path.dirname(key) 714 | if dir_name not in files_by_dir: 715 | files_by_dir[dir_name] = { 716 | "blank": 0, 717 | "comment": 0, 718 | "code": 0, 719 | "language": dir_name, 720 | } 721 | files_by_dir[dir_name]["blank"] += data["blank"] 722 | files_by_dir[dir_name]["comment"] += data["comment"] 723 | files_by_dir[dir_name]["code"] += data["code"] 724 | 725 | files_by_language = files_by_language 726 | files_by_dir = files_by_dir 727 | 728 | files_data_grouped = { 729 | "no_group": files_data, 730 | "files_by_lang": files_by_language, 731 | "files_by_dir": files_by_dir, 732 | } 733 | 734 | self.worker_finished_msg = ClocTUI.WorkerFinished( 735 | header_data=header_data, 736 | summary_data=summary_data, 737 | files_data_grouped=files_data_grouped, 738 | ) 739 | 740 | self.post_message(self.worker_finished_msg) 741 | 742 | @on(WorkerFinished) 743 | async def worker_finished(self, message: WorkerFinished) -> None: 744 | 745 | spinner_container = self.query_one("#spinner_container", Horizontal) 746 | spinner_container.remove() 747 | await self.push_screen( 748 | TableScreen(message, group_mode=CustomDataTable.UpdateMode.NO_GROUP), 749 | ) 750 | 751 | @on(OptionsBar.GroupByLang) 752 | async def group_by_lang(self) -> None: 753 | 754 | table_screen = self.screen 755 | assert isinstance(table_screen, TableScreen), "Expected TableScreen instance." 756 | table_screen.dismiss() 757 | await self.push_screen( 758 | TableScreen(self.worker_finished_msg, group_mode=CustomDataTable.UpdateMode.GROUP_BY_LANG), 759 | ) 760 | 761 | @on(OptionsBar.GroupByDir) 762 | async def group_by_dir(self) -> None: 763 | 764 | table_screen = self.screen 765 | assert isinstance(table_screen, TableScreen), "Expected TableScreen instance." 766 | table_screen.dismiss() 767 | await self.push_screen( 768 | TableScreen(self.worker_finished_msg, group_mode=CustomDataTable.UpdateMode.GROUP_BY_DIR), 769 | ) 770 | 771 | @on(OptionsBar.NoGroup) 772 | async def no_group(self) -> None: 773 | 774 | table_screen = self.screen 775 | assert isinstance(table_screen, TableScreen), "Expected TableScreen instance." 776 | table_screen.dismiss() 777 | await self.push_screen( 778 | TableScreen(self.worker_finished_msg, group_mode=CustomDataTable.UpdateMode.NO_GROUP), 779 | ) 780 | --------------------------------------------------------------------------------