├── .tool-versions ├── .gitignore ├── src ├── cli.cr ├── thyme.cr └── thyme │ ├── daemon.cr │ ├── signal_handler.cr │ ├── process_handler.cr │ ├── format.cr │ ├── hook_collection.cr │ ├── option.cr │ ├── hook.cr │ ├── tmux.cr │ ├── timer.cr │ ├── command.cr │ └── config.cr ├── asset └── thyme.gif ├── spec ├── thyme │ ├── tmux_spec.cr │ ├── daemon_spec.cr │ ├── timer_spec.cr │ ├── command_spec.cr │ ├── signal_handler_spec.cr │ ├── process_handler_spec.cr │ ├── format_spec.cr │ ├── hook_spec.cr │ ├── hook_collection_spec.cr │ ├── option_spec.cr │ └── config_spec.cr ├── thyme_spec.cr └── spec_helper.cr ├── shard.lock ├── .editorconfig ├── shard.yml ├── Makefile ├── LICENSE └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | crystal 1.0.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | -------------------------------------------------------------------------------- /src/cli.cr: -------------------------------------------------------------------------------- 1 | require "./thyme" 2 | 3 | Thyme::Command.new.run 4 | -------------------------------------------------------------------------------- /asset/thyme.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hughbien/thyme/HEAD/asset/thyme.gif -------------------------------------------------------------------------------- /spec/thyme/tmux_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Thyme::Tmux do 4 | end 5 | -------------------------------------------------------------------------------- /spec/thyme/daemon_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Thyme::Daemon do 4 | end 5 | -------------------------------------------------------------------------------- /spec/thyme/timer_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Thyme::Timer do 4 | end 5 | -------------------------------------------------------------------------------- /spec/thyme/command_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Thyme::Command do 4 | end 5 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | shards: 3 | toml: 4 | github: crystal-community/toml.cr 5 | commit: d02de85eed68a70dc97ab6d6e52831a4d0e890fe 6 | 7 | -------------------------------------------------------------------------------- /src/thyme.cr: -------------------------------------------------------------------------------- 1 | require "./thyme/**" 2 | 3 | module Thyme 4 | VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify }} 5 | 6 | class Error < Exception; end 7 | end 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: thyme 2 | version: 0.1.6 3 | 4 | authors: 5 | - Hugh Bien 6 | 7 | targets: 8 | thyme: 9 | main: src/cli.cr 10 | 11 | crystal: 1.0.0 12 | 13 | license: BSD 14 | -------------------------------------------------------------------------------- /spec/thyme_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Thyme do 4 | it "sets VERSION" do 5 | Thyme::VERSION.should_not be_nil 6 | end 7 | 8 | it "sets Error" do 9 | Thyme::Error.should be < Exception 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /src/thyme/daemon.cr: -------------------------------------------------------------------------------- 1 | lib LibC 2 | fun setsid : PidT 3 | end 4 | 5 | module Thyme::Daemon 6 | DEV_NULL = "/dev/null" 7 | ROOT_DIR = "/" 8 | 9 | # Daemonizes the current process. TODO: add logging for development. 10 | def self.start! 11 | exit if Process.fork 12 | LibC.setsid 13 | exit if Process.fork 14 | Dir.cd(ROOT_DIR) 15 | 16 | STDIN.reopen(File.open(DEV_NULL, "a+")) 17 | STDOUT.reopen(File.open(DEV_NULL, "a")) 18 | STDERR.reopen(File.open(DEV_NULL, "a")) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /src/thyme/signal_handler.cr: -------------------------------------------------------------------------------- 1 | require "../thyme" 2 | 3 | # Thyme runs in multiple processes (sort of): 4 | # 1. the main daemon process - where the timer runs continuously 5 | # 2. secondary processes - which start to send stop/pause/unpause to the main process 6 | # 7 | # SignalHandler is used for sending/receiving messages. Receiving should only be done 8 | # on the main process. Sending should only be done on the secondary process. 9 | module Thyme::SignalHandler 10 | extend self 11 | 12 | def on_stop(&block) 13 | Signal::INT.trap { block.call } 14 | end 15 | 16 | def send_stop 17 | Process.signal(Signal::INT, ProcessHandler.read_pid) 18 | end 19 | 20 | def on_toggle(&block) 21 | Signal::USR1.trap { block.call } 22 | end 23 | 24 | def send_toggle 25 | Process.signal(Signal::USR1, ProcessHandler.read_pid) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/thyme" 3 | 4 | def setup_pid(pid = nil) 5 | if pid 6 | File.write(Thyme::ProcessHandler::PID_FILE, pid.not_nil!) 7 | elsif File.exists?(Thyme::ProcessHandler::PID_FILE) 8 | File.delete(Thyme::ProcessHandler::PID_FILE) 9 | end 10 | end 11 | 12 | def build_config(values = Hash(String, String | UInt32 | Bool).new) 13 | file = File.tempfile 14 | values.each do |key, value| 15 | value = "\"#{value}\"" if value.as?(String) 16 | file << "#{key}: #{value}\n" 17 | end 18 | file.close 19 | Thyme::Config.parse(file.path) 20 | ensure 21 | file.delete if file 22 | end 23 | 24 | def build_hook(event = "before", command = "") 25 | Thyme::Hook.new(event, [Thyme::HookEvent.parse(event)], command) 26 | end 27 | 28 | def hooks_args 29 | { 30 | repeat_index: "1", 31 | repeat_total: "2", 32 | repeat_suffix: "(1/2)" 33 | } 34 | end 35 | -------------------------------------------------------------------------------- /spec/thyme/signal_handler_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Thyme::SignalHandler do 4 | before_all do 5 | setup_pid(Process.pid.to_s) 6 | end 7 | 8 | after_all do 9 | setup_pid(nil) 10 | Signal::INT.reset 11 | Signal::USR1.reset 12 | end 13 | 14 | describe "stopping" do 15 | it "traps and sends INT signal" do 16 | called = false 17 | Thyme::SignalHandler.on_stop do 18 | called = true 19 | end 20 | Thyme::SignalHandler.send_stop 21 | sleep(0.01) # TODO: how do I block here until handler is called? 22 | called.should be_true 23 | end 24 | end 25 | 26 | describe "toggling" do 27 | it "traps and sends INT signal" do 28 | called = false 29 | Thyme::SignalHandler.on_toggle do 30 | called = true 31 | end 32 | Thyme::SignalHandler.send_toggle 33 | sleep(0.01) # TODO: :( 34 | called.should be_true 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | INSTALL_BIN ?= /usr/local/bin 2 | SHARDS ?= shards 3 | VERSION = $(shell cat shard.yml | grep ^version | sed -e "s/version: //") 4 | 5 | build: bin/thyme 6 | bin/thyme: 7 | $(SHARDS) build --production 8 | rm -f bin/thyme.dwarf 9 | 10 | build-static: 11 | docker run --rm -it -v $(PWD):/workspace -w /workspace crystallang/crystal:1.0.0-alpine shards build --production --static 12 | mv bin/thyme bin/thyme-linux-amd64 13 | 14 | install: build 15 | cp bin/thyme $(INSTALL_BIN) 16 | 17 | release: build-static 18 | $(eval MD5 := $(shell md5sum bin/thyme-linux-amd64 | cut -d" " -f1)) 19 | @echo v$(VERSION) $(MD5) 20 | sed -i "" -E "s/v[0-9]+\.[0-9]+\.[0-9]+/v$(VERSION)/g" README.md 21 | sed -i "" -E "s/[0-9a-f]{32}/$(MD5)/g" README.md 22 | 23 | push: 24 | git tag v$(VERSION) 25 | git push --tags 26 | gh release create -R hughbien/thyme -t v$(VERSION) v$(VERSION) ./bin/thyme-linux-amd64 27 | 28 | spec: test 29 | test: 30 | crystal spec $(ARGS) 31 | 32 | clean: 33 | rm -rf bin 34 | 35 | reset: 36 | tmux source-file ~/.tmux.conf 37 | 38 | run: 39 | crystal run src/cli.cr -- $(ARGS) 40 | -------------------------------------------------------------------------------- /src/thyme/process_handler.cr: -------------------------------------------------------------------------------- 1 | require "../thyme" 2 | 3 | # Handles all things related to PID! 4 | # 5 | # The Thyme timer runs as a daemon process. Stopping/pausing/unpausing are all initiated by a 6 | # secondary process. It sends a signal to the main daemon. In order to do so, it needs the daemon's 7 | # PID which is stored in the PID_FILE. 8 | # 9 | # See Thyme::SignalHandler for the actual message sending. This module only deals with reading/writing 10 | # the daemon's PID. 11 | module Thyme::ProcessHandler 12 | PID_FILE = "#{ENV["HOME"]}/.thyme-pid" 13 | 14 | extend self 15 | 16 | # Returns the PID of the main daemon process. Should only be used by the secondary 17 | # processes (eg during stopping/pausing/unpausing). 18 | def read_pid 19 | File.read(PID_FILE).strip.to_i 20 | rescue IO::Error 21 | raise Error.new("Cannot read #{PID_FILE}, try re-starting thyme") 22 | end 23 | 24 | # Writes the current process's PID to the PID_FILE. Must only be used by the main process. 25 | # Must never be used by secondary processes. 26 | def write_pid 27 | File.write(PID_FILE, Process.pid.to_s) 28 | end 29 | 30 | # Clean up by removing PID_FILE. 31 | def delete_pid 32 | File.delete(PID_FILE) 33 | rescue IO::Error 34 | # ignore, file already deleted 35 | end 36 | 37 | # Returns true if main daemon is running. 38 | def running? 39 | File.exists?(PID_FILE) && Process.exists?(read_pid) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/thyme/process_handler_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Thyme::ProcessHandler do 4 | describe ".read_pid" do 5 | it "raises error when PID_FILE does not exist" do 6 | setup_pid 7 | expect_raises(Thyme::Error, /Cannot read/) do 8 | Thyme::ProcessHandler.read_pid 9 | end 10 | end 11 | 12 | it "returns contents of PID_FILE" do 13 | num = Random.rand(100) 14 | setup_pid(num) 15 | Thyme::ProcessHandler.read_pid.should eq(num) 16 | end 17 | end 18 | 19 | describe ".write_pid" do 20 | it "writes current process to PID_FILE" do 21 | Thyme::ProcessHandler.write_pid 22 | File.read(Thyme::ProcessHandler::PID_FILE).should eq(Process.pid.to_s) 23 | end 24 | end 25 | 26 | describe ".delete_pid" do 27 | it "removes PID_FILE" do 28 | setup_pid(100) 29 | Thyme::ProcessHandler.delete_pid 30 | File.exists?(Thyme::ProcessHandler::PID_FILE).should be_false 31 | end 32 | end 33 | 34 | describe ".running?" do 35 | it "returns false if PID_FILE is missing" do 36 | setup_pid 37 | Thyme::ProcessHandler.running?.should be_false 38 | end 39 | 40 | it "returns false if process does not exist" do 41 | setup_pid(-99999999) 42 | Thyme::ProcessHandler.running?.should be_false 43 | end 44 | 45 | it "returns true if process exists" do 46 | setup_pid(Process.pid) 47 | Thyme::ProcessHandler.running?.should be_true 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Hugh Bien 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted 4 | provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions 7 | and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions 10 | and the following disclaimer in the documentation and/or other materials provided with the 11 | distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse 14 | or promote products derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR 17 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND 18 | FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 19 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 21 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 22 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF 23 | THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /src/thyme/format.cr: -------------------------------------------------------------------------------- 1 | require "../thyme" 2 | 3 | # Handles returning formatted strings presented to the end user. 4 | module Thyme::Format 5 | extend self 6 | 7 | # Returns formatted timer string for Tmux's status. Note that suffix is a parameter here 8 | # so we can cache the repeat_suffix outside of the seconds loop, since it only gets re-calculated 9 | # after a pomodoro/break ends -- while the rest of the status is updated per second. 10 | def status(total_seconds : Int64, suffix : String, on_break : Bool, config : Config) 11 | seconds = total_seconds % 60 12 | with_color( 13 | "#{(total_seconds / 60).to_i}:#{seconds >= 10 ? seconds : "0#{seconds}"}#{suffix}", 14 | tmux_color(total_seconds, on_break, config) 15 | ) 16 | end 17 | 18 | # Returns repeat string to tell user which pomodoro they're currently on and how many 19 | # there are in total. If repeat is off, return a blank string. 20 | def repeat_suffix(repeat_index : UInt32, repeat_total : UInt32) 21 | if repeat_total == 1 22 | "" 23 | elsif repeat_total == 0 # unlimited 24 | " (#{repeat_index})" 25 | else 26 | " (#{repeat_index}/#{repeat_total})" 27 | end 28 | end 29 | 30 | # Wraps a string with a Tmux template for colors. 31 | def with_color(text : String, color : String) 32 | "#[fg=#{color}]#{text}#[default]" 33 | end 34 | 35 | # Determines which color to use for the current time: break, warning, or default. 36 | def tmux_color(total_seconds : Int64, on_break : Bool, config : Config) 37 | if on_break 38 | config.color_break 39 | elsif total_seconds <= config.timer_warning 40 | config.color_warning 41 | else 42 | config.color_default 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /src/thyme/hook_collection.cr: -------------------------------------------------------------------------------- 1 | require "../thyme" 2 | require "yaml" 3 | 4 | # Neatly holds multiple Thyme::Hook, categorizing them by their events. The same hook may belong 5 | # to multiple categories. 6 | class Thyme::HookCollection 7 | @hooks : Hash(HookEvent, Array(Hook)) = Hash.zip( 8 | HookEvent.values, 9 | HookEvent.values.map { Array(Thyme::Hook).new } 10 | ) 11 | 12 | def initialize(hooks = Array(Hook).new) 13 | hooks.each do |hook| 14 | hook.events.each do |event| 15 | @hooks[event] << hook 16 | end 17 | end 18 | end 19 | 20 | def size 21 | @hooks.values.sum { |arr| arr.size } 22 | end 23 | 24 | def before(hooks_args) 25 | call(HookEvent::Before, hooks_args) 26 | end 27 | 28 | def before_break(hooks_args) 29 | call(HookEvent::BeforeBreak, hooks_args) 30 | end 31 | 32 | def before_all(hooks_args) 33 | call(HookEvent::BeforeAll, hooks_args) 34 | end 35 | 36 | def after(hooks_args) 37 | call(HookEvent::After, hooks_args) 38 | end 39 | 40 | def after_break(hooks_args) 41 | call(HookEvent::AfterBreak, hooks_args) 42 | end 43 | 44 | def after_all(hooks_args) 45 | call(HookEvent::AfterAll, hooks_args) 46 | end 47 | 48 | private def call(event : HookEvent, hooks_args : NamedTuple) 49 | @hooks[event].each(&.call(hooks_args)) 50 | end 51 | 52 | # Given YAML from the THYMERC_FILE, returns a HookCollection with hooks parsed and neatly sorted 53 | def self.parse(hooks : YAML::Any) 54 | self.new( 55 | hooks.as_h.map do |key, value| 56 | Hook.parse(key.as_s, value.as(YAML::Any)) 57 | end 58 | ) 59 | rescue TypeCastError 60 | raise Error.new("Invalid value for `hooks` in `#{Config::THYMERC_FILE}`") 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /src/thyme/option.cr: -------------------------------------------------------------------------------- 1 | require "../thyme" 2 | require "yaml" 3 | 4 | # Wraps the option extension in THYMERC_FILE configuration. Used by end users to extend Thyme with 5 | # their own options and commands. 6 | class Thyme::Option 7 | getter name : String 8 | getter flag : String 9 | getter flag_long : String 10 | getter description : String 11 | 12 | private getter command : String 13 | 14 | def initialize( 15 | @name : String, 16 | @flag : String, 17 | @flag_long : String, 18 | @description : String, 19 | @command : String 20 | ) 21 | end 22 | 23 | # Calls `#command` as a system command. Replaces placeholders with actual values: 24 | # `#{flag}` - value given to a flag 25 | # `#{args}` - additional arguments given to the `thyme` command 26 | def call(option_args : NamedTuple) 27 | cmd = command 28 | option_args.each do |key, value| 29 | cmd = cmd.sub("\#{#{key}}", value) 30 | end 31 | output = `#{cmd}` 32 | print(output) unless output.empty? 33 | end 34 | 35 | # Parses YAML and returns a Thyme::Option. All fields are required. 36 | def self.parse(name, yaml) 37 | h = yaml.as_h? ? yaml.as_h : Hash(String, YAML::Any).new 38 | self.new( 39 | name, 40 | validate!(h, name, "flag"), 41 | validate!(h, name, "flag_long"), 42 | validate!(h, name, "description"), 43 | validate!(h, name, "command") 44 | ) 45 | end 46 | 47 | # Verifies key is available and is a String. 48 | def self.validate!(yaml, name, key) 49 | yaml[key].as_s 50 | rescue KeyError 51 | raise Error.new("Option `#{name}` is missing `#{key}` in `#{Config::THYMERC_FILE}`") 52 | rescue TypeCastError 53 | raise Error.new("Option `#{name}` has invalid `#{key}` in `#{Config::THYMERC_FILE}`") 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /src/thyme/hook.cr: -------------------------------------------------------------------------------- 1 | require "../thyme" 2 | require "yaml" 3 | 4 | enum Thyme::HookEvent 5 | Before 6 | After 7 | BeforeBreak 8 | AfterBreak 9 | BeforeAll 10 | AfterAll 11 | end 12 | 13 | class Thyme::Hook 14 | getter name : String 15 | getter events : Array(HookEvent) 16 | 17 | private getter command : String 18 | 19 | def initialize(@name : String, @events : Array(HookEvent), @command : String) 20 | end 21 | 22 | def call(hooks_args : NamedTuple) 23 | cmd = command 24 | hooks_args.each do |key, value| 25 | cmd = cmd.sub("\#{#{key}}", value) 26 | end 27 | output = `#{cmd}` 28 | print(output) unless output.empty? 29 | rescue error : Exception 30 | raise Error.new("Hook `#{name}` with command `#{cmd}` failed: #{error}") 31 | end 32 | 33 | def self.parse(name : String, hook : YAML::Any) 34 | self.new( 35 | name, 36 | parse_events(name, hook), 37 | parse_command(name, hook) 38 | ) 39 | end 40 | 41 | private def self.parse_events(name, hook : YAML::Any) 42 | if event = hook["events"].as_s? 43 | [HookEvent.parse(event)] 44 | else 45 | hook["events"].as_a.map { |e| HookEvent.parse(e.as_s) } 46 | end 47 | rescue KeyError 48 | raise Error.new("Hook `#{name}` is missing `events` in `#{Config::THYMERC_FILE}`") 49 | rescue ArgumentError | TypeCastError 50 | raise Error.new("Hook `#{name}` has invalid `events` in `#{Config::THYMERC_FILE}`: #{hook["events"]}") 51 | end 52 | 53 | private def self.parse_command(name, hook : YAML::Any) 54 | hook["command"].as_s 55 | rescue KeyError 56 | raise Error.new("Hook `#{name}` is missing `command` in `#{Config::THYMERC_FILE}`") 57 | rescue TypeCastError 58 | raise Error.new("Hook `#{name}` has invalid `command` in `#{Config::THYMERC_FILE}`: #{hook["command"]}") 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /src/thyme/tmux.cr: -------------------------------------------------------------------------------- 1 | require "../thyme" 2 | 3 | enum Thyme::StatusAlign 4 | Left 5 | Right 6 | 7 | def alignment 8 | to_s.downcase 9 | end 10 | end 11 | 12 | # Handles communication with Tmux. Before/after all pomodoros, the status will be init/reset. Every 13 | # second, the timer will call #set_status with the new label (eg "4:25 (3/4)"). 14 | class Thyme::Tmux 15 | STATUS_INTERVAL = "status-interval" 16 | TMUX_FILE = "#{ENV["HOME"]}/.thyme-tmux" 17 | TMUX_STATUS_VAL = "'#(cat #{TMUX_FILE})'" 18 | 19 | @config : Config 20 | @status_key : String 21 | @original_status_val : String 22 | @original_interval_val : String 23 | 24 | def initialize(@config) 25 | @file = File.open(TMUX_FILE, "w") 26 | @status_key = "status-#{@config.status_align.alignment}" 27 | @original_status_val = fetch_tmux_val(@status_key) 28 | @original_interval_val = fetch_tmux_val(STATUS_INTERVAL) 29 | end 30 | 31 | def init_status 32 | return unless @config.status_override 33 | `tmux set-option -g #{@status_key} #{TMUX_STATUS_VAL}` 34 | `tmux set-option -g #{STATUS_INTERVAL} 1` 35 | end 36 | 37 | # Originally tried to set the status directly via a system call to tmux set-option, 38 | # but ran into a consistent FileDescriptor and `END_OF_STACK` exceptions being raised. 39 | # See 40 | def set_status(status) 41 | @file.truncate 42 | @file.rewind 43 | @file.print(status) 44 | @file.flush 45 | end 46 | 47 | def reset_status 48 | if @config.status_override 49 | # Don't wrap value with quotes, Tmux does this automatically when fetching 50 | `tmux set-option -g #{@status_key} #{@original_status_val}` 51 | `tmux set-option -g #{STATUS_INTERVAL} #{@original_interval_val}` 52 | end 53 | ensure 54 | delete_tmux_file 55 | end 56 | 57 | private def fetch_tmux_val(key) : String 58 | result = `tmux show-options -g #{key}`.strip 59 | raise Error.new("Unable to fetch tmux option: #{key}") if result =~ /^invalid option/ 60 | result.split(2).fetch(1, "''") 61 | end 62 | 63 | private def delete_tmux_file 64 | @file.delete 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /src/thyme/timer.cr: -------------------------------------------------------------------------------- 1 | require "../thyme" 2 | 3 | class Thyme::Timer 4 | @config : Config 5 | @tmux : Tmux 6 | @stop : Bool = false 7 | @end_time : Int64 8 | @pause_time : Int64 | Nil 9 | 10 | def initialize(@config) 11 | @tmux = Tmux.new(@config) 12 | @end_time = now + @config.timer 13 | end 14 | 15 | def run 16 | @tmux.init_status 17 | repeat_index : UInt32 = 1 18 | 19 | while @config.repeat == 0 || repeat_index <= @config.repeat 20 | break if @stop 21 | 22 | run_single(repeat_index) 23 | run_single(repeat_index, true) unless repeat_index == @config.repeat 24 | repeat_index += 1 25 | end 26 | ensure 27 | @tmux.reset_status 28 | end 29 | 30 | def stop 31 | @stop = true 32 | end 33 | 34 | def toggle 35 | if @pause_time # unpausing, set new end_time 36 | delta = now - @pause_time.not_nil! 37 | @end_time = @end_time + delta 38 | @pause_time = nil 39 | else # pausing 40 | @pause_time = now 41 | end 42 | end 43 | 44 | private def run_single(repeat_index, on_break = false) 45 | return if @stop 46 | 47 | repeat_suffix = Format.repeat_suffix(repeat_index, @config.repeat) 48 | hooks_args = { 49 | repeat_index: repeat_index, 50 | repeat_total: @config.repeat, 51 | repeat_suffix: repeat_suffix.strip 52 | } 53 | @config.hooks.before_all(hooks_args) if repeat_index == 1 && !on_break 54 | on_break ? @config.hooks.before_break(hooks_args) : @config.hooks.before(hooks_args) 55 | 56 | @end_time = now + (on_break ? @config.timer_break : @config.timer) 57 | while now < @end_time || @pause_time 58 | return if @stop 59 | 60 | @tmux.set_status( 61 | Format.status(time_remaining, repeat_suffix, on_break, @config) 62 | ) unless @pause_time 63 | sleep(1) 64 | end 65 | 66 | on_break ? @config.hooks.after_break(hooks_args) : @config.hooks.after(hooks_args) 67 | @config.hooks.after_all(hooks_args) if repeat_index == @config.repeat && !on_break 68 | end 69 | 70 | private def now 71 | Crystal::System::Time.monotonic.first 72 | end 73 | 74 | private def time_remaining 75 | @end_time - now 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/thyme/format_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Thyme::Format do 4 | config = build_config({ 5 | "timer" => 100, 6 | "timer_warning" => 10, 7 | "color_default" => "default", 8 | "color_warning" => "red", 9 | "color_break" => "blue" 10 | }) 11 | 12 | describe "#status" do 13 | it "returns formatted minutes and seconds" do 14 | Thyme::Format.status(630, "", false, config).should contain("10:30") 15 | Thyme::Format.status(100, "", false, config).should contain("1:40") 16 | Thyme::Format.status(65, "", false, config).should contain("1:05") 17 | Thyme::Format.status(1, "", false, config).should contain("0:01") 18 | end 19 | 20 | it "returns repeat suffix" do 21 | Thyme::Format.status(65, " (1/4)", false, config).should contain("1:05 (1/4)") 22 | end 23 | 24 | it "returns wrapped in color" do 25 | Thyme::Format.status(65, "", true, config).should contain("fg=blue") 26 | end 27 | end 28 | 29 | describe "#repeat_suffix" do 30 | it "handles single loop case" do 31 | Thyme::Format.repeat_suffix(1, 1).should eq("") 32 | end 33 | 34 | it "handles unlimited loops case" do 35 | Thyme::Format.repeat_suffix(2, 0).should eq(" (2)") 36 | end 37 | 38 | it "handles specified loop case" do 39 | Thyme::Format.repeat_suffix(3, 4).should eq(" (3/4)") 40 | end 41 | end 42 | 43 | describe "#with_color" do 44 | it "wraps text with color" do 45 | Thyme::Format.with_color("Lorem Ipsum", "default").should eq( 46 | "#[fg=default]Lorem Ipsum#[default]" 47 | ) 48 | end 49 | end 50 | 51 | describe "#tmux_color" do 52 | it "returns break color when on_break" do 53 | Thyme::Format.tmux_color(100, true, config).should eq("blue") 54 | end 55 | 56 | it "returns warning color when below warning threshold" do 57 | Thyme::Format.tmux_color(10, false, config).should eq("red") 58 | Thyme::Format.tmux_color(9, false, config).should eq("red") 59 | end 60 | 61 | it "returns default color when not on break or below warning threshold" do 62 | Thyme::Format.tmux_color(11, false, config).should eq("default") 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /src/thyme/command.cr: -------------------------------------------------------------------------------- 1 | require "../thyme" 2 | require "option_parser" 3 | 4 | class Thyme::Command 5 | private getter args : Array(String) 6 | private getter io : IO 7 | private getter foreground : Bool = false 8 | 9 | def initialize(@args = ARGV, @io = STDOUT) 10 | end 11 | 12 | def run 13 | config = Config.parse 14 | # see https://github.com/crystal-lang/crystal/issues/5338 15 | # OptionParser can't handle optional flag argument for now 16 | config.set_repeat if args.any?(Set{"-r", "--repeat"}) 17 | 18 | parser = OptionParser.parse(args) do |parser| 19 | parser.banner = "Usage: thyme [options]" 20 | 21 | parser.on("-h", "--help", "print help message") { print_help(parser); exit } 22 | parser.on("-v", "--version", "print version") { print_version; exit } 23 | parser.on("-f", "--foreground", "run in foreground") { @foreground = true } 24 | parser.on("-r", "--repeat [count]", "repeat timer") { |r| config.set_repeat(r) } 25 | parser.on("-s", "--stop", "stop timer") { stop; exit } 26 | config.options.each do |option| 27 | parser.on( 28 | option.flag, 29 | option.flag_long, 30 | option.description 31 | ) { |flag| option.call({ flag: flag, args: args.join(" ") }); exit } 32 | end 33 | end 34 | 35 | if args.size > 0 36 | print_help(parser) 37 | elsif ProcessHandler.running? 38 | SignalHandler.send_toggle 39 | else 40 | start(config) 41 | end 42 | rescue error : OptionParser::InvalidOption | OptionParser::MissingOption | Error 43 | io.puts(error) 44 | end 45 | 46 | private def start(config : Config) 47 | Daemon.start! unless foreground 48 | ProcessHandler.write_pid 49 | 50 | timer = Timer.new(config) 51 | SignalHandler.on_stop { timer.stop } 52 | SignalHandler.on_toggle { timer.toggle } 53 | timer.run 54 | rescue error : Error 55 | io.puts(error) 56 | ProcessHandler.delete_pid 57 | end 58 | 59 | private def stop 60 | SignalHandler.send_stop if ProcessHandler.running? 61 | ensure 62 | ProcessHandler.delete_pid 63 | end 64 | 65 | private def print_help(parser) 66 | io.puts(parser) 67 | end 68 | 69 | private def print_version 70 | io.puts(VERSION) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/thyme/hook_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "yaml" 3 | require "uuid" 4 | 5 | describe Thyme::Hook do 6 | describe "#initialize" do 7 | it "sets fields" do 8 | events = [Thyme::HookEvent::Before, Thyme::HookEvent::AfterAll] 9 | hook = Thyme::Hook.new("name", events, "echo") 10 | hook.name.should eq("name") 11 | hook.events.should eq(events) 12 | end 13 | end 14 | 15 | describe "#call" do 16 | it "runs command with placeholders filled" do 17 | uuid = UUID.random 18 | file = File.tempfile 19 | hook = build_hook( 20 | "before", 21 | "echo '#{uuid} \#{repeat_index} \#{repeat_total} \#{repeat_suffix}' > #{file.path}" 22 | ) 23 | hook.call(hooks_args) 24 | File.read(file.path).should eq("#{uuid} 1 2 (1/2)\n") 25 | file.delete 26 | end 27 | end 28 | 29 | describe ".parse" do 30 | it "raises error on missing events" do 31 | yaml = YAML.parse("command: \"\"") 32 | expect_raises(Thyme::Error, /missing `events`/) do 33 | Thyme::Hook.parse("name", yaml) 34 | end 35 | end 36 | 37 | it "raises error on invalid event" do 38 | yaml = YAML.parse("events: 1\ncommand: \"\"") 39 | expect_raises(Thyme::Error, /invalid `events`/) do 40 | Thyme::Hook.parse("name", yaml) 41 | end 42 | end 43 | 44 | it "raises error on unknown event" do 45 | yaml = YAML.parse("events: \"not-an-event\"\ncommand: \"\"") 46 | expect_raises(Thyme::Error, /invalid `events`/) do 47 | Thyme::Hook.parse("name", yaml) 48 | end 49 | end 50 | 51 | it "raises error on missing command" do 52 | yaml = YAML.parse("events: \"before\"") 53 | expect_raises(Thyme::Error, /missing `command`/) do 54 | Thyme::Hook.parse("name", yaml) 55 | end 56 | end 57 | 58 | it "raises error on invalid command" do 59 | yaml = YAML.parse("events: \"before\"\ncommand: 1") 60 | expect_raises(Thyme::Error, /invalid `command`/) do 61 | Thyme::Hook.parse("name", yaml) 62 | end 63 | end 64 | 65 | it "returns a Hook" do 66 | yaml = YAML.parse("events: \"before\"\ncommand: \"\"") 67 | hook = Thyme::Hook.parse("name", yaml) 68 | hook.name.should eq("name") 69 | hook.events.should eq([Thyme::HookEvent::Before]) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/thyme/hook_collection_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "yaml" 3 | 4 | describe Thyme::HookCollection do 5 | file = File.tempfile 6 | hooks = Thyme::HookCollection.new([ 7 | "before", "before_break", "before_all", 8 | "after", "after_break", "after_all" 9 | ].map { |event| build_hook( 10 | event, "echo '#{event} \#{repeat_index} \#{repeat_total} \#{repeat_suffix}' >> #{file.path}" 11 | ) 12 | }) 13 | 14 | before_each do 15 | File.write(file.path, "") 16 | end 17 | 18 | after_all do 19 | file.delete 20 | end 21 | 22 | describe "#size" do 23 | it "delegates to hooks" do 24 | hooks.size.should eq(6) 25 | end 26 | end 27 | 28 | describe "#before" do 29 | it "runs before hooks" do 30 | hooks.before(hooks_args) 31 | File.read(file.path).should eq("before 1 2 (1/2)\n") 32 | end 33 | end 34 | 35 | describe "#before_break" do 36 | it "runs before_break hooks" do 37 | hooks.before_break(hooks_args) 38 | File.read(file.path).should eq("before_break 1 2 (1/2)\n") 39 | end 40 | end 41 | 42 | describe "#before_all" do 43 | it "runs before_all hooks" do 44 | hooks.before_all(hooks_args) 45 | File.read(file.path).should eq("before_all 1 2 (1/2)\n") 46 | end 47 | end 48 | 49 | describe "#after" do 50 | it "runs after hooks" do 51 | hooks.after(hooks_args) 52 | File.read(file.path).should eq("after 1 2 (1/2)\n") 53 | end 54 | end 55 | 56 | describe "#after_break" do 57 | it "runs after_break hooks" do 58 | hooks.after_break(hooks_args) 59 | File.read(file.path).should eq("after_break 1 2 (1/2)\n") 60 | end 61 | end 62 | 63 | describe "#after_all" do 64 | it "runs after_all hooks" do 65 | hooks.after_all(hooks_args) 66 | File.read(file.path).should eq("after_all 1 2 (1/2)\n") 67 | end 68 | end 69 | 70 | describe ".parse" do 71 | it "raises error when hooks is invalid" do 72 | yaml = YAML.parse("hooks: 1\n") 73 | expect_raises(Thyme::Error, /Invalid value for `hooks`/) do 74 | Thyme::HookCollection.parse(yaml["hooks"]) 75 | end 76 | end 77 | 78 | it "returns collection on success" do 79 | yaml = YAML.parse("hooks:\n notify:\n events: \"before\"\n command: \"echo\"") 80 | collection = Thyme::HookCollection.parse(yaml["hooks"]) 81 | collection.is_a?(Thyme::HookCollection).should be_true 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/thyme/option_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "yaml" 3 | 4 | describe Thyme::Option do 5 | describe "#initialize" do 6 | it "sets fields" do 7 | option = Thyme::Option.new("example", "-f", "--flag", "test", "echo") 8 | option.name.should eq("example") 9 | option.flag.should eq("-f") 10 | option.flag_long.should eq("--flag") 11 | option.description.should eq("test") 12 | end 13 | end 14 | 15 | describe "#call" do 16 | it "executes command with placeholders filled" do 17 | file = File.tempfile 18 | option = Thyme::Option.new( 19 | "example", 20 | "-f", 21 | "--flag", 22 | "test", 23 | "echo \"flag=\#{flag} args=\#{args}\" > #{file.path}" 24 | ) 25 | option.call({ flag: "flag1", args: "arg1 arg2" }) 26 | 27 | contents = File.read(file.path) 28 | contents.should contain("flag=flag1") 29 | contents.should contain("args=arg1 arg2") 30 | file.delete 31 | end 32 | end 33 | 34 | describe ".parse" do 35 | it "raises error when field is not a string" do 36 | expect_raises(Thyme::Error, /invalid/) do 37 | Thyme::Option.parse("example", YAML.parse("flag: 1")) 38 | end 39 | end 40 | 41 | it "raises error when field is missing" do 42 | expect_raises(Thyme::Error, /missing/) do 43 | Thyme::Option.parse("example", YAML.parse("")) 44 | end 45 | end 46 | 47 | it "returns Thyme::Option from YAML" do 48 | yaml = YAML.parse( 49 | <<-CONFIG 50 | flag: "-f" 51 | flag_long: "--flag" 52 | description: "Lorem Ipsum" 53 | command: "echo hello" 54 | CONFIG 55 | ) 56 | option = Thyme::Option.parse("example", yaml) 57 | option.name.should eq("example") 58 | option.flag.should eq("-f") 59 | option.flag_long.should eq("--flag") 60 | option.description.should eq("Lorem Ipsum") 61 | end 62 | end 63 | 64 | describe ".validate!" do 65 | yaml = YAML.parse("int_value: 1\nstr_value: \"test\"") 66 | 67 | it "raises error when field is not a string" do 68 | expect_raises(Thyme::Error, /invalid/) do 69 | Thyme::Option.validate!(yaml, "", "int_value") 70 | end 71 | end 72 | 73 | it "raises error when field is missing" do 74 | expect_raises(Thyme::Error, /missing/) do 75 | Thyme::Option.validate!(yaml, "", "missing_value") 76 | end 77 | end 78 | 79 | it "returns value for key" do 80 | Thyme::Option.validate!(yaml, "", "str_value").should eq("test") 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /src/thyme/config.cr: -------------------------------------------------------------------------------- 1 | require "../thyme" 2 | require "yaml" 3 | 4 | # Reads THYMERC_FILE, parses it, and stores its configuration. There should be one instance of 5 | # Config which gets passed to all other classes. All values are optional. 6 | class Thyme::Config 7 | THYMERC_FILE = "#{ENV["HOME"]}/.thymerc" 8 | 9 | private getter yaml : YAML::Any 10 | 11 | getter timer : UInt32 = (25 * 60).to_u32 12 | getter timer_break : UInt32 = (5 * 60).to_u32 13 | getter timer_warning : UInt32 = (5 * 60).to_u32 14 | getter repeat : UInt32 = 1 15 | 16 | getter color_default : String = "default" 17 | getter color_warning : String = "red" 18 | getter color_break : String = "default" 19 | 20 | getter status_align : StatusAlign = StatusAlign::Right 21 | getter status_override : Bool = true 22 | 23 | getter hooks : HookCollection = HookCollection.new 24 | getter options : Array(Option) = Array(Option).new 25 | 26 | # THYMERC_FILE is validated on initialization 27 | def initialize(input : YAML::Any) 28 | @yaml = as_nil?(input) ? YAML::Any.new(Hash(YAML::Any, YAML::Any).new) : input 29 | 30 | as_u32 = ->(v : YAML::Any) { v.as_i64.to_u32 } 31 | as_str = ->(v : YAML::Any) { v.as_s } 32 | as_bool = ->(v : YAML::Any) { v.as_bool } 33 | as_align = ->(v : YAML::Any) { StatusAlign.parse(v.as_s) } 34 | 35 | @timer = validate!("timer", as_u32) if has?("timer") 36 | @timer_break = validate!("timer_break", as_u32) if has?("timer_break") 37 | @timer_warning = validate!("timer_warning", as_u32) if has?("timer_warning") 38 | validate!("repeat", as_u32) if has?("repeat") # only sets if `-r` flag is given 39 | 40 | @color_default = validate!("color_default", as_str) if has?("color_default") 41 | @color_warning = validate!("color_warning", as_str) if has?("color_warning") 42 | @color_break = validate!("color_break", as_str) if has?("color_break") 43 | 44 | @status_align = validate!("status_align", as_align) if has?("status_align") 45 | @status_override = validate!("status_override", as_bool) if has?("status_override") 46 | 47 | @hooks = HookCollection.parse(yaml["hooks"]) if has?("hooks") 48 | parse_and_add_options if has?("options") 49 | end 50 | 51 | # Called when the --repeat flag is used. If no argument is given, falls back to 52 | # default used in THYMERC_FILE. If no default is there, set to 0 for unlimited repeats. 53 | def set_repeat(count : String | Nil = nil) 54 | if count && !count.to_s.strip.empty? 55 | @repeat = count.to_u32 56 | elsif has?("repeat") 57 | @repeat = yaml["repeat"].as_i64.to_u32 58 | else 59 | @repeat = 0 60 | end 61 | rescue error : ArgumentError 62 | raise Error.new("Invalid value for `repeat`: #{count}") 63 | end 64 | 65 | # Returns a Config from a YAML file 66 | def self.parse(file = THYMERC_FILE) 67 | contents = File.exists?(file) ? File.read(file) : "" 68 | yaml = if contents.strip == "" # empty configs will have empty key/values 69 | YAML::Any.new(Hash(YAML::Any, YAML::Any).new) 70 | else 71 | YAML.parse(contents) 72 | end 73 | raise ArgumentError.new("Config must be a key value map") unless yaml.as_h? 74 | Thyme::Config.new(yaml) 75 | rescue error : YAML::ParseException | ArgumentError 76 | raise Error.new("Unable to parse `#{THYMERC_FILE}` -- #{error.to_s}") 77 | end 78 | 79 | private def has?(key) 80 | !yaml[key]?.nil? 81 | end 82 | 83 | private def validate!(key, convert) 84 | convert.call(yaml[key]) 85 | rescue error : TypeCastError | ArgumentError | OverflowError 86 | raise Error.new("Invalid value for `#{key}` in `#{THYMERC_FILE}`: #{yaml[key]}") 87 | end 88 | 89 | private def parse_and_add_options 90 | yaml["options"].as_h.each do |name, option| 91 | @options << Option.parse(name.as_s, option) 92 | end 93 | rescue TypeCastError 94 | raise Error.new("Invalid value for `options` in #{Config::THYMERC_FILE}") 95 | end 96 | 97 | private def as_nil?(any : YAML::Any) : Bool 98 | any.as_nil 99 | true 100 | rescue TypeCastError 101 | false 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/thyme/config_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "yaml" 3 | 4 | describe Thyme::Config do 5 | describe "#initialize" do 6 | it "raises error on invalid integer" do 7 | expect_raises(Thyme::Error, /Invalid value for `timer`/) do 8 | Thyme::Config.new(YAML.parse("timer: \"1\"")) 9 | end 10 | end 11 | 12 | it "raises error on invalid unsigned integer" do 13 | expect_raises(Thyme::Error, /Invalid value for `timer`/) do 14 | Thyme::Config.new(YAML.parse("timer: -1")) 15 | end 16 | end 17 | 18 | it "raises error on invalid string" do 19 | expect_raises(Thyme::Error, /Invalid value for `color_default`/) do 20 | Thyme::Config.new(YAML.parse("color_default: 1")) 21 | end 22 | end 23 | 24 | it "raises error on invalid boolean" do 25 | expect_raises(Thyme::Error, /Invalid value for `status_override`/) do 26 | Thyme::Config.new(YAML.parse("status_override: 1")) 27 | end 28 | end 29 | 30 | it "raises error on invalid alignment" do 31 | expect_raises(Thyme::Error, /Invalid value for `status_align`/) do 32 | Thyme::Config.new(YAML.parse("status_align: \"invalid-align\"")) 33 | end 34 | end 35 | 36 | it "sets configuration defaults" do 37 | config = Thyme::Config.new(YAML.parse("")) 38 | config.timer.should eq(1500) 39 | config.timer_break.should eq(300) 40 | config.timer_warning.should eq(300) 41 | config.repeat.should eq(1) 42 | 43 | config.color_default.should eq("default") 44 | config.color_warning.should eq("red") 45 | config.color_break.should eq("default") 46 | 47 | config.status_align.should eq(Thyme::StatusAlign::Right) 48 | config.status_override.should be_true 49 | 50 | config.hooks.size.should eq(0) 51 | config.options.size.should eq(0) 52 | end 53 | 54 | it "sets configuration values" do 55 | yaml = <<-CONFIG 56 | timer: 3 57 | timer_break: 2 58 | timer_warning: 1 59 | repeat: 4 60 | 61 | color_default: "red" 62 | color_warning: "green" 63 | color_break: "blue" 64 | 65 | status_align: "left" 66 | status_override: false 67 | 68 | hooks: 69 | notify: 70 | events: "after" 71 | command: "echo" 72 | 73 | options: 74 | hello: 75 | flag: "-h" 76 | flag_long: "--hello" 77 | description: "say hello" 78 | command: "echo" 79 | CONFIG 80 | 81 | config = Thyme::Config.new(YAML.parse(yaml)) 82 | config.timer.should eq(3) 83 | config.timer_break.should eq(2) 84 | config.timer_warning.should eq(1) 85 | config.repeat.should eq(1) # thymerc value is only used with `-r` flag 86 | 87 | config.color_default.should eq("red") 88 | config.color_warning.should eq("green") 89 | config.color_break.should eq("blue") 90 | 91 | config.status_align.should eq(Thyme::StatusAlign::Left) 92 | config.status_override.should be_false 93 | 94 | config.hooks.size.should eq(1) 95 | config.options.size.should eq(1) 96 | 97 | option = config.options[0] 98 | option.flag.should eq("-h") 99 | option.flag_long.should eq("--hello") 100 | option.description.should eq("say hello") 101 | end 102 | end 103 | 104 | describe "#set_repeat" do 105 | it "sets to repeat count from flag" do 106 | config = Thyme::Config.new(YAML.parse("")) 107 | config.set_repeat("33") 108 | config.repeat.should eq(33) 109 | end 110 | 111 | it "sets to repeat count from config" do 112 | config = Thyme::Config.new(YAML.parse("repeat: 32")) 113 | config.set_repeat 114 | config.repeat.should eq(32) 115 | end 116 | 117 | it "sets repeat count to zero as last resort" do 118 | config = Thyme::Config.new(YAML.parse("")) 119 | config.set_repeat 120 | config.repeat.should eq(0) 121 | end 122 | 123 | it "raises error on invalid repeat count" do 124 | config = Thyme::Config.new(YAML.parse("")) 125 | expect_raises(Thyme::Error, /Invalid value for `repeat`/) do 126 | config.set_repeat("invalid-repeat") 127 | end 128 | end 129 | end 130 | 131 | describe ".parse" do 132 | file = File.tempfile 133 | 134 | before_each do 135 | File.write(file.path, "") 136 | end 137 | 138 | after_all do 139 | file.delete 140 | end 141 | 142 | it "raises error on invalid YAML" do 143 | File.write(file.path, "=\"\n") 144 | expect_raises(Thyme::Error, /Unable to parse/) do 145 | Thyme::Config.parse(file.path) 146 | end 147 | end 148 | 149 | it "handles non existing file" do 150 | config = Thyme::Config.parse(file.path) 151 | config.should be_a(Thyme::Config) 152 | end 153 | 154 | it "returns Config on valid YAML" do 155 | File.write(file.path, "one: 2") 156 | config = Thyme::Config.parse(file.path) 157 | config.should be_a(Thyme::Config) 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Thyme 2 | 3 | Thyme is a pomodoro timer for tmux. 4 | 5 | ![Thyme Example](asset/thyme.gif) 6 | 7 | ## Installation 8 | 9 | **Mac** 10 | 11 | ``` 12 | brew install hughbien/tap/thyme 13 | ``` 14 | 15 | This will install Crystal 1.0.0 as a dependency. If you already have this version of Crystal 16 | installed (not via Homebrew), you can run: 17 | 18 | ``` 19 | brew install hughbien/tap/thyme --without-crystal 20 | ``` 21 | 22 | If Crystal/Shard cannot be found on your system, it may be because it's shimmed or cannot be found 23 | by Homebrew. You'll need to pass in additional options: 24 | 25 | ``` 26 | # if you installed Crystal via Asdf 27 | HOMEBREW_BIN=$(asdf which crystal)/../../embedded/bin HOMEBREW_CRYSTAL_PATH=`crystal env CRYSTAL_PATH` brew install hughbien/tap/thyme --without-crystal 28 | 29 | # if you have a custom install 30 | HOMEBREW_BIN=$(which crystal)/.. brew install hughbien/tap/thyme --without-crystal 31 | ``` 32 | 33 | **Linux** 34 | 35 | Download the latest binary and place it in your `$PATH`: 36 | 37 | ``` 38 | wget -O thyme https://github.com/hughbien/thyme/releases/download/v0.1.4/thyme-linux-amd64 39 | ``` 40 | 41 | MD5 checksum is: `221d80b1fb7ec32ac58ca193852d9afb` 42 | 43 | **From Source** 44 | 45 | Checkout this repo, run `make` and `make install`: 46 | 47 | ``` 48 | git clone https://github.com/hughbien/thyme.git 49 | cd thyme 50 | make 51 | make install 52 | ``` 53 | 54 | ## Usage 55 | 56 | Start thyme with: 57 | 58 | ``` 59 | thyme 60 | ``` 61 | 62 | You'll have 25 minutes by default. Other useful commands: 63 | 64 | ``` 65 | thyme # run again to pause/unpause 66 | thyme -s # to stop 67 | thyme -r # repeats timer until manually stopped; default break of 5 minutes 68 | thyme -r10 # repeat timer 10 times 69 | thyme -f # run in foreground, useful for debugging hooks 70 | ``` 71 | 72 | ## Configuration 73 | 74 | Configure via the `~/.thymerc` file: 75 | 76 | ```yaml 77 | timer: 1500 # 25 minutes per pomodoro (in seconds) 78 | timer_break: 300 # 5 minutes per break (in seconds) 79 | timer_warning: 300 # show warning color at 5 minutes left (in seconds) 80 | repeat: 4 # set default for -r flag, otherwise repeat indefinitely 81 | color_default: "default" # set default timer color for tmux 82 | color_warning: "red" # set warning color for tmux, set to "default" to disable 83 | color_break: "default" # set break color for tmux 84 | status_align: "left" # use tmux's left status line instead, defaults to "right" 85 | ``` 86 | 87 | Thyme sets tmux's status-right/left and interval for you. If you'd prefer to do this yourself (or 88 | need to combine it with other statuses), set `status_override`: 89 | 90 | ```yaml 91 | status_override: false # don't let thyme set tmux's status-right/left/interval 92 | ``` 93 | 94 | Then in your `~/.tmux.conf` file, set the status command and interval: 95 | 96 | ``` 97 | set -g status-right '#(cat /path/to/thyme-status)' 98 | set -g status-interval 1 99 | ``` 100 | 101 | Custom options can be added via the `options` group. The today example below adds a `-t` option 102 | for opening a todo today file. The hello example echos to STDOUT. 103 | 104 | ```yaml 105 | options: 106 | today: 107 | flag: "-t" 108 | flag_long: "--today" 109 | description: "Open TODO today file" 110 | command: "vim ~/path/to/todo.md" 111 | 112 | hello: 113 | flag: "-H" 114 | flag_long: "--hello name" 115 | description: "Say hello!" 116 | command: "echo \"Hello #{flag}! #{args}.\"" # eg `thyme -H John "How are you?"` 117 | ``` 118 | 119 | The following placeholders are available for options: 120 | 121 | * `#{flag}` - the argument passed to your flag 122 | * `#{args}` - any additional arguments passed to the thyme binary 123 | 124 | Custom hooks can be added via the `hooks` group. Valid events are: `before`/`after` a pomodoro, 125 | `before_break`/`after_break` for breaks, and `before_all`/`after_all` for the entire session. 126 | 127 | ```yaml 128 | hooks: 129 | notify: 130 | events: ["after"] 131 | command: "terminal-notifier -message \"Pomodoro finished #{repeat_suffix}\" -title \"thyme\"" 132 | 133 | notify_break: 134 | events: ["after_break"] 135 | command: "terminal-notifier -message \"Break finished #{repeat_suffix}\" -title \"thyme\"" 136 | ``` 137 | 138 | The following placeholders are available for hooks: 139 | 140 | * `#{repeat_index}` - current repeat index 141 | * `#{repeat_total}` - total repeat count for this session 142 | * `#{repeat_suffix}` - if repeating is on, will return `(index/total)` eg `(3/5)`. Otherwise empty string. 143 | 144 | ## Development 145 | 146 | Use `make` for common tasks: 147 | 148 | ``` 149 | make build # to create a release binary in the bin directory 150 | make build-static # to create a static release binary for Linux 151 | make install # to copy release binary into system bin (uses $INSTALL_BIN) 152 | make spec # to run all tests 153 | make spec ARGS=path/to/spec # to run a single test 154 | make clean # to remove build artifacts and bin directory 155 | make reset # to reload ~/.tmux.conf file (useful while debugging) 156 | make run # to run locally 157 | make run ARGS=-h # to run with local arguments 158 | ``` 159 | 160 | ## TODO 161 | 162 | * optimize timer IO: only write start time (+ pauses), client side calculation, socket/fs/signal 163 | * at exit clean up thyme PID/TMUX file 164 | 165 | ## License 166 | 167 | Copyright 2021 Hugh Bien. 168 | 169 | Released under BSD License, see LICENSE for details. 170 | --------------------------------------------------------------------------------