├── .github ├── dependabot.yml └── workflows │ ├── go-test.yml │ ├── goreleaser.yml │ └── spellchecker.yml ├── .gitignore ├── .golangci.yml ├── .typos.toml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── app ├── constants.go ├── delegate.go ├── keys.go ├── model.go ├── preview.go ├── realtime.go ├── styles.go ├── update.go └── view.go ├── config ├── config.go ├── constants.go ├── history.go └── theme.go ├── go.mod ├── go.sum ├── handlers ├── constants.go ├── defaultListener.go └── wayland.go ├── main.go ├── nix └── clipse │ ├── clipseShell.nix │ └── default.nix ├── resources ├── examples │ ├── catppuccin.png │ ├── default.png │ ├── dracula.png │ ├── gruvbox.png │ ├── htop.png │ ├── light.png │ ├── nord.png │ ├── pinned.png │ └── tokyo_night.png ├── library.md ├── setup_data │ ├── clipboard_history.json │ ├── config.json │ └── custom_theme.json ├── test_data │ ├── image.png │ ├── lorum_ipsum.csv │ ├── nix-logo.png │ ├── swappy-20240202-194248.png │ └── top_secret_credentials.txt └── themes │ ├── catppuccin.json │ ├── dracula.json │ ├── gruvbox.json │ ├── light.json │ ├── nord.json │ └── tokyo_night.json ├── shell ├── cmd.go ├── constants.go └── image.go ├── tests ├── app │ └── app_test.go ├── config │ └── config_test.go ├── handlers │ └── handlers_test.go ├── main_test.go ├── shell │ └── shell_test.go └── utils │ └── utils_test.go ├── themes ├── catppuccin.json ├── dracula.json ├── gruvbox.json └── nord.json └── utils ├── constants.go ├── err.go ├── image.go ├── int.go ├── logger.go └── string.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain golang dependencies defined in go.mod 4 | # These would open PR, these PR would be tested with the CI 5 | # They will have to be merged manually by a maintainer 6 | - package-ecosystem: "gomod" 7 | directory: "/" 8 | open-pull-requests-limit: 10 # avoid spam, if no one reacts 9 | schedule: 10 | interval: "weekly" 11 | time: "11:00" 12 | 13 | # Maintain dependencies for GitHub Actions 14 | # These would open PR, these PR would be tested with the CI 15 | # They will have to be merged manually by a maintainer 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | open-pull-requests-limit: 10 # avoid spam, if no one reacts 19 | schedule: 20 | interval: "weekly" 21 | time: "11:00" 22 | -------------------------------------------------------------------------------- /.github/workflows/go-test.yml: -------------------------------------------------------------------------------- 1 | name: Test go build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | paths: 8 | - '**/*.go' 9 | 10 | jobs: 11 | go-test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: 'stable' 21 | 22 | - name: Run go test (syntax check) 23 | id: test-syntax 24 | run: go test -v ./... 25 | 26 | golangci-all-codebase: 27 | runs-on: ubuntu-latest 28 | continue-on-error: true 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v4 32 | 33 | - name: Set up Go 34 | uses: actions/setup-go@v5 35 | with: 36 | go-version: 'stable' 37 | 38 | - name: golangci-lint 39 | uses: golangci/golangci-lint-action@v6 40 | with: 41 | version: latest 42 | 43 | golangci-updated-code: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - name: Checkout code 47 | uses: actions/checkout@v4 48 | 49 | - name: Set up Go 50 | uses: actions/setup-go@v5 51 | with: 52 | go-version: 'stable' 53 | 54 | - name: golangci-lint-updated-code 55 | uses: golangci/golangci-lint-action@v6 56 | with: 57 | version: latest 58 | only-new-issues: true -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: Release Workflow 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' # Trigger workflow on tags that start with 'v' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: stable 23 | 24 | #- name: Install modules # This is needed before running goreleaser as parallel builds crash the process when having to install deps 25 | # run: go mod tidy 26 | 27 | - name: Run GoReleaser 28 | uses: goreleaser/goreleaser-action@v6 29 | with: 30 | distribution: goreleaser 31 | version: latest 32 | args: release --clean 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/spellchecker.yml: -------------------------------------------------------------------------------- 1 | name: spell checking 2 | on: [pull_request] 3 | 4 | jobs: 5 | typos: 6 | name: Spell Check with Typos 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout Actions Repository 10 | uses: actions/checkout@v4 11 | - name: typos-action 12 | uses: crate-ci/typos@v1.28.2 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | clipse 2 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # golangci-lint configuration file made by @ccoVeille 3 | # Source: https://github.com/ccoVeille/golangci-lint-config-examples/ 4 | # Author: @ccoVeille 5 | # License: MIT 6 | # Variant: 03-safe 7 | # Version: v1.0.0 8 | # 9 | linters: 10 | # some linters are enabled by default 11 | # https://golangci-lint.run/usage/linters/ 12 | # 13 | # enable some extra linters 14 | enable: 15 | # Errcheck is a program for checking for unchecked errors in Go code. 16 | - errcheck 17 | 18 | # Linter for Go source code that specializes in simplifying code. 19 | - gosimple 20 | 21 | # Vet examines Go source code and reports suspicious constructs. 22 | - govet 23 | 24 | # Detects when assignments to existing variables are not used. 25 | - ineffassign 26 | 27 | # It's a set of rules from staticcheck. See https://staticcheck.io/ 28 | - staticcheck 29 | 30 | # Fast, configurable, extensible, flexible, and beautiful linter for Go. 31 | # Drop-in replacement of golint. 32 | - revive 33 | 34 | # check imports order and makes it always deterministic. 35 | - gci 36 | 37 | # make sure to use t.Helper() when needed 38 | - thelper 39 | 40 | # mirror suggests rewrites to avoid unnecessary []byte/string conversion 41 | - mirror 42 | 43 | # detect the possibility to use variables/constants from the Go standard library. 44 | - usestdlibvars 45 | 46 | # Finds commonly misspelled English words. 47 | - misspell 48 | 49 | # Checks for duplicate words in the source code. 50 | - dupword 51 | 52 | linters-settings: 53 | gci: # define the section orders for imports 54 | sections: 55 | # Standard section: captures all standard packages. 56 | - standard 57 | # Default section: catchall that is not standard or custom 58 | - default 59 | # linters that related to local tool, so they should be separated 60 | - localmodule 61 | 62 | revive: 63 | rules: 64 | # these are the default revive rules 65 | # you can remove the whole "rules" node if you want 66 | # BUT 67 | # ! /!\ they all need to be present when you want to add more rules than the default ones 68 | # otherwise, you won't have the default rules, but only the ones you define in the "rules" node 69 | 70 | # Blank import should be only in a main or test package, or have a comment justifying it. 71 | - name: blank-imports 72 | 73 | # context.Context() should be the first parameter of a function when provided as argument. 74 | - name: context-as-argument 75 | arguments: 76 | - allowTypesBefore: "*testing.T" 77 | 78 | # Basic types should not be used as a key in `context.WithValue` 79 | - name: context-keys-type 80 | 81 | # Importing with `.` makes the programs much harder to understand 82 | - name: dot-imports 83 | 84 | # Empty blocks make code less readable and could be a symptom of a bug or unfinished refactoring. 85 | - name: empty-block 86 | 87 | # for better readability, variables of type `error` must be named with the prefix `err`. 88 | - name: error-naming 89 | 90 | # for better readability, the errors should be last in the list of returned values by a function. 91 | - name: error-return 92 | 93 | # for better readability, error messages should not be capitalized or end with punctuation or a newline. 94 | - name: error-strings 95 | 96 | # report when replacing `errors.New(fmt.Sprintf())` with `fmt.Errorf()` is possible 97 | - name: errorf 98 | 99 | # incrementing an integer variable by 1 is recommended to be done using the `++` operator 100 | - name: increment-decrement 101 | 102 | # highlights redundant else-blocks that can be eliminated from the code 103 | - name: indent-error-flow 104 | 105 | # This rule suggests a shorter way of writing ranges that do not use the second value. 106 | - name: range 107 | 108 | # receiver names in a method should reflect the struct name (p for Person, for example) 109 | - name: receiver-naming 110 | 111 | # redefining built in names (true, false, append, make) can lead to bugs very difficult to detect. 112 | - name: redefines-builtin-id 113 | 114 | # redundant else-blocks that can be eliminated from the code. 115 | - name: superfluous-else 116 | 117 | # prevent confusing name for variables when using `time` package 118 | - name: time-naming 119 | 120 | # warns when an exported function or method returns a value of an un-exported type. 121 | - name: unexported-return 122 | 123 | # spots and proposes to remove unreachable code. also helps to spot errors 124 | - name: unreachable-code 125 | 126 | # Functions or methods with unused parameters can be a symptom of an unfinished refactoring or a bug. 127 | - name: unused-parameter 128 | 129 | # report when a variable declaration can be simplified 130 | - name: var-declaration 131 | 132 | # warns when initialism, variable or package naming conventions are not followed. 133 | - name: var-naming 134 | 135 | dupword: 136 | # Keywords used to ignore detection. 137 | # Default: [] 138 | ignore: 139 | # - "blah" # this will accept "blah blah …" as a valid duplicate word 140 | 141 | misspell: 142 | # Correct spellings using locale preferences for US or UK. 143 | # Setting locale to US will correct the British spelling of 'colour' to 'color'. 144 | # Default ("") is to use a neutral variety of English. 145 | locale: US 146 | 147 | # List of words to ignore 148 | # among the one defined in https://github.com/golangci/misspell/blob/master/words.go 149 | ignore-words: 150 | # - valor 151 | # - and 152 | 153 | # Extra word corrections. 154 | extra-words: 155 | # - typo: "whattever" 156 | # correction: "whatever" 157 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | [default] 2 | locale = "en-us" 3 | 4 | [files] 5 | # excluded file 6 | # go.sum and go.mod contains URLs with hash, they can provide false positive 7 | extend-exclude = [ 8 | "go.sum", "go.mod", # these files are specific to Go, they shouldn't get parsed for typos 9 | "resources/test_data/*.csv" # lorem ipsum are of course invalid 10 | ] 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.0.6 -> v0.0.71 2 | 3 | - feat: removed need for `$PPID` arg. made optional with `-fc` flag 4 | - feat: pinned items view 5 | - feat: custom paths for source files and temp dir 6 | - bug fix: high CPU usage when image files copies 7 | - bug fix: `clipse -clear` not deleting all temp files 8 | - feat: replace `` image indicator with 📷 9 | 10 | ## v0.0.71 -> v0.1.0 11 | 12 | - feat: additional `clear` commands to save pinned items/images 13 | - feat: multi-select for copy and delete 14 | - feat: multi-select copy from active filter 15 | - feat: warn on deleting a pinned item in the ui 16 | - feat: custom theme support for all ui components 17 | - feat: previews for text and images 18 | - feat: added debug logging 19 | - ci: `golangci-lint` added to build workflow (thank you @ccoVeille) 20 | - bug fix: updated description to show local time rather than UTC 21 | - bug fix: removed duplicate `No items.` status message when clipboard empty 22 | - optimization: improved the listener's go routine pattern to save CPU usage 23 | - optimization: refactored the core codebase to make fewer calls to external files 24 | 25 | ## v1.0.0 -> v1.0.3 26 | 27 | - bug fix: toggle pin status message showing opposite event 28 | - bug fix: duplicated images sharing the same reference file 29 | - feat: optional duplicates 30 | 31 | ## v1.0.3 -> v1.0.7 32 | 33 | - feat: added a separate Wayland listener client to access data directly from the stdin using `wl-clipboard --watch`. 34 | - feat: significantly improved CPU usage if using Wayland 35 | - fix: not able to copy images from a browser if using Wayland 36 | - bug fix: images copied form stdin and from their temp file no longer share the same byte length for wayland. This lead to a bug where the initial image would not be 'de-duplicated' and would sometimes cause rendering issues. Implemented a fix where all no images can now be duplicated, even if `duplicatesAllowed` is set to `true`. 37 | - bug fix: images not keeping pinned status after being chosen on Wayland 38 | 39 | 40 | ## v1.0.7 -> v1.0.8 41 | 42 | - bug fix: image binary data sometimes parsing as a string on Wayland 43 | - bug fix: inconsistent viewport start position 44 | - bug fix: inconsistent confirmation list start position 45 | 46 | ## v1.0.9 -> v1.1.0 47 | 48 | - bug fix: EDT timezone bug when saving images 49 | - feat: custom keybinding 50 | - feat: custom image preview rendering with `kitty` shell 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 M Savedra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY_NAME=clipse 2 | INSTALL_DIR="/usr/bin/" 3 | 4 | all: build run 5 | 6 | install: 7 | go build -o "${INSTALL_DIR}${BINARY_NAME}" 8 | 9 | build: 10 | go build -o "${BINARY_NAME}" 11 | 12 | run: 13 | go build -o ${BINARY_NAME} 14 | ./${BINARY_NAME} 15 | 16 | clean: 17 | go clean 18 | rm ${BINARY_NAME} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build](https://img.shields.io/github/actions/workflow/status/savedra1/clipse/go-test.yml)](https://github.com/savedra1/clipse/actions) 2 | [![Last Commit](https://img.shields.io/github/last-commit/savedra1/clipse)](https://github.com/savedra1/clipse) 3 | [![GitHub closed issues](https://img.shields.io/github/issues-closed-raw/savedra1/clipse.svg?color=green)](https://github.com/savedra1/clipse/issues) 4 |
5 | 6 | 7 | 8 | [![nix](https://img.shields.io/static/v1?label=Nix&message=1.1.0&color=blue)](https://search.nixos.org/packages?channel=unstable&show=clipse&from=0&size=50&sort=relevance&type=packages&query=clipse) 9 | [![AUR](https://img.shields.io/aur/version/clipse.svg)](https://aur.archlinux.org/packages/clipse/) 10 |
11 | 12 | ### Table of contents 13 | 14 | - [Features](#features) 15 | - [Installation](#installation) 16 | - [Set up](#set-up) 17 | - [Configuration](#configuration) 18 | - [All commands](#all-commands-) 19 | - [How it works](#how-it-works-) 20 | - [Contributing](#contributing-) 21 | - [FAQs](#faq) 22 | 23 | ### Release information 24 | 25 | If moving to a new release of `clipse` please review the [changelog](https://github.com/savedra1/clipse/blob/main/CHANGELOG.md). 26 | 27 | # About 📋 28 | 29 | `clipse` is a configurable, TUI-based clipboard manager application written in Go with minimal dependency. Though the app is optimized for a Linux OS using a dedicated window manager, `clipse` can also be used on any Unix-based system. Simply install the package and bind the open command to get your desired clipboard behavior. Further instructions for setting this up can be found below. 30 | 31 | ### Dependency info and libraries used 32 | 33 | __[atotto/clipboard](https://github.com/atotto/clipboard)__ 34 | 35 | This requires a system clipboard. I would recommend using `wl-clipboard` (Wayland) or `xclip` (X11/macOs) to get the best results. You can also use `xsel` and `termux-clipboard`, but these will not allow you to copy images. 36 | 37 | __[BubbleTea](https://pkg.go.dev/github.com/charmbracelet/bubbletea)__ 38 | 39 | Does not require any additional dependency, but may require you to use a terminal environment that's compatible with [termenv](https://github.com/muesli/termenv). 40 | 41 | # Features ✨ 42 | 43 | - Persistent history 44 | - Supports text and image 45 | - [Customizable UI theme](#Customization) 46 | - [Customizable file paths](#configuration) 47 | - [Customizable maximum history limit](#configuration) 48 | - Filter items using a fuzzy find 49 | - Image and text previews 50 | - Mult-selection of items for copy and delete operations 51 | - Bulk copy all active filter matches 52 | - Pin items/pinned items view 53 | - Vim-like keybindings for navigation available 54 | - [Run on any Unix machine](#Versatility) with single binary for the clipbboard monitor and TUI 55 | - Optional duplicates 56 | - Ability to set custom key bindings 57 | 58 | ### Customization 🧰 59 | 60 | A customizable TUI allows you to easily match your system's theme. The app is based on your terminal's theme by default but is editable from a `custom_theme.json` file that gets created when the program is run for the first time. See the [library](https://github.com/savedra1/clipse/blob/main/resources/library.md) for some example themes to give you inspiration. 61 | 62 | An example `custom_theme.json` file: 63 | 64 | ```json 65 | { 66 | "UseCustom": true, 67 | "TitleFore": "#ffffff", 68 | "TitleBack": "#6F4CBC", 69 | "TitleInfo": "#3498db", 70 | "NormalTitle": "#ffffff", 71 | "DimmedTitle": "#808080", 72 | "SelectedTitle": "#FF69B4", 73 | "NormalDesc": "#808080", 74 | "DimmedDesc": "#808080", 75 | "SelectedDesc": "#FF69B4", 76 | "StatusMsg": "#2ecc71", 77 | "PinIndicatorColor": "#FFD700", 78 | "SelectedBorder": "#3498db", 79 | "SelectedDescBorder": "#3498db", 80 | "FilteredMatch": "#ffffff", 81 | "FilterPrompt": "#2ecc71", 82 | "FilterInfo": "#3498db", 83 | "FilterText": "#ffffff", 84 | "FilterCursor": "#FFD700", 85 | "HelpKey": "#999999", 86 | "HelpDesc": "#808080", 87 | "PageActiveDot": "#3498db", 88 | "PageInactiveDot": "#808080", 89 | "DividerDot": "#3498db", 90 | "PreviewedText": "#ffffff", 91 | "PreviewBorder": "#3498db", 92 | } 93 | ``` 94 | 95 | You can also easily specify source config like custom paths and max history limit in the apps `config.json` file. For more information see [Configuration](#configuration) section. 96 | 97 | ### Versatility 🌐 98 | 99 | The `clipse` binary, installable from the repository, can run on pretty much any Unix-based OS, though currently optimized for Linux. Being terminal-based also allows for easy integration with a window manager and configuration of how the TUI behaves. For example, binding a floating window to the `clipse` command as shown at the top of the page using [Hyprland window manager](https://hyprland.org/) on __NixOs__. 100 | 101 | __Note that working with image files will require one of the following dependencies to be installed on your system__: 102 | 103 | - Linux (X11) & macOS: [xclip](https://github.com/astrand/xclip) 104 | - Linux (Wayland): [wl-clipboard](https://github.com/bugaevc/wl-clipboard) 105 | 106 | # Setup & installation 🏗️ 107 | 108 | See below for instructions on getting clipse installed and configured effectively. 109 | 110 | ## Installation 111 | 112 | ### Installing on NixOs 113 | 114 | Due to how irregularly the stable branch of Nixpkgs is updated, you may find the unstable package is more up to date. The Nix package for `clipse` can be found [here](https://search.nixos.org/packages?channel=24.05&from=0&size=50&sort=relevance&type=packages&query=clipse) 115 | 116 | __Direct install__ 117 | 118 | ```nix 119 | nix-env -iA nixpkgs.clipse # OS == NixOs 120 | nix-env -f channel:nixpkgs -iA clipse # OS != NixOs 121 | ``` 122 | 123 | __Nix shell__ 124 | 125 | ```nix 126 | nix shell -p clipse 127 | ``` 128 | 129 | __System package__ 130 | 131 | ```nix 132 | environment.systemPackages = [ 133 | pkgs.clipse 134 | ]; 135 | ``` 136 | 137 | If building `clipse` from the unstable branch as a system package, I would suggest referencing [this article](https://discourse.nixos.org/t/installing-only-a-single-package-from-unstable/5598) for best practice. The derivation can also be built from source using the following: 138 | 139 | ```nix 140 | { lib 141 | , buildGoModule 142 | , fetchFromGitHub 143 | }: 144 | 145 | buildGoModule rec { 146 | pname = "clipse"; 147 | version = "1.1.0"; 148 | 149 | src = fetchFromGitHub { 150 | owner = "savedra1"; 151 | repo = "clipse"; 152 | rev = "v${version}"; 153 | hash = "sha256-Kpe/LiAreZXRqh6BHvUIn0GcHloKo3A0WOdlRF2ygdc="; 154 | }; 155 | 156 | vendorHash = "sha256-Hdr9NRqHJxpfrV2G1KuHGg3T+cPLKhZXEW02f1ptgsw="; 157 | 158 | meta = { 159 | description = "Useful clipboard manager TUI for Unix"; 160 | homepage = "https://github.com/savedra1/clipse"; 161 | license = lib.licenses.mit; 162 | mainProgram = "clipse"; 163 | maintainers = [ lib.maintainers.savedra1 ]; 164 | }; 165 | } 166 | ``` 167 | 168 | ### Installing on Arch 169 | 170 | Thank you [@raininja](https://github.com/raininja) for creating and maintaining the [AUR package](https://aur.archlinux.org/packages/clipse)! 171 | 172 | __Installing with yay__ 173 | 174 | ```shell 175 | yay -S clipse 176 | ``` 177 | 178 | __Installing from pkg source__ 179 | 180 | ```shell 181 | git clone https://aur.archlinux.org/clipse.git && cd clipse && makepkg -si 182 | ``` 183 | 184 | ### Installing on Fedora/Rhel 185 | 186 | Thank you [@RadioAndrea](https://github.com/RadioAndrea) for creating and maintaining the [COPR package](https://copr.fedorainfracloud.org/coprs/azandure/clipse/)! 187 | 188 | ```shell 189 | dnf copr enable azandure/clipse 190 | ``` 191 | 192 | ### Installing with wget 193 | 194 |
195 | Linux arm64 196 | 197 | ```shell 198 | wget -c https://github.com/savedra1/clipse/releases/download/v1.1.0/clipse_1.1.0_linux_arm64.tar.gz -O - | tar -xz 199 | ``` 200 | 201 |
202 | 203 |
204 | Linux amd64 205 | 206 | ```shell 207 | wget -c https://github.com/savedra1/clipse/releases/download/v1.1.0/clipse_1.1.0_linux_amd64.tar.gz -O - | tar -xz 208 | ``` 209 | 210 |
211 | 212 |
213 | Linux 836 214 | 215 | ```shell 216 | wget -c https://github.com/savedra1/clipse/releases/download/v1.1.0/clipse_1.1.0_linux_836.tar.gz -O - | tar -xz 217 | ``` 218 | 219 |
220 | 221 |
222 | Darwin arm64 223 | 224 | ```shell 225 | wget -c https://github.com/savedra1/clipse/releases/download/v1.1.0/clipse_1.1.0_darwin_arm64.tar.gz -O - | tar -xz 226 | ``` 227 | 228 |
229 | 230 |
231 | Darwin amd64 232 | 233 | ```shell 234 | wget -c https://github.com/savedra1/clipse/releases/download/v1.1.0/clipse_1.1.0_darwin_amd64.tar.gz -O - | tar -xz 235 | ``` 236 | 237 |
238 | 239 | ### Installing with Go 240 | 241 | ```shell 242 | go install github.com/savedra1/clipse@v1.1.0 243 | ``` 244 | 245 | ### Building from source 246 | 247 | ```shell 248 | git clone https://github.com/savedra1/clipse 249 | cd clipse 250 | go mod tidy 251 | go build -o clipse 252 | ``` 253 | 254 | ## Set up 255 | 256 | As mentioned earlier, to get the most out of `clipse`, it's recommended to bind the two primary key commands to your system's config. The first key command is to open the clipboard history TUI: 257 | 258 | ```shell 259 | clipse 260 | ``` 261 | 262 | The second command doesn't need to be bound to a key combination, but rather to the system boot to run the background listener on start-up: 263 | 264 | ```shell 265 | clipse -listen 266 | ``` 267 | 268 | The above command creates a `nohup` process of `clipse --listen-shell`, which if called on its own will start a listener in your current terminal session instead. If `nohup` is not supported on your system, you can use your preferred method of running `clipse --listen-shell` in the background instead. 269 | 270 | __Note: The following examples are based on bash/zsh shell environments. If you use something else like `foot` or `fish`, you may need to construct the command differently, referencing the relevant documentation.__ 271 | 272 | ### Hyprland 273 | 274 | Add the following lines to your Hyprland config file: 275 | 276 | ```shell 277 | 278 | exec-once = clipse -listen # run listener on startup 279 | 280 | windowrulev2 = float,class:(clipse) # ensure you have a floating window class set if you want this behavior 281 | windowrulev2 = size 622 652,class:(clipse) # set the size of the window as necessary 282 | 283 | bind = SUPER, V, exec, --class clipse -e 'clipse' 284 | 285 | # Example: bind = SUPER, V, exec, alacritty --class clipse -e 'clipse' 286 | ``` 287 | 288 | [Hyprland reference](https://wiki.hyprland.org/Configuring/Window-Rules/) 289 | 290 | ### i3 291 | 292 | Add the following commands to your `~/.config/i3/config` file: 293 | 294 | ```shell 295 | exec --no-startup-id clipse -listen # run listener on startup 296 | bindsym $mod+V exec --no-startup-id urxvt -e "$SHELL" -c "i3-msg 'floating enable' && clipse" # Bind floating shell with TUI selection to something nice 297 | ``` 298 | 299 | [i3 reference](https://wiki.archlinux.org/title/i3) 300 | 301 | ### Sway 302 | 303 | Add the following config to your `~/.config/sway/config` file: 304 | 305 | ```shell 306 | exec clipse -listen # run the background listener on startup 307 | bindsym $mod+V exec -e sh -c "swaymsg floating enable, move position center; swaymsg resize set 80ppt 80ppt && clipse" # Bind floating shell with TUI selection to something nice 308 | ``` 309 | 310 | [Sway reference](https://wiki.archlinux.org/title/sway#:~:text=To%20enable%20floating%20windows%20or,enable%20floating%20windows%2Fwindow%20assignments.) 311 | 312 | ### MacOs 313 | 314 | The native terminal on MacOs will not close once the `clipse` program completes, even when using the `-fc` argument. You will therefore need to use a different terminal environment like [Alacritty](https://alacritty.org/) to achieve the "close on selection" effect. The bindings used to open the TUI will then need to be defined in your settings/window manager. 315 | 316 | ### Other 317 | 318 | Every system/window manager is different and hard to determine exactly how to achieve the more ‘GUI-like’ behavior. If using something not mentioned above, just refer to your systems documentation to find how to: 319 | 320 | - Run the `clipse -listen` / `clipse --listen-shell` command on startup 321 | - Bind the `clipse` command to a key that opens a terminal session (ideally in a window) 322 | 323 | If you're not calling `clipse` with a command like `exec -e sh -c` and want to force the terminal window to close on selection of an item, use the `-fc` arg to pass in the `$PPID` variable so the program can force kill the shell session. EG `clipse -fc $PPID`. _Note that the $PPID variable is not available in every terminal environment, like fish terminal where you'd need to use $fish_pid instead._ 324 | 325 | ## Configuration 326 | 327 | The configuration capabilities of `clipse` will change as `clipse` evolves and grows. Currently, clipse supports the following configuration: 328 | 329 | - Setting custom paths for: 330 | - The clipboard history file 331 | - The clipboard binaries directory (copied images and other binary data is stored in here) 332 | - The debug log file 333 | - The clipboard UI theme file 334 | - Setting a custom max history limit 335 | - Custom themes 336 | - If duplicates are allowed 337 | - Setting custom key bindings 338 | - Image display mode 339 | 340 | `clipse` looks for a base config file in `$CONFIG_DIR/clipse/config.json` _(`$CONFIG_DIR` being `$XDG_DATA_HOME` or `$HOME/.config`)_, and creates a default file if it does not find anything. The default config looks like this: 341 | 342 | ```json 343 | { 344 | "historyFile": "clipboard_history.json", 345 | "maxHistory": 100, 346 | "allowDuplicates": false, 347 | "themeFile": "custom_theme.json", 348 | "tempDir": "tmp_files", 349 | "logFile": "clipse.log", 350 | "keyBindings": { 351 | "choose": "enter", 352 | "clearSelected": "S", 353 | "down": "down", 354 | "end": "end", 355 | "filter": "/", 356 | "home": "home", 357 | "more": "?", 358 | "nextPage": "right", 359 | "prevPage": "left", 360 | "preview": "t", 361 | "quit": "q", 362 | "remove": "x", 363 | "selectDown": "ctrl+down", 364 | "selectSingle": "s", 365 | "selectUp": "ctrl+up", 366 | "togglePin": "p", 367 | "togglePinned": "tab", 368 | "up": "up", 369 | "yankFilter": "ctrl+s" 370 | }, 371 | "imageDisplay": { 372 | "type": "basic", 373 | "scaleX": 9, 374 | "scaleY": 9, 375 | "heightCut": 2 376 | } 377 | } 378 | ``` 379 | 380 | Note that all the paths provided (the theme, `historyFile`, and `tempDir`) are all relative paths. They are relative to the location of the config file that holds them. Thus, a file `config.json` at location `$HOME/.config/clipse/config.json` will have all relative paths defined in it relative to its directory of `$HOME/.config/clipse`. 381 | 382 | Absolute paths starting with `/`, paths relative to the user home dir using `~`, or any environment variables like `$HOME` and `$XDG_CONFIG_HOME` are also valid paths that can be used in this file instead. 383 | 384 | Currently these are the supported options for `imageDisplay.type`: 385 | - `basic` 386 | - `kitty` 387 | - `sixel` 388 | 389 | The `scaleX` and `scaleY` options are the scaling factors for the images. Depending on the situation, you need to find suitable numbers to ensure the images are displayed correctly and completely. You can make adjustments based on [this implementation](https://github.com/savedra1/clipse/pull/138#issue-2530565414). 390 | 391 | ## All commands 💻 392 | 393 | `clipse` is more than just a TUI. It also offers a number of CLI commands for managing clipboard content directly from the terminal. 394 | 395 | ```shell 396 | # Operational commands 397 | 398 | clipse -a # Adds directly to the clipboard history without copying to system clipboard (string 399 | 400 | clipse -a # Adds any standard input directly to the clipboard history without copying to the system clipboard. 401 | 402 | # For example: echo "some data" | clipse -a 403 | 404 | clipse -c # Copy the to the system clipboard (string). This also adds to clipboard history if currently listening. 405 | 406 | clipse -c # Copies any standard input directly to the system clipboard. 407 | 408 | # For example: echo "some data" | clipse -c 409 | 410 | clipse -p # Prints the current clipboard content to the console. 411 | 412 | # Example: clipse -p > file.txt 413 | 414 | # TUI management commands 415 | 416 | clipse # Open Clipboard TUI in persistent/debug mode 417 | 418 | clipse -fc $PPID # Open Clipboard TUI in 'force kill' mode 419 | 420 | clipse -listen # Run a background listener process 421 | 422 | clipse --listen-shell # Run a listener process in the current terminal (useful for debugging) 423 | 424 | clipse -help # Display menu option 425 | 426 | clipse -v # Get version 427 | 428 | clipse -clear # Wipe all clipboard history except for pinned items 429 | 430 | clipse -clear-images # Wipe all images from the history 431 | 432 | clipse -clear-text # Wipe all text items from the clipboard history 433 | 434 | clipse -clear-all # Wipe entire clipboard history 435 | 436 | clipse keep # Keep the TUI open after selecting an item to copy (useful for debugging) 437 | 438 | clipse -kill # Kill any existing background processes 439 | ``` 440 | 441 | You can also view the full list of TUI key commands by hitting the `?` key when the `clipse` UI is open. 442 | 443 | ## How it works 🤔 444 | 445 | When the app is run for the first time it creates a `/home/$USER/.config/clipse` dir containing `config.json`, `clipboard_history.json`, `custom_theme.json` and a dir called `tmp_files` for storing image data. After the `clipse -listen` command is executed, a background process will be watching for clipboard activity and adding any changes to the `clipboard_history.json` file, unless a different path is specified in `config.json`. 446 | 447 | The TUI that displays the clipboard history with the defined theme should then be called with the `clipse` command. Operations within the TUI are defined with the [BubbleTea](https://pkg.go.dev/github.com/charmbracelet/bubbletea) framework, allowing for efficient concurrency and a smooth UX. `delete` operations will remove the selected item from the TUI view and the storage file, `select` operations will copy the item to the systems clipboard and exit the program. 448 | 449 | The maximum item storage limit defaults at __100__ but can be customized to anything you like in the `config.json` file. 450 | 451 | ## Contributing 🙏 452 | 453 | I would love to receive contributions to this project and welcome PRs from everyone. The following is a list of example future enhancements I'd like to implement: 454 | 455 | - [x] ~~Image previews in TUI view~~ 456 | - [x] ~~Pinned items~~ 457 | - [x] ~~Warn on deleting pinned items~~ 458 | - [x] ~~Color theme customizations for all UI elements~~ 459 | - Customizations for: 460 | - [x] ~~max history limit~~ 461 | - [x] ~~config file paths~~ 462 | - [x] ~~Duplicates allowed~~ 463 | - [x] ~~key bindings~~ 464 | - [x] ~~image preview display render type~~ 465 | - [x] ~~Option to disable duplicate items~~ 466 | - [ ] Auto-forget based on where the text was copied 467 | - [x] ~~Multi-select feature for copying multiple items at once~~ 468 | - [ ] Categorized pinned items with _potentially_ different tabs/views 469 | - [ ] System paste option _(building functionality to paste the chosen item directly into the next place of focus after the TUI closes)_ 470 | - Packages for: 471 | - [ ] apt 472 | - [x] ~~dnf~~ 473 | - [ ] brew 474 | - [ ] other 475 | - [ ] Theme/config adjustments made available via CLI 476 | - [ ] Your custom theme for the [library](https://github.com/savedra1/clipse/blob/main/resources/library.md) 477 | - [ ] debug mode _(eg `clipse --debug` / debug file / system alert on panic)_ 478 | - [ ] Cross compile binaries for `wl-clipboard`/`xclip` to remove dependency 479 | - [x] TUI / theming enhancements: 480 | - [x] ~~Menu theme~~ 481 | - [x] ~~Filter theme~~ 482 | - [x] ~~Clear TUI view on select and close _(mirror close effect from `q` or `esc`)_~~ 483 | - [ ] Private mode _(eg `clipse --pause 1` )_ 484 | 485 | ## FAQ 486 | 487 | __Clipse crashes when I enter certain characters into the search bar__ 488 | 489 | See issue #148. This is caused by the fuzzy find algo _(within the BubbleTea TUI framework code)_ crashing when it encounters non-compatible characters in the history file, such as an irregular image binary pattern or a rare non-ascii text character. The fix is to to remove the clipboard entry that contains the problematic character. I would recommend pinning any items you do not want to lose and running `clipse -clear`. 490 | 491 | 492 | __My terminal window does not close on selection, even when using `clipse -fc $PPID`__ 493 | 494 | Some terminal environments reference system variables differently. For example, the fish terminal will need to use `$fish_pid` instead. To debug this error you can run `echo $PPID` to see what gets returned. To get the "close on selection" effect for macOs, you will need to install a different terminal environment like `Alacritty`._ 495 |
496 | 497 | __Is there risk of multiple parallel processes running?__ 498 | 499 | _No. The `clipse` command kills any existing TUI processes before opening up and the `clipse -listen` command kills any existing background listeners before starting a new one._ 500 |
501 | 502 | __High CPU usage?__ 503 | 504 | When an image file has an irregular binary data pattern it can cause a lot of strain on the program when `clipse` reads its history file (even when the TUI is not open). If this happens, you will need to remove the image file from the TUI or by using `clipse -clear-images`. See issue #33 for an example. 505 |
506 | 507 | __My copied entries are not recorded when starting the clipse listener on boot with a systemd service__ 508 | 509 | There may be a few ways around this but the workaround discovered in issue #41 was to use a `.desktop` file, stored in `~/.config/autostart/`. Eg: 510 | 511 | ```shell 512 | [Desktop Entry] 513 | Name=clipse 514 | Comment=Clipse event listener autostart. 515 | Exec=/home/usrname/Applications/bin/clipse/clipse_1.1.0_linux_amd64/clipse --listen %f 516 | Terminal=false 517 | Type=Application 518 | ``` 519 | 520 |
521 | 522 | __Copying images from a browser does not work correctly__ 523 | 524 | Depending on the clipboard utility you are using (`wl-clipboard`/`xclip` etc) the data sent to the system clipboard is read differently when copying from browser locations. 525 |
526 | If using `wayland`, copying images from your browser should now work from most sites if using `v1.0.4` or later. This may copy the binary data as well as the metadata sting as a separate entry. Some sites/browsers may add the browser image data to the stdin in a way that `wl-clipboard` does not recognize. 527 |
528 | If using `x11`, `MacOs` or other and copying browser images does not work, feel free to raise and issue (or a PR) detailing which sites/browser engines this does not work with for you. 529 | 530 |
531 | -------------------------------------------------------------------------------- /app/constants.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | const ( 4 | pinChar = "  " 5 | pinColorDefault = "#FF0000" 6 | clipboardTitle = "Clipboard History" 7 | confirmationTitle = "Delete pinned item(s)?" 8 | previewHeader = "Preview" 9 | borderRightChar = "├" 10 | borderLeftChar = "┤" 11 | borderMiddleChar = "─" 12 | defaultMsgColor = "#04B575" 13 | spaceChar = "␣" 14 | ) 15 | -------------------------------------------------------------------------------- /app/delegate.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/list" 9 | tea "github.com/charmbracelet/bubbletea" 10 | 11 | "github.com/savedra1/clipse/config" 12 | ) 13 | 14 | func (m *Model) newItemDelegate() itemDelegate { 15 | return itemDelegate{ 16 | theme: m.theme, 17 | } 18 | } 19 | 20 | type itemDelegate struct { 21 | theme config.CustomTheme 22 | } 23 | 24 | func (d itemDelegate) Height() int { return 2 } 25 | func (d itemDelegate) Spacing() int { return 1 } 26 | func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 27 | 28 | func (d itemDelegate) Render( 29 | w io.Writer, 30 | m list.Model, 31 | index int, 32 | listItem list.Item, 33 | ) { 34 | i, ok := listItem.(item) 35 | if !ok { 36 | return 37 | } 38 | 39 | var renderStr string 40 | 41 | switch { 42 | 43 | case m.SettingFilter(): 44 | if strings.Contains( 45 | strings.ToLower(i.titleFull), 46 | strings.ToLower(m.FilterValue()), 47 | ) && m.FilterValue() != "" { 48 | renderStr = d.itemSelectedStyle(i) 49 | } else { 50 | renderStr = d.itemFilterStyle(i) 51 | } 52 | 53 | case index == m.Index(): 54 | renderStr = d.itemChosenStyle(i) 55 | 56 | case i.selected: 57 | renderStr = d.itemSelectedStyle(i) 58 | 59 | default: 60 | renderStr = d.itemNormalStyle(i) 61 | } 62 | 63 | fmt.Fprint(w, renderStr) 64 | } 65 | -------------------------------------------------------------------------------- /app/keys.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | 6 | "github.com/savedra1/clipse/config" 7 | ) 8 | 9 | // default keybind definitions 10 | type keyMap struct { 11 | filter key.Binding 12 | quit key.Binding 13 | more key.Binding 14 | choose key.Binding 15 | remove key.Binding 16 | togglePin key.Binding 17 | togglePinned key.Binding 18 | preview key.Binding 19 | selectDown key.Binding 20 | selectUp key.Binding 21 | selectSingle key.Binding 22 | clearSelected key.Binding 23 | yankFilter key.Binding 24 | up key.Binding 25 | down key.Binding 26 | nextPage key.Binding 27 | prevPage key.Binding 28 | home key.Binding 29 | end key.Binding 30 | } 31 | 32 | func newKeyMap() *keyMap { 33 | config := config.ClipseConfig.KeyBindings 34 | 35 | previewChar := config["preview"] 36 | if previewChar == " " { 37 | previewChar = spaceChar 38 | } 39 | 40 | return &keyMap{ 41 | filter: key.NewBinding( 42 | key.WithKeys(config["filter"]), 43 | key.WithHelp(config["filter"], "filter"), 44 | ), 45 | quit: key.NewBinding( 46 | key.WithKeys(config["quit"]), 47 | key.WithHelp(config["quit"], "quit"), 48 | ), 49 | more: key.NewBinding( 50 | key.WithKeys(config["more"]), 51 | key.WithHelp(config["more"], "more"), 52 | ), 53 | choose: key.NewBinding( 54 | key.WithKeys(config["choose"]), 55 | key.WithHelp("↵", "copy"), 56 | ), 57 | remove: key.NewBinding( 58 | key.WithKeys(config["remove"]), 59 | key.WithHelp(config["remove"], "delete"), 60 | ), 61 | togglePin: key.NewBinding( 62 | key.WithKeys(config["togglePin"]), 63 | key.WithHelp(config["togglePin"], "pin/unpin"), 64 | ), 65 | togglePinned: key.NewBinding( 66 | key.WithKeys(config["togglePinned"]), 67 | key.WithHelp(config["togglePinned"], "show pinned"), 68 | ), 69 | preview: key.NewBinding( 70 | key.WithKeys(config["preview"]), 71 | key.WithHelp(previewChar, "preview"), 72 | ), 73 | selectDown: key.NewBinding( 74 | key.WithKeys(config["selectDown"]), 75 | key.WithHelp(config["selectDown"], "select"), 76 | ), 77 | selectUp: key.NewBinding( 78 | key.WithKeys(config["selectUp"]), 79 | key.WithHelp(config["selectUp"], "select"), 80 | ), 81 | selectSingle: key.NewBinding( 82 | key.WithKeys(config["selectSingle"]), 83 | key.WithHelp(config["selectSingle"], "select single"), 84 | ), 85 | clearSelected: key.NewBinding( 86 | key.WithKeys(config["clearSelected"]), 87 | key.WithHelp(config["clearSelected"], "clear selected"), 88 | ), 89 | yankFilter: key.NewBinding( 90 | key.WithKeys(config["yankFilter"]), 91 | key.WithHelp(config["yankFilter"], "yank filter results"), 92 | ), 93 | up: key.NewBinding( 94 | key.WithKeys(config["up"]), 95 | ), 96 | down: key.NewBinding( 97 | key.WithKeys(config["down"]), 98 | ), 99 | nextPage: key.NewBinding( 100 | key.WithKeys(config["nextPage"]), 101 | ), 102 | prevPage: key.NewBinding( 103 | key.WithKeys(config["prevPage"]), 104 | ), 105 | home: key.NewBinding( 106 | key.WithKeys(config["home"]), 107 | ), 108 | end: key.NewBinding( 109 | key.WithKeys(config["end"]), 110 | ), 111 | } 112 | } 113 | 114 | func (k keyMap) ShortHelp() []key.Binding { 115 | return []key.Binding{ 116 | k.choose, k.remove, k.togglePin, k.togglePinned, k.more, 117 | } 118 | } 119 | 120 | // not currently in use as intentionally being overridden by the default 121 | // full help view 122 | func (k keyMap) FullHelp() [][]key.Binding { 123 | return [][]key.Binding{ 124 | {k.up, k.down, k.home, k.end}, 125 | {k.choose, k.remove}, 126 | {k.togglePin, k.togglePinned}, 127 | {k.selectDown, k.selectSingle, k.yankFilter}, 128 | {k.filter, k.quit}, 129 | } 130 | } 131 | 132 | // used only for the default filter input view 133 | type filterKeyMap struct { 134 | apply key.Binding 135 | cancel key.Binding 136 | yankMatches key.Binding 137 | } 138 | 139 | func newFilterKeymap() *filterKeyMap { 140 | config := config.ClipseConfig.KeyBindings 141 | 142 | return &filterKeyMap{ 143 | apply: key.NewBinding( 144 | key.WithKeys(config["choose"]), 145 | key.WithHelp(config["choose"], "apply"), 146 | ), 147 | cancel: key.NewBinding( 148 | key.WithKeys(config["quit"]), 149 | key.WithHelp(config["quit"], "cancel"), 150 | ), 151 | yankMatches: key.NewBinding( 152 | key.WithKeys(config["yankFilter"]), 153 | key.WithHelp(config["yankFilter"], "yank matched"), 154 | ), 155 | } 156 | } 157 | 158 | func (fk filterKeyMap) FilterHelp() []key.Binding { 159 | return []key.Binding{ 160 | fk.apply, fk.cancel, fk.yankMatches, 161 | } 162 | } 163 | 164 | type confirmationKeyMap struct { 165 | up key.Binding 166 | down key.Binding 167 | choose key.Binding 168 | } 169 | 170 | func newConfirmationKeymap() *confirmationKeyMap { 171 | config := config.ClipseConfig.KeyBindings 172 | 173 | return &confirmationKeyMap{ 174 | up: key.NewBinding( 175 | key.WithKeys(config["up"]), 176 | key.WithHelp(config["up"], "↑"), 177 | ), 178 | down: key.NewBinding( 179 | key.WithKeys(config["down"]), 180 | key.WithHelp(config["down"], "↓"), 181 | ), 182 | choose: key.NewBinding( 183 | key.WithKeys(config["choose"]), 184 | key.WithHelp(config["choose"], "choose"), 185 | ), 186 | } 187 | } 188 | 189 | func (ck confirmationKeyMap) ConfirmationHelp() []key.Binding { 190 | return []key.Binding{ 191 | ck.up, ck.down, ck.choose, 192 | } 193 | } 194 | 195 | type previewKeymap struct { 196 | up key.Binding 197 | down key.Binding 198 | back key.Binding 199 | pageDown key.Binding 200 | pageUp key.Binding 201 | } 202 | 203 | func newPreviewKeyMap() *previewKeymap { 204 | config := config.ClipseConfig.KeyBindings 205 | 206 | previewChar := config["preview"] 207 | if previewChar == " " { 208 | previewChar = spaceChar 209 | } 210 | 211 | return &previewKeymap{ 212 | up: key.NewBinding( 213 | key.WithKeys(config["up"]), 214 | key.WithHelp(config["up"], "↑"), 215 | ), 216 | down: key.NewBinding( 217 | key.WithKeys(config["down"]), 218 | key.WithHelp(config["down"], "↓"), 219 | ), 220 | pageDown: key.NewBinding( 221 | key.WithKeys("PgDn"), 222 | key.WithHelp("PgDn", "page down"), 223 | ), 224 | pageUp: key.NewBinding( 225 | key.WithKeys("PgUp"), 226 | key.WithHelp("PgUp", "page up"), 227 | ), 228 | back: key.NewBinding( 229 | key.WithKeys(config["preview"], config["quit"]), 230 | key.WithHelp(previewChar, "back"), 231 | ), 232 | } 233 | } 234 | 235 | func (pk previewKeymap) PreviewHelp() []key.Binding { 236 | return []key.Binding{ 237 | pk.up, pk.down, pk.pageDown, pk.pageUp, pk.back, 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /app/model.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/help" 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/list" 10 | "github.com/charmbracelet/bubbles/viewport" 11 | tea "github.com/charmbracelet/bubbletea" 12 | 13 | "github.com/savedra1/clipse/config" 14 | "github.com/savedra1/clipse/utils" 15 | ) 16 | 17 | type Model struct { 18 | list list.Model // list items 19 | keys *keyMap // keybindings 20 | filterKeys *filterKeyMap // keybindings for filter view 21 | confirmationKeys *confirmationKeyMap // keybindings for the confirmation view 22 | help help.Model // custom help menu 23 | togglePinned bool // pinned view indicator 24 | theme config.CustomTheme // colors scheme to uses 25 | prevDirection string // prev direction used to track selections 26 | confirmationList list.Model // secondary list Model used for confirmation screen 27 | showConfirmation bool // whether to show confirmation screen 28 | itemCache []SelectedItem // easy access for related items following confirmation screen 29 | preview viewport.Model // viewport model used for displaying previews 30 | originalHeight int // for restore height of preview viewport in sixel mode 31 | previewReady bool // viewport needs to wait for the initial window size message 32 | showPreview bool // whether the viewport preview should be displayed 33 | previewKeys *previewKeymap // keybindings for the viewport model 34 | lastUpdated time.Time 35 | } 36 | 37 | type item struct { 38 | title string // display title in list 39 | titleBase string // unstyled string used for rendering 40 | titleFull string // full value stored in history file 41 | timeStamp string // local date and time of copy event 42 | description string // displayed description in list 43 | descriptionBase string // unstyled string used for rendering 44 | filePath string // "path/to/file" | "null" 45 | pinned bool // pinned status 46 | selected bool // selected status 47 | } 48 | 49 | type SelectedItem struct { 50 | Index int // list index needed for deletion 51 | TimeStamp string // timestamp needed for deletion 52 | Value string // full val needed for copy 53 | Pinned bool // pinned val needed to determine whether confirmation screen is needed 54 | } 55 | 56 | func (i item) Title() string { return i.title } 57 | func (i item) TitleFull() string { return i.titleFull } 58 | func (i item) TimeStamp() string { return i.timeStamp } 59 | func (i item) Description() string { return i.description } 60 | func (i item) FilePath() string { return i.filePath } 61 | func (i item) FilterValue() string { return i.title } 62 | 63 | func (m Model) Init() tea.Cmd { 64 | return tea.EnterAltScreen 65 | } 66 | 67 | func NewModel() Model { 68 | var ( 69 | listKeys = newKeyMap() 70 | filterKeys = newFilterKeymap() 71 | confirmationKeys = newConfirmationKeymap() 72 | ) 73 | 74 | clipboardItems := config.GetHistory() 75 | 76 | theme := config.GetTheme() 77 | 78 | m := Model{ 79 | keys: listKeys, 80 | filterKeys: filterKeys, 81 | confirmationKeys: confirmationKeys, 82 | help: help.New(), 83 | togglePinned: false, 84 | theme: theme, 85 | prevDirection: "", 86 | showConfirmation: false, 87 | preview: NewPreview(), 88 | showPreview: false, 89 | previewKeys: newPreviewKeyMap(), 90 | } 91 | 92 | entryItems := filterItems(clipboardItems, false, m.theme) 93 | 94 | del := m.newItemDelegate() 95 | 96 | clipboardList := list.New(entryItems, del, 0, 0) 97 | 98 | clipboardList.Title = clipboardTitle // set hardcoded title 99 | clipboardList.SetShowHelp(false) // override with custom 100 | clipboardList.Styles.PaginationStyle = style.MarginBottom(1).MarginLeft(2) // set custom pagination spacing 101 | //clipboardList.StatusMessageLifetime = time.Second // can override this if necessary 102 | clipboardList.AdditionalFullHelpKeys = func() []key.Binding { 103 | return []key.Binding{ 104 | listKeys.preview, 105 | listKeys.selectDown, 106 | listKeys.selectSingle, 107 | listKeys.clearSelected, 108 | } 109 | } 110 | 111 | confirmationList := newConfirmationList(del) 112 | 113 | if len(clipboardItems) < 1 { 114 | clipboardList.SetShowStatusBar(false) // remove duplicate "No items" 115 | } 116 | 117 | statusMessageStyle = styledStatusMessage(theme) 118 | m.help = styledHelp(m.help, theme) 119 | m.list = styledList(clipboardList, theme) 120 | m.confirmationList = styledList(confirmationList, theme) 121 | m.enableConfirmationKeys(false) 122 | 123 | return m 124 | } 125 | 126 | // if isPinned is true, returns only an array of pinned items, otherwise all 127 | func filterItems(clipboardItems []config.ClipboardItem, isPinned bool, theme config.CustomTheme) []list.Item { 128 | var filteredItems []list.Item 129 | 130 | for _, entry := range clipboardItems { 131 | shortenedVal := utils.Shorten(entry.Value) 132 | item := item{ 133 | title: shortenedVal, 134 | titleBase: shortenedVal, 135 | titleFull: entry.Value, 136 | description: "Date copied: " + entry.Recorded, 137 | descriptionBase: "Date copied: " + entry.Recorded, 138 | filePath: entry.FilePath, 139 | pinned: entry.Pinned, 140 | timeStamp: entry.Recorded, 141 | selected: false, 142 | } 143 | 144 | if entry.Pinned { 145 | item.description = fmt.Sprintf("Date copied: %s %s", entry.Recorded, styledPin(theme)) 146 | } 147 | 148 | if !isPinned || entry.Pinned { 149 | filteredItems = append(filteredItems, item) 150 | } 151 | } 152 | 153 | return filteredItems 154 | } 155 | 156 | func newConfirmationList(del itemDelegate) list.Model { 157 | items := confirmationItems() 158 | l := list.New(items, del, 0, 10) 159 | l.Title = confirmationTitle 160 | l.SetShowStatusBar(false) 161 | l.SetFilteringEnabled(false) 162 | l.SetShowHelp(false) 163 | l.SetShowPagination(false) 164 | l.KeyMap.Quit.SetEnabled(false) 165 | return l 166 | } 167 | 168 | func confirmationItems() []list.Item { 169 | return []list.Item{ 170 | item{ 171 | title: "No", 172 | titleBase: "No", 173 | descriptionBase: "go back", 174 | }, 175 | item{ 176 | title: "Yes", 177 | titleBase: "Yes", 178 | descriptionBase: "delete the item(s)", 179 | }, 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /app/preview.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/color/palette" 8 | "image/draw" 9 | "os" 10 | "strings" 11 | 12 | "github.com/BourgeoisBear/rasterm" 13 | "github.com/charmbracelet/bubbles/viewport" 14 | "github.com/muesli/termenv" 15 | "github.com/nfnt/resize" 16 | 17 | "github.com/savedra1/clipse/config" 18 | "github.com/savedra1/clipse/utils" 19 | ) 20 | 21 | func NewPreview() viewport.Model { 22 | return viewport.New(20, 40) // default sizing updated on tea.WindowSizeMsg 23 | } 24 | 25 | func getImgPreview(fp string, windowWidth int, windowHeight int) string { 26 | img, err := getDecodedImg(fp) 27 | if err != nil { 28 | utils.LogERROR(fmt.Sprintf("failed to decode image file for preview | %s", err)) 29 | return fmt.Sprintf("failed to open image file for preview | %s", err) 30 | } 31 | switch config.ClipseConfig.ImageDisplay.Type { 32 | case "sixel": 33 | return getSixelString(img, windowWidth, windowHeight) 34 | case "kitty": 35 | return getKittyString(img, windowWidth, windowHeight) 36 | default: 37 | return getBasicString(img, windowWidth) 38 | } 39 | } 40 | 41 | func getBasicString(img image.Image, windowSize int) string { 42 | img = resize.Resize(uint(windowSize), 0, img, resize.Lanczos3) 43 | 44 | bounds := img.Bounds() 45 | width, height := bounds.Max.X, bounds.Max.Y 46 | 47 | p := termenv.ColorProfile() 48 | 49 | var sb strings.Builder 50 | 51 | for y := 0; y < height; y += 2 { 52 | for x := 0; x < width; x++ { 53 | upperR, upperG, upperB, _ := img.At(x, y).RGBA() 54 | lowerR, lowerG, lowerB, _ := img.At(x, y+1).RGBA() 55 | 56 | upperColor := p.Color( 57 | fmt.Sprintf( 58 | "#%02x%02x%02x", uint8(upperR>>8), uint8(upperG>>8), uint8(upperB>>8), 59 | ), 60 | ) 61 | lowerColor := p.Color( 62 | fmt.Sprintf( 63 | "#%02x%02x%02x", uint8(lowerR>>8), uint8(lowerG>>8), uint8(lowerB>>8), 64 | ), 65 | ) 66 | sb.WriteString(termenv.String("▀").Foreground(lowerColor).Background(upperColor).String()) 67 | } 68 | sb.WriteString("\n") 69 | } 70 | return sb.String() 71 | } 72 | 73 | func getSixelString(img image.Image, windowWidth int, windowHeight int) string { 74 | img = smartResize(img, windowWidth, windowHeight) 75 | palettedImg := image.NewPaletted(img.Bounds(), palette.Plan9) 76 | draw.FloydSteinberg.Draw(palettedImg, img.Bounds(), img, image.Point{}) 77 | var buf bytes.Buffer 78 | err := rasterm.SixelWriteImage(&buf, palettedImg) 79 | if err != nil { 80 | utils.LogERROR(fmt.Sprintf("failed to decode image file to sixel | %s", err)) 81 | return fmt.Sprintf("failed to decode image file to sixel | %s", err) 82 | } 83 | return buf.String() 84 | } 85 | 86 | func getKittyString(img image.Image, windowWidth int, windowHeight int) string { 87 | img = smartResize(img, windowWidth, windowHeight) 88 | var buf bytes.Buffer 89 | var opts rasterm.KittyImgOpts 90 | err := rasterm.KittyWriteImage(&buf, img, opts) 91 | if err != nil { 92 | utils.LogERROR(fmt.Sprintf("failed to decode image file to kitty | %s", err)) 93 | return fmt.Sprintf("failed to decode image file to kitty | %s", err) 94 | } 95 | return buf.String() 96 | } 97 | 98 | func smartResize(img image.Image, windowWidth int, windowHeight int) image.Image { 99 | maxWidth := windowWidth * config.ClipseConfig.ImageDisplay.ScaleX 100 | maxHeight := windowHeight * config.ClipseConfig.ImageDisplay.ScaleY 101 | imageWidth := img.Bounds().Dx() 102 | imageHeight := img.Bounds().Dy() 103 | if imageWidth/imageHeight > maxWidth/maxHeight { 104 | return resize.Resize(uint(maxWidth), 0, img, resize.Lanczos3) 105 | } 106 | return resize.Resize(0, uint(maxHeight), img, resize.Lanczos3) 107 | } 108 | 109 | func getDecodedImg(fp string) (image.Image, error) { 110 | file, err := os.Open(fp) 111 | if err != nil { 112 | return nil, err 113 | } 114 | defer file.Close() 115 | 116 | img, _, err := image.Decode(file) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | return img, nil 122 | } 123 | -------------------------------------------------------------------------------- /app/realtime.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | 9 | "github.com/savedra1/clipse/config" 10 | "github.com/savedra1/clipse/utils" 11 | ) 12 | 13 | type ReRender struct{} 14 | 15 | func (m Model) ListenRealTime(p *tea.Program) { 16 | historyPath := config.ClipseConfig.HistoryFilePath 17 | info, err := os.Stat(historyPath) 18 | if err != nil { 19 | utils.LogERROR("Could not get Modification time of history file, starting real time mode failed") 20 | return 21 | } 22 | m.lastUpdated = info.ModTime() 23 | 24 | rr := ReRender{} 25 | var currModTime time.Time 26 | for { 27 | historyFileInfo, err := os.Stat(historyPath) 28 | if err != nil { 29 | continue 30 | } 31 | currModTime = historyFileInfo.ModTime() 32 | 33 | if currModTime.After(m.lastUpdated) { 34 | m.lastUpdated = currModTime 35 | p.Send(rr) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/styles.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/help" 7 | "github.com/charmbracelet/bubbles/list" 8 | "github.com/charmbracelet/lipgloss" 9 | 10 | "github.com/savedra1/clipse/config" 11 | ) 12 | 13 | var ( 14 | style = lipgloss.NewStyle() 15 | titleStyle, descStyle string 16 | appStyle = style.Padding(1, 2) 17 | statusMessageStyle = style.Foreground( 18 | lipgloss.AdaptiveColor{ 19 | Light: defaultMsgColor, 20 | Dark: defaultMsgColor, 21 | }, 22 | ).Render 23 | 24 | previewTitleStyle = func() lipgloss.Style { 25 | b := lipgloss.RoundedBorder() 26 | b.Right = borderRightChar 27 | return style.BorderStyle(b) //.MarginTop(1) 28 | }() 29 | 30 | previewInfoStyle = func() lipgloss.Style { 31 | b := lipgloss.RoundedBorder() 32 | b.Left = borderLeftChar 33 | return style.BorderStyle(b) //MarginBottom(1) 34 | }() 35 | ) 36 | 37 | func (d itemDelegate) itemFilterStyle(i item) string { 38 | titleStyle := style. 39 | Foreground(lipgloss.Color(d.theme.DimmedTitle)). 40 | PaddingLeft(2). 41 | Render(i.titleBase) 42 | 43 | descStyle := style. 44 | Foreground(lipgloss.Color(d.theme.DimmedDesc)). 45 | PaddingLeft(2). 46 | Render(i.descriptionBase) 47 | 48 | return fmt.Sprintf("%s\n%s", titleStyle, descStyle) 49 | } 50 | 51 | func (d itemDelegate) itemChosenStyle(i item) string { 52 | titleStyle = style. 53 | Foreground(lipgloss.Color(d.theme.SelectedTitle)). 54 | PaddingLeft(1). 55 | BorderLeft(true).BorderStyle(lipgloss.NormalBorder()). 56 | BorderForeground(lipgloss.Color(d.theme.SelectedDescBorder)). 57 | Render(i.titleBase) 58 | 59 | descStyle = style. 60 | Foreground(lipgloss.Color(d.theme.SelectedDesc)). 61 | PaddingLeft(1). 62 | BorderLeft(true).BorderStyle(lipgloss.NormalBorder()). 63 | BorderForeground(lipgloss.Color(d.theme.SelectedDescBorder)). 64 | Render(i.descriptionBase) 65 | 66 | if i.pinned { 67 | descStyle += styledPin(d.theme) 68 | } 69 | 70 | return fmt.Sprintf("%s\n%s", titleStyle, descStyle) 71 | } 72 | 73 | func (d itemDelegate) itemSelectedStyle(i item) string { 74 | 75 | titleStyle = style. 76 | Foreground(lipgloss.Color(d.theme.SelectedTitle)). 77 | PaddingLeft(2). 78 | Render(i.titleBase) 79 | 80 | descStyle = style. 81 | Foreground(lipgloss.Color(d.theme.SelectedDesc)). 82 | PaddingLeft(2). 83 | Render(i.descriptionBase) 84 | 85 | if i.pinned { 86 | descStyle += styledPin(d.theme) 87 | } 88 | 89 | return fmt.Sprintf("%s\n%s", titleStyle, descStyle) 90 | } 91 | 92 | func (d itemDelegate) itemNormalStyle(i item) string { 93 | titleStyle = style. 94 | Foreground(lipgloss.Color(d.theme.NormalTitle)). 95 | PaddingLeft(2). 96 | Render(i.titleBase) 97 | 98 | descStyle = style. 99 | Foreground(lipgloss.Color(d.theme.NormalDesc)). 100 | PaddingLeft(2). 101 | Render(i.descriptionBase) 102 | 103 | if i.pinned { 104 | descStyle += styledPin(d.theme) 105 | } 106 | 107 | return fmt.Sprintf("%s\n%s", titleStyle, descStyle) 108 | } 109 | 110 | func styledList(clipboardList list.Model, ct config.CustomTheme) list.Model { 111 | clipboardList.FilterInput.PromptStyle = style. 112 | Foreground(lipgloss.Color(ct.FilterPrompt)). 113 | PaddingTop(1) 114 | clipboardList.FilterInput.TextStyle = style.Foreground(lipgloss.Color(ct.FilterText)) 115 | clipboardList.Styles.StatusBarFilterCount = style.Foreground(lipgloss.Color(ct.FilterInfo)) 116 | clipboardList.FilterInput.Cursor.Style = style.Foreground(lipgloss.Color(ct.FilterCursor)) 117 | clipboardList.Styles.StatusEmpty = style.Foreground(lipgloss.Color(ct.FilterInfo)) 118 | clipboardList.Help.Styles.ShortKey = style.Foreground(lipgloss.Color(ct.HelpKey)) 119 | clipboardList.Help.Styles.ShortDesc = style.Foreground(lipgloss.Color(ct.HelpDesc)) 120 | clipboardList.Help.Styles.FullKey = style.Foreground(lipgloss.Color(ct.HelpKey)) 121 | clipboardList.Help.Styles.FullDesc = style.Foreground(lipgloss.Color(ct.HelpDesc)) 122 | clipboardList.Paginator.ActiveDot = style. 123 | Foreground(lipgloss.Color(ct.PageActiveDot)). 124 | Render("•") 125 | clipboardList.Paginator.InactiveDot = style. 126 | Foreground(lipgloss.Color(ct.PageInactiveDot)). 127 | Render("•") 128 | clipboardList.Styles.StatusBar = style. 129 | Foreground(lipgloss.Color(ct.TitleInfo)). 130 | MarginBottom(1). 131 | MarginLeft(2) 132 | clipboardList.Styles.Title = style. 133 | Foreground(lipgloss.Color(ct.TitleFore)). 134 | Background(lipgloss.Color(ct.TitleBack)). 135 | MarginTop(1). 136 | Align(lipgloss.Position(1)) 137 | clipboardList.Styles.DividerDot = style. 138 | Foreground(lipgloss.Color(ct.DividerDot)). 139 | SetString("•"). 140 | PaddingLeft(1). 141 | PaddingRight(1) 142 | clipboardList.Help.FullSeparator = style. 143 | Foreground(lipgloss.Color(ct.DividerDot)). 144 | PaddingLeft(1). 145 | PaddingRight(1). 146 | Render("•") 147 | clipboardList.Help.ShortSeparator = style. 148 | Foreground(lipgloss.Color(ct.DividerDot)). 149 | PaddingLeft(1). 150 | PaddingRight(1). 151 | Render("•") 152 | clipboardList.Styles.NoItems = style. 153 | Foreground(lipgloss.Color(ct.TitleInfo)). 154 | PaddingBottom(1). 155 | PaddingLeft(2) 156 | 157 | return clipboardList 158 | } 159 | 160 | func styledHelp(help help.Model, ct config.CustomTheme) help.Model { 161 | help.Styles.ShortKey = style.Foreground(lipgloss.Color(ct.HelpKey)) 162 | help.Styles.ShortDesc = style.Foreground(lipgloss.Color(ct.HelpDesc)) 163 | help.Styles.FullKey = style.Foreground(lipgloss.Color(ct.HelpKey)) 164 | help.Styles.FullDesc = style.Foreground(lipgloss.Color(ct.HelpDesc)) 165 | help.FullSeparator = style.Foreground(lipgloss.Color(ct.DividerDot)). 166 | PaddingLeft(1). 167 | PaddingRight(1). 168 | Render("•") 169 | help.ShortSeparator = style. 170 | Foreground(lipgloss.Color(ct.DividerDot)). 171 | PaddingLeft(1). 172 | PaddingRight(1). 173 | Render("•") 174 | return help 175 | } 176 | 177 | func styledStatusMessage(ct config.CustomTheme) func(strs ...string) string { 178 | return style. 179 | Foreground(lipgloss.AdaptiveColor{Light: ct.StatusMsg, Dark: ct.StatusMsg}). 180 | Render 181 | } 182 | 183 | func styledPin(theme config.CustomTheme) string { 184 | return style. 185 | Foreground(lipgloss.Color(theme.PinIndicatorColor)). 186 | Render(pinChar) 187 | } 188 | 189 | func (m *Model) styledPreviewHeader(str string) string { 190 | return style. 191 | Foreground(lipgloss.Color(m.theme.PreviewBorder)). 192 | MarginTop(2). 193 | Render(str) 194 | } 195 | 196 | func (m *Model) styledPreviewFooter(str string) string { 197 | return style. 198 | Foreground(lipgloss.Color(m.theme.PreviewBorder)). 199 | Render(str) 200 | } 201 | 202 | func (m *Model) styledPreviewContent(content string) string { 203 | return style. 204 | Foreground(lipgloss.Color(m.theme.PreviewedText)). 205 | Render(content) 206 | } 207 | -------------------------------------------------------------------------------- /app/update.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/atotto/clipboard" 9 | "github.com/charmbracelet/bubbles/key" 10 | "github.com/charmbracelet/bubbles/viewport" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | 14 | "github.com/savedra1/clipse/config" 15 | "github.com/savedra1/clipse/shell" 16 | "github.com/savedra1/clipse/utils" 17 | ) 18 | 19 | /* 20 | The main update function used to handle core TUI logic and update 21 | the Model state. 22 | */ 23 | 24 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 25 | var cmds []tea.Cmd 26 | 27 | switch msg := msg.(type) { 28 | case ReRender: 29 | clipboardItems := config.GetHistory() 30 | entryItems := filterItems(clipboardItems, false, m.theme) 31 | cmds = append(cmds, m.list.SetItems(entryItems)) 32 | return m, tea.Batch(cmds...) 33 | case tea.WindowSizeMsg: 34 | h, v := appStyle.GetFrameSize() 35 | m.list.SetSize(msg.Width-h, msg.Height-v) 36 | m.confirmationList.SetSize(msg.Width-h, msg.Height-v) 37 | 38 | headerHeight := lipgloss.Height(m.previewHeaderView()) 39 | footerHeight := lipgloss.Height(m.previewFooterView()) 40 | verticalMarginHeight := headerHeight + footerHeight 41 | 42 | if !m.previewReady { 43 | m.preview = viewport.New(msg.Width, msg.Height-verticalMarginHeight) 44 | m.previewReady = true 45 | m.preview.YPosition = headerHeight // '+ 1' needed for high performance rendering only 46 | break 47 | } 48 | m.preview.Width = msg.Width 49 | m.preview.Height = msg.Height - verticalMarginHeight 50 | 51 | case tea.KeyMsg: 52 | if key.Matches(msg, m.keys.filter) && m.list.ShowHelp() { 53 | m.list.Help.ShowAll = false // change default back to short help to keep in sync 54 | m.list.SetShowHelp(false) 55 | m.updatePaginator() 56 | } 57 | 58 | if m.list.SettingFilter() && key.Matches(msg, m.keys.yankFilter) { 59 | filterMatches := m.filterMatches() 60 | if len(filterMatches) >= 1 { 61 | if err := clipboard.WriteAll(strings.Join(filterMatches, "\n")); err == nil { 62 | return m, tea.Quit 63 | } 64 | cmds = append( 65 | cmds, 66 | m.list.NewStatusMessage(statusMessageStyle("Failed to copy all selected items.")), 67 | ) 68 | } 69 | return m, tea.Batch(cmds...) 70 | } 71 | 72 | // Don't match any of the keys below if we're actively filtering. 73 | if m.list.SettingFilter() { 74 | m.setQuitEnabled(false) // disable main list quit to allow filter cancel 75 | break 76 | } 77 | 78 | i, ok := m.list.SelectedItem().(item) 79 | if !ok { 80 | 81 | switch { 82 | case key.Matches(msg, m.keys.more): 83 | m.list.SetShowHelp(!m.list.ShowHelp()) 84 | m.updatePaginator() 85 | } 86 | break 87 | } 88 | title := i.Title() 89 | fullValue := i.TitleFull() 90 | fp := i.FilePath() 91 | timestamp := i.TimeStamp() 92 | 93 | switch { 94 | 95 | case key.Matches(msg, m.keys.choose): 96 | if m.showConfirmation && m.confirmationList.Index() == 0 { // No 97 | m.itemCache = []SelectedItem{} 98 | m.showConfirmation = false 99 | m.setPreviewKeys(false) 100 | m.enableConfirmationKeys(false) 101 | m.setConfirmationKeys(false) 102 | break 103 | 104 | } else if m.showConfirmation && m.confirmationList.Index() == 1 { // Yes 105 | m.showConfirmation = false 106 | m.setPreviewKeys(false) 107 | m.enableConfirmationKeys(false) 108 | m.setConfirmationKeys(false) 109 | currentContent, _ := clipboard.ReadAll() 110 | timeStamps := []string{} 111 | for _, item := range m.itemCache { 112 | if item.Value == currentContent { 113 | if err := clipboard.WriteAll(""); err != nil { 114 | utils.LogERROR(fmt.Sprintf("could not delete all items from history: %s", err)) 115 | 116 | } 117 | } 118 | timeStamps = append(timeStamps, item.TimeStamp) 119 | m.removeCachedItem(item.TimeStamp) 120 | } 121 | 122 | statusMsg := "Deleted: *selected items*" 123 | if len(m.itemCache) == 1 { 124 | statusMsg += strings.Replace(statusMsg, "*selected items*", m.itemCache[0].Value, 1) 125 | } 126 | 127 | if err := config.DeleteItems(timeStamps); err != nil { 128 | utils.LogERROR(fmt.Sprintf("could not delete all items from history: %s", err)) 129 | } 130 | 131 | cmds = append( 132 | cmds, 133 | m.list.NewStatusMessage(statusMessageStyle(statusMsg)), 134 | ) 135 | m.itemCache = []SelectedItem{} 136 | 137 | if len(m.list.Items()) == 0 { 138 | m.keys.remove.SetEnabled(false) 139 | m.list.SetShowStatusBar(false) 140 | } 141 | break 142 | } 143 | 144 | selectedItems := m.selectedItems() 145 | 146 | if len(selectedItems) < 1 { 147 | switch { 148 | case fp != "null": 149 | ds := config.DisplayServer() // eg "wayland" 150 | utils.HandleError(shell.CopyImage(fp, ds)) 151 | return m, tea.Quit 152 | 153 | case len(os.Args) > 2 && utils.IsInt(os.Args[2]): 154 | shell.KillProcess(os.Args[2]) 155 | return m, tea.Quit 156 | 157 | case len(os.Args) > 1 && os.Args[1] == "keep": 158 | utils.HandleError(clipboard.WriteAll(fullValue)) 159 | cmds = append( 160 | cmds, 161 | m.list.NewStatusMessage(statusMessageStyle("Copied to clipboard: "+title)), 162 | ) 163 | return m, tea.Batch(cmds...) 164 | 165 | default: 166 | utils.HandleError(clipboard.WriteAll(fullValue)) 167 | return m, tea.Quit 168 | } 169 | } 170 | 171 | yank := "" 172 | for _, item := range selectedItems { 173 | if fullValue != item.Value { 174 | yank += item.Value + "\n" 175 | } 176 | } 177 | yank += fullValue 178 | switch { 179 | 180 | case len(os.Args) > 2 && utils.IsInt(os.Args[2]): 181 | utils.HandleError(clipboard.WriteAll(yank)) 182 | shell.KillProcess(os.Args[2]) 183 | return m, tea.Quit 184 | 185 | case len(os.Args) > 1 && os.Args[1] == "keep": 186 | statusMsg := "Copied to clipboard: *selected items*" 187 | if err := clipboard.WriteAll(yank); err != nil { 188 | statusMsg = "Could not copy all selected items." 189 | } 190 | cmds = append( 191 | cmds, 192 | m.list.NewStatusMessage(statusMessageStyle(statusMsg)), 193 | ) 194 | return m, tea.Batch(cmds...) 195 | 196 | default: 197 | if err := clipboard.WriteAll(yank); err == nil { 198 | return m, tea.Quit 199 | } 200 | cmds = append( 201 | cmds, 202 | m.list.NewStatusMessage(statusMessageStyle("Could not copy all selected items.")), 203 | ) 204 | } 205 | 206 | case key.Matches(msg, m.keys.remove): 207 | selectedItems := m.selectedItems() 208 | var pinnedItemSelected bool 209 | 210 | m.itemCache = append( 211 | m.itemCache, 212 | SelectedItem{ 213 | Index: m.list.Index(), 214 | TimeStamp: timestamp, 215 | Value: i.titleFull, 216 | Pinned: i.pinned, 217 | }, 218 | ) 219 | 220 | if i.pinned { 221 | pinnedItemSelected = true 222 | } 223 | 224 | for _, selectedItem := range selectedItems { 225 | if selectedItem.Pinned { 226 | pinnedItemSelected = true 227 | } 228 | m.itemCache = append( 229 | m.itemCache, 230 | selectedItem, 231 | ) 232 | } 233 | 234 | if pinnedItemSelected { 235 | m.confirmationList.Select(0) 236 | m.setConfirmationKeys(true) 237 | m.enableConfirmationKeys(true) 238 | m.showConfirmation = true 239 | break 240 | } 241 | 242 | currentIndex := m.list.Index() 243 | currentContent, _ := clipboard.ReadAll() 244 | statusMsg := "Deleted: " 245 | 246 | if len(selectedItems) >= 1 { 247 | for _, item := range selectedItems { 248 | if item.Value == currentContent { 249 | if err := clipboard.WriteAll(""); err != nil { 250 | utils.LogERROR(fmt.Sprintf("failed to reset clipboard buffer value: %s", err)) 251 | } 252 | } 253 | } 254 | timeStamps := []string{} 255 | m.list.RemoveItem(currentIndex) 256 | m.removeMultiSelected() 257 | for _, item := range selectedItems { 258 | timeStamps = append(timeStamps, item.TimeStamp) 259 | } 260 | 261 | timeStamps = append(timeStamps, timestamp) 262 | statusMsg += "*selected items*" 263 | if err := config.DeleteItems(timeStamps); err != nil { 264 | utils.LogERROR(fmt.Sprintf("failed to delete all items from history file: %s", err)) 265 | } 266 | } else { 267 | m.list.RemoveItem(currentIndex) 268 | utils.HandleError(config.DeleteItems([]string{timestamp})) 269 | statusMsg += title 270 | } 271 | 272 | if len(m.list.Items()) == 0 { 273 | m.keys.remove.SetEnabled(false) 274 | m.list.SetShowStatusBar(false) 275 | } 276 | 277 | m.itemCache = []SelectedItem{} 278 | cmds = append( 279 | cmds, 280 | m.list.NewStatusMessage(statusMessageStyle(statusMsg)), 281 | ) 282 | 283 | case key.Matches(msg, m.keys.togglePin): 284 | if len(m.list.Items()) == 0 { 285 | m.keys.togglePin.SetEnabled(false) 286 | } 287 | isPinned, err := config.TogglePinClipboardItem(timestamp) 288 | utils.HandleError(err) 289 | m.togglePinUpdate() 290 | 291 | pinEvent := "Pinned" 292 | if isPinned { 293 | pinEvent = "Unpinned" 294 | } 295 | cmds = append( 296 | cmds, 297 | m.list.NewStatusMessage(statusMessageStyle(fmt.Sprintf("%s: %s", pinEvent, title))), 298 | ) 299 | 300 | case key.Matches(msg, m.keys.togglePinned): 301 | if len(m.list.Items()) == 0 { 302 | m.keys.togglePinned.SetEnabled(false) 303 | } 304 | m.togglePinned = !m.togglePinned 305 | m.list.Title = clipboardTitle 306 | if m.togglePinned { 307 | m.list.Title = "Pinned " + clipboardTitle 308 | } 309 | 310 | clipboardItems := config.GetHistory() 311 | filteredItems := filterItems(clipboardItems, m.togglePinned, m.theme) 312 | 313 | if len(filteredItems) == 0 { 314 | m.list.Title = clipboardTitle 315 | cmds = append( 316 | cmds, 317 | m.list.NewStatusMessage(statusMessageStyle("No pinned items")), 318 | ) 319 | break 320 | } 321 | 322 | for i := len(m.list.Items()) - 1; i >= 0; i-- { // clear all items 323 | m.list.RemoveItem(i) 324 | } 325 | for _, i := range filteredItems { // redraw all required items 326 | m.list.InsertItem(len(m.list.Items()), i) 327 | } 328 | 329 | case key.Matches(msg, m.keys.selectDown): 330 | if m.list.IsFiltered() { 331 | cmds = append( 332 | cmds, 333 | m.list.NewStatusMessage(statusMessageStyle("cannot select items with filter applied")), 334 | ) 335 | break 336 | } 337 | m.toggleSelected("down") 338 | 339 | case key.Matches(msg, m.keys.selectUp): 340 | if m.list.IsFiltered() { 341 | cmds = append( 342 | cmds, 343 | m.list.NewStatusMessage(statusMessageStyle("cannot select items with filter applied")), 344 | ) 345 | break 346 | } 347 | m.toggleSelected("up") 348 | 349 | case key.Matches(msg, m.keys.selectSingle): 350 | if m.list.IsFiltered() { 351 | cmds = append( 352 | cmds, 353 | m.list.NewStatusMessage(statusMessageStyle("cannot select items with filter applied")), 354 | ) 355 | break 356 | } 357 | m.toggleSelectedSingle() 358 | 359 | case key.Matches(msg, m.keys.clearSelected), key.Matches(msg, m.keys.filter): 360 | m.resetSelected() 361 | 362 | case key.Matches(msg, m.keys.yankFilter): 363 | cmds = append( 364 | cmds, 365 | m.list.NewStatusMessage(statusMessageStyle("no filtered items")), 366 | ) 367 | 368 | case key.Matches(msg, m.keys.more): 369 | // switch to default help for full view (better rendering) 370 | m.list.SetShowHelp(!m.list.ShowHelp()) 371 | m.updatePaginator() 372 | 373 | case key.Matches(msg, m.keys.up), 374 | key.Matches(msg, m.keys.down), 375 | key.Matches(msg, m.keys.nextPage), 376 | key.Matches(msg, m.keys.prevPage), 377 | key.Matches(msg, m.keys.home), 378 | key.Matches(msg, m.keys.end): 379 | m.prevDirection = "" 380 | 381 | case key.Matches(msg, m.keys.preview): 382 | m.showPreview = !m.showPreview 383 | if config.ClipseConfig.ImageDisplay.Type == "kitty" { 384 | fmt.Print("\x1B_Ga=d\x1B\\") 385 | } 386 | if m.showPreview { 387 | content := m.styledPreviewContent(i.titleFull) 388 | if i.filePath != "null" { 389 | content = getImgPreview(i.filePath, m.preview.Width, m.preview.Height) 390 | if config.ClipseConfig.ImageDisplay.Type != "basic" { 391 | m.originalHeight = m.preview.Height 392 | m.preview.Height /= config.ClipseConfig.ImageDisplay.HeightCut 393 | } 394 | } 395 | m.preview.SetContent(content) 396 | var cmd tea.Cmd 397 | m.preview, cmd = m.preview.Update(msg) 398 | cmds = append(cmds, cmd) 399 | m.preview.GotoTop() 400 | m.setPreviewKeys(true) 401 | 402 | return m, tea.Batch(cmds...) 403 | } else { 404 | if i.filePath != "null" && config.ClipseConfig.ImageDisplay.Type != "basic" { 405 | m.preview.Height = m.originalHeight 406 | } 407 | } 408 | m.setPreviewKeys(false) 409 | } 410 | } 411 | 412 | newListModel, cmd := m.list.Update(msg) 413 | m.list = newListModel 414 | cmds = append(cmds, cmd) 415 | 416 | m.confirmationList, cmd = m.confirmationList.Update(msg) 417 | cmds = append(cmds, cmd) 418 | 419 | m.preview, cmd = m.preview.Update(msg) 420 | cmds = append(cmds, cmd) 421 | 422 | return m, tea.Batch(cmds...) 423 | } 424 | 425 | /* 426 | HELPER FUNCS 427 | */ 428 | 429 | func (m *Model) togglePinUpdate() { 430 | index := m.list.Index() 431 | item, ok := m.list.SelectedItem().(item) 432 | if !ok { 433 | return 434 | } 435 | item.description = fmt.Sprintf("Date copied: %s", item.timeStamp) 436 | if !item.pinned { 437 | item.description = fmt.Sprintf("Date copied: %s %s", item.timeStamp, styledPin(m.theme)) 438 | } 439 | 440 | item.pinned = !item.pinned 441 | m.list.SetItem(index, item) 442 | if m.list.IsFiltered() { 443 | m.list.ResetFilter() // move selected pinned item to front 444 | } 445 | } 446 | 447 | func (m *Model) updatePaginator() { 448 | pagStyle := lipgloss.NewStyle().MarginBottom(1).MarginLeft(2) 449 | if m.list.ShowHelp() { 450 | pagStyle = lipgloss.NewStyle().MarginBottom(0).MarginLeft(2) 451 | } 452 | m.list.Styles.PaginationStyle = pagStyle 453 | } 454 | 455 | func (m *Model) toggleSelectedSingle() { 456 | m.prevDirection = "" 457 | index := m.list.Index() 458 | item, ok := m.list.SelectedItem().(item) 459 | if !ok { 460 | return 461 | } 462 | item.selected = !item.selected 463 | m.list.SetItem(index, item) 464 | } 465 | 466 | func (m *Model) toggleSelected(direction string) { 467 | if m.prevDirection == "" { 468 | m.prevDirection = direction 469 | } 470 | 471 | item, ok := m.list.SelectedItem().(item) 472 | if !ok { 473 | return 474 | } 475 | 476 | index := m.list.Index() 477 | 478 | switch { 479 | case item.selected: 480 | item.selected = false 481 | case m.prevDirection == direction && !item.selected: 482 | item.selected = true 483 | default: 484 | m.prevDirection = "" 485 | } 486 | 487 | m.list.SetItem(index, item) 488 | 489 | switch direction { 490 | case "down": 491 | m.list.CursorDown() 492 | case "up": 493 | m.list.CursorUp() 494 | } 495 | } 496 | 497 | func (m *Model) selectedItems() []SelectedItem { 498 | selectedItems := []SelectedItem{} 499 | for index, i := range m.list.Items() { 500 | item, ok := i.(item) 501 | if !ok { 502 | continue 503 | } 504 | if item.selected { 505 | selectedItems = append( 506 | selectedItems, 507 | SelectedItem{ 508 | Index: index, 509 | TimeStamp: item.TimeStamp(), 510 | Value: item.titleFull, 511 | Pinned: item.pinned, 512 | }, 513 | ) 514 | } 515 | 516 | } 517 | return selectedItems 518 | } 519 | 520 | func (m *Model) removeMultiSelected() { 521 | items := m.list.Items() 522 | for i := len(items) - 1; i >= 0; i-- { 523 | if item, ok := items[i].(item); ok && item.selected { 524 | m.list.RemoveItem(i) 525 | } 526 | } 527 | } 528 | 529 | func (m *Model) resetSelected() { 530 | items := m.list.Items() 531 | for i := len(items) - 1; i >= 0; i-- { 532 | if item, ok := items[i].(item); ok && item.selected { 533 | item.selected = false 534 | m.list.SetItem(i, item) 535 | } 536 | } 537 | } 538 | 539 | func (m *Model) filterMatches() []string { 540 | filteredItems := []string{} 541 | for _, i := range m.list.Items() { 542 | item, ok := i.(item) 543 | if !ok { 544 | continue 545 | } 546 | if strings.Contains( 547 | strings.ToLower(item.titleFull), 548 | strings.ToLower(m.list.FilterValue()), 549 | ) { 550 | filteredItems = append(filteredItems, item.titleFull) 551 | } 552 | } 553 | 554 | return filteredItems 555 | } 556 | 557 | func (m *Model) removeCachedItem(ts string) { 558 | items := m.list.Items() 559 | for i := len(items) - 1; i >= 0; i-- { 560 | if item, ok := items[i].(item); ok && item.timeStamp == ts { 561 | m.list.RemoveItem(i) 562 | } 563 | } 564 | } 565 | 566 | // enable/disable the main keys relevant to the preview view 567 | func (m *Model) setPreviewKeys(v bool) { 568 | m.list.KeyMap.CursorUp.SetEnabled(!v) 569 | m.list.KeyMap.CursorDown.SetEnabled(!v) 570 | m.list.KeyMap.Filter.SetEnabled(!v) 571 | m.list.KeyMap.GoToEnd.SetEnabled(!v) 572 | m.list.KeyMap.GoToStart.SetEnabled(!v) 573 | m.list.KeyMap.Quit.SetEnabled(!v) 574 | m.list.KeyMap.NextPage.SetEnabled(!v) 575 | m.list.KeyMap.PrevPage.SetEnabled(!v) 576 | m.list.KeyMap.ShowFullHelp.SetEnabled(!v) 577 | 578 | m.keys.remove.SetEnabled(!v) 579 | m.keys.choose.SetEnabled(!v) 580 | m.keys.togglePin.SetEnabled(!v) 581 | m.keys.togglePinned.SetEnabled(!v) 582 | m.keys.selectDown.SetEnabled(!v) 583 | m.keys.selectUp.SetEnabled(!v) 584 | m.keys.selectSingle.SetEnabled(!v) 585 | m.keys.clearSelected.SetEnabled(!v) 586 | } 587 | 588 | // enable/disable the main keys relevant to the confirmation view 589 | func (m *Model) setConfirmationKeys(v bool) { 590 | m.list.KeyMap.CursorUp.SetEnabled(!v) 591 | m.list.KeyMap.CursorDown.SetEnabled(!v) 592 | m.list.KeyMap.Filter.SetEnabled(!v) 593 | m.list.KeyMap.GoToEnd.SetEnabled(!v) 594 | m.list.KeyMap.GoToStart.SetEnabled(!v) 595 | m.list.KeyMap.Quit.SetEnabled(!v) 596 | m.list.KeyMap.NextPage.SetEnabled(!v) 597 | m.list.KeyMap.PrevPage.SetEnabled(!v) 598 | m.list.KeyMap.ShowFullHelp.SetEnabled(!v) 599 | 600 | m.keys.remove.SetEnabled(!v) 601 | m.keys.togglePin.SetEnabled(!v) 602 | m.keys.togglePinned.SetEnabled(!v) 603 | m.keys.selectDown.SetEnabled(!v) 604 | m.keys.selectUp.SetEnabled(!v) 605 | m.keys.selectSingle.SetEnabled(!v) 606 | m.keys.clearSelected.SetEnabled(!v) 607 | m.keys.preview.SetEnabled(!v) 608 | } 609 | 610 | // enable/disable the navigation for the confirmation list 611 | func (m *Model) enableConfirmationKeys(v bool) { 612 | m.confirmationList.KeyMap.CursorUp.SetEnabled(v) 613 | m.confirmationList.KeyMap.CursorDown.SetEnabled(v) 614 | } 615 | 616 | func (m *Model) setQuitEnabled(v bool) { 617 | m.list.KeyMap.Quit.SetEnabled(v) 618 | } 619 | -------------------------------------------------------------------------------- /app/view.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | func (m Model) View() string { 11 | render := style.PaddingLeft(1).Render 12 | 13 | listView := m.list.View() 14 | helpView := style.PaddingLeft(2).Render(m.help.View(m.keys)) 15 | 16 | switch { 17 | 18 | case m.showPreview: 19 | helpView = style.PaddingLeft(2).Render( 20 | m.list.Help.ShortHelpView(m.previewKeys.PreviewHelp())) 21 | return fmt.Sprintf( 22 | "\n%s\n%s\n%s\n%s\n", 23 | m.previewHeaderView(), m.preview.View(), m.previewFooterView(), helpView, 24 | ) 25 | 26 | case m.showConfirmation: 27 | listView = m.confirmationList.View() 28 | helpView = style.PaddingLeft(2).Render( 29 | m.list.Help.ShortHelpView(m.confirmationKeys.ConfirmationHelp())) 30 | return render(listView + "\n" + helpView) 31 | 32 | case m.list.SettingFilter(): 33 | return render(listView + "\n" + style.PaddingLeft(2).Render( 34 | m.list.Help.ShortHelpView(m.filterKeys.FilterHelp())), 35 | ) 36 | 37 | case m.list.ShowHelp(): 38 | return render(listView) 39 | 40 | default: 41 | return render(listView + "\n" + helpView) 42 | } 43 | } 44 | 45 | func (m *Model) previewHeaderView() string { 46 | title := previewTitleStyle.Render(previewHeader) 47 | line := strings.Repeat(borderMiddleChar, max(0, m.preview.Width-lipgloss.Width(title))) 48 | return m.styledPreviewHeader(lipgloss.JoinHorizontal(lipgloss.Center, title, line)) 49 | } 50 | 51 | func (m *Model) previewFooterView() string { 52 | info := previewInfoStyle.Render(fmt.Sprintf("%3.f%%", m.preview.ScrollPercent()*100)) 53 | line := strings.Repeat(borderMiddleChar, max(0, m.preview.Width-lipgloss.Width(info))) 54 | return m.styledPreviewFooter(lipgloss.JoinHorizontal(lipgloss.Center, line, info)) 55 | } 56 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | 10 | "github.com/savedra1/clipse/shell" 11 | "github.com/savedra1/clipse/utils" 12 | ) 13 | 14 | type Config struct { 15 | AllowDuplicates bool `json:"allowDuplicates"` 16 | HistoryFilePath string `json:"historyFile"` 17 | MaxHistory int `json:"maxHistory"` 18 | LogFilePath string `json:"logFile"` 19 | ThemeFilePath string `json:"themeFile"` 20 | TempDirPath string `json:"tempDir"` 21 | KeyBindings map[string]string `json:"keyBindings"` 22 | ImageDisplay ImageDisplay `json:"imageDisplay"` 23 | } 24 | type ImageDisplay struct { 25 | Type string `json:"type"` 26 | ScaleX int `json:"scaleX"` 27 | ScaleY int `json:"scaleY"` 28 | HeightCut int `json:"heightCut"` 29 | } 30 | 31 | // Global config object, accessed and used when any configuration is needed. 32 | var ClipseConfig = defaultConfig() 33 | 34 | func Init() (string, string, bool, error) { 35 | /* 36 | Ensure $HOME/.config/clipse/clipboard_history.json OR $XDG_CONFIG_HOME 37 | exists and create the path if not. 38 | */ 39 | 40 | // returns $HOME/.config || $XDG_CONFIG_HOME 41 | userHome, err := os.UserConfigDir() 42 | if err != nil { 43 | return "", "", false, fmt.Errorf("failed to read home dir.\nerror: %s", err) 44 | } 45 | 46 | // Construct the path to the config directory 47 | clipseDir := filepath.Join(userHome, clipseDir) // the ~/.config/clipse dir 48 | configPath := filepath.Join(clipseDir, configFile) // the path to the config.json file 49 | 50 | // Does Config dir exist, if no make it. 51 | _, err = os.Stat(clipseDir) 52 | if os.IsNotExist(err) { 53 | utils.HandleError(os.MkdirAll(clipseDir, 0755)) 54 | } 55 | 56 | // load the config from file into ClipseConfig struct 57 | loadConfig(configPath) 58 | 59 | // The history path is absolute at this point. Create it if it does not exist 60 | utils.HandleError(initHistoryFile()) 61 | 62 | // Create TempDir for images if it does not exist. 63 | _, err = os.Stat(ClipseConfig.TempDirPath) 64 | if os.IsNotExist(err) { 65 | utils.HandleError(os.MkdirAll(ClipseConfig.TempDirPath, 0755)) 66 | } 67 | 68 | ds := DisplayServer() 69 | ie := shell.ImagesEnabled(ds) // images enabled? 70 | 71 | return ClipseConfig.LogFilePath, ds, ie, nil 72 | } 73 | 74 | func loadConfig(configPath string) { 75 | _, err := os.Stat(configPath) 76 | 77 | if os.IsNotExist(err) { 78 | baseConfig := defaultConfig() 79 | jsonData, err := json.MarshalIndent(baseConfig, "", " ") 80 | utils.HandleError(err) 81 | utils.HandleError(os.WriteFile(configPath, jsonData, 0644)) 82 | } 83 | 84 | configDir := filepath.Dir(configPath) 85 | confData, err := os.ReadFile(configPath) 86 | utils.HandleError(err) 87 | 88 | if err = json.Unmarshal(confData, &ClipseConfig); err != nil { 89 | fmt.Println("Failed to read config. Skipping.\nErr: %w", err) 90 | utils.LogERROR(fmt.Sprintf("failed to read config. Skipping.\nsrr: %s", err)) 91 | } 92 | 93 | // Expand HistoryFile, ThemeFile, LogFile and TempDir paths 94 | ClipseConfig.HistoryFilePath = utils.ExpandRel(utils.ExpandHome(ClipseConfig.HistoryFilePath), configDir) 95 | ClipseConfig.TempDirPath = utils.ExpandRel(utils.ExpandHome(ClipseConfig.TempDirPath), configDir) 96 | ClipseConfig.ThemeFilePath = utils.ExpandRel(utils.ExpandHome(ClipseConfig.ThemeFilePath), configDir) 97 | ClipseConfig.LogFilePath = utils.ExpandRel(utils.ExpandHome(ClipseConfig.LogFilePath), configDir) 98 | } 99 | 100 | func DisplayServer() string { 101 | /* Determine runtime and return appropriate window server. 102 | used to determine which dependency is required for handling 103 | image files. 104 | */ 105 | osName := runtime.GOOS 106 | switch osName { 107 | case "linux": 108 | waylandDisplay := os.Getenv("WAYLAND_DISPLAY") 109 | if waylandDisplay != "" { 110 | return "wayland" 111 | } 112 | return "x11" 113 | case "darwin": 114 | return "darwin" 115 | default: 116 | return "unknown" 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /config/constants.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const ( 4 | configFile = "config.json" 5 | clipseDir = "clipse" 6 | defaultAllowDuplicates = false 7 | defaultHistoryFile = "clipboard_history.json" 8 | defaultMaxHist = 100 9 | defaultLogFile = "clipse.log" 10 | defaultTempDir = "tmp_files" 11 | defaultThemeFile = "custom_theme.json" 12 | listenCmd = "--listen-shell" 13 | maxChar = 65 14 | ) 15 | 16 | // Initialize default key bindings 17 | func defaultKeyBindings() map[string]string { 18 | return map[string]string{ 19 | "filter": "/", 20 | "quit": "q", 21 | "more": "?", 22 | "choose": "enter", 23 | "remove": "x", 24 | "togglePin": "p", 25 | "togglePinned": "tab", 26 | "preview": " ", 27 | "selectDown": "ctrl+down", 28 | "selectUp": "ctrl+up", 29 | "selectSingle": "s", 30 | "clearSelected": "S", 31 | "yankFilter": "ctrl+s", 32 | "up": "up", 33 | "down": "down", 34 | "nextPage": "right", 35 | "prevPage": "left", 36 | "home": "home", 37 | "end": "end", 38 | } 39 | } 40 | 41 | // Because Go does not support constant Structs :( 42 | func defaultConfig() Config { 43 | return Config{ 44 | HistoryFilePath: defaultHistoryFile, 45 | MaxHistory: defaultMaxHist, 46 | AllowDuplicates: defaultAllowDuplicates, 47 | TempDirPath: defaultTempDir, 48 | LogFilePath: defaultLogFile, 49 | ThemeFilePath: defaultThemeFile, 50 | KeyBindings: defaultKeyBindings(), 51 | ImageDisplay: ImageDisplay{ 52 | Type: "basic", 53 | ScaleX: 9, 54 | ScaleY: 9, 55 | HeightCut: 2, 56 | }, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /config/history.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/savedra1/clipse/shell" 9 | "github.com/savedra1/clipse/utils" 10 | ) 11 | 12 | /* File contains logic for parsing the clipboard history. 13 | - fileName defined in constants.go 14 | - dirName defined in constants.go 15 | */ 16 | 17 | type ClipboardItem struct { 18 | Value string `json:"value"` 19 | Recorded string `json:"recorded"` 20 | FilePath string `json:"filePath"` 21 | Pinned bool `json:"pinned"` 22 | } 23 | 24 | type ClipboardHistory struct { 25 | ClipboardHistory []ClipboardItem `json:"clipboardHistory"` 26 | } 27 | 28 | func initHistoryFile() error { 29 | /* Used to create the clipboard_history.json file 30 | in relative path. 31 | */ 32 | _, err := os.Stat(ClipseConfig.HistoryFilePath) // File already exist? 33 | if os.IsNotExist(err) { 34 | baseConfig := ClipboardHistory{ 35 | ClipboardHistory: []ClipboardItem{}, 36 | } 37 | 38 | jsonData, err := json.MarshalIndent(baseConfig, "", " ") 39 | if err != nil { 40 | return err 41 | } 42 | if err = os.WriteFile(ClipseConfig.HistoryFilePath, jsonData, 0644); err != nil { 43 | utils.LogERROR(fmt.Sprintf("Failed to create %s", ClipseConfig.HistoryFilePath)) 44 | return err 45 | } 46 | return nil 47 | } 48 | 49 | if err != nil { 50 | utils.LogERROR("Unable to check if history file exists. Please update binary permissions.") 51 | return err 52 | } 53 | return nil 54 | } 55 | 56 | func GetHistory() []ClipboardItem { 57 | /* returns the clipboardHistory array from the 58 | clipboard_history.json file 59 | */ 60 | file, err := os.OpenFile(ClipseConfig.HistoryFilePath, os.O_RDWR|os.O_CREATE, 0644) 61 | utils.HandleError(err) 62 | 63 | var data ClipboardHistory 64 | 65 | utils.HandleError(json.NewDecoder(file).Decode(&data)) 66 | 67 | return data.ClipboardHistory 68 | } 69 | 70 | func fileContents() ClipboardHistory { 71 | file, err := os.OpenFile(ClipseConfig.HistoryFilePath, os.O_RDWR|os.O_CREATE, 0644) 72 | utils.HandleError(err) 73 | 74 | var data ClipboardHistory 75 | 76 | utils.HandleError(json.NewDecoder(file).Decode(&data)) 77 | 78 | return data 79 | } 80 | 81 | func WriteUpdate(data ClipboardHistory) error { 82 | updatedJSON, err := json.Marshal(data) 83 | if err != nil { 84 | return fmt.Errorf("failed to marshal JSON: %w", err) 85 | } 86 | 87 | if err := os.WriteFile(ClipseConfig.HistoryFilePath, updatedJSON, 0644); err != nil { 88 | return fmt.Errorf("failed writing to file: %w", err) 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func DeleteItems(timeStamps []string) error { 95 | data := fileContents() 96 | updatedData := []ClipboardItem{} 97 | 98 | toDelete := make(map[string]bool) 99 | for _, ts := range timeStamps { 100 | toDelete[ts] = true 101 | } 102 | for _, item := range data.ClipboardHistory { 103 | if toDelete[item.Recorded] { 104 | if item.FilePath == "null" { 105 | continue 106 | } 107 | if err := shell.DeleteImage(item.FilePath); err != nil { 108 | utils.LogERROR(fmt.Sprintf("failed to delete image file | %s", item.FilePath)) 109 | } 110 | continue 111 | } 112 | updatedData = append(updatedData, item) 113 | } 114 | updatedFile := ClipboardHistory{ 115 | ClipboardHistory: updatedData, 116 | } 117 | return WriteUpdate(updatedFile) 118 | 119 | } 120 | 121 | func ClearHistory(clearType string) error { 122 | var data ClipboardHistory 123 | switch clearType { 124 | case "all": 125 | data = ClipboardHistory{ 126 | ClipboardHistory: []ClipboardItem{}, 127 | } 128 | if err := shell.DeleteAllImages(ClipseConfig.TempDirPath); err != nil { 129 | utils.LogERROR(fmt.Sprintf("could not delete all images: %s", err)) 130 | } 131 | case "images": 132 | data = ClipboardHistory{ 133 | ClipboardHistory: TextItems(), 134 | } 135 | if err := shell.DeleteAllImages(ClipseConfig.TempDirPath); err != nil { 136 | utils.LogERROR(fmt.Sprintf("could not read file dir: %s", err)) 137 | } 138 | case "text": 139 | data = ClipboardHistory{ 140 | ClipboardHistory: imageItems(), 141 | } 142 | default: 143 | data = ClipboardHistory{ 144 | ClipboardHistory: pinnedItems(), 145 | } 146 | } 147 | return WriteUpdate(data) 148 | 149 | } 150 | 151 | func pinnedItems() []ClipboardItem { 152 | pinnedItems := []ClipboardItem{} 153 | history := GetHistory() 154 | for _, item := range history { 155 | if item.Pinned { 156 | pinnedItems = append(pinnedItems, item) 157 | } 158 | } 159 | return pinnedItems 160 | } 161 | 162 | func imageItems() []ClipboardItem { 163 | images := []ClipboardItem{} 164 | history := GetHistory() 165 | for _, item := range history { 166 | if item.FilePath != "null" { 167 | images = append(images, item) 168 | } 169 | } 170 | return images 171 | } 172 | 173 | func TextItems() []ClipboardItem { 174 | textItems := []ClipboardItem{} 175 | history := GetHistory() 176 | for _, item := range history { 177 | if item.FilePath == "null" { 178 | textItems = append(textItems, item) 179 | } 180 | } 181 | return textItems 182 | } 183 | 184 | func AddClipboardItem(text, fp string) error { 185 | data := fileContents() 186 | item := ClipboardItem{ 187 | Value: text, 188 | Recorded: utils.GetTime(), 189 | FilePath: fp, 190 | Pinned: false, 191 | } 192 | 193 | if !ClipseConfig.AllowDuplicates { 194 | duplicates, isPinned := duplicateItems(data.ClipboardHistory, item) 195 | data.ClipboardHistory = removeDuplicates(data.ClipboardHistory, duplicates) 196 | item.Pinned = isPinned 197 | } 198 | 199 | // Append the new item to the beginning of the array to appear at top of list 200 | data.ClipboardHistory = append([]ClipboardItem{item}, data.ClipboardHistory...) 201 | 202 | if len(data.ClipboardHistory) > ClipseConfig.MaxHistory { 203 | for i := len(data.ClipboardHistory) - 1; i >= 0; i-- { 204 | // remove the first unpinned entry starting with the oldest 205 | if !data.ClipboardHistory[i].Pinned { 206 | data.ClipboardHistory = append(data.ClipboardHistory[:i], data.ClipboardHistory[i+1:]...) 207 | break 208 | } 209 | } 210 | } 211 | return WriteUpdate(data) 212 | } 213 | 214 | func duplicateItems(currentHistory []ClipboardItem, newItem ClipboardItem) ([]string, bool) { 215 | isPinned := false 216 | timestamps := []string{} 217 | 218 | for _, item := range currentHistory { 219 | if isItemDuplicate(item, newItem) { 220 | timestamps = append(timestamps, item.Recorded) 221 | if item.Pinned { 222 | isPinned = true 223 | } 224 | } 225 | } 226 | 227 | return timestamps, isPinned 228 | } 229 | 230 | func isItemDuplicate(item, newItem ClipboardItem) bool { 231 | if item.FilePath == "null" && newItem.FilePath == "null" { 232 | return item.Value == newItem.Value 233 | } 234 | if item.FilePath != "null" && newItem.FilePath != "null" { 235 | return utils.GetImgIdentifier(item.Value) == utils.GetImgIdentifier(newItem.Value) 236 | } 237 | return false 238 | } 239 | 240 | func removeDuplicates(clipboardHistory []ClipboardItem, duplicates []string) []ClipboardItem { 241 | toDelete := make(map[string]bool) 242 | for _, ts := range duplicates { 243 | toDelete[ts] = true 244 | } 245 | updatedHistory := []ClipboardItem{} 246 | for _, item := range clipboardHistory { 247 | if !toDelete[item.Recorded] { 248 | updatedHistory = append(updatedHistory, item) 249 | continue 250 | } 251 | 252 | if item.FilePath != "null" { 253 | if err := shell.DeleteImage(item.FilePath); err != nil { 254 | utils.LogERROR(fmt.Sprintf("failed to delete image | %s | %s", item.FilePath, err)) 255 | } 256 | } 257 | } 258 | return updatedHistory 259 | } 260 | 261 | // This pins and unpins an item in the clipboard 262 | func TogglePinClipboardItem(timeStamp string) (bool, error) { 263 | data := fileContents() 264 | var pinned bool 265 | 266 | for i, item := range data.ClipboardHistory { 267 | if item.Recorded == timeStamp { 268 | // Toggle the pinned state 269 | data.ClipboardHistory[i].Pinned = !item.Pinned 270 | pinned = item.Pinned 271 | break 272 | } 273 | } 274 | 275 | if err := WriteUpdate(data); err != nil { 276 | return pinned, err 277 | } 278 | return pinned, nil 279 | } 280 | -------------------------------------------------------------------------------- /config/theme.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/savedra1/clipse/utils" 9 | ) 10 | 11 | type CustomTheme struct { 12 | UseCustom bool `json:"useCustomTheme"` 13 | TitleFore string `json:"TitleFore"` 14 | TitleBack string `json:"TitleBack"` 15 | TitleInfo string `json:"TitleInfo"` 16 | NormalTitle string `json:"NormalTitle"` 17 | DimmedTitle string `json:"DimmedTitle"` 18 | SelectedTitle string `json:"SelectedTitle"` 19 | NormalDesc string `json:"NormalDesc"` 20 | DimmedDesc string `json:"DimmedDesc"` 21 | SelectedDesc string `json:"SelectedDesc"` 22 | StatusMsg string `json:"StatusMsg"` 23 | PinIndicatorColor string `json:"PinIndicatorColor"` 24 | SelectedBorder string `json:"SelectedBorder"` 25 | SelectedDescBorder string `json:"SelectedDescBorder"` 26 | FilteredMatch string `json:"FilteredMatch"` 27 | FilterPrompt string `json:"FilterPrompt"` 28 | FilterInfo string `json:"FilterInfo"` 29 | FilterText string `json:"FilterText"` 30 | FilterCursor string `json:"FilterCursor"` 31 | HelpKey string `json:"HelpKey"` 32 | HelpDesc string `json:"HelpDesc"` 33 | PageActiveDot string `json:"PageActiveDot"` 34 | PageInactiveDot string `json:"PageInactiveDot"` 35 | DividerDot string `json:"DividerDot"` 36 | PreviewedText string `json:"PreviewedText"` 37 | PreviewBorder string `json:"PreviewBorder"` 38 | } 39 | 40 | func GetTheme() CustomTheme { 41 | _, err := os.Stat(ClipseConfig.ThemeFilePath) 42 | if os.IsNotExist(err) { 43 | if err = initDefaultTheme(); err != nil { 44 | utils.LogERROR(fmt.Sprintf("could not initialize theme: %s", err)) 45 | return defaultTheme() 46 | } 47 | } 48 | 49 | file, err := os.OpenFile(ClipseConfig.ThemeFilePath, os.O_RDONLY, 0644) 50 | if err != nil { 51 | file.Close() 52 | } 53 | 54 | var theme CustomTheme 55 | 56 | if err := json.NewDecoder(file).Decode(&theme); err != nil { 57 | utils.LogERROR( 58 | fmt.Sprintf( 59 | "Error decoding JSON for custom_theme.json. Try creating this file manually instead. Err: %s", 60 | err, 61 | ), 62 | ) 63 | } 64 | if !theme.UseCustom { 65 | return defaultTheme() 66 | } 67 | return theme 68 | } 69 | 70 | func initDefaultTheme() error { 71 | /* 72 | Creates custom_theme.json file is not found in path 73 | and sets base config. 74 | */ 75 | _, err := os.Stat(ClipseConfig.ThemeFilePath) 76 | if os.IsNotExist(err) { 77 | 78 | baseConfig := defaultTheme() 79 | 80 | jsonData, err := json.MarshalIndent(baseConfig, "", " ") 81 | if err != nil { 82 | return err 83 | } 84 | 85 | if err = os.WriteFile(ClipseConfig.ThemeFilePath, jsonData, 0644); err != nil { 86 | return err 87 | } 88 | 89 | return nil 90 | } 91 | return nil 92 | } 93 | 94 | // hardcoded default theme when UseCustom set to false 95 | func defaultTheme() CustomTheme { 96 | return CustomTheme{ 97 | UseCustom: false, 98 | TitleFore: "#ffffff", 99 | TitleBack: "#6F4CBC", 100 | TitleInfo: "#3498db", 101 | NormalTitle: "#ffffff", 102 | DimmedTitle: "#808080", 103 | SelectedTitle: "#FF69B4", 104 | NormalDesc: "#808080", 105 | DimmedDesc: "#808080", 106 | SelectedDesc: "#FF69B4", 107 | StatusMsg: "#2ecc71", 108 | PinIndicatorColor: "#FFD700", 109 | SelectedBorder: "#3498db", 110 | SelectedDescBorder: "#3498db", 111 | FilteredMatch: "#ffffff", 112 | FilterPrompt: "#2ecc71", 113 | FilterInfo: "#3498db", 114 | FilterText: "#ffffff", 115 | FilterCursor: "#FFD700", 116 | HelpKey: "#999999", 117 | HelpDesc: "#808080", 118 | PageActiveDot: "#3498db", 119 | PageInactiveDot: "#808080", 120 | DividerDot: "#3498db", 121 | PreviewedText: "#ffffff", 122 | PreviewBorder: "#3498db", 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/savedra1/clipse 2 | 3 | go 1.21.7 4 | 5 | require ( 6 | github.com/BourgeoisBear/rasterm v1.1.1 7 | github.com/atotto/clipboard v0.1.4 8 | github.com/charmbracelet/bubbles v0.20.0 9 | github.com/charmbracelet/bubbletea v1.2.4 10 | github.com/charmbracelet/lipgloss v1.0.0 11 | github.com/mitchellh/go-ps v1.0.0 12 | ) 13 | 14 | require golang.org/x/term v0.18.0 // indirect 15 | 16 | require ( 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 18 | github.com/charmbracelet/x/ansi v0.4.5 // indirect 19 | github.com/charmbracelet/x/term v0.2.1 // indirect 20 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 21 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 22 | github.com/mattn/go-isatty v0.0.20 // indirect 23 | github.com/mattn/go-localereader v0.0.1 // indirect 24 | github.com/mattn/go-runewidth v0.0.16 // indirect 25 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 26 | github.com/muesli/cancelreader v0.2.2 // indirect 27 | github.com/muesli/termenv v0.15.2 // direct 28 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // direct 29 | github.com/rivo/uniseg v0.4.7 // indirect 30 | github.com/sahilm/fuzzy v0.1.1 // indirect 31 | golang.org/x/sync v0.9.0 // indirect 32 | golang.org/x/sys v0.27.0 // indirect 33 | golang.org/x/text v0.3.8 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BourgeoisBear/rasterm v1.1.1 h1:J94gv2pRv+G0jXj9Pf3jUk2qQtWPCiTsiRGxlXoQvgo= 2 | github.com/BourgeoisBear/rasterm v1.1.1/go.mod h1:Ifd+To5s/uyUiYx+B4fxhS8lUNwNLSxDBjskmC5pEyw= 3 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 4 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 7 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= 8 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 9 | github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= 10 | github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= 11 | github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= 12 | github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= 13 | github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= 14 | github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 15 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 16 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 17 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 18 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 19 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 20 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 21 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 22 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 23 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 24 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 25 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 26 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 27 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 28 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 29 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 30 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 31 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 32 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 33 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 34 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 35 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 36 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 37 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 38 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 39 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 40 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 41 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 42 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 43 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 44 | golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= 45 | golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 46 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 49 | golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= 50 | golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 51 | golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= 52 | golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 53 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 54 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 55 | -------------------------------------------------------------------------------- /handlers/constants.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "time" 4 | 5 | const ( 6 | imgIcon = "📷" // alternatives: ["🎨", "🖼️"] // rotation based on file type? 7 | defaultPollInterval = 10 * time.Millisecond 8 | mediaPollInterval = 500 * time.Millisecond 9 | Text = "text" 10 | PNG = "png" 11 | JPEG = "jpeg" 12 | JPG = "jpg" 13 | ) 14 | -------------------------------------------------------------------------------- /handlers/defaultListener.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "path/filepath" 8 | "strconv" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/atotto/clipboard" 13 | 14 | "github.com/savedra1/clipse/config" 15 | "github.com/savedra1/clipse/shell" 16 | "github.com/savedra1/clipse/utils" 17 | ) 18 | 19 | /* 20 | runListener is essentially a while loop to be created as a system background process on boot. 21 | can be stopped at any time with: 22 | clipse -kill 23 | pkill -f clipse 24 | killall clipse 25 | */ 26 | 27 | var prevClipboardContent string // used to store clipboard content to avoid re-checking media data unnecessarily 28 | var dataType string // used to determine which poll interval to use based on current clipboard data format 29 | 30 | func RunListener(displayServer string, imgEnabled bool) error { 31 | // Listen for SIGINT (Ctrl+C) and SIGTERM signals to properly close the program 32 | interrupt := make(chan os.Signal, 1) 33 | signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) 34 | 35 | // channel to pass clipboard events to 36 | clipboardData := make(chan string, 1) 37 | 38 | // Goroutine to monitor clipboard 39 | go func() { 40 | for { 41 | input, err := clipboard.ReadAll() 42 | if err != nil { 43 | time.Sleep(1 * time.Second) // wait for boot 44 | } 45 | if input != prevClipboardContent { 46 | clipboardData <- input // Pass clipboard data to main goroutine 47 | prevClipboardContent = input // update previous content 48 | } 49 | if dataType == Text { 50 | time.Sleep(defaultPollInterval) 51 | continue 52 | } 53 | time.Sleep(mediaPollInterval) 54 | } 55 | }() 56 | 57 | MainLoop: 58 | for { 59 | select { 60 | case input := <-clipboardData: 61 | if input == "" { 62 | continue 63 | } 64 | dataType = utils.DataType(input) 65 | switch dataType { 66 | case Text: 67 | if err := config.AddClipboardItem(input, "null"); err != nil { 68 | utils.LogERROR(fmt.Sprintf("failed to add new item `( %s )` | %s", input, err)) 69 | } 70 | case PNG, JPEG: 71 | if imgEnabled { 72 | fileName := fmt.Sprintf("%s-%s.%s", strconv.Itoa(len(input)), utils.GetTimeStamp(), dataType) 73 | itemTitle := fmt.Sprintf("%s %s", imgIcon, fileName) 74 | filePath := filepath.Join(config.ClipseConfig.TempDirPath, fileName) 75 | 76 | if err := shell.SaveImage(filePath, displayServer); err != nil { 77 | utils.LogERROR(fmt.Sprintf("failed to save image | %s", err)) 78 | break 79 | } 80 | if err := config.AddClipboardItem(itemTitle, filePath); err != nil { 81 | utils.LogERROR(fmt.Sprintf("failed to save image | %s", err)) 82 | } 83 | } 84 | } 85 | case <-interrupt: 86 | break MainLoop 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /handlers/wayland.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/jpeg" 8 | "image/png" 9 | "io" 10 | "os" 11 | "path/filepath" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/savedra1/clipse/config" 16 | "github.com/savedra1/clipse/shell" 17 | "github.com/savedra1/clipse/utils" 18 | ) 19 | 20 | func StoreWLData() { 21 | input, err := io.ReadAll(os.Stdin) 22 | if err != nil { 23 | utils.LogERROR(fmt.Sprintf("failed to read stdin: %s", err)) 24 | return 25 | } 26 | 27 | dt := Text 28 | if len(input) > 0 && input[0] == 0x89 && string(input[1:4]) == "PNG" { 29 | dt = PNG 30 | } else if len(input) > 10 && string(input[6:10]) == "JFIF" { 31 | dt = JPEG 32 | } 33 | 34 | switch dt { 35 | case Text: 36 | inputStr := string(input) 37 | if inputStr == "" { 38 | return 39 | } 40 | if err := config.AddClipboardItem(inputStr, "null"); err != nil { 41 | utils.LogERROR(fmt.Sprintf("failed to add new item `( %s )` | %s", input, err)) 42 | } 43 | 44 | case PNG, JPEG: 45 | /* 46 | When saving image data from the stdin using wl-paste --watch, 47 | the byte size if different to when the image data is copied 48 | from the saved file with wl-copy -t image/path. 49 | This means to maintain consistency with identifying duplicates 50 | we need to save a temporary image file, then update the file name 51 | using the file size as the identifier, so any duplicates can be 52 | auto-removed during the AddClipboardItem call in the same way 53 | as non-wayland specific data. 54 | */ 55 | 56 | fileName := fmt.Sprintf("%s.%s", utils.GetTimeStamp(), "png") 57 | filePath := filepath.Join(config.ClipseConfig.TempDirPath, fileName) 58 | 59 | img, format, err := image.Decode(bytes.NewReader(input)) 60 | 61 | if err != nil { 62 | /* 63 | if the image data cannot be decoded here it means this has 64 | not loaded properly from the wl-paste --watch api. 65 | the image is then created using `wl-paste -t image/png ` 66 | */ 67 | 68 | if err = shell.SaveImage(filePath, "wayland"); err != nil { 69 | utils.LogERROR(fmt.Sprintf("failed to save new image: %s", err)) 70 | return 71 | } 72 | 73 | updatedFileName, updatedFilePath, err := renameImgFile(filePath, fileName, dt) 74 | if err != nil { 75 | utils.LogERROR(fmt.Sprintf("failed to rename new image file: %s", err)) 76 | return 77 | } 78 | 79 | itemTitle := fmt.Sprintf("%s %s", imgIcon, updatedFileName) 80 | 81 | if err := config.AddClipboardItem(itemTitle, updatedFilePath); err != nil { 82 | utils.LogERROR(fmt.Sprintf("failed to save image | %s", err)) 83 | } 84 | 85 | return 86 | } 87 | 88 | out, err := os.Create(filePath) 89 | if err != nil { 90 | utils.LogERROR(fmt.Sprintf("failed to create img file: %s", err)) 91 | return 92 | } 93 | 94 | defer out.Close() 95 | 96 | switch format { 97 | case PNG: 98 | err = png.Encode(out, img) 99 | case JPEG, JPG: 100 | err = jpeg.Encode(out, img, nil) 101 | default: 102 | // default to PNG if format not recognized 103 | err = png.Encode(out, img) 104 | } 105 | 106 | if err != nil { 107 | utils.LogERROR(fmt.Sprintf("failed to encode img data: %s", err)) 108 | return 109 | } 110 | 111 | updatedFileName, updatedFilePath, err := renameImgFile(filePath, fileName, dt) 112 | 113 | if err != nil { 114 | utils.LogERROR(fmt.Sprintf("failed to rename new image file: %s", err)) 115 | return 116 | } 117 | 118 | itemTitle := fmt.Sprintf("%s %s", imgIcon, updatedFileName) 119 | 120 | if err := config.AddClipboardItem(itemTitle, updatedFilePath); err != nil { 121 | utils.LogERROR(fmt.Sprintf("failed to save image | %s", err)) 122 | } 123 | } 124 | } 125 | 126 | func renameImgFile(filePath, fileName, dt string) (string, string, error) { 127 | fileInfo, err := os.Stat(filePath) 128 | if err != nil { 129 | return "", "", err 130 | } 131 | 132 | fileSize := fileInfo.Size() 133 | updatedFileName := fmt.Sprintf( 134 | "%s-%s.%s", 135 | strconv.Itoa(int(fileSize)), 136 | strings.Split(fileName, ".")[0], 137 | dt, 138 | ) 139 | updatedFilePath := filepath.Join(config.ClipseConfig.TempDirPath, updatedFileName) 140 | 141 | if err = os.Rename(filePath, updatedFilePath); err != nil { 142 | return "", "", err 143 | } 144 | 145 | return updatedFileName, updatedFilePath, nil 146 | } 147 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/atotto/clipboard" 9 | tea "github.com/charmbracelet/bubbletea" 10 | 11 | "github.com/savedra1/clipse/app" 12 | "github.com/savedra1/clipse/config" 13 | "github.com/savedra1/clipse/handlers" 14 | "github.com/savedra1/clipse/shell" 15 | "github.com/savedra1/clipse/utils" 16 | ) 17 | 18 | var ( 19 | version = "v1.1.0" 20 | help = flag.Bool("help", false, "Show help message.") 21 | v = flag.Bool("v", false, "Show app version.") 22 | add = flag.Bool("a", false, "Add the following arg to the clipboard history.") 23 | copyInput = flag.Bool("c", false, "Copy the input to your systems clipboard.") 24 | paste = flag.Bool("p", false, "Prints the current clipboard content.") 25 | listen = flag.Bool("listen", false, "Start background process for monitoring clipboard activity on wayland/x11/macOS.") 26 | listenShell = flag.Bool("listen-shell", false, "Starts a clipboard monitor process in the current shell.") 27 | kill = flag.Bool("kill", false, "Kill any existing background processes.") 28 | clear = flag.Bool("clear", false, "Remove all contents from the clipboard history except for pinned items.") 29 | clearAll = flag.Bool("clear-all", false, "Remove all contents the clipboard history including pinned items.") 30 | clearImages = flag.Bool("clear-images", false, "Removes all images from the clipboard history including pinned images.") 31 | clearText = flag.Bool("clear-text", false, "Removes all text from the clipboard history including pinned text entries.") 32 | forceClose = flag.Bool("fc", false, "Forces the terminal session to quick by taking the $PPID var as an arg. EG `clipse -fc $PPID`") 33 | wlStore = flag.Bool("wl-store", false, "Store data from the stdin directly using the wl-clipboard API.") 34 | realTime = flag.Bool("enable-real-time", false, "Enable real time updates to the TUI") 35 | outputAll = flag.String("output-all", "", "Print clipboard text content to stdout, each entry separated by a newline, possible values: (raw, unescaped)") 36 | ) 37 | 38 | func main() { 39 | flag.Parse() 40 | logPath, displayServer, imgEnabled, err := config.Init() 41 | utils.HandleError(err) 42 | utils.SetUpLogger(logPath) 43 | 44 | switch { 45 | 46 | case flag.NFlag() == 0: 47 | if len(os.Args) > 2 { 48 | fmt.Println("Too many args provided. See usage:") 49 | flag.PrintDefaults() 50 | return 51 | } 52 | launchTUI() 53 | 54 | case flag.NFlag() > 1: 55 | fmt.Printf("Too many flags provided. Use %s --help for more info.", os.Args[0]) 56 | 57 | case *help: 58 | flag.PrintDefaults() 59 | 60 | case *v: 61 | fmt.Println(os.Args[0], version) 62 | 63 | case *add: 64 | handleAdd() 65 | 66 | case *paste: 67 | handlePaste() 68 | 69 | case *copyInput: 70 | handleCopy() 71 | 72 | case *listen: 73 | handleListen(displayServer) 74 | 75 | case *listenShell: 76 | handleListenShell(displayServer, imgEnabled) 77 | 78 | case *kill: 79 | handleKill() 80 | 81 | case *clear, *clearAll, *clearImages, *clearText: 82 | handleClear() 83 | 84 | case *forceClose: 85 | handleForceClose() 86 | 87 | case *wlStore: 88 | handlers.StoreWLData() 89 | 90 | case *realTime: 91 | launchTUI() 92 | 93 | case *outputAll != "": 94 | handleOutputAll(*outputAll) 95 | 96 | default: 97 | fmt.Printf("Command not recognized. See %s --help for usage instructions.", os.Args[0]) 98 | } 99 | } 100 | 101 | func launchTUI() { 102 | shell.KillExistingFG() 103 | newModel := app.NewModel() 104 | p := tea.NewProgram(newModel) 105 | if *realTime { 106 | go newModel.ListenRealTime(p) 107 | } 108 | _, err := p.Run() 109 | utils.HandleError(err) 110 | } 111 | 112 | func handleAdd() { 113 | var input string 114 | switch { 115 | case len(os.Args) < 3: 116 | input = utils.GetStdin() 117 | default: 118 | input = os.Args[2] 119 | } 120 | utils.HandleError(config.AddClipboardItem(input, "null")) 121 | } 122 | 123 | func handleListen(displayServer string) { 124 | if err := shell.KillExisting(); err != nil { 125 | fmt.Printf("ERROR: failed to kill existing listener process: %s", err) 126 | utils.LogERROR(fmt.Sprintf("failed to kill existing listener process: %s", err)) 127 | } 128 | shell.RunNohupListener(displayServer) 129 | } 130 | 131 | func handleListenShell(displayServer string, imgEnabled bool) { 132 | utils.HandleError(handlers.RunListener(displayServer, imgEnabled)) 133 | } 134 | 135 | func handleKill() { 136 | shell.KillAll(os.Args[0]) 137 | } 138 | 139 | func handleClear() { 140 | if err := clipboard.WriteAll(""); err != nil { 141 | utils.LogERROR(fmt.Sprintf("failed to reset clipboard buffer value: %s", err)) 142 | } 143 | 144 | var clearType string 145 | 146 | switch { 147 | case *clearImages: 148 | clearType = "images" 149 | case *clearAll: 150 | clearType = "all" 151 | case *clearText: 152 | clearType = "text" 153 | default: 154 | clearType = "default" 155 | } 156 | 157 | utils.HandleError(config.ClearHistory(clearType)) 158 | } 159 | 160 | func handleCopy() { 161 | var input string 162 | switch { 163 | case len(os.Args) < 3: 164 | input = utils.GetStdin() 165 | default: 166 | input = os.Args[2] 167 | } 168 | if input != "" { 169 | fmt.Println(input) 170 | utils.HandleError(clipboard.WriteAll(input)) 171 | } 172 | } 173 | 174 | func handlePaste() { 175 | currentItem, err := clipboard.ReadAll() 176 | utils.HandleError(err) 177 | if currentItem != "" { 178 | fmt.Println(currentItem) 179 | } 180 | } 181 | 182 | func handleForceClose() { 183 | if len(os.Args) < 3 { 184 | fmt.Printf("No PPID provided. Usage: %s' -fc $PPID'", os.Args[0]) 185 | return 186 | } 187 | 188 | if len(os.Args) > 3 { 189 | fmt.Printf("Too many args. Usage: %s' -fc $PPID'", os.Args[0]) 190 | return 191 | } 192 | 193 | if !utils.IsInt(os.Args[2]) { 194 | fmt.Printf("Invalid PPID supplied: %s\nPPID must be integer. use var `$PPID` as the arg.", os.Args[2]) 195 | return 196 | } 197 | 198 | launchTUI() 199 | } 200 | 201 | func handleOutputAll(format string) { 202 | items := config.TextItems() 203 | 204 | if format == "raw" { 205 | for _, v := range items { 206 | fmt.Printf("%q\n", v.Value) 207 | } 208 | } else if format == "unescaped" { 209 | for _, v := range items { 210 | fmt.Println(v.Value) 211 | } 212 | } else { 213 | fmt.Printf("Invalid argument to -output-all\nSee %s --help for usage", os.Args[0]) 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /nix/clipse/clipseShell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | pkgs.mkShell { 4 | buildInputs = with pkgs; [ 5 | go 6 | ]; 7 | shellHook = '' 8 | go install https://github.com/savedra1/clipse@latest 9 | ''; 10 | } -------------------------------------------------------------------------------- /nix/clipse/default.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , buildGoModule 3 | , fetchFromGitHub 4 | }: 5 | 6 | buildGoModule rec { 7 | pname = "clipse"; 8 | version = "1.0.9"; 9 | 10 | src = fetchFromGitHub { 11 | owner = "savedra1"; 12 | repo = "clipse"; 13 | rev = "v${version}"; 14 | hash = "sha256-Kpe/LiAreZXRqh6BHvUIn0GcHloKo3A0WOdlRF2ygdc="; 15 | }; 16 | 17 | vendorHash = "sha256-Hdr9NRqHJxpfrV2G1KuHGg3T+cPLKhZXEW02f1ptgsw="; 18 | 19 | meta = { 20 | description = "Useful clipboard manager TUI for Unix"; 21 | homepage = "https://github.com/savedra1/clipse"; 22 | license = lib.licenses.mit; 23 | mainProgram = "clipse"; 24 | maintainers = [ lib.maintainers.savedra1 ]; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /resources/examples/catppuccin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/examples/catppuccin.png -------------------------------------------------------------------------------- /resources/examples/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/examples/default.png -------------------------------------------------------------------------------- /resources/examples/dracula.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/examples/dracula.png -------------------------------------------------------------------------------- /resources/examples/gruvbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/examples/gruvbox.png -------------------------------------------------------------------------------- /resources/examples/htop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/examples/htop.png -------------------------------------------------------------------------------- /resources/examples/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/examples/light.png -------------------------------------------------------------------------------- /resources/examples/nord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/examples/nord.png -------------------------------------------------------------------------------- /resources/examples/pinned.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/examples/pinned.png -------------------------------------------------------------------------------- /resources/examples/tokyo_night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/examples/tokyo_night.png -------------------------------------------------------------------------------- /resources/library.md: -------------------------------------------------------------------------------- 1 | 2 | ## Nord 3 | 4 |

5 | 6 | Nord 7 | 8 |

9 | 10 | ## Gruvbox 11 | 12 |

13 | 14 | Gruvbox 15 | 16 |

17 | 18 | ## Dracula 19 | 20 |

21 | 22 | Dracula 23 | 24 |

25 | 26 | ## Catcppuccin 27 | 28 |

29 | 30 | Catppuccin 31 | 32 |

33 | 34 | ## Tokyo Night 35 | 36 |

37 | 38 | TokyoNight 39 | 40 |

41 | 42 | ## Light 43 | 44 |

45 | 46 | Light 47 | 48 |

49 | 50 | ## Default 51 | 52 |

53 | 54 | Default 55 | 56 |

-------------------------------------------------------------------------------- /resources/setup_data/clipboard_history.json: -------------------------------------------------------------------------------- 1 | { 2 | "clipboardHistory": [] 3 | } -------------------------------------------------------------------------------- /resources/setup_data/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "historyFile": "clipboard_history.json", 3 | "maxHistory": 100, 4 | "themeFile": "custom_theme.json", 5 | "tempDir": "tmp_files", 6 | "logFile": "clipse.log" 7 | } -------------------------------------------------------------------------------- /resources/setup_data/custom_theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "UseCustom": true, 3 | "TitleFore": "#ffffff", 4 | "TitleBack": "#6F4CBC", 5 | "TitleInfo": "#3498db", 6 | "NormalTitle": "#ffffff", 7 | "DimmedTitle": "#808080", 8 | "SelectedTitle": "#FF69B4", 9 | "NormalDesc": "#808080", 10 | "DimmedDesc": "#808080", 11 | "SelectedDesc": "#FF69B4", 12 | "StatusMsg": "#2ecc71", 13 | "PinIndicatorColor": "#FFD700", 14 | "SelectedBorder": "#3498db", 15 | "SelectedDescBorder": "#3498db", 16 | "FilteredMatch": "#ffffff", 17 | "FilterPrompt": "#2ecc71", 18 | "FilterInfo": "#3498db", 19 | "FilterText": "#ffffff", 20 | "FilterCursor": "#FFD700", 21 | "HelpKey": "#999999", 22 | "HelpDesc": "#808080", 23 | "PageActiveDot": "#3498db", 24 | "PageInactiveDot": "#808080", 25 | "DividerDot": "#3498db", 26 | "PreviewedText": "#ffffff", 27 | "PreviewBorder": "#3498db" 28 | } 29 | -------------------------------------------------------------------------------- /resources/test_data/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/test_data/image.png -------------------------------------------------------------------------------- /resources/test_data/lorum_ipsum.csv: -------------------------------------------------------------------------------- 1 | Lorem,ipsum,dolor,sit,"amet,",consectetur,adipiscing,"elit,",sed,do,eiusmod,tempor,incididunt,ut,labore,et,dolore,magna,aliqua.,Risus,nullam,eget,felis,eget.,Et,ligula,ullamcorper,malesuada,proin,libero,nunc.,Ac,tortor,vitae,purus,faucibus,ornare,suspendisse,sed,nisi,lacus.,In,ante,metus,dictum,at,tempor.,Orci,nulla,pellentesque,dignissim,enim,sit,amet.,Morbi,tristique,senectus,et,netus.,Cum,sociis,natoque,penatibus,et,magnis,dis.,Orci,dapibus,ultrices,in,iaculis,nunc,sed,augue,lacus.,Quis,lectus,nulla,at,volutpat,diam.,Sem,nulla,pharetra,diam,sit,amet,nisl,suscipit,adipiscing,bibendum.,Scelerisque,fermentum,dui,faucibus,in,ornare,quam,viverra,orci,sagittis.,Nulla,aliquet,porttitor,lacus,luctus,accumsan,tortor,posuere,ac,ut.,Aliquet,risus,feugiat,in,ante,metus,dictum,at,tempor.,Proin,fermentum,leo,vel,orci,porta,non,pulvinar,neque.,In,eu,mi,bibendum,neque,egestas,congue,quisque,egestas,diam. 2 | Tortor,dignissim,convallis,aenean,et,tortor,at,risus,viverra,adipiscing.,Vel,risus,commodo,viverra,maecenas,accumsan,lacus,vel,facilisis,volutpat.,Bibendum,ut,tristique,et,egestas,quis,ipsum,suspendisse.,Bibendum,at,varius,vel,pharetra,vel.,Faucibus,et,molestie,ac,feugiat,sed,lectus.,Justo,nec,ultrices,dui,sapien,eget,mi,proin,sed.,Amet,luctus,venenatis,lectus,magna,fringilla.,Congue,nisi,vitae,suscipit,tellus,mauris,a.,Turpis,in,eu,mi,bibendum.,At,auctor,urna,nunc,id,cursus,metus,aliquam.,Justo,nec,ultrices,dui,sapien,eget.,Commodo,elit,at,imperdiet,dui,accumsan,sit.,Massa,massa,ultricies,mi,quis,hendrerit,dolor,magna,eget,est.,Amet,consectetur,adipiscing,elit,pellentesque.,Amet,volutpat,consequat,mauris,nunc,congue,nisi,vitae,suscipit,tellus.,Nulla,facilisi,nullam,vehicula,ipsum.,,,,,,,,,,,,,,,,,,,, 3 | Risus,sed,vulputate,odio,ut,enim,blandit,volutpat.,Egestas,erat,imperdiet,sed,euismod,nisi,porta,lorem,mollis.,Felis,donec,et,odio,pellentesque,diam,volutpat,commodo,sed.,Fermentum,odio,eu,feugiat,pretium,nibh,ipsum,consequat,nisl,vel.,Dignissim,enim,sit,amet,venenatis,urna.,Euismod,nisi,porta,lorem,mollis.,Nibh,tortor,id,aliquet,lectus,proin,nibh,nisl.,Id,donec,ultrices,tincidunt,arcu.,At,urna,condimentum,mattis,pellentesque,id,nibh.,Tortor,posuere,ac,ut,consequat,semper.,Velit,laoreet,id,donec,ultrices,tincidunt,arcu,non,sodales,neque.,Nulla,facilisi,etiam,dignissim,diam,quis,enim,lobortis,scelerisque.,Neque,convallis,a,cras,semper,auctor.,At,elementum,eu,facilisis,sed,odio,morbi,quis.,Sagittis,purus,sit,amet,volutpat,consequat,mauris,nunc,congue.,,,,,,,,,,,,,,,,,,,,,,,, 4 | Nibh,mauris,cursus,mattis,molestie,a.,Tempor,id,eu,nisl,nunc,mi.,Erat,nam,at,lectus,urna.,Consectetur,lorem,donec,massa,sapien,faucibus.,Venenatis,cras,sed,felis,eget,velit,aliquet,sagittis,id,consectetur.,Ante,metus,dictum,at,tempor,commodo,ullamcorper.,Duis,at,consectetur,lorem,donec,massa,sapien,faucibus,et,molestie.,Netus,et,malesuada,fames,ac,turpis,egestas.,Amet,nulla,facilisi,morbi,tempus.,In,arcu,cursus,euismod,quis,viverra,nibh,cras,pulvinar,mattis.,Volutpat,ac,tincidunt,vitae,semper,quis,lectus.,Dui,accumsan,sit,amet,nulla,facilisi,morbi,tempus.,At,consectetur,lorem,donec,massa.,Vestibulum,sed,arcu,non,odio,euismod,lacinia,at,quis.,Massa,vitae,tortor,condimentum,lacinia,quis,vel,eros.,At,erat,pellentesque,adipiscing,commodo.,Enim,tortor,at,auctor,urna,nunc.,Massa,sed,elementum,tempus,egestas,sed,sed.,,,,,,,,,,,, 5 | Donec,ac,odio,tempor,orci,dapibus,ultrices.,Quis,enim,lobortis,scelerisque,fermentum.,A,erat,nam,at,lectus,urna,duis,convallis.,Laoreet,non,curabitur,gravida,arcu,ac,tortor.,Lacus,laoreet,non,curabitur,gravida.,Sagittis,nisl,rhoncus,mattis,rhoncus,urna,neque,viverra,justo,nec.,Mattis,vulputate,enim,nulla,aliquet,porttitor,lacus.,Libero,id,faucibus,nisl,tincidunt,eget.,Non,diam,phasellus,vestibulum,lorem,sed,risus,ultricies.,Tortor,pretium,viverra,suspendisse,potenti,nullam,ac,tortor,vitae.,Risus,commodo,viverra,maecenas,accumsan,lacus,vel,facilisis,volutpat.,Nec,tincidunt,praesent,semper,feugiat,nibh,sed.,Pellentesque,diam,volutpat,commodo,sed.,Bibendum,est,ultricies,integer,quis,auctor,elit.,Volutpat,lacus,laoreet,non,curabitur,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, -------------------------------------------------------------------------------- /resources/test_data/nix-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/test_data/nix-logo.png -------------------------------------------------------------------------------- /resources/test_data/swappy-20240202-194248.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/savedra1/clipse/a7b613d2b3beb345613ecb9e1a124a064bd13723/resources/test_data/swappy-20240202-194248.png -------------------------------------------------------------------------------- /resources/test_data/top_secret_credentials.txt: -------------------------------------------------------------------------------- 1 | Username: your.email@example.com 2 | Password: WcX634&lk^l* 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/themes/catppuccin.json: -------------------------------------------------------------------------------- 1 | { 2 | "useCustomTheme": true, 3 | "TitleFore": "#cdd6f4", 4 | "TitleBack": "#1e1e2e", 5 | "TitleInfo": "#cdd6f4", 6 | "NormalTitle": "#f38ba8", 7 | "DimmedTitle": "#a6adc8", 8 | "SelectedTitle": "#cba6f7", 9 | "NormalDesc": "#a6e3a1", 10 | "DimmedDesc": "#a6adc8", 11 | "SelectedDesc": "#cba6f7", 12 | "StatusMsg": "#b4befe", 13 | "PinIndicatorColor": "#ff0000", 14 | "SelectedBorder": "#cba6f7", 15 | "SelectedDescBorder": "#cba6f7", 16 | "FilteredMatch": "#f38ba8", 17 | "FilterPrompt": "#f5c2e7", 18 | "FilterInfo": "#cdd6f4", 19 | "FilterText": "#f5e0dc", 20 | "FilterCursor": "#fab387", 21 | "HelpKey": "#cba6f7", 22 | "HelpDesc": "#cdd6f4", 23 | "PageActiveDot": "#a6e3a1", 24 | "PageInactiveDot": "#a6adc8", 25 | "DividerDot": "#f38ba8", 26 | "PreviewedText": "#cdd6f4", 27 | "PreviewBorder": "#cba6f7" 28 | } 29 | -------------------------------------------------------------------------------- /resources/themes/dracula.json: -------------------------------------------------------------------------------- 1 | { 2 | "useCustomTheme": true, 3 | "TitleFore": "#F8F8F2", 4 | "TitleBack": "#282A36", 5 | "TitleInfo": "#F8F8F2", 6 | "NormalTitle": "#FF79C6", 7 | "DimmedTitle": "#6272A4", 8 | "SelectedTitle": "#8BE9FD", 9 | "NormalDesc": "#BD93F9", 10 | "DimmedDesc": "#6272A4", 11 | "SelectedDesc": "#8BE9FD", 12 | "StatusMsg": "#BD93F9", 13 | "PinIndicatorColor": "#ff5555", 14 | "SelectedBorder": "#8BE9FD", 15 | "SelectedDescBorder": "#8BE9FD", 16 | "FilteredMatch": "#50FA7B", 17 | "FilterPrompt": "#FFB86C", 18 | "FilterInfo": "#F8F8F2", 19 | "FilterText": "#F8F8F2", 20 | "FilterCursor": "#FF79C6", 21 | "HelpKey": "#8BE9FD", 22 | "HelpDesc": "#F8F8F2", 23 | "PageActiveDot": "#50FA7B", 24 | "PageInactiveDot": "#6272A4", 25 | "DividerDot": "#FF5555", 26 | "PreviewedText": "#F8F8F2", 27 | "PreviewBorder": "#BD93F9" 28 | } 29 | -------------------------------------------------------------------------------- /resources/themes/gruvbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "useCustomTheme": true, 3 | "TitleFore": "#EBDBB2", 4 | "TitleBack": "#282828", 5 | "TitleInfo": "#EBDBB2", 6 | "NormalTitle": "#D3869B", 7 | "DimmedTitle": "#928374", 8 | "SelectedTitle": "#8EC07C", 9 | "NormalDesc": "#A89984", 10 | "DimmedDesc": "#928374", 11 | "SelectedDesc": "#8EC07C", 12 | "StatusMsg": "#B8BB26", 13 | "PinIndicatorColor": "#f73028", 14 | "SelectedBorder": "#83A598", 15 | "SelectedDescBorder": "#83A598", 16 | "FilteredMatch": "#B8BB26", 17 | "FilterPrompt": "#D79921", 18 | "FilterInfo": "#EBDBB2", 19 | "FilterText": "#FBF1C7", 20 | "FilterCursor": "#FABD2F", 21 | "HelpKey": "#83A598", 22 | "HelpDesc": "#EBDBB2", 23 | "PageActiveDot": "#8EC07C", 24 | "PageInactiveDot": "#928374", 25 | "DividerDot": "#FB4934", 26 | "PreviewedText": "#FBF1C7", 27 | "PreviewBorder": "#FE8019" 28 | } 29 | -------------------------------------------------------------------------------- /resources/themes/light.json: -------------------------------------------------------------------------------- 1 | { 2 | "useCustomTheme": true, 3 | "TitleFore": "#2E3440", 4 | "TitleBack": "#ECEFF4", 5 | "TitleInfo": "#2E3440", 6 | "NormalTitle": "#5E81AC", 7 | "DimmedTitle": "#4C566A", 8 | "SelectedTitle": "#BF616A", 9 | "NormalDesc": "#3B4252", 10 | "DimmedDesc": "#434C5E", 11 | "SelectedDesc": "#BF616A", 12 | "StatusMsg": "#8FBCBB", 13 | "PinIndicatorColor": "#D08770", 14 | "SelectedBorder": "#88C0D0", 15 | "SelectedDescBorder": "#88C0D0", 16 | "FilteredMatch": "#A3BE8C", 17 | "FilterPrompt": "#D08770", 18 | "FilterInfo": "#2E3440", 19 | "FilterText": "#3B4252", 20 | "FilterCursor": "#BF616A", 21 | "HelpKey": "#5E81AC", 22 | "HelpDesc": "#2E3440", 23 | "PageActiveDot": "#A3BE8C", 24 | "PageInactiveDot": "#4C566A", 25 | "DividerDot": "#2E3440", 26 | "PreviewedText": "#2E3440", 27 | "PreviewBorder": "#88C0D0" 28 | } 29 | -------------------------------------------------------------------------------- /resources/themes/nord.json: -------------------------------------------------------------------------------- 1 | { 2 | "useCustomTheme": true, 3 | "TitleFore": "#E5E9F0", 4 | "TitleBack": "#2E3440", 5 | "TitleInfo": "#E5E9F0", 6 | "NormalTitle": "#88C0D0", 7 | "DimmedTitle": "#4C566A", 8 | "SelectedTitle": "#5E81AC", 9 | "NormalDesc": "#D8DEE9", 10 | "DimmedDesc": "#434C5E", 11 | "SelectedDesc": "#81A1C1", 12 | "StatusMsg": "#8FBCBB", 13 | "PinIndicatorColor": "#EBCB8B", 14 | "SelectedBorder": "#88C0D0", 15 | "SelectedDescBorder": "#81A1C1", 16 | "FilteredMatch": "#A3BE8C", 17 | "FilterPrompt": "#D08770", 18 | "FilterInfo": "#E5E9F0", 19 | "FilterText": "#ECEFF4", 20 | "FilterCursor": "#B48EAD", 21 | "HelpKey": "#5E81AC", 22 | "HelpDesc": "#E5E9F0", 23 | "PageActiveDot": "#A3BE8C", 24 | "PageInactiveDot": "#4C566A", 25 | "DividerDot": "#BF616A", 26 | "PreviewedText": "#ffffff", 27 | "PreviewBorder": "#3498db" 28 | } 29 | -------------------------------------------------------------------------------- /resources/themes/tokyo_night.json: -------------------------------------------------------------------------------- 1 | { 2 | "useCustomTheme": true, 3 | "TitleFore": "#C0CAF5", 4 | "TitleBack": "#1A1B26", 5 | "TitleInfo": "#C0CAF5", 6 | "NormalTitle": "#7AA2F7", 7 | "DimmedTitle": "#3B4261", 8 | "SelectedTitle": "#7DCFFF", 9 | "NormalDesc": "#A9B1D6", 10 | "DimmedDesc": "#3B4261", 11 | "SelectedDesc": "#7AA2F7", 12 | "StatusMsg": "#BB9AF7", 13 | "PinIndicatorColor": "#FF9E64", 14 | "SelectedBorder": "#7AA2F7", 15 | "SelectedDescBorder": "#7DCFFF", 16 | "FilteredMatch": "#9ECE6A", 17 | "FilterPrompt": "#E0AF68", 18 | "FilterInfo": "#C0CAF5", 19 | "FilterText": "#C0CAF5", 20 | "FilterCursor": "#F7768E", 21 | "HelpKey": "#7AA2F7", 22 | "HelpDesc": "#C0CAF5", 23 | "PageActiveDot": "#9ECE6A", 24 | "PageInactiveDot": "#3B4261", 25 | "DividerDot": "#F7768E", 26 | "PreviewedText": "#C0CAF5", 27 | "PreviewBorder": "#BB9AF7" 28 | } 29 | -------------------------------------------------------------------------------- /shell/cmd.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strconv" 8 | "strings" 9 | "syscall" 10 | 11 | ps "github.com/mitchellh/go-ps" 12 | 13 | "github.com/savedra1/clipse/utils" 14 | ) 15 | 16 | func KillExisting() error { 17 | /* 18 | Kills any existing clipse processes but keeps current ps live 19 | */ 20 | currentPS := syscall.Getpid() 21 | psList, err := ps.Processes() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | for _, p := range psList { 27 | if strings.Contains(os.Args[0], p.Executable()) || strings.Contains(wlPasteHandler, p.Executable()) { 28 | if p.Pid() != currentPS { 29 | KillProcess(strconv.Itoa(p.Pid())) 30 | } 31 | } 32 | } 33 | return nil 34 | } 35 | 36 | func KillExistingFG() { 37 | /* 38 | Only kill other clipboard TUI windows to prevent 39 | file conflicts. 40 | */ 41 | currentPS := strconv.Itoa(syscall.Getpid()) 42 | cmd := exec.Command("sh", "-c", pgrepCmd) 43 | output, err := cmd.Output() 44 | /* 45 | EG Output returns as: 46 | 156842 clipse --listen-shell >/dev/null 2>&1 & 47 | 310228 clipse 48 | */ 49 | if err != nil { 50 | utils.LogWARN(fmt.Sprintf("failed to get processes | err msg: %s | output: %s", err, output)) 51 | return 52 | } 53 | if output == nil { 54 | return // no clipse processes running 55 | } 56 | 57 | psList := strings.Split(string(output), "\n") 58 | for _, ps := range psList { 59 | if strings.Contains(ps, currentPS) || strings.Contains(ps, listenCmd) || strings.Contains(ps, wlStoreCmd) { 60 | continue 61 | } 62 | if ps != "" { 63 | KillProcess(strings.Split(ps, " ")[0]) 64 | } 65 | } 66 | } 67 | 68 | func KillAll(bin string) { 69 | cmd := exec.Command("pkill", "-f", bin) 70 | err := cmd.Run() // Wait for this to finish before executing 71 | if err != nil { 72 | utils.LogERROR(fmt.Sprintf("Failed to kill all existing processes for %s.", bin)) 73 | return 74 | } 75 | } 76 | 77 | func RunNohupListener(displayServer string) { 78 | switch displayServer { 79 | case "wayland": 80 | // run optimized wl-clipboard listener 81 | utils.HandleError(nohupCmdWL("image/png").Start()) 82 | utils.HandleError(nohupCmdWL("text").Start()) 83 | 84 | default: 85 | // run default poll listener 86 | cmd := exec.Command("nohup", os.Args[0], listenCmd, ">/dev/null", "2>&1", "&") 87 | utils.HandleError(cmd.Start()) 88 | } 89 | } 90 | 91 | func nohupCmdWL(dataType string) *exec.Cmd { 92 | cmd := exec.Command( 93 | "nohup", 94 | wlPasteHandler, 95 | wlTypeSpec, 96 | dataType, 97 | wlPasteWatcher, 98 | os.Args[0], 99 | wlStoreCmd, 100 | ">/dev/null", 101 | "2>&1", 102 | "&", 103 | ) 104 | return cmd 105 | } 106 | 107 | func KillProcess(ppid string) { 108 | cmd := exec.Command("kill", ppid) 109 | if err := cmd.Run(); err != nil { 110 | utils.LogERROR(fmt.Sprintf("failed to kill process: %s", err)) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /shell/constants.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | const ( 4 | listenCmd = "--listen-shell" // internal 5 | pgrepCmd = "pgrep -a clipse" 6 | wlVersionCmd = "wl-copy -v" 7 | wlPasteHandler = "wl-paste" 8 | wlPasteWatcher = "--watch" 9 | wlCopyImgCmd = "wl-copy -t image/png <" 10 | wlPasteImgCmd = "wl-paste -t image/png >" 11 | wlStoreCmd = "--wl-store" // internal 12 | wlTypeSpec = "--type" 13 | xVersionCmd = "xclip -v" 14 | xCopyImgCmd = "xclip -selection clipboard -t image/png -i" 15 | xPasteImgCmd = "xclip -selection clipboard -t image/png -o >" 16 | ) 17 | -------------------------------------------------------------------------------- /shell/image.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/savedra1/clipse/utils" 10 | ) 11 | 12 | func ImagesEnabled(displayServer string) bool { 13 | var cmd *exec.Cmd 14 | switch displayServer { 15 | case "wayland": 16 | cmd = exec.Command("sh", "-c", wlVersionCmd) 17 | case "x11", "darwin": 18 | cmd = exec.Command("sh", "-c", xVersionCmd) 19 | default: 20 | return false 21 | } 22 | if err := cmd.Run(); err != nil { 23 | return false 24 | } 25 | return true 26 | } 27 | 28 | func CopyImage(imagePath, displayServer string) error { 29 | cmd := fmt.Sprintf("%s %s", xCopyImgCmd, imagePath) 30 | if displayServer == "wayland" { 31 | cmd = fmt.Sprintf("%s %s", wlCopyImgCmd, imagePath) 32 | } 33 | if err := exec.Command("sh", "-c", cmd).Run(); err != nil { 34 | return err 35 | } 36 | return nil 37 | } 38 | 39 | func SaveImage(imagePath, displayServer string) error { 40 | cmd := fmt.Sprintf("%s %s", xPasteImgCmd, imagePath) 41 | if displayServer == "wayland" { 42 | cmd = fmt.Sprintf("%s %s", wlPasteImgCmd, imagePath) 43 | } 44 | if err := exec.Command("sh", "-c", cmd).Run(); err != nil { 45 | return err 46 | } 47 | return nil 48 | } 49 | func DeleteImage(imagePath string) error { 50 | if err := os.Remove(imagePath); err != nil { 51 | return err 52 | } 53 | return nil 54 | } 55 | 56 | func DeleteAllImages(imgDir string) error { 57 | files, err := os.ReadDir(imgDir) 58 | if err != nil { 59 | return err 60 | } 61 | for _, file := range files { 62 | if err := os.Remove(filepath.Join(imgDir, file.Name())); err != nil { 63 | utils.LogERROR(fmt.Sprintf("failed to delete file %s | %s", file.Name(), err)) 64 | } 65 | } 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /tests/app/app_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test(_ *testing.T) {} 8 | -------------------------------------------------------------------------------- /tests/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test(_ *testing.T) {} 8 | -------------------------------------------------------------------------------- /tests/handlers/handlers_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test(_ *testing.T) {} 8 | -------------------------------------------------------------------------------- /tests/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test(_ *testing.T) {} 8 | -------------------------------------------------------------------------------- /tests/shell/shell_test.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test(_ *testing.T) {} 8 | -------------------------------------------------------------------------------- /tests/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test(_ *testing.T) {} 8 | -------------------------------------------------------------------------------- /themes/catppuccin.json: -------------------------------------------------------------------------------- 1 | { 2 | "useCustomTheme": true, 3 | "DimmedDesc": "#a6adc8", 4 | "DimmedTitle": "#a6adc8", 5 | "FilteredMatch": "#f38ba8", 6 | "NormalTitle": "#f38ba8", 7 | "NormalDesc": "#a6e3a1", 8 | "SelectedDesc": "#cba6f7", 9 | "SelectedTitle": "#cba6f7", 10 | "SelectedBorder": "#cba6f7", 11 | "SelectedDescBorder": "#cba6f7", 12 | "TitleFore": "#cdd6f4", 13 | "Titleback": "#1e1e2e", 14 | "StatusMsg": "#b4befe", 15 | "PinIndicatorColor": "#D20F39" 16 | } 17 | -------------------------------------------------------------------------------- /themes/dracula.json: -------------------------------------------------------------------------------- 1 | { 2 | "useCustomTheme": true, 3 | "DimmedDesc": "#6272A4", 4 | "DimmedTitle": "#6272A4", 5 | "FilteredMatch": "#50FA7B", 6 | "NormalDesc": "#BD93F9", 7 | "NormalTitle": "#FF79C6", 8 | "SelectedDesc": "#8BE9FD", 9 | "SelectedTitle": "#8BE9FD", 10 | "SelectedBorder": "#8BE9FD", 11 | "SelectedDescBorder": "#8BE9FD", 12 | "TitleFore": "#F8F8F2", 13 | "Titleback": "#282A36", 14 | "StatusMsg": "#BD93F9", 15 | "PinIndicatorColor": "#ff5555" 16 | } 17 | -------------------------------------------------------------------------------- /themes/gruvbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "useCustomTheme": true, 3 | "DimmedDesc": "#928374", 4 | "DimmedTitle": "#928374", 5 | "FilteredMatch": "#B8BB26", 6 | "NormalDesc": "#A89984", 7 | "NormalTitle": "#D3869B", 8 | "SelectedDesc": "#8EC07C", 9 | "SelectedTitle": "#8EC07C", 10 | "SelectedBorder": "#83A598", 11 | "SelectedDescBorder": "#83A598", 12 | "TitleFore": "#EBDBB2", 13 | "Titleback": "#282828", 14 | "StatusMsg": "#B8BB26", 15 | "PinIndicatorColor": "#f73028" 16 | } 17 | -------------------------------------------------------------------------------- /themes/nord.json: -------------------------------------------------------------------------------- 1 | { 2 | "useCustomTheme": true, 3 | "DimmedDesc": "#4C566A", 4 | "DimmedTitle": "#4C566A", 5 | "FilteredMatch": "#A3BE8C", 6 | "NormalDesc": "#81A1C1", 7 | "NormalTitle": "#B48EAD", 8 | "SelectedDesc": "#A3BE8C", 9 | "SelectedTitle": "#A3BE8C", 10 | "SelectedBorder": "#88C0D0", 11 | "SelectedDescBorder": "#88C0D0", 12 | "TitleFore": "#D8DEE9", 13 | "Titleback": "#3B4252", 14 | "StatusMsg": "#8FBCBB", 15 | "PinIndicatorColor": "#bf616a" 16 | } 17 | -------------------------------------------------------------------------------- /utils/constants.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | const ( 4 | maxChar = 65 5 | imgNameRegEx = `^(\d{1,10})-\d{1,10}\.png$` 6 | ) 7 | -------------------------------------------------------------------------------- /utils/err.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime/debug" 7 | ) 8 | 9 | func HandleError(err error) { 10 | if err != nil { 11 | debug.PrintStack() 12 | if logger != nil { 13 | LogERROR(fmt.Sprint(err)) 14 | } 15 | os.Exit(1) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /utils/image.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "image/jpeg" 6 | "image/png" 7 | ) 8 | 9 | func DataType(data string) string { 10 | dataBytes := []byte(data) 11 | reader := bytes.NewReader(dataBytes) 12 | 13 | _, err := png.Decode(reader) 14 | if err == nil { 15 | return "png" 16 | } 17 | _, err = jpeg.Decode(reader) 18 | if err == nil { 19 | return "jpg" 20 | } 21 | 22 | return "text" 23 | } 24 | -------------------------------------------------------------------------------- /utils/int.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "strconv" 4 | 5 | func IsInt(arg string) bool { 6 | if _, err := strconv.Atoi(arg); err != nil { 7 | return false 8 | } 9 | return true 10 | } 11 | -------------------------------------------------------------------------------- /utils/logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | var logger *log.Logger 9 | 10 | func SetUpLogger(logFilePath string) { 11 | file, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) 12 | if err != nil { 13 | log.Fatalf("Failed to open log file: %s", err) 14 | } 15 | logger = log.New(file, "", log.LstdFlags|log.Lshortfile) 16 | } 17 | 18 | func LogERROR(message string) { 19 | logger.Printf("ERROR: %s", message) 20 | } 21 | 22 | func LogINFO(message string) { 23 | logger.Printf("INFO: %s", message) 24 | } 25 | 26 | func LogWARN(message string) { 27 | logger.Printf("WARN: %s", message) 28 | } 29 | -------------------------------------------------------------------------------- /utils/string.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | /* General purpose functions to be used by other modules 14 | */ 15 | 16 | func Shorten(s string) string { 17 | sl := strings.TrimSpace( 18 | strings.ReplaceAll( 19 | strings.ReplaceAll(s, "\n", "\\n"), 20 | "\t", " ", 21 | ), 22 | ) 23 | if len(sl) <= maxChar { 24 | return strings.ReplaceAll(sl, " ", " ") 25 | } 26 | return strings.ReplaceAll(sl[:maxChar-3], " ", " ") + "..." 27 | } 28 | 29 | func GetStdin() string { 30 | /* 31 | Gets piped input from the terminal when n 32 | no additional arg provided 33 | */ 34 | buffer := make([]byte, 1024) 35 | n, err := os.Stdin.Read(buffer) 36 | if err != nil && err != io.EOF { 37 | return "Error reading Stdin" 38 | } 39 | return string(buffer[:n]) 40 | } 41 | 42 | func GetTime() string { 43 | return time.Now().Format(("2006-01-02 15:04:05.000000000")) 44 | } 45 | 46 | func GetTimeStamp() string { 47 | return strings.Split(GetTime(), ".")[1] 48 | } 49 | 50 | func GetImgIdentifier(filename string) string { 51 | parts := strings.SplitN(filename, " ", 2) 52 | if len(parts) < 2 { 53 | LogERROR( 54 | fmt.Sprintf( 55 | "could not get img identifier due to missing space in filename | '%s'", 56 | filename, 57 | ), 58 | ) 59 | return "" 60 | } 61 | filename = parts[1] 62 | fileNamePattern := regexp.MustCompile(imgNameRegEx) 63 | matches := fileNamePattern.FindStringSubmatch(filename) 64 | if matches == nil { 65 | LogERROR( 66 | fmt.Sprintf( 67 | "could not get img identifier due to irregular filename | '%s'", 68 | filename, 69 | ), 70 | ) 71 | return "" 72 | } 73 | return matches[1] 74 | } 75 | 76 | // Expands the path to include the home directory if the path is prefixed 77 | // with `~`. If it isn't prefixed with `~`, the path is returned as-is. 78 | func ExpandHome(relPath string) string { 79 | if len(relPath) == 0 { 80 | return relPath 81 | } 82 | 83 | if relPath[0] != '~' { 84 | // if not ~, it could be $HOME. Expand that. 85 | return os.ExpandEnv(relPath) 86 | } 87 | 88 | curUserHome, err := os.UserHomeDir() 89 | HandleError(err) 90 | 91 | return filepath.Join(curUserHome, relPath[1:]) 92 | } 93 | 94 | func ExpandRel(relPath, absPath string) string { 95 | // Already absolute. 96 | if filepath.IsAbs(relPath) { 97 | return relPath 98 | } 99 | 100 | absRelPath, err := filepath.Abs(filepath.Join(absPath, relPath)) 101 | if err != nil { 102 | fmt.Println("Current working directory is INVALID! How did you manage this?") 103 | } 104 | return absRelPath 105 | } 106 | 107 | /* NOT IN USE - Remove bad chars - can cause issues with fuzzy finder 108 | func cleanString(s string) string { 109 | regex := regexp.MustCompile("[^a-zA-Z0-9 !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~]+") 110 | sanitized := regex.ReplaceAllString(s, "") 111 | sl := strings.ReplaceAll(sanitized, "\n", "\\n") 112 | return strings.ReplaceAll(sl, " ", " ") // remove trailing space 113 | }*/ 114 | --------------------------------------------------------------------------------