Created
January 25, 2026 21:07
-
-
Save danpariente/b7367ad2b37ccea250200829f39bc0ad to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Pinwidoro Installer - One-command install for macOS | |
| # Run with: curl -fsSL <url> | bash | |
| # Or: bash install_pinwidoro.sh | |
| set -e | |
| echo "π§ Installing Pinwidoro - Pomodoro Timer with Penguins!" | |
| echo "========================================================" | |
| echo "" | |
| # Colors | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| NC='\033[0m' # No Color | |
| # Check macOS | |
| if [[ "$OSTYPE" != "darwin"* ]]; then | |
| echo -e "${RED}Error: This script is for macOS only${NC}" | |
| exit 1 | |
| fi | |
| # Install Homebrew if not present | |
| if ! command -v brew &> /dev/null; then | |
| echo -e "${YELLOW}Installing Homebrew...${NC}" | |
| /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" | |
| # Add brew to path for this session | |
| if [[ -f "/opt/homebrew/bin/brew" ]]; then | |
| eval "$(/opt/homebrew/bin/brew shellenv)" | |
| elif [[ -f "/usr/local/bin/brew" ]]; then | |
| eval "$(/usr/local/bin/brew shellenv)" | |
| fi | |
| else | |
| echo -e "${GREEN}β Homebrew already installed${NC}" | |
| fi | |
| # Install Ruby if not present or using system ruby | |
| RUBY_VERSION=$(ruby -v 2>/dev/null || echo "none") | |
| if [[ "$RUBY_VERSION" == *"system"* ]] || ! command -v ruby &> /dev/null; then | |
| echo -e "${YELLOW}Installing Ruby via Homebrew...${NC}" | |
| brew install ruby | |
| else | |
| echo -e "${GREEN}β Ruby already installed: $RUBY_VERSION${NC}" | |
| fi | |
| # Ensure brew ruby is in path | |
| if [[ -d "/opt/homebrew/opt/ruby/bin" ]]; then | |
| export PATH="/opt/homebrew/opt/ruby/bin:$PATH" | |
| export PATH="$(gem environment gemdir)/bin:$PATH" | |
| elif [[ -d "/usr/local/opt/ruby/bin" ]]; then | |
| export PATH="/usr/local/opt/ruby/bin:$PATH" | |
| export PATH="$(gem environment gemdir)/bin:$PATH" | |
| fi | |
| # Install required gems | |
| echo -e "${YELLOW}Installing required gems...${NC}" | |
| gem install gosu sqlite3 --no-document 2>/dev/null || sudo gem install gosu sqlite3 --no-document | |
| echo -e "${GREEN}β Gems installed${NC}" | |
| # Create app directory | |
| APP_DIR="$HOME/Pinwidoro" | |
| mkdir -p "$APP_DIR" | |
| cd "$APP_DIR" | |
| echo -e "${YELLOW}Creating Pinwidoro app...${NC}" | |
| # Create pomodoro_db.rb | |
| cat > "$APP_DIR/pomodoro_db.rb" << 'DBEOF' | |
| #!/usr/bin/env ruby | |
| # Pomodoro Database Module - SQLite persistence layer | |
| require 'sqlite3' | |
| require 'fileutils' | |
| require 'date' | |
| class PomodoroDatabase | |
| DB_PATH = File.expand_path('~/.pinwidoro.db') | |
| def initialize | |
| @db = SQLite3::Database.new(DB_PATH) | |
| @db.results_as_hash = true | |
| setup_tables | |
| end | |
| def setup_tables | |
| @db.execute <<-SQL | |
| CREATE TABLE IF NOT EXISTS sessions ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| completed_at DATETIME DEFAULT CURRENT_TIMESTAMP, | |
| duration_seconds INTEGER, | |
| mode TEXT DEFAULT 'work' | |
| ); | |
| SQL | |
| @db.execute <<-SQL | |
| CREATE TABLE IF NOT EXISTS settings ( | |
| key TEXT PRIMARY KEY, | |
| value TEXT | |
| ); | |
| SQL | |
| @db.execute <<-SQL | |
| CREATE TABLE IF NOT EXISTS daily_stats ( | |
| date TEXT PRIMARY KEY, | |
| pomodoros_completed INTEGER DEFAULT 0, | |
| total_focus_seconds INTEGER DEFAULT 0 | |
| ); | |
| SQL | |
| set_default_settings | |
| end | |
| def set_default_settings | |
| defaults = { | |
| 'background_color' => '#FFFFFF', | |
| 'button_color' => '#373C46', | |
| 'button_hover_color' => '#464B55', | |
| 'work_ring_color' => '#E57373', | |
| 'break_ring_color' => '#81C784', | |
| 'penguin_body_color' => '#1E1E23', | |
| 'penguin_belly_color' => '#FFFFFF', | |
| 'work_duration' => '1500', | |
| 'short_break_duration' => '300', | |
| 'long_break_duration' => '900' | |
| } | |
| defaults.each do |key, value| | |
| existing = @db.get_first_value("SELECT value FROM settings WHERE key = ?", [key]) | |
| unless existing | |
| @db.execute("INSERT INTO settings (key, value) VALUES (?, ?)", [key, value]) | |
| end | |
| end | |
| end | |
| def record_session(duration_seconds, mode = 'work') | |
| @db.execute( | |
| "INSERT INTO sessions (duration_seconds, mode) VALUES (?, ?)", | |
| [duration_seconds, mode] | |
| ) | |
| today = Date.today.to_s | |
| existing = @db.get_first_row("SELECT * FROM daily_stats WHERE date = ?", [today]) | |
| if existing | |
| @db.execute( | |
| "UPDATE daily_stats SET pomodoros_completed = pomodoros_completed + 1, total_focus_seconds = total_focus_seconds + ? WHERE date = ?", | |
| [duration_seconds, today] | |
| ) | |
| else | |
| @db.execute( | |
| "INSERT INTO daily_stats (date, pomodoros_completed, total_focus_seconds) VALUES (?, 1, ?)", | |
| [today, duration_seconds] | |
| ) | |
| end | |
| end | |
| def today_count | |
| today = Date.today.to_s | |
| result = @db.get_first_value("SELECT pomodoros_completed FROM daily_stats WHERE date = ?", [today]) | |
| result || 0 | |
| end | |
| def week_count | |
| week_start = (Date.today - Date.today.wday).to_s | |
| result = @db.get_first_value( | |
| "SELECT SUM(pomodoros_completed) FROM daily_stats WHERE date >= ?", | |
| [week_start] | |
| ) | |
| result || 0 | |
| end | |
| def total_count | |
| result = @db.get_first_value("SELECT COUNT(*) FROM sessions WHERE mode = 'work'") | |
| result || 0 | |
| end | |
| def current_streak | |
| streak = 0 | |
| date = Date.today | |
| loop do | |
| count = @db.get_first_value( | |
| "SELECT pomodoros_completed FROM daily_stats WHERE date = ?", | |
| [date.to_s] | |
| ) | |
| if count && count > 0 | |
| streak += 1 | |
| date -= 1 | |
| else | |
| if date == Date.today | |
| date -= 1 | |
| next | |
| end | |
| break | |
| end | |
| end | |
| streak | |
| end | |
| def longest_streak | |
| dates = @db.execute( | |
| "SELECT date FROM daily_stats WHERE pomodoros_completed > 0 ORDER BY date" | |
| ).map { |r| Date.parse(r['date']) } | |
| return 0 if dates.empty? | |
| max_streak = 1 | |
| current = 1 | |
| (1...dates.length).each do |i| | |
| if dates[i] - dates[i - 1] == 1 | |
| current += 1 | |
| max_streak = [max_streak, current].max | |
| else | |
| current = 1 | |
| end | |
| end | |
| max_streak | |
| end | |
| def today_hourly_stats | |
| today = Date.today.to_s | |
| result = @db.execute( | |
| "SELECT strftime('%H', completed_at) as hour, COUNT(*) as count FROM sessions WHERE date(completed_at) = ? AND mode = 'work' GROUP BY hour ORDER BY hour", | |
| [today] | |
| ) | |
| hourly = Array.new(24, 0) | |
| result.each do |row| | |
| hour = row['hour'].to_i | |
| hourly[hour] = row['count'] | |
| end | |
| hourly | |
| end | |
| def week_daily_stats | |
| stats = [] | |
| 7.times do |i| | |
| date = (Date.today - (6 - i)).to_s | |
| count = @db.get_first_value( | |
| "SELECT pomodoros_completed FROM daily_stats WHERE date = ?", | |
| [date] | |
| ) || 0 | |
| stats << { date: date, count: count } | |
| end | |
| stats | |
| end | |
| def get_setting(key) | |
| @db.get_first_value("SELECT value FROM settings WHERE key = ?", [key]) | |
| end | |
| def set_setting(key, value) | |
| @db.execute( | |
| "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", | |
| [key, value.to_s] | |
| ) | |
| end | |
| def get_all_settings | |
| result = {} | |
| @db.execute("SELECT key, value FROM settings").each do |row| | |
| result[row['key']] = row['value'] | |
| end | |
| result | |
| end | |
| def self.hex_to_gosu(hex) | |
| hex = hex.gsub('#', '') | |
| r = hex[0..1].to_i(16) | |
| g = hex[2..3].to_i(16) | |
| b = hex[4..5].to_i(16) | |
| [r, g, b] | |
| end | |
| def self.gosu_to_hex(r, g, b) | |
| "#%02X%02X%02X" % [r, g, b] | |
| end | |
| def close | |
| @db.close | |
| end | |
| end | |
| DBEOF | |
| # Create main app file | |
| cat > "$APP_DIR/pinwidoro.rb" << 'APPEOF' | |
| #!/usr/bin/env ruby | |
| # Pinwidoro - Pomodoro Timer with Penguins! | |
| # Built with Gosu (Ruby 2D game library) | |
| require 'gosu' | |
| require_relative 'pomodoro_db' | |
| module Seasons | |
| WINTER = { | |
| name: 'Winter', | |
| bg_elements: :snowflakes, | |
| ground_color: [240, 248, 255], | |
| accent_color: [135, 206, 235], | |
| particle_color: [255, 255, 255] | |
| } | |
| SPRING = { | |
| name: 'Spring', | |
| bg_elements: :flowers, | |
| ground_color: [144, 238, 144], | |
| accent_color: [255, 182, 193], | |
| particle_color: [255, 192, 203] | |
| } | |
| SUMMER = { | |
| name: 'Summer', | |
| bg_elements: :sun_rays, | |
| ground_color: [250, 218, 94], | |
| accent_color: [255, 165, 0], | |
| particle_color: [255, 223, 0] | |
| } | |
| FALL = { | |
| name: 'Fall', | |
| bg_elements: :leaves, | |
| ground_color: [210, 105, 30], | |
| accent_color: [255, 140, 0], | |
| particle_color: [205, 92, 0] | |
| } | |
| def self.for_pomodoro(index) | |
| case index % 4 | |
| when 0 then WINTER | |
| when 1 then SPRING | |
| when 2 then SUMMER | |
| when 3 then FALL | |
| end | |
| end | |
| end | |
| class Particle | |
| attr_accessor :x, :y, :vx, :vy, :life, :max_life, :size, :rotation | |
| def initialize(x, y, type) | |
| @x = x | |
| @y = y | |
| @type = type | |
| @rotation = rand * Math::PI * 2 | |
| @rotation_speed = (rand - 0.5) * 0.1 | |
| case type | |
| when :snowflake | |
| @vx = (rand - 0.5) * 0.5 | |
| @vy = rand * 0.5 + 0.3 | |
| @size = rand * 3 + 2 | |
| @max_life = @life = 300 | |
| when :flower | |
| @vx = (rand - 0.5) * 0.3 | |
| @vy = -rand * 0.3 - 0.1 | |
| @size = rand * 4 + 3 | |
| @max_life = @life = 200 | |
| when :leaf | |
| @vx = (rand - 0.5) * 1.0 | |
| @vy = rand * 0.8 + 0.5 | |
| @size = rand * 4 + 4 | |
| @max_life = @life = 250 | |
| when :sun_ray | |
| @vx = 0 | |
| @vy = 0 | |
| @size = rand * 2 + 1 | |
| @max_life = @life = 100 | |
| end | |
| end | |
| def update | |
| @x += @vx | |
| @y += @vy | |
| @rotation += @rotation_speed | |
| @life -= 1 | |
| @life > 0 | |
| end | |
| def alpha | |
| (@life.to_f / @max_life * 150).to_i | |
| end | |
| end | |
| class Button | |
| attr_reader :x, :y, :width, :height, :label | |
| attr_accessor :bg_color, :hover_color, :border_color | |
| def initialize(x, y, width, height, label, font, colors = {}) | |
| @x = x | |
| @y = y | |
| @width = width | |
| @height = height | |
| @label = label | |
| @font = font | |
| @hovered = false | |
| @bg_color = colors[:bg] || [55, 60, 70] | |
| @hover_color = colors[:hover] || [70, 75, 85] | |
| @border_color = colors[:border] || [80, 85, 95] | |
| end | |
| def label=(new_label) | |
| @label = new_label | |
| end | |
| def draw | |
| if @hovered | |
| bg = Gosu::Color.new(255, *@hover_color) | |
| border = Gosu::Color.new(255, *@border_color.map { |c| [c + 40, 255].min }) | |
| else | |
| bg = Gosu::Color.new(255, *@bg_color) | |
| border = Gosu::Color.new(255, *@border_color) | |
| end | |
| Gosu.draw_rect(@x, @y, @width, @height, bg, 2) | |
| draw_border(@x, @y, @width, @height, border) | |
| text_x = @x + (@width - @font.text_width(@label)) / 2 | |
| text_y = @y + (@height - @font.height) / 2 | |
| @font.draw_text(@label, text_x, text_y, 3, 1, 1, Gosu::Color::WHITE) | |
| end | |
| def draw_border(x, y, w, h, color) | |
| Gosu.draw_line(x, y, color, x + w, y, color, 2) | |
| Gosu.draw_line(x + w, y, color, x + w, y + h, color, 2) | |
| Gosu.draw_line(x + w, y + h, color, x, y + h, color, 2) | |
| Gosu.draw_line(x, y + h, color, x, y, color, 2) | |
| end | |
| def contains?(mx, my) | |
| mx >= @x && mx <= @x + @width && my >= @y && my <= @y + @height | |
| end | |
| def update(mx, my) | |
| @hovered = contains?(mx, my) | |
| end | |
| end | |
| class BabyPenguin | |
| attr_accessor :x, :y, :offset_x | |
| def initialize(x, y, offset_x) | |
| @x = x | |
| @y = y | |
| @offset_x = offset_x | |
| @frame = rand(100) | |
| @bounce = 0 | |
| end | |
| def update | |
| @frame += 1 | |
| @bounce = Math.sin(@frame * 0.15) * 2 | |
| end | |
| def draw(parent_x, colors) | |
| px = parent_x + @offset_x | |
| py = @y + @bounce | |
| body_color = Gosu::Color.new(255, *colors[:body]) | |
| belly_color = Gosu::Color.new(255, *colors[:belly]) | |
| beak_color = Gosu::Color.new(255, 255, 165, 0) | |
| draw_oval(px, py, 8, 14, body_color) | |
| draw_oval(px, py + 2, 5, 10, belly_color) | |
| draw_oval(px - 3, py + 13, 3, 2, beak_color) | |
| draw_oval(px + 3, py + 13, 3, 2, beak_color) | |
| draw_oval(px - 2, py - 6, 2, 2, Gosu::Color::WHITE) | |
| draw_oval(px + 2, py - 6, 2, 2, Gosu::Color::WHITE) | |
| draw_oval(px - 2, py - 6, 1, 1, Gosu::Color::BLACK) | |
| draw_oval(px + 2, py - 6, 1, 1, Gosu::Color::BLACK) | |
| Gosu.draw_triangle( | |
| px, py - 2, beak_color, | |
| px - 2, py - 5, beak_color, | |
| px + 2, py - 5, beak_color, 1 | |
| ) | |
| end | |
| def draw_oval(cx, cy, rx, ry, color) | |
| segments = 12 | |
| segments.times do |i| | |
| angle1 = (i * 360.0 / segments) * Math::PI / 180 | |
| angle2 = ((i + 1) * 360.0 / segments) * Math::PI / 180 | |
| x1 = cx + Math.cos(angle1) * rx | |
| y1 = cy + Math.sin(angle1) * ry | |
| x2 = cx + Math.cos(angle2) * rx | |
| y2 = cy + Math.sin(angle2) * ry | |
| Gosu.draw_triangle(cx, cy, color, x1, y1, color, x2, y2, color, 1) | |
| end | |
| end | |
| end | |
| class Penguin | |
| attr_accessor :x, :y, :state, :progress, :is_female, :blushing, :colors | |
| def initialize(x, y, is_female = false) | |
| @base_x = x | |
| @x = x | |
| @y = y | |
| @is_female = is_female | |
| @state = :idle | |
| @frame = 0 | |
| @bounce_offset = 0 | |
| @wing_angle = 0 | |
| @tilt_angle = 0 | |
| @sway_offset = 0 | |
| @left_foot_lift = 0 | |
| @right_foot_lift = 0 | |
| @celebration_timer = 0 | |
| @fall_angle = 0 | |
| @blushing = false | |
| @progress = 0.0 | |
| @start_x = 60 | |
| @end_x = 305 | |
| @colors = { body: [30, 30, 35], belly: [255, 255, 255] } | |
| @babies = [] | |
| end | |
| def add_babies(count) | |
| @babies = [] | |
| offsets = case count | |
| when 1 then [0] | |
| when 2 then [-12, 12] | |
| else [-15, 0, 15] | |
| end | |
| offsets.first(count).each do |offset| | |
| @babies << BabyPenguin.new(@x, @y + 35, offset) | |
| end | |
| end | |
| def clear_babies | |
| @babies = [] | |
| end | |
| def update | |
| @frame += 1 | |
| @babies.each(&:update) | |
| case @state | |
| when :walking | |
| waddle_cycle = Math.sin(@frame * 0.2) | |
| @tilt_angle = waddle_cycle * 0.2 | |
| @sway_offset = waddle_cycle * 6 | |
| @bounce_offset = Math.sin(@frame * 0.4).abs * 3 | |
| @wing_angle = -waddle_cycle * 0.2 | |
| @left_foot_lift = [waddle_cycle * 8, 0].max | |
| @right_foot_lift = [-waddle_cycle * 8, 0].max | |
| unless @is_female | |
| @x = @start_x + (@end_x - @start_x) * (1.0 - @progress) | |
| end | |
| when :celebrating | |
| @bounce_offset = Math.sin(@frame * 0.4) * 8 | |
| @wing_angle = Math.sin(@frame * 0.3) * 0.4 | |
| @tilt_angle = Math.sin(@frame * 0.5) * 0.1 | |
| @sway_offset = 0 | |
| @left_foot_lift = 0 | |
| @right_foot_lift = 0 | |
| @celebration_timer += 1 | |
| if @celebration_timer > 180 | |
| @state = :idle | |
| @celebration_timer = 0 | |
| end | |
| when :fallen | |
| @fall_angle = [@fall_angle + 0.15, 1.2].min | |
| @bounce_offset = @fall_angle * 15 | |
| @tilt_angle = 0 | |
| @sway_offset = 0 | |
| @wing_angle = 0.5 | |
| @left_foot_lift = 0 | |
| @right_foot_lift = 0 | |
| else | |
| @bounce_offset = Math.sin(@frame * 0.08) * 1.5 | |
| @tilt_angle = Math.sin(@frame * 0.05) * 0.03 | |
| @sway_offset = Math.sin(@frame * 0.05) * 1 | |
| @wing_angle = Math.sin(@frame * 0.05) * 0.05 | |
| @left_foot_lift = 0 | |
| @right_foot_lift = 0 | |
| @fall_angle = 0 | |
| end | |
| end | |
| def draw | |
| y_pos = @y + @bounce_offset | |
| x_pos = @x + @sway_offset | |
| body_color = Gosu::Color.new(255, *@colors[:body]) | |
| belly_color = Gosu::Color.new(255, *@colors[:belly]) | |
| fall_offset_x = Math.sin(@fall_angle) * 25 | |
| fall_offset_y = (1 - Math.cos(@fall_angle)) * 10 | |
| body_x = x_pos + Math.sin(@tilt_angle) * 15 + fall_offset_x | |
| body_y = y_pos + fall_offset_y | |
| draw_oval(body_x, body_y, 16, 28, body_color) | |
| belly_x = x_pos + Math.sin(@tilt_angle) * 12 + fall_offset_x * 0.8 | |
| belly_y = y_pos + 4 + fall_offset_y | |
| draw_oval(belly_x, belly_y, 10, 20, belly_color) | |
| left_wing_x = body_x - 16 | |
| right_wing_x = body_x + 16 | |
| wing_y = y_pos + fall_offset_y | |
| draw_wing(left_wing_x, wing_y, -@wing_angle + @tilt_angle, body_color) | |
| draw_wing(right_wing_x, wing_y, @wing_angle + @tilt_angle, body_color) | |
| foot_color = Gosu::Color.new(255, 255, 165, 0) | |
| left_foot_y = y_pos + 26 - @left_foot_lift | |
| draw_oval(x_pos - 5, left_foot_y, 5, 3, foot_color) | |
| right_foot_y = y_pos + 26 - @right_foot_lift | |
| draw_oval(x_pos + 5, right_foot_y, 5, 3, foot_color) | |
| head_x = x_pos + Math.sin(@tilt_angle) * 18 + fall_offset_x * 1.3 | |
| head_y = y_pos + fall_offset_y * 1.5 | |
| eye_offset = @is_female ? -1.5 : 1.5 | |
| if @state == :fallen | |
| spiral = @frame * 0.2 | |
| draw_oval(head_x - 5, head_y - 12, 4, 4, Gosu::Color::WHITE) | |
| draw_oval(head_x + 5, head_y - 12, 4, 4, Gosu::Color::WHITE) | |
| draw_oval(head_x - 5 + Math.sin(spiral) * 2, head_y - 12, 2, 2, Gosu::Color::BLACK) | |
| draw_oval(head_x + 5 + Math.cos(spiral) * 2, head_y - 12, 2, 2, Gosu::Color::BLACK) | |
| else | |
| draw_oval(head_x - 5, head_y - 12, 4, 4, Gosu::Color::WHITE) | |
| draw_oval(head_x + 5, head_y - 12, 4, 4, Gosu::Color::WHITE) | |
| pupil_look = @state == :celebrating ? Math.sin(@frame * 0.2) * 2 : eye_offset | |
| draw_oval(head_x - 5 + pupil_look, head_y - 12, 2, 2, Gosu::Color::BLACK) | |
| draw_oval(head_x + 5 + pupil_look, head_y - 12, 2, 2, Gosu::Color::BLACK) | |
| end | |
| beak_color = Gosu::Color.new(255, 255, 165, 0) | |
| Gosu.draw_triangle( | |
| head_x, head_y - 4, beak_color, | |
| head_x - 5, head_y - 10, beak_color, | |
| head_x + 5, head_y - 10, beak_color, 1 | |
| ) | |
| if @is_female | |
| bow_color = Gosu::Color.new(255, 255, 105, 180) | |
| bow_x = head_x + Math.sin(@tilt_angle) * 5 | |
| draw_oval(bow_x + 8, head_y - 26, 6, 4, bow_color) | |
| draw_oval(bow_x + 2, head_y - 26, 6, 4, bow_color) | |
| draw_oval(bow_x + 5, head_y - 26, 3, 3, Gosu::Color.new(255, 255, 80, 150)) | |
| lash_color = Gosu::Color::BLACK | |
| Gosu.draw_line(head_x - 7, head_y - 15, lash_color, head_x - 9, head_y - 18, lash_color, 2) | |
| Gosu.draw_line(head_x - 5, head_y - 16, lash_color, head_x - 5, head_y - 19, lash_color, 2) | |
| Gosu.draw_line(head_x + 3, head_y - 16, lash_color, head_x + 3, head_y - 19, lash_color, 2) | |
| Gosu.draw_line(head_x + 5, head_y - 15, lash_color, head_x + 7, head_y - 18, lash_color, 2) | |
| end | |
| if @state == :celebrating || (@is_female && @progress < 0.1) || @blushing | |
| draw_oval(head_x - 12, head_y - 4, 3, 2, Gosu::Color.new(130, 255, 150, 150)) | |
| draw_oval(head_x + 12, head_y - 4, 3, 2, Gosu::Color.new(130, 255, 150, 150)) | |
| end | |
| if @state == :celebrating && @frame % 30 < 15 | |
| draw_heart(head_x + ((@frame / 10) % 3 - 1) * 15, head_y - 45 - (@frame % 20), 6) | |
| end | |
| @babies.each { |baby| baby.draw(@x, @colors) } | |
| end | |
| def draw_wing(x, y, angle, color) | |
| offset_x = Math.sin(angle) * 6 | |
| offset_y = Math.cos(angle) * 6 | |
| draw_oval(x + offset_x, y + offset_y, 5, 14, color) | |
| end | |
| def draw_oval(cx, cy, rx, ry, color) | |
| segments = 20 | |
| segments.times do |i| | |
| angle1 = (i * 360.0 / segments) * Math::PI / 180 | |
| angle2 = ((i + 1) * 360.0 / segments) * Math::PI / 180 | |
| x1 = cx + Math.cos(angle1) * rx | |
| y1 = cy + Math.sin(angle1) * ry | |
| x2 = cx + Math.cos(angle2) * rx | |
| y2 = cy + Math.sin(angle2) * ry | |
| Gosu.draw_triangle(cx, cy, color, x1, y1, color, x2, y2, color, 1) | |
| end | |
| end | |
| def draw_heart(cx, cy, size) | |
| color = Gosu::Color.new(200, 255, 100, 120) | |
| draw_oval(cx - size/2, cy, size/2, size/2, color) | |
| draw_oval(cx + size/2, cy, size/2, size/2, color) | |
| Gosu.draw_triangle( | |
| cx - size, cy + 2, color, | |
| cx + size, cy + 2, color, | |
| cx, cy + size * 1.5, color, 1 | |
| ) | |
| end | |
| def celebrate! | |
| @state = :celebrating | |
| @celebration_timer = 0 | |
| end | |
| end | |
| class SettingsPanel | |
| attr_reader :visible | |
| def initialize(window, font, db) | |
| @window = window | |
| @font = font | |
| @db = db | |
| @visible = false | |
| @settings = db.get_all_settings | |
| @color_options = { | |
| 'Background' => ['#FFFFFF', '#F5F5F5', '#E8E8E8', '#2D2D2D', '#1A1A2E'], | |
| 'Buttons' => ['#373C46', '#4A5568', '#2D3748', '#1A202C', '#553C9A'], | |
| 'Work Ring' => ['#E57373', '#F56565', '#FC8181', '#FEB2B2', '#C53030'], | |
| 'Break Ring' => ['#81C784', '#68D391', '#9AE6B4', '#C6F6D5', '#38A169'], | |
| 'Penguin Body' => ['#1E1E23', '#2D3748', '#4A5568', '#553C9A', '#744210'], | |
| 'Penguin Belly' => ['#FFFFFF', '#F7FAFC', '#EDF2F7', '#FEEBC8', '#FED7E2'] | |
| } | |
| @scroll_y = 0 | |
| end | |
| def toggle | |
| @visible = !@visible | |
| @settings = @db.get_all_settings if @visible | |
| end | |
| def hide | |
| @visible = false | |
| end | |
| def update(mx, my) | |
| return unless @visible | |
| end | |
| def draw | |
| return unless @visible | |
| Gosu.draw_rect(0, 0, 400, 700, Gosu::Color.new(200, 0, 0, 0), 10) | |
| Gosu.draw_rect(30, 50, 340, 600, Gosu::Color.new(255, 45, 45, 55), 11) | |
| @font.draw_text("Settings", 160, 70, 12, 1, 1, Gosu::Color::WHITE) | |
| y = 110 | |
| @color_options.each do |name, colors| | |
| @font.draw_text(name, 50, y, 12, 1, 1, Gosu::Color::WHITE) | |
| y += 25 | |
| colors.each_with_index do |hex, i| | |
| x = 50 + i * 55 | |
| rgb = PomodoroDatabase.hex_to_gosu(hex) | |
| color = Gosu::Color.new(255, *rgb) | |
| Gosu.draw_rect(x, y, 45, 30, color, 12) | |
| setting_key = setting_key_for(name) | |
| if @settings[setting_key] == hex | |
| Gosu.draw_rect(x - 2, y - 2, 49, 34, Gosu::Color::WHITE, 11) | |
| Gosu.draw_rect(x, y, 45, 30, color, 12) | |
| end | |
| end | |
| y += 50 | |
| end | |
| @font.draw_text("[X] Close", 165, 620, 12, 1, 1, Gosu::Color.new(255, 200, 200, 200)) | |
| end | |
| def handle_click(mx, my) | |
| return false unless @visible | |
| if my >= 610 && my <= 640 && mx >= 150 && mx <= 250 | |
| @visible = false | |
| return true | |
| end | |
| y = 135 | |
| @color_options.each do |name, colors| | |
| colors.each_with_index do |hex, i| | |
| x = 50 + i * 55 | |
| if mx >= x && mx <= x + 45 && my >= y && my <= y + 30 | |
| key = setting_key_for(name) | |
| @db.set_setting(key, hex) | |
| @settings[key] = hex | |
| @window.reload_settings | |
| return true | |
| end | |
| end | |
| y += 75 | |
| end | |
| true | |
| end | |
| private | |
| def setting_key_for(name) | |
| case name | |
| when 'Background' then 'background_color' | |
| when 'Buttons' then 'button_color' | |
| when 'Work Ring' then 'work_ring_color' | |
| when 'Break Ring' then 'break_ring_color' | |
| when 'Penguin Body' then 'penguin_body_color' | |
| when 'Penguin Belly' then 'penguin_belly_color' | |
| end | |
| end | |
| end | |
| class PomodoroTimer < Gosu::Window | |
| WORK_TIME = 25 * 60 | |
| SHORT_BREAK = 5 * 60 | |
| LONG_BREAK = 15 * 60 | |
| POMODOROS_UNTIL_LONG = 4 | |
| def initialize | |
| super(400, 750, false) | |
| self.caption = "Pinwidoro" | |
| @db = PomodoroDatabase.new | |
| load_settings | |
| @title_font = Gosu::Font.new(18, bold: true) | |
| @mode_font = Gosu::Font.new(14, bold: true) | |
| @timer_font = Gosu::Font.new(48, bold: true) | |
| @info_font = Gosu::Font.new(14) | |
| @button_font = Gosu::Font.new(13) | |
| @tip_font = Gosu::Font.new(11) | |
| @stats_font = Gosu::Font.new(12) | |
| @time_remaining = WORK_TIME | |
| @total_time = WORK_TIME | |
| @running = false | |
| @mode = :work | |
| @completed_pomodoros = @db.today_count | |
| @last_tick = Gosu.milliseconds | |
| @early_celebration_triggered = false | |
| @male_penguin = Penguin.new(60, 510, false) | |
| @female_penguin = Penguin.new(340, 510, true) | |
| apply_penguin_colors | |
| update_babies | |
| @particles = [] | |
| @current_season = Seasons.for_pomodoro(@completed_pomodoros) | |
| btn_y = 340 | |
| btn_width = 80 | |
| btn_height = 32 | |
| spacing = 12 | |
| start_x = (400 - (btn_width * 3 + spacing * 2)) / 2 | |
| @start_btn = Button.new(start_x, btn_y, btn_width, btn_height, "Start", @button_font) | |
| @reset_btn = Button.new(start_x + btn_width + spacing, btn_y, btn_width, btn_height, "Reset", @button_font) | |
| @skip_btn = Button.new(start_x + (btn_width + spacing) * 2, btn_y, btn_width, btn_height, "Skip", @button_font) | |
| mode_y = 385 | |
| @work_btn = Button.new(start_x, mode_y, btn_width, btn_height, "Work", @button_font) | |
| @short_btn = Button.new(start_x + btn_width + spacing, mode_y, btn_width, btn_height, "Short", @button_font) | |
| @long_btn = Button.new(start_x + (btn_width + spacing) * 2, mode_y, btn_width, btn_height, "Long", @button_font) | |
| @settings_btn = Button.new(350, 10, 40, 25, "...", @button_font) | |
| @buttons = [@start_btn, @reset_btn, @skip_btn, @work_btn, @short_btn, @long_btn, @settings_btn] | |
| apply_button_colors | |
| @settings_panel = SettingsPanel.new(self, @info_font, @db) | |
| @ring_animation = 0 | |
| update_menu_bar | |
| end | |
| def load_settings | |
| @settings = @db.get_all_settings | |
| @bg_color = PomodoroDatabase.hex_to_gosu(@settings['background_color'] || '#FFFFFF') | |
| @button_color = PomodoroDatabase.hex_to_gosu(@settings['button_color'] || '#373C46') | |
| @work_ring_color = PomodoroDatabase.hex_to_gosu(@settings['work_ring_color'] || '#E57373') | |
| @break_ring_color = PomodoroDatabase.hex_to_gosu(@settings['break_ring_color'] || '#81C784') | |
| @penguin_body_color = PomodoroDatabase.hex_to_gosu(@settings['penguin_body_color'] || '#1E1E23') | |
| @penguin_belly_color = PomodoroDatabase.hex_to_gosu(@settings['penguin_belly_color'] || '#FFFFFF') | |
| end | |
| def reload_settings | |
| load_settings | |
| apply_button_colors | |
| apply_penguin_colors | |
| end | |
| def apply_button_colors | |
| @buttons.each do |btn| | |
| btn.bg_color = @button_color | |
| btn.hover_color = @button_color.map { |c| [c + 15, 255].min } | |
| btn.border_color = @button_color.map { |c| [c + 25, 255].min } | |
| end | |
| end | |
| def apply_penguin_colors | |
| colors = { body: @penguin_body_color, belly: @penguin_belly_color } | |
| @male_penguin.colors = colors | |
| @female_penguin.colors = colors | |
| end | |
| def update_babies | |
| total = @db.total_count | |
| if total >= 8 | |
| baby_count = [total / 8, 3].min | |
| @female_penguin.add_babies(baby_count) | |
| else | |
| @female_penguin.clear_babies | |
| end | |
| end | |
| def update | |
| @buttons.each { |btn| btn.update(mouse_x, mouse_y) } | |
| @settings_panel.update(mouse_x, mouse_y) | |
| @male_penguin.update | |
| @female_penguin.update | |
| @ring_animation += 0.02 | |
| spawn_particles if @running && rand < 0.1 | |
| @particles.reject! { |p| !p.update } | |
| progress = @total_time > 0 ? @time_remaining.to_f / @total_time : 0 | |
| @male_penguin.progress = progress | |
| @female_penguin.progress = progress | |
| if @running | |
| now = Gosu.milliseconds | |
| if now - @last_tick >= 1000 | |
| @last_tick = now | |
| @time_remaining -= 1 | |
| update_menu_bar | |
| timer_complete if @time_remaining <= 0 | |
| end | |
| @female_penguin.blushing = @time_remaining <= 10 && @time_remaining > 0 | |
| if @time_remaining <= 5 && @time_remaining > 0 && !@early_celebration_triggered | |
| @male_penguin.celebrate! | |
| @female_penguin.celebrate! | |
| @early_celebration_triggered = true | |
| elsif @time_remaining > 5 | |
| @male_penguin.state = :walking | |
| @female_penguin.state = :idle | |
| end | |
| elsif @male_penguin.state == :walking && !@early_celebration_triggered | |
| @male_penguin.state = :fallen | |
| end | |
| end | |
| def spawn_particles | |
| season = @current_season | |
| case season[:bg_elements] | |
| when :snowflakes | |
| @particles << Particle.new(rand(400), -10, :snowflake) | |
| when :flowers | |
| @particles << Particle.new(rand(400), 500, :flower) | |
| when :leaves | |
| @particles << Particle.new(rand(400), -10, :leaf) | |
| when :sun_rays | |
| @particles << Particle.new(350, 50, :sun_ray) if rand < 0.3 | |
| end | |
| end | |
| def draw | |
| draw_gradient_bg | |
| draw_particles | |
| draw_season_indicator | |
| text_color = is_dark_bg? ? Gosu::Color::WHITE : Gosu::Color.new(255, 80, 80, 90) | |
| draw_centered_text("PINWIDORO", @title_font, 25, text_color) | |
| mode_color = @mode == :work ? | |
| Gosu::Color.new(255, *@work_ring_color) : | |
| Gosu::Color.new(255, *@break_ring_color) | |
| mode_text = mode_name | |
| pill_width = @mode_font.text_width(mode_text) + 24 | |
| pill_x = (400 - pill_width) / 2 | |
| Gosu.draw_rect(pill_x, 52, pill_width, 24, Gosu::Color.new(100, mode_color.red, mode_color.green, mode_color.blue), 1) | |
| draw_centered_text(mode_text, @mode_font, 56, mode_color) | |
| draw_progress_ring(200, 175, 85, 8) | |
| draw_centered_text(format_time(@time_remaining), @timer_font, 155, text_color) | |
| draw_pomodoro_dots(200, 290) | |
| draw_centered_text("#{@completed_pomodoros} today", @info_font, 310, Gosu::Color.new(255, 120, 120, 130)) | |
| @buttons.each(&:draw) | |
| draw_stats | |
| @male_penguin.draw | |
| @female_penguin.draw | |
| draw_mini_graph(200, 680, 150, 40) | |
| @settings_panel.draw | |
| end | |
| def draw_gradient_bg | |
| Gosu.draw_rect(0, 0, 400, 750, Gosu::Color.new(255, *@bg_color), 0) | |
| end | |
| def is_dark_bg? | |
| @bg_color.sum < 400 | |
| end | |
| def draw_particles | |
| @particles.each do |p| | |
| color = Gosu::Color.new(p.alpha, *@current_season[:particle_color]) | |
| case @current_season[:bg_elements] | |
| when :snowflakes | |
| draw_snowflake(p.x, p.y, p.size, p.rotation, color) | |
| when :flowers | |
| draw_flower(p.x, p.y, p.size, p.rotation, color) | |
| when :leaves | |
| draw_leaf(p.x, p.y, p.size, p.rotation, color) | |
| when :sun_rays | |
| draw_sun_ray(p.x, p.y, p.size, color) | |
| end | |
| end | |
| end | |
| def draw_snowflake(x, y, size, rot, color) | |
| 6.times do |i| | |
| angle = rot + i * Math::PI / 3 | |
| x2 = x + Math.cos(angle) * size | |
| y2 = y + Math.sin(angle) * size | |
| Gosu.draw_line(x, y, color, x2, y2, color, 1) | |
| end | |
| end | |
| def draw_flower(x, y, size, rot, color) | |
| 5.times do |i| | |
| angle = rot + i * Math::PI * 2 / 5 | |
| px = x + Math.cos(angle) * size | |
| py = y + Math.sin(angle) * size | |
| draw_circle(px, py, size / 2, color) | |
| end | |
| end | |
| def draw_leaf(x, y, size, rot, color) | |
| Gosu.draw_triangle( | |
| x, y - size, color, | |
| x - size / 2, y + size, color, | |
| x + size / 2, y + size, color, 1 | |
| ) | |
| end | |
| def draw_sun_ray(x, y, size, color) | |
| angle = rand * Math::PI * 2 | |
| x2 = x + Math.cos(angle) * 30 | |
| y2 = y + Math.sin(angle) * 30 | |
| Gosu.draw_line(x, y, color, x2, y2, color, 1) | |
| end | |
| def draw_season_indicator | |
| season = @current_season | |
| color = Gosu::Color.new(150, *season[:accent_color]) | |
| @tip_font.draw_text(season[:name], 10, 730, 1, 1, 1, color) | |
| end | |
| def draw_stats | |
| streak = @db.current_streak | |
| week = @db.week_count | |
| stats_y = 430 | |
| stats_color = is_dark_bg? ? Gosu::Color.new(255, 180, 180, 190) : Gosu::Color.new(255, 100, 100, 110) | |
| @stats_font.draw_text("Week: #{week}", 50, stats_y, 1, 1, 1, stats_color) | |
| @stats_font.draw_text("Streak: #{streak} day#{streak != 1 ? 's' : ''}", 280, stats_y, 1, 1, 1, stats_color) | |
| end | |
| def draw_mini_graph(cx, cy, width, height) | |
| stats = @db.week_daily_stats | |
| max_count = [stats.map { |s| s[:count] }.max || 1, 1].max | |
| bar_width = width / 7 - 4 | |
| start_x = cx - width / 2 | |
| stats.each_with_index do |day, i| | |
| x = start_x + i * (bar_width + 4) | |
| bar_height = (day[:count].to_f / max_count * height).to_i | |
| bar_height = [bar_height, 2].max if day[:count] > 0 | |
| color = i == 6 ? | |
| Gosu::Color.new(255, *@work_ring_color) : | |
| Gosu::Color.new(150, *@work_ring_color) | |
| Gosu.draw_rect(x, cy - bar_height, bar_width, bar_height, color, 1) | |
| end | |
| label_color = is_dark_bg? ? Gosu::Color.new(255, 150, 150, 160) : Gosu::Color.new(255, 100, 100, 110) | |
| @tip_font.draw_text("7-day activity", cx - 35, cy + 5, 1, 1, 1, label_color) | |
| end | |
| def draw_progress_ring(cx, cy, radius, thickness) | |
| segments = 60 | |
| progress = @total_time > 0 ? @time_remaining.to_f / @total_time : 0 | |
| bg_color = is_dark_bg? ? | |
| Gosu::Color.new(255, 60, 60, 70) : | |
| Gosu::Color.new(255, 230, 230, 235) | |
| draw_arc(cx, cy, radius, thickness, 0, 1, bg_color) | |
| if progress > 0 | |
| ring_color = @mode == :work ? | |
| Gosu::Color.new(255, *@work_ring_color) : | |
| Gosu::Color.new(255, *@break_ring_color) | |
| glow_color = Gosu::Color.new(50, ring_color.red, ring_color.green, ring_color.blue) | |
| draw_arc(cx, cy, radius, thickness + 6, 0, progress, glow_color) | |
| draw_arc(cx, cy, radius, thickness, 0, progress, ring_color) | |
| if @running | |
| pulse = (Math.sin(@ring_animation * 3) + 1) / 2 | |
| end_angle = -Math::PI / 2 + (2 * Math::PI * progress) | |
| end_x = cx + Math.cos(end_angle) * radius | |
| end_y = cy + Math.sin(end_angle) * radius | |
| pulse_size = 4 + pulse * 3 | |
| draw_circle(end_x, end_y, pulse_size, ring_color) | |
| end | |
| end | |
| end | |
| def draw_arc(cx, cy, radius, thickness, start_pct, end_pct, color) | |
| segments = 60 | |
| start_angle = -Math::PI / 2 + (2 * Math::PI * start_pct) | |
| end_angle = -Math::PI / 2 + (2 * Math::PI * end_pct) | |
| inner_r = radius - thickness / 2 | |
| outer_r = radius + thickness / 2 | |
| step = (end_angle - start_angle) / segments | |
| segments.times do |i| | |
| a1 = start_angle + step * i | |
| a2 = start_angle + step * (i + 1) | |
| x1_in = cx + Math.cos(a1) * inner_r | |
| y1_in = cy + Math.sin(a1) * inner_r | |
| x1_out = cx + Math.cos(a1) * outer_r | |
| y1_out = cy + Math.sin(a1) * outer_r | |
| x2_in = cx + Math.cos(a2) * inner_r | |
| y2_in = cy + Math.sin(a2) * inner_r | |
| x2_out = cx + Math.cos(a2) * outer_r | |
| y2_out = cy + Math.sin(a2) * outer_r | |
| Gosu.draw_quad( | |
| x1_in, y1_in, color, | |
| x1_out, y1_out, color, | |
| x2_out, y2_out, color, | |
| x2_in, y2_in, color, 1 | |
| ) | |
| end | |
| end | |
| def draw_circle(cx, cy, radius, color) | |
| segments = 16 | |
| segments.times do |i| | |
| a1 = (i * 360.0 / segments) * Math::PI / 180 | |
| a2 = ((i + 1) * 360.0 / segments) * Math::PI / 180 | |
| Gosu.draw_triangle( | |
| cx, cy, color, | |
| cx + Math.cos(a1) * radius, cy + Math.sin(a1) * radius, color, | |
| cx + Math.cos(a2) * radius, cy + Math.sin(a2) * radius, color, 2 | |
| ) | |
| end | |
| end | |
| def draw_pomodoro_dots(cx, y) | |
| dot_radius = 5 | |
| spacing = 18 | |
| total_width = (4 - 1) * spacing | |
| start_x = cx - total_width / 2 | |
| 4.times do |i| | |
| x = start_x + i * spacing | |
| season = Seasons.for_pomodoro(i) | |
| if i < @completed_pomodoros % 4 || (@completed_pomodoros > 0 && @completed_pomodoros % 4 == 0 && i < 4) | |
| color = Gosu::Color.new(255, *season[:accent_color]) | |
| else | |
| color = is_dark_bg? ? | |
| Gosu::Color.new(255, 80, 80, 90) : | |
| Gosu::Color.new(255, 220, 220, 225) | |
| end | |
| draw_circle(x, y, dot_radius, color) | |
| end | |
| end | |
| def draw_centered_text(text, font, y, color) | |
| x = (400 - font.text_width(text)) / 2 | |
| font.draw_text(text, x, y, 5, 1, 1, color) | |
| end | |
| def button_down(id) | |
| if id == Gosu::MS_LEFT | |
| return if @settings_panel.handle_click(mouse_x, mouse_y) | |
| if @settings_btn.contains?(mouse_x, mouse_y) | |
| @settings_panel.toggle | |
| elsif @start_btn.contains?(mouse_x, mouse_y) | |
| toggle_timer | |
| elsif @reset_btn.contains?(mouse_x, mouse_y) | |
| reset_timer | |
| elsif @skip_btn.contains?(mouse_x, mouse_y) | |
| skip_mode | |
| elsif @work_btn.contains?(mouse_x, mouse_y) | |
| pause_timer | |
| switch_mode(:work) | |
| elsif @short_btn.contains?(mouse_x, mouse_y) | |
| pause_timer | |
| switch_mode(:short_break) | |
| elsif @long_btn.contains?(mouse_x, mouse_y) | |
| pause_timer | |
| switch_mode(:long_break) | |
| end | |
| elsif id == Gosu::KB_ESCAPE | |
| @settings_panel.hide | |
| end | |
| end | |
| def format_time(seconds) | |
| seconds = [seconds, 0].max | |
| mins = seconds / 60 | |
| secs = seconds % 60 | |
| "%02d:%02d" % [mins, secs] | |
| end | |
| def mode_name | |
| case @mode | |
| when :work then "FOCUS" | |
| when :short_break then "SHORT BREAK" | |
| when :long_break then "LONG BREAK" | |
| end | |
| end | |
| def toggle_timer | |
| @running ? pause_timer : start_timer | |
| end | |
| def start_timer | |
| return if @running | |
| @running = true | |
| @last_tick = Gosu.milliseconds | |
| @start_btn.label = "Pause" | |
| if @male_penguin.state == :fallen | |
| @male_penguin.state = :walking | |
| @male_penguin.instance_variable_set(:@fall_angle, 0) | |
| end | |
| end | |
| def pause_timer | |
| return unless @running | |
| @running = false | |
| @start_btn.label = "Start" | |
| update_menu_bar | |
| end | |
| def reset_timer | |
| pause_timer | |
| @time_remaining = get_mode_time | |
| @total_time = @time_remaining | |
| @early_celebration_triggered = false | |
| @male_penguin.x = 60 | |
| @male_penguin.progress = 1.0 | |
| @male_penguin.state = :idle | |
| @male_penguin.instance_variable_set(:@fall_angle, 0) | |
| @female_penguin.blushing = false | |
| update_menu_bar | |
| end | |
| def get_mode_time | |
| case @mode | |
| when :work then WORK_TIME | |
| when :short_break then SHORT_BREAK | |
| when :long_break then LONG_BREAK | |
| end | |
| end | |
| def timer_complete | |
| pause_timer | |
| @male_penguin.celebrate! | |
| @female_penguin.celebrate! | |
| system('afplay /System/Library/Sounds/Glass.aiff &') | |
| if @mode == :work | |
| @db.record_session(WORK_TIME, 'work') | |
| @completed_pomodoros = @db.today_count | |
| update_babies | |
| @current_season = Seasons.for_pomodoro(@completed_pomodoros) | |
| if @completed_pomodoros % POMODOROS_UNTIL_LONG == 0 | |
| switch_mode(:long_break) | |
| show_notification("Great job! #{@completed_pomodoros} pomodoros!", "Time for a long break") | |
| else | |
| switch_mode(:short_break) | |
| show_notification("Pomodoro complete!", "Time for a short break") | |
| end | |
| else | |
| switch_mode(:work) | |
| show_notification("Break's over!", "Ready for another pomodoro?") | |
| end | |
| end | |
| def show_notification(title, message) | |
| system(%Q(osascript -e 'display notification "#{message}" with title "#{title}"')) | |
| end | |
| def switch_mode(new_mode) | |
| @mode = new_mode | |
| @time_remaining = get_mode_time | |
| @total_time = @time_remaining | |
| @early_celebration_triggered = false | |
| @male_penguin.x = 60 | |
| @male_penguin.progress = 1.0 | |
| @male_penguin.state = :idle | |
| @male_penguin.instance_variable_set(:@fall_angle, 0) | |
| @female_penguin.blushing = false | |
| update_menu_bar | |
| end | |
| def skip_mode | |
| pause_timer | |
| switch_mode(@mode == :work ? :short_break : :work) | |
| end | |
| def update_menu_bar | |
| time_str = format_time(@time_remaining) | |
| mode_icon = @mode == :work ? "π " : "β" | |
| status = @running ? "#{mode_icon} #{time_str}" : "#{mode_icon} Paused" | |
| self.caption = "Pinwidoro - #{status}" | |
| end | |
| def close | |
| @db.close | |
| super | |
| end | |
| end | |
| PomodoroTimer.new.show | |
| APPEOF | |
| # Create launcher script | |
| cat > "$APP_DIR/pinwidoro" << 'LAUNCHEOF' | |
| #!/bin/bash | |
| cd "$(dirname "$0")" | |
| # Find Ruby | |
| if command -v ruby &> /dev/null; then | |
| RUBY_CMD="ruby" | |
| elif [[ -f "/opt/homebrew/opt/ruby/bin/ruby" ]]; then | |
| RUBY_CMD="/opt/homebrew/opt/ruby/bin/ruby" | |
| elif [[ -f "/usr/local/opt/ruby/bin/ruby" ]]; then | |
| RUBY_CMD="/usr/local/opt/ruby/bin/ruby" | |
| else | |
| echo "Ruby not found. Please install Ruby first." | |
| exit 1 | |
| fi | |
| $RUBY_CMD pinwidoro.rb | |
| LAUNCHEOF | |
| chmod +x "$APP_DIR/pinwidoro" | |
| # Add to shell profile for easy access | |
| SHELL_RC="" | |
| if [[ -f "$HOME/.zshrc" ]]; then | |
| SHELL_RC="$HOME/.zshrc" | |
| elif [[ -f "$HOME/.bashrc" ]]; then | |
| SHELL_RC="$HOME/.bashrc" | |
| fi | |
| if [[ -n "$SHELL_RC" ]]; then | |
| if ! grep -q "alias pinwidoro" "$SHELL_RC" 2>/dev/null; then | |
| echo "" >> "$SHELL_RC" | |
| echo "# Pinwidoro - Pomodoro Timer" >> "$SHELL_RC" | |
| echo "alias pinwidoro='$APP_DIR/pinwidoro'" >> "$SHELL_RC" | |
| echo -e "${GREEN}β Added 'pinwidoro' command to your shell${NC}" | |
| fi | |
| fi | |
| echo "" | |
| echo -e "${GREEN}========================================${NC}" | |
| echo -e "${GREEN}π§ Pinwidoro installed successfully! π§${NC}" | |
| echo -e "${GREEN}========================================${NC}" | |
| echo "" | |
| echo "To run Pinwidoro:" | |
| echo " 1. Open a NEW terminal window" | |
| echo " 2. Type: pinwidoro" | |
| echo "" | |
| echo "Or run directly:" | |
| echo " $APP_DIR/pinwidoro" | |
| echo "" | |
| echo -e "${YELLOW}Starting Pinwidoro now...${NC}" | |
| echo "" | |
| # Run the app | |
| cd "$APP_DIR" | |
| ruby pinwidoro.rb |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment