Claude を使ってVibe Codingのテストを行った. 題材として, Rubyを使ってシンプルなToDoアプリを作ることとした. GUIライブラリはGTK4を使うこととした.
最新版は以下のgistに置こうと思う. https://gist.github.com/mephistobooks/18eb63bafca1370a21494ec1289caad2#file-simple_todo_app-rb
大まかな作りはClaudeが吐いたものそのままだが, 細かな挙動部分については修正を行っている. 例えばファイル名の初期値, セーブ・ロードする際のファイル名の自動入力といったところだ.
またRuby/GTK4でのGObjectの扱いについて, Claude は全くできなかった. この点については筆者がClaudeに対してGObjectの生成方法を具体的に指示することとなった.
Ruby/GTK4 のドキュメントについてだが, ほぼ皆無に等しい. これについては, これからいくつかの記事で補足したいと思う.
#! /usr/bin/env ruby
# frozen_string_literal: true
# require libraries.
#
#
require 'gtk4'
require 'yaml'
require 'fileutils'
# Item data model.
#
#
class TodoItem
attr_accessor :text, :done
def initialize(text, done: false)
@text = text
@done = done
end
def to_h
{ 'text' => @text, 'done' => @done }
end
def self.from_h(h)
new(h['text'] || '', done: h['done'] || false)
end
end
# Todo app main.
#
#
class SimpleTodoApp
CONFIG_FILE = 'simple_todo_app_config.yaml'
TEMPLATE_FILE = 'template.yaml'
TEMPLATE_DIR = "#{__dir__}/template"
# TEMPLATE_PATH = File.expand_path("template.yaml", __dir__)
TEMPLATE_PATH = File.expand_path(TEMPLATE_FILE, TEMPLATE_DIR)
DOCUMENTS_DIR = File.expand_path("~/Documents")
APP_DIR_NAME = 'SimpleToDo'
USER_DIR = File.expand_path("#{DOCUMENTS_DIR}/#{APP_DIR_NAME}")
APP_CONFIG_PATH = File.expand_path("#{USER_DIR}/#{CONFIG_FILE}")
# the initializer.
#
#
attr_reader :app_id
def initialize(app_id: 'com.example.simple_todo_app')
# data model.
@items = []
@current_file = nil
@todo_title = 'My TODO List'
@dragging_row = nil
@app_id = app_id
# @app = Gtk::Application.new('com.example.todoapp', :flags_none)
@app = Gtk::Application.new(@app_id, :flags_none)
@app.signal_connect('activate') do
build_ui
load_last_file_or_template
end
end
def run
@app.run
end
end
#
#
#
class SimpleTodoApp
private
# ─── UI構築 ──────────────────────────────────────────────
def build_ui
@window = Gtk::ApplicationWindow.new(@app)
@window.title = "TODO App"
@window.set_default_size(480, 600)
# CSSでドラッグ中のハイライト
css = Gtk::CssProvider.new
css.load_from_string(<<~CSS)
row.dragging {
opacity: 0.4;
}
.insert-line {
background: @accent_color;
min-height: 3px;
}
CSS
Gtk::StyleContext.add_provider_for_display(
Gdk::Display.default, css, Gtk::StyleProvider::PRIORITY_APPLICATION
)
vbox = Gtk::Box.new(:vertical, 6)
vbox.margin_top = 10
vbox.margin_bottom = 10
vbox.margin_start = 10
vbox.margin_end = 10
vbox.append(build_toolbar)
vbox.append(build_title_bar)
vbox.append(build_entry_bar)
vbox.append(build_list_scroll)
vbox.append(build_action_bar)
@window.child = vbox
@window.present
end
def build_toolbar
bar = Gtk::Box.new(:horizontal, 6)
open_btn = Gtk::Button.new(label: "📂 ファイルを開く")
# open_btn.signal_connect("clicked") { on_open_file }
open_btn.signal_connect('clicked') do
if @app_config && @app_config['last_file']
dir = File.dirname(@app_config['last_file'])
file = File.basename(@app_config['last_file'])
else
dir = TEMPLATE_DIR
file = 'template.yaml'
end
on_open_file(file: file, dir: dir)
end
save_btn = Gtk::Button.new(label: "💾 保存")
save_btn.signal_connect('clicked') { on_save_file }
save_as_btn = Gtk::Button.new(label: "名前を付けて保存")
# save_as_btn.signal_connect("clicked") { on_save_file_as }
save_as_btn.signal_connect('clicked') do
on_save_file_as
end
bar.append(open_btn)
bar.append(save_btn)
bar.append(save_as_btn)
bar
end
def build_title_bar
box = Gtk::Box.new(:horizontal, 6)
label = Gtk::Label.new('タイトル:')
@title_entry = Gtk::Entry.new
@title_entry.text = @todo_title
@title_entry.hexpand = true
@title_entry.signal_connect('changed') do
@todo_title = @title_entry.text
end
box.append(label)
box.append(@title_entry)
box
end
def build_entry_bar
box = Gtk::Box.new(:horizontal, 6)
@new_entry = Gtk::Entry.new
@new_entry.placeholder_text = '新しいタスクを入力...'
@new_entry.hexpand = true
@new_entry.signal_connect('activate') { on_add_item }
add_btn = Gtk::Button.new(label: '追加')
add_btn.signal_connect('clicked') { on_add_item }
box.append(@new_entry)
box.append(add_btn)
box
end
def build_list_scroll
@list_box = Gtk::ListBox.new
@list_box.selection_mode = :none
@list_box.vexpand = true
scroll = Gtk::ScrolledWindow.new
scroll.vexpand = true
scroll.child = @list_box
# 挿入線をオーバーレイするためにGtk::Overlayで包む
@scroll_overlay = Gtk::Overlay.new
@scroll_overlay.vexpand = true
@scroll_overlay.child = scroll
setup_list_drop_target
@scroll_overlay
end
def build_action_bar
bar = Gtk::Box.new(:horizontal, 6)
reset_btn = Gtk::Button.new(label: '✅ チェックをすべてクリア')
reset_btn.signal_connect("clicked") { on_reset_checks }
@status_label = Gtk::Label.new("0 / 0 (0 %) 完了")
@status_label.hexpand = true
@status_label.xalign = 1.0
bar.append(reset_btn)
bar.append(@status_label)
bar
end
end
#
#
#
class SimpleTodoApp
private
# ─── ドラッグ&ドロップ ────────────────────────────────────
# DragSource: 各行に付けてインデックスをINTで渡す
# DropTarget: ListBox全体に1つ付けてy座標でドロップ先を特定
# motion シグナルで挿入線をリアルタイム更新
#
#
# ==== See Also
# - `#add_drag_drop_to_row()`
#
def setup_list_drop_target
@drag_start_y = nil
# 挿入線: Gtk::Fixed で絶対座標配置
@fixed_layer = Gtk::Fixed.new
@fixed_layer.can_target = false
@scroll_overlay.add_overlay(@fixed_layer)
@insert_line = Gtk::Box.new(:horizontal, 0)
@insert_line.add_css_class('insert-line')
@insert_line.visible = false
@insert_line.can_target = false
@fixed_layer.put(@insert_line, 0, 0)
# main.
drop_target = Gtk::DropTarget.new(GLib::Type::INT, :move)
drop_target.signal_connect('motion') do |_tgt, _x, y|
@drag_start_y ||= y
update_insert_line(y, y >= @drag_start_y)
Gdk::DragAction::MOVE
end
drop_target.signal_connect('leave') do
@insert_line.visible = false
@drag_start_y = nil
end
drop_target.signal_connect('drop') do |_tgt, value, _x, y|
@insert_line.visible = false
@drag_start_y = nil
src_idx = value.value
dst_row = @list_box.get_row_at_y(y.to_i)
dst_idx = dst_row ? row_index(dst_row) : @items.size - 1
unless src_idx == dst_idx
item = @items.delete_at(src_idx)
@items.insert(dst_idx, item)
src_row = @list_box.get_row_at_index(src_idx)
@list_box.remove(src_row)
@list_box.insert(src_row, dst_idx)
update_status
end
# ドラッグ元行の半透明を解除
if @dragging_row
@dragging_row.remove_css_class('dragging')
@dragging_row = nil
end
true
end
@list_box.add_controller(drop_target)
end
# y座標から挿入線の位置を計算して表示
def update_insert_line(y, going_down)
row = @list_box.get_row_at_y(y.to_i)
return unless row
_lx, row_y = row.translate_coordinates(@scroll_overlay, 0.0, 0.0)
row_h = row.height
# 下方向ドラッグ → 行の下に線、上方向ドラッグ → 行の上に線
line_y = going_down ? row_y + row_h : row_y
width = @scroll_overlay.width
@insert_line.width_request = width
@fixed_layer.move(@insert_line, 0, line_y.to_i)
@insert_line.visible = true
end
def add_drag_drop_to_row(row, item)
drag_source = Gtk::DragSource.new
drag_source.actions = :move
drag_source.signal_connect('prepare') do |_src, _x, _y|
idx = @items.index(item)
next nil unless idx
Gdk::ContentProvider.new(GLib::Value.new(GLib::Type::INT, idx))
end
drag_source.signal_connect('drag-begin') do |_src, _drag|
@dragging_row = row
row.add_css_class("dragging")
end
drag_source.signal_connect('drag-end') do |_src, _drag, _delete|
row.remove_css_class('dragging')
@dragging_row = nil
@insert_line.visible = false
end
row.add_controller(drag_source)
end
def row_index(row)
@list_box.observe_children.each_with_index do |child, i|
return i if child == row
end
nil
end
# ─── リスト描画 ───────────────────────────────────────────
def refresh_list
$stderr.puts "{#{self.class}##{__method__}} begin."
while (child = @list_box.first_child)
@list_box.remove(child)
end
@items.each do |item|
row = build_row(item)
@list_box.append(row)
add_drag_drop_to_row(row, item)
end
update_status
end
def build_row(item)
row = Gtk::ListBoxRow.new
hbox = Gtk::Box.new(:horizontal, 6)
hbox.margin_top = 4
hbox.margin_bottom = 4
hbox.margin_start = 6
hbox.margin_end = 6
handle = Gtk::Label.new('⠿')
hbox.append(handle)
check = Gtk::CheckButton.new
check.active = item.done
check.signal_connect('toggled') do
item.done = check.active?
update_status
end
hbox.append(check)
text_entry = Gtk::Entry.new
text_entry.text = item.text
text_entry.hexpand = true
text_entry.has_frame = false
text_entry.signal_connect('changed') { item.text = text_entry.text }
hbox.append(text_entry)
del_btn = Gtk::Button.new(label: '🗑')
del_btn.signal_connect('clicked') do
@items.delete(item)
@list_box.remove(row)
update_status
end
hbox.append(del_btn)
row.child = hbox
row
end
def update_status
total = @items.size
done = @items.count(&:done)
pct = if total > 0
(100 * done / total).floor
else
Float::INFINITY
end
@status_label.text = "#{done} / #{total} (#{pct} %) 完了"
end
end
# ─── イベントハンドラ ─────────────────────────────────────
#
#
class SimpleTodoApp
private
def on_add_item
text = @new_entry.text.strip
return if text.empty?
item = TodoItem.new(text)
@items << item
@new_entry.text = ''
row = build_row(item)
@list_box.append(row)
add_drag_drop_to_row(row, item)
update_status
end
def on_reset_checks
@items.each { |i| i.done = false }
refresh_list
end
def on_open_file(file: nil, dir: nil)
$stderr.puts "{#{self.class}##{__method__}} file: #{file}, dir: #{dir}"
dialog = Gtk::FileChooserDialog.new(
title: 'TODOファイルを開く', parent: @window, action: :open
)
dialog.add_button('キャンセル', :cancel)
dialog.add_button('開く', :accept)
add_yaml_filter(dialog)
dialog.set_current_folder(Gio::File.new_for_path(dir)) if dir
dialog.set_current_name(file) if file
dialog.signal_connect('response') do |dlg, response|
load_file(dlg.file.path) if response == Gtk::ResponseType::ACCEPT
dlg.destroy
end
dialog.present
end
def on_save_file
# if @current_file.nil? || @current_file == TEMPLATE_PATH
$stderr.puts "{#{self.class}##{__method__}}" \
" @current_file: #{@current_file} v. #{TEMPLATE_DIR}"
if @current_file.nil? || \
File.dirname(File.realpath(@current_file)) \
== File.realpath(TEMPLATE_DIR)
# テンプレートまたは未保存の場合は名前を付けて保存に誘導
on_save_file_as
else
save_to_file(@current_file)
end
end
def on_save_file_as(file: nil, dir: nil)
dialog = Gtk::FileChooserDialog.new(
title: '名前を付けて保存', parent: @window, action: :save
)
dialog.add_button('キャンセル', :cancel)
dialog.add_button('保存', :accept)
add_yaml_filter(dialog)
# 初期フォルダ: 既存ファイルがあればそのディレクトリ,
# なければ USER_DIR
#
_initial_dir = if @current_file && \
File.dirname(File.realpath(@current_file)) \
!= File.realpath(TEMPLATE_DIR)
File.dirname(@current_file)
elsif Dir.exist?(USER_DIR)
USER_DIR
else
FileUtils.mkdir_p(USER_DIR)
USER_DIR
end
initial_dir = dir || _initial_dir
$stderr.puts "{#{self.class}##{__method__}} initial_dir: #{initial_dir}"
$stderr.puts "{#{self.class}##{__method__}}" \
" @current_file: #{@current_file}, TEMPLATE_PATH: #{TEMPLATE_PATH}"
# Directory and File.
dialog.set_current_folder(Gio::File.new_for_path(initial_dir))
# 新規保存時はタイトルをデフォルトのファイル名として設定
if @current_file
safe_name = File.basename(@current_file)
dialog.set_current_name(safe_name)
elsif file.nil?
safe_name = @todo_title.gsub(/[\/\\\:\*\?\"\<\>\|]/, "_")
dialog.set_current_name("#{safe_name}.yaml")
else
safe_name = file.gsub(/[\/\\\:\*\?\"\<\>\|]/, "_")
safe_name = safe_name.gsub(/(\.yaml)+$/, '')
dialog.set_current_name("#{safe_name}.yaml")
end
# main.
dialog.signal_connect('response') do |dlg, response|
if response == Gtk::ResponseType::ACCEPT
path = dlg.file.path
# path += ".yaml" if File.extname(path).empty?
path += '.yaml' if File.extname(path) != '.yaml'
$stderr.puts "{#{self.class}##{__method__}} path: #{path}"
# テンプレートへの上書きは禁止
if File.exist?(TEMPLATE_DIR) \
&& (File.dirname(File.realpath(File.dirname(path))) \
== File.realpath(TEMPLATE_DIR))
show_error(
'テンプレートファイルへの上書きはできません。' \
'別の名前で保存してください。'
)
else
$stderr.puts "{#{self.class}##{__method__}} saving" \
" ToDo list as #{path}"
FileUtils.mkdir_p TEMPLATE_DIR unless File.exist?(TEMPLATE_DIR)
save_to_file(path)
# @current_file = path # mod in save_to_file().
end
end
dlg.destroy
end
dialog.present
end
def add_yaml_filter(dialog)
f = Gtk::FileFilter.new
f.name = 'YAML files (*.yaml, *.yml)'
f.add_suffix('yaml')
f.add_suffix('yml')
dialog.add_filter(f)
all = Gtk::FileFilter.new
all.name = 'すべてのファイル'
all.add_pattern('*')
dialog.add_filter(all)
end
end
# ─── ファイルI/O ──────────────────────────────────────────
#
#
class SimpleTodoApp
private
def load_last_file_or_template
last = load_app_config['last_file']
$stderr.puts "{#{self.class}##{__method__}} last_file: #{last}"
# if last && File.exist?(last) && last != TEMPLATE_PATH
if last && File.exist?(last)
if File.dirname(File.realpath(last)) != File.realpath(TEMPLATE_DIR)
$stderr.print "loading last file: #{last}..."
else
$stderr.print "loading a template file: #{last}..."
end
load_file(last)
$stderr.puts ' done.'
# elsif File.exist?(TEMPLATE_PATH)
# last = TEMPLATE_PATH
# $stderr.print "loading a template file: #{last}..."
# load_file(last, as_template: true)
# $stderr.puts " done."
else
$stderr.print 'opening a template file...'
last = TEMPLATE_PATH
load_file(last, as_template: true)
$stderr.puts ' done.'
end
end
def load_app_config
@app_config = (YAML.load_file(APP_CONFIG_PATH) || {})
rescue
{}
end
def save_app_config
current = load_app_config
current['last_file'] = @current_file
unless File.exist? APP_CONFIG_PATH
dir = File.dirname APP_CONFIG_PATH
FileUtils.mkdir_p(dir)
end
File.write(APP_CONFIG_PATH, current.to_yaml)
rescue => e
$stderr.puts "Cannot save the config file: #{e.message}:#{e.class}"
end
def load_file(path, as_template: false)
data = YAML.load_file(path)
# main.
@todo_title = data['title'] || 'TODO List'
@items = (data['items'] || []).map { |h| TodoItem.from_h(h) }
$stderr.puts "{#{self.class}##{__method__}} @todo_title: #{@todo_title}"
$stderr.puts "{#{self.class}##{__method__}} @items: #{@items}"
# テンプレートとして読み込んだ場合は @current_file を設定しない
if as_template
@current_file = nil
@window.title = "TODO App — 新規 (#{File.basename(path)} から)"
else
@current_file = path
@window.title = "TODO App — #{File.basename(path)}"
save_app_config
end
@title_entry.text = @todo_title
refresh_list
rescue => e
err_msg = "{#{__method__}} 読み込みエラー: #{e.message}:#{e.class}"
err_msg += e.backtrace.join(" ")
show_error(err_msg)
end
def save_to_file(path)
dir = File.dirname(path)
file = File.basename(path)
if File.exist?(TEMPLATE_DIR) && \
(File.realpath(dir) == File.realpath(TEMPLATE_DIR))
# raise "file is template: #{path}"
path = "#{USER_DIR}/#{file}"
@current_file = path
$stderr.puts "file #{file} is template (#{dir}). save this into" \
" #{path} (#{@current_file})."
end
#
$stderr.print "{#{self.class}##{__method__}} save todo" \
" list #{path} and app config file #{APP_CONFIG_PATH}..."
data = { 'title' => @todo_title, 'items' => @items.map(&:to_h) }
File.write(path, data.to_yaml)
@window.title = "TODO App — #{File.basename(path)}"
@current_file = path
save_app_config
$stderr.puts " done."
rescue => e
show_error("保存エラー: #{e.message}")
end
def show_error(msg)
dlg = Gtk::MessageDialog.new(
parent: @window, flags: :modal, type: :error,
buttons_type: :ok, message: msg
)
dlg.signal_connect('response') { dlg.destroy }
dlg.present
end
end
# execute.
SimpleTodoApp.new.run
#### endof simple_todo_app.rb
0 件のコメント:
コメントを投稿
何かありましたら、どうぞ: