Skip to content

Instantly share code, notes, and snippets.

@danpariente
Created January 25, 2026 21:07
Show Gist options
  • Select an option

  • Save danpariente/b7367ad2b37ccea250200829f39bc0ad to your computer and use it in GitHub Desktop.

Select an option

Save danpariente/b7367ad2b37ccea250200829f39bc0ad to your computer and use it in GitHub Desktop.
#!/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