# frozen_string_literal: true require 'tox' require 'thread' require 'faker' require 'screen' class Main SAVEDATA_FILENAME = File.expand_path '../savedata', __dir__ def self.inherited(_base) raise "#{self} is final" end def self.mutex (@mutex ||= Mutex.new).tap { freeze } end def initialize raise "#{self.class} is singleton" unless self.class.mutex.try_lock call end private def call before_loop before_iteration @tox_client.run ensure after_loop end def before_loop tox_options = Tox::Options.new tox_options.savedata = File.binread SAVEDATA_FILENAME if File.exist? SAVEDATA_FILENAME @tox_client = Tox::Client.new tox_options on_friends_load @tox_client.friends @tox_client.on_iteration do after_iteration before_iteration end @tox_client.on_friend_request do |public_key| on_friend_add @tox_client.friend_add_norequest public_key end @tox_client.on_friend_message(&method(:on_friend_message)) @screen = Screen.new Style.default = Style.new end def after_loop @screen&.close File.binwrite SAVEDATA_FILENAME, @tox_client.savedata if @tox_client end def before_iteration @screen.props = state @screen.render end def after_iteration @screen.poll end def state @state ||= { x: 0, y: 0, width: Curses.stdscr.maxx, height: Curses.stdscr.maxy, focus: :sidebar, focused: true, on_window_left: method(:on_window_left), on_window_right: method(:on_window_right), on_menu_up: method(:on_menu_up), on_menu_down: method(:on_menu_down), on_new_message_putc: method(:on_new_message_putc), on_new_message_left: method(:on_new_message_left), on_new_message_right: method(:on_new_message_right), on_new_message_home: method(:on_new_message_home), on_new_message_end: method(:on_new_message_end), on_new_message_backspace: method(:on_new_message_backspace), on_new_message_delete: method(:on_new_message_delete), sidebar: { x: 0, y: 0, width: Widgets::Logo::WIDTH, height: Curses.stdscr.maxy, focus: :menu, focused: true, logo: { x: 0, y: 0, width: Widgets::Logo::WIDTH, height: Widgets::Logo::HEIGHT, }.freeze, menu: { x: 0, y: Widgets::Logo::HEIGHT, width: Widgets::Logo::WIDTH, height: Curses.stdscr.maxy - Widgets::Logo::HEIGHT, focused: true, active: 0, top: 0, items: [], }.freeze, }.freeze, chat: { x: Widgets::Logo::WIDTH, y: 0, width: Curses.stdscr.maxx - Widgets::Logo::WIDTH, height: Curses.stdscr.maxy, focus: :new_message, focused: false, info: { x: Widgets::Logo::WIDTH, y: 0, width: Curses.stdscr.maxx - Widgets::Logo::WIDTH, height: 2, focused: false, name: Faker::Name.name, public_key: SecureRandom.hex(32), }.freeze, new_message: { x: Widgets::Logo::WIDTH, y: Curses.stdscr.maxy - 1, width: Curses.stdscr.maxx - Widgets::Logo::WIDTH, height: 1, focused: false, text: '', cursor_pos: 0, }.freeze, history: { x: Widgets::Logo::WIDTH, y: 2, width: Curses.stdscr.maxx - Widgets::Logo::WIDTH, height: Curses.stdscr.maxy - 3, focused: true, messages: [], }.freeze, }.freeze, }.freeze end def on_friends_load(friends) @state = state.merge( sidebar: state[:sidebar].merge( menu: state[:sidebar][:menu].merge( items: friends.map do |friend| { name: friend.name.freeze, online: friend.status == Tox::UserStatus::NONE, }.freeze end.freeze, ).freeze, ).freeze, ).freeze end def on_friend_add(friend) @state = state.merge( sidebar: state[:sidebar].merge( menu: state[:sidebar][:menu].merge( items: (state[:sidebar][:menu][:items] + [ { name: friend.name.freeze, online: friend.status == Tox::UserStatus::NONE, }.freeze, ]).freeze, ).freeze, ).freeze, ).freeze end def on_friend_message(friend, text) @state = state.merge( chat: state[:chat].merge( history: state[:chat][:history].merge( messages: (state[:chat][:history][:messages] + [ { out: false, time: Time.now.utc.freeze, name: friend.name.freeze, text: text.freeze, }.freeze, ]).freeze, ).freeze, ).freeze, ).freeze end def on_window_left @state = state.merge( focus: :sidebar, sidebar: state[:sidebar].merge( focused: true, logo: state[:sidebar][:logo].merge(focused: state[:sidebar][:focus] == :logo).freeze, menu: state[:sidebar][:menu].merge(focused: state[:sidebar][:focus] == :menu).freeze, ).freeze, chat: state[:chat].merge( focused: false, info: state[:chat][:info].merge(focused: false).freeze, new_message: state[:chat][:new_message].merge(focused: false).freeze, history: state[:chat][:history].merge(focused: false).freeze, ).freeze, ).freeze end def on_window_right @state = state.merge( focus: :chat, sidebar: state[:sidebar].merge( focused: true, logo: state[:sidebar][:logo].merge(focused: false).freeze, menu: state[:sidebar][:menu].merge(focused: false).freeze, ).freeze, chat: state[:chat].merge( focused: false, info: state[:chat][:info].merge(focused: state[:chat][:focus] == :info).freeze, new_message: state[:chat][:new_message].merge(focused: state[:chat][:focus] == :new_message).freeze, history: state[:chat][:history].merge(focused: state[:chat][:focus] == :history).freeze, ).freeze, ).freeze end def on_menu_up @state = state.merge( sidebar: state[:sidebar].merge( menu: self.class.update_menu( state[:sidebar][:menu], active: state[:sidebar][:menu][:active] - 1, ), ).freeze, ).freeze end def on_menu_down @state = state.merge( sidebar: state[:sidebar].merge( menu: self.class.update_menu( state[:sidebar][:menu], active: state[:sidebar][:menu][:active] + 1, ), ).freeze, ).freeze end def on_new_message_putc(char) text = state[:chat][:new_message][:text] cursor_pos = state[:chat][:new_message][:cursor_pos] @state = state.merge( chat: state[:chat].merge( new_message: self.class.update_new_message( state[:chat][:new_message], text: "#{text[0...cursor_pos]}#{char}#{text[cursor_pos..-1]}", cursor_pos: cursor_pos + 1, ), ).freeze, ).freeze end def on_new_message_left @state = state.merge( chat: state[:chat].merge( new_message: self.class.update_new_message( state[:chat][:new_message], text: state[:chat][:new_message][:text], cursor_pos: state[:chat][:new_message][:cursor_pos] - 1, ), ).freeze, ).freeze end def on_new_message_right @state = state.merge( chat: state[:chat].merge( new_message: self.class.update_new_message( state[:chat][:new_message], text: state[:chat][:new_message][:text], cursor_pos: state[:chat][:new_message][:cursor_pos] + 1, ), ).freeze, ).freeze end def on_new_message_home @state = state.merge( chat: state[:chat].merge( new_message: self.class.update_new_message( state[:chat][:new_message], text: state[:chat][:new_message][:text], cursor_pos: 0, ), ).freeze, ).freeze end def on_new_message_end @state = state.merge( chat: state[:chat].merge( new_message: self.class.update_new_message( state[:chat][:new_message], text: state[:chat][:new_message][:text], cursor_pos: state[:chat][:new_message][:text].length, ), ).freeze, ).freeze end def on_new_message_backspace text = state[:chat][:new_message][:text] cursor_pos = state[:chat][:new_message][:cursor_pos] return unless cursor_pos.positive? @state = state.merge( chat: state[:chat].merge( new_message: self.class.update_new_message( state[:chat][:new_message], text: "#{text[0...(cursor_pos - 1)]}#{text[cursor_pos..-1]}", cursor_pos: cursor_pos - 1, ), ).freeze, ).freeze end def on_new_message_delete text = state[:chat][:new_message][:text] cursor_pos = state[:chat][:new_message][:cursor_pos] return if cursor_pos > text.length @state = state.merge( chat: state[:chat].merge( new_message: self.class.update_new_message( state[:chat][:new_message], text: "#{text[0...cursor_pos]}#{text[(cursor_pos + 1)..-1]}", cursor_pos: cursor_pos, ), ).freeze, ).freeze end class << self def update_menu(state, active:) top = state[:top] if active.negative? active = state[:items].count - 1 elsif active >= state[:items].count active = 0 end if active < state[:top] top = active elsif active >= state[:top] + state[:height] top = active - state[:height] + 1 end state.merge( active: active, top: top, ).freeze end def update_new_message(state, text:, cursor_pos:) if cursor_pos.negative? cursor_pos = 0 elsif cursor_pos > text.length cursor_pos = text.length end state.merge( text: text, cursor_pos: cursor_pos, ).freeze end end end