bar_1

contents_map

2026年7月15日水曜日

[習作][Ruby/GTK4][VibeCoding] シンプルなToDoアプリ

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 件のコメント:

コメントを投稿

何かありましたら、どうぞ: