2026-02-06 23:03:50 -06:00
#!/usr/bin/env ruby
require 'socket'
require 'io/console'
require 'optparse'
PUBLIC_HOST = '0.0.0.0'
PRIVATE_HOST = '127.0.0.1'
DEFAULT_CLIENT_HOST = '127.0.0.1'
2026-02-12 13:46:36 -06:00
VERSION = {
:SRV_TXT = > '0.01' ,
:CLN_TXT = > '0.01' ,
:CLN_ARO = > '0.02' ,
:SRV_CO8 = > '0.30' , # 8-color
:CLN_CO8 = > '0.30' , # 8-color
:SRV_GSZ = > '0.40' ,
:FIL_TXT = > '0.01' ,
:FIL_CUP = > '0.02' ,
:FIL_CO8 = > '0.30' ,
}
2026-02-06 23:03:50 -06:00
options = {
:port = > 7570 ,
:server = > false ,
:host = > DEFAULT_CLIENT_HOST ,
:server_host = > PRIVATE_HOST ,
:rows = > 24 ,
:cols = > 40 ,
2026-02-12 13:46:36 -06:00
:file = > nil ,
:file_lockout = > false ,
:file_interval = > 600 ,
2026-02-06 23:03:50 -06:00
}
OptionParser . new do | opt |
opt . on ( '-pPORT' , '--port PORT' , Integer ,
2026-02-07 17:36:50 -06:00
'Port to serve/listen on (defaults to 7570)' ) do | o |
2026-02-06 23:03:50 -06:00
options [ :port ] = o
end
opt . on ( '-S' , '--server' ,
2026-02-07 17:36:50 -06:00
'Make this instance function as a server'
2026-02-06 23:03:50 -06:00
) do
options [ :server ] = true
end
opt . on ( '-s' , '--client' ,
'Make this instance function as a client'
) do
options [ :server ] = false
end
opt . on ( '-HHOST' , '--host HOST' , String ,
2026-02-10 13:35:44 -06:00
'(client only) Host to connect to (defaults to `localhost`)' ) do | o |
2026-02-06 23:03:50 -06:00
options [ :host ] = o
end
2026-02-12 13:46:36 -06:00
opt . on ( '-tSECONDS' , '--save-interval SECONDS' , Float ,
'(server only) How long to wait between file writes in seconds. (defaults to 600 which is 10 minutes)'
) do | o |
options [ :file_interval ] = o
end
2026-02-06 23:03:50 -06:00
opt . on ( '-rROWS' , '--rows ROWS' , Integer ,
'(server only) The number of rows to have in the shared buffer'
) do | o |
options [ :rows ] = o
end
opt . on ( '-cCOLS' , '--cols COLS' , Integer ,
'(server only) The number of columns to have in the shared buffer'
) do | o |
options [ :cols ] = o
end
2026-02-07 17:36:50 -06:00
opt . on ( '--expose-this-instance-for-the-world-to-see-because-I-know-what-I\'m-doing [please]' ,
2026-02-06 23:03:50 -06:00
# X -- eXposed
2026-02-07 17:36:50 -06:00
'(server only) Serve publicly, rather than privately. Please ONLY add this option if you entierly know what you\'re doing and have permission from/are the server owner. Say please to expose.'
) do | o |
2026-02-10 13:35:44 -06:00
if o == " please "
options [ :server_host ] = PUBLIC_HOST
end
2026-02-06 23:03:50 -06:00
end
opt . on ( '-x' , '--private' ,
# x -- not visible
'(server only) Serve privately, rather than publicly (default)'
) do
options [ :server_host ] = PRIVATE_HOST
end
2026-02-12 13:46:36 -06:00
opt . on ( '-f FILE' , '--save-file FILE' ,
'(server only) File to read initial state from and save state to.'
) do | f |
break if options [ :file_lockout ]
options [ :file_lockout ] = true
file_stuff = {
:name = > f ,
:text = > '' ,
:color = > [ ] ,
:cursor = > 0 ,
:writeme = > false
}
unless File . exist? f
file_stuff [ :writeme ] = true
options [ :file ] = file_stuff
options [ :file_lockout ] = false
next
end
File . readlines ( f , chomp : true ) . each do | msg |
ver , msg = msg . chomp . split ( '|' , 2 )
next unless msg != nil
case ver
when VERSION [ :FIL_TXT ]
file_stuff [ :text ] = msg
when VERSION [ :FIL_CO8 ]
msg . each_char . with_index do | c , i |
next unless c =~ / [0-7] /
file_stuff [ :color ] << {
:co8 = > 7
} until file_stuff [ :color ] . length > i
file_stuff [ :color ] [ i ] [ :co8 ] = c . to_i . clamp ( 0 , 7 )
end
when VERSION [ :FIL_CUP ]
file_stuff [ :cursor ] = msg . to_i
end
end
options [ :file ] = file_stuff
end
2026-02-06 23:03:50 -06:00
opt . on ( '-h' , '--help' , 'Get help with this command' ) do
puts opt
exit
end
2026-02-12 13:46:36 -06:00
end . parse!
2026-02-06 23:03:50 -06:00
HOST = options [ :host ]
SRV_HOST = options [ :server_host ]
PORT = options [ :port ]
ROWS = options [ :rows ]
COLS = options [ :cols ]
2026-02-12 13:46:36 -06:00
FILE_INTERVAL = options [ :file_interval ]
def do_server ( file_opts )
def write_current ( wi , buffer , cobuf , file_opts )
return if file_opts . nil?
File . open ( file_opts [ :name ] , 'w' ) do | f |
f . puts ( " #{ VERSION [ :FIL_TXT ] } | #{ clean_chars ( buffer ) } \n " )
f . write ( " #{ VERSION [ :FIL_CO8 ] } | " )
cobuf . each do | color |
f . write ( color [ :co8 ] . to_s )
end
f . puts ''
f . puts ( " #{ VERSION [ :FIL_CUP ] } | #{ wi } " )
end
end
2026-02-06 23:03:50 -06:00
server = TCPServer . new ( SRV_HOST , PORT )
sockets = [ server ]
2026-02-10 13:35:44 -06:00
sockats = {
server = > {
:color = > {
:co8 = > 7
}
}
}
2026-02-12 13:46:36 -06:00
if file_opts . nil? || file_opts [ :writeme ]
pre = " #{ ROWS } r #{ COLS } c >>> "
buffer = pre . ljust ( ROWS * COLS ) [ 0 ... ROWS * COLS ]
cobuf = Array . new ( ROWS * COLS , {
:co8 = > 7
} )
windex = pre . length # Wipe your buffer clean with your write index
else
buffer = file_opts [ :text ] . ljust ( ROWS * COLS ) [ 0 ... ROWS * COLS ]
cobuf = file_opts [ :color ]
if cobuf . length < ROWS * COLS
cobuf += Array . new ( ROWS * COLS - cobuf . length ) { { :co8 = > 7 } }
else
cobuf = cobuf . first ( ROWS * COLS )
end
windex = file_opts [ :cursor ]
end
windex = windex % buffer . length
if not file_opts . nil?
write_current ( windex , buffer , cobuf , file_opts )
end
2026-02-06 23:03:50 -06:00
puts " PORT: #{ PORT } "
puts " GRID SIZE: #{ ROWS } r #{ COLS } c "
2026-02-07 17:36:50 -06:00
if SRV_HOST == PUBLIC_HOST then puts " EXPOSED " end
2026-02-06 23:03:50 -06:00
puts 'Listening...'
2026-02-07 17:07:50 -06:00
motions = [ ]
2026-02-06 23:03:50 -06:00
2026-02-10 13:35:44 -06:00
def new_buf_cob_wi_out ( buf , cob , wi , newtext , newcolors , motions : [ ] )
2026-02-06 23:03:50 -06:00
buf = buf . dup
2026-02-10 13:35:44 -06:00
cob = cob . dup
2026-02-06 23:03:50 -06:00
out = ''
nti = 0 # New Text Index
2026-02-10 13:35:44 -06:00
wco = { } #Writing Color
cur = motions . length > 0 #Update cursor at end
2026-02-07 17:07:50 -06:00
while ( nti < newtext . length ) || ( motions . length > 0 )
if motions . length > 0 && nti > = motions [ 0 ] [ :nti ]
case motions [ 0 ] [ :dir ]
when 'u'
wi -= COLS
when 'd'
wi += COLS
when 'l'
wi -= 1
when 'r'
wi += 1
end
2026-02-10 13:35:44 -06:00
wi = wi %( ROWS*COLS )
2026-02-07 17:07:50 -06:00
motions . shift # remove first motion
end
2026-02-10 13:35:44 -06:00
next if nti > = newtext . length
if newcolors [ nti ] != wco
wco = newcolors [ nti ]
if wco . has_key? :co8
out << " #{ VERSION [ :SRV_CO8 ] } | #{ wco [ :co8 ] } \n "
end
end
2026-02-07 17:07:50 -06:00
out << " #{ VERSION [ :SRV_TXT ] } | "
2026-02-06 23:03:50 -06:00
out << ( ( wi / COLS ) + 1 ) . to_s + '|'
out << ( ( wi % COLS ) + 1 ) . to_s + '|'
loop do
out << newtext [ nti ]
buf [ wi ] = newtext [ nti ]
2026-02-10 13:35:44 -06:00
cob [ wi ] = wco
2026-02-06 23:03:50 -06:00
wi += 1
nti += 1
2026-02-10 13:35:44 -06:00
break if newcolors [ nti ] != wco
2026-02-07 17:07:50 -06:00
break if motions . length > 0 && nti > = motions [ 0 ] [ :nti ]
2026-02-06 23:03:50 -06:00
break if nti > = newtext . length
break if wi % COLS == 0
end
2026-02-10 13:35:44 -06:00
wi = wi %( ROWS*COLS )
2026-02-06 23:03:50 -06:00
out << " \n "
end
2026-02-10 13:35:44 -06:00
if cur
out << " #{ VERSION [ :SRV_TXT ] } | "
out << ( ( wi / COLS ) + 1 ) . to_s + '|'
out << ( ( wi % COLS ) + 1 ) . to_s + " | \n "
end
[ buf , cob , wi , out ]
2026-02-06 23:03:50 -06:00
end
2026-02-12 13:46:36 -06:00
begin
old = {
:windex = > windex . dup ,
:buffer = > buffer . dup ,
:cobuf = > cobuf . dup ,
:lastsave = > Time . now ,
}
Thread . new do
loop do
time_since_next_save = Time . now - old [ :lastsave ]
time_till_next_save = FILE_INTERVAL - time_since_next_save
# To ensure thread doesn't bog down cpu too much.....
sleep [
[
[ 1 . 0 , # Wait a second
time_till_next_save / 2 # or half the remaining time
] . max , # Whichever's longer
time_till_next_save ] . min , # Unless the remaining time is shorter than a second
1 . 0 ] . max # But don't wait longer than no time at all
unless ( old [ :windex ] == windex &&
old [ :buffer ] == buffer &&
old [ :cobuf ] == cobuf )
next if file_opts . nil?
next if time_since_next_save < FILE_INTERVAL
old = {
:lastsave = > Time . now ,
:windex = > windex . dup ,
:buffer = > buffer . dup ,
:cobuf = > cobuf . dup
2026-02-10 13:35:44 -06:00
}
2026-02-12 13:46:36 -06:00
write_current ( windex , buffer , cobuf , file_opts )
puts " [ #{ Time . now . strftime ( " %Y-%m-%d %H:%M:%S " ) } ] File autosaved. "
2026-02-06 23:03:50 -06:00
end
end
2026-02-12 13:46:36 -06:00
end
loop do
ready , = IO . select ( sockets )
newtext = ''
newcolors = [ ]
ready . each do | sck |
if sck == server
client = server . accept
sockats [ client ] = {
:color = > {
:co8 = > 7
}
}
sockets << client
puts " Connection: #{ client . peeraddr } "
client . puts " #{ VERSION [ :SRV_GSZ ] } | #{ ROWS } | #{ COLS } "
client . puts new_buf_cob_wi_out ( buffer , cobuf , 0 , buffer , cobuf ) [ 3 ]
if buffer . length > 1
client . puts new_buf_cob_wi_out (
buffer , cobuf , windex - 1 ,
buffer [ windex - 1 ] , [ cobuf [ windex - 1 ] ] ) [ 3 ]
end
2026-02-06 23:03:50 -06:00
next
end
2026-02-12 13:46:36 -06:00
begin
msg = sck . gets
unless msg
puts " Disconnect: #{ sck . peeraddr } "
sck . close
sockets . delete ( sck )
sockats . delete ( sck )
next
2026-02-10 13:35:44 -06:00
end
2026-02-12 13:46:36 -06:00
ver , msg = msg . chomp . split ( '|' , 2 )
next unless msg != nil
case ver
when VERSION [ :CLN_TXT ]
newtext << clean_chars ( msg )
msg . length . times do
newcolors << sockats [ sck ] [ :color ] . dup
end
when VERSION [ :CLN_ARO ]
2026-02-07 17:07:50 -06:00
dir = 'l'
2026-02-12 13:46:36 -06:00
case msg
when 'A'
dir = 'u'
when 'B'
dir = 'd'
when 'C'
dir = 'r'
when 'D'
dir = 'l'
else
next
end
motions << {
:nti = > newtext . length - 1 ,
:dir = > dir ,
}
when VERSION [ :CLN_CO8 ]
sockats [ sck ] [ :color ] [ :co8 ] = msg . to_i . clamp ( 0 , 7 )
2026-02-07 17:07:50 -06:00
end
rescue Errno :: ECONNRESET , Errno :: EPIPE
2026-02-12 13:46:36 -06:00
puts " Disconnect (err): #{ sck . peeraddr } "
sck . close
sockets . delete ( sck )
2026-02-07 17:07:50 -06:00
end
2026-02-12 13:46:36 -06:00
buffer , cobuf , windex , out = new_buf_cob_wi_out ( buffer , cobuf , windex , newtext , newcolors , motions : motions )
motions = [ ]
clients = sockets - [ server ]
clients . each do | c |
begin
c . puts out
rescue Errno :: ECONNRESET , Errno :: EPIPE
puts " Disconnect (err): #{ c . peeraddr } "
c . close
sockets . delete ( c )
end
end
#if out != ''
# puts "#{windex}: (#{(windex/COLS)+1},#{(windex%COLS)+1})"
#end
2026-02-07 17:07:50 -06:00
end
2026-02-06 23:03:50 -06:00
end
2026-02-12 13:46:36 -06:00
ensure
write_current ( windex , buffer , cobuf , file_opts )
puts " File saved on exit. "
2026-02-06 23:03:50 -06:00
end
end
2026-02-07 17:07:50 -06:00
def clean_chars ( chars )
out = ''
chars . each_char do | char |
if char . ord < 0x20 # Non printing
out << '^' + ( char . ord + 0x40 ) . chr
elsif char . ord == 0x7F # DEL (Non print)
out << '^?'
else
out << char
end
end
out
end
2026-02-12 13:46:36 -06:00
2026-02-06 23:03:50 -06:00
def do_client
socket = TCPSocket . new ( HOST , PORT )
2026-02-12 13:46:36 -06:00
grid_rows = 24
grid_cols = 40
2026-02-06 23:03:50 -06:00
puts " PORT: #{ PORT } "
puts " HOST: #{ HOST } "
2026-02-12 13:46:36 -06:00
def print_mode ( mode , gridrows )
return if ENV [ " TERM " ] . downcase == " dumb "
#print ("\x1b[s"+ # Save cursor
# "\x1b[m"+ # Reset color
# "\x1b[#{gridrows+1};1H"+# Cursor pos
# "\x1b[2K"+ # Clear line
# mode+ # Print mode
# "\x1b[u") # Restore cursor
print " \e [s \e [m \e [ #{ gridrows + 1 } ;1H \e [2K #{ mode } \e [u "
end
def parse_esckey ( gridrows )
begin
char2 = STDIN . read_nonblock ( 1 )
return [ :RAW , " \e #{ char2 } " ] unless char2 == '['
rescue
return [ :KEY_ESCAPE , '' ]
end
begin
char3 = STDIN . read_nonblock ( 1 )
rescue
return [ :RAW , " \e #{ char2 } " ]
end
case char3
when 'A'
return [ :ARROW , 'A' ]
when 'B'
return [ :ARROW , 'B' ]
when 'C'
return [ :ARROW , 'C' ]
when 'D'
return [ :ARROW , 'D' ]
else
loop do
begin
char3 << STDIN . read_nonblock ( 1 )
break unless char3 [ - 1 ] =~ / [0-9:; \ <= \ > \ ?] /
rescue
break
end
end
print_mode " #{ clean_chars ( " \e " + char2 + char3 ) } -- I'm unfamiliar with that key. " , gridrows
return [ :RAW , " \e #{ char2 } #{ char3 } " ]
end
end
2026-02-06 23:03:50 -06:00
begin
print " \x1b [?1049h \x1b [2J \x1b [3J " # Open and clear alternate buffer
2026-02-10 13:35:44 -06:00
print " \x1b [m " # Reset color
2026-02-06 23:03:50 -06:00
#reader = Thread.new do
Thread . new do
loop do
msg = socket . gets
exit 0 unless msg
ver , msg = msg . split ( '|' , 2 )
next unless msg != nil
2026-02-10 13:35:44 -06:00
case ver
when VERSION [ :SRV_TXT ]
row , col , chars = msg . split ( '|' , 3 )
print " \x1b [ #{ row } ; #{ col } H #{ chars . chomp } "
when VERSION [ :SRV_CO8 ]
next if ENV [ " TERM " ] . downcase == " dumb "
print " \x1b [3 #{ msg . to_i . clamp ( 0 , 7 ) } m "
2026-02-12 13:46:36 -06:00
when VERSION [ :SRV_GSZ ]
grid_rows , grid_cols = msg . split ( '|' , 2 ) . map { | p | p . to_i }
print_mode " :3 " , grid_rows
2026-02-10 13:35:44 -06:00
end
2026-02-06 23:03:50 -06:00
end
end
2026-02-10 13:35:44 -06:00
socket . puts " #{ VERSION [ :CLN_CO8 ] } | #{ rand ( 1 .. 7 ) } "
2026-02-06 23:03:50 -06:00
while ( char = STDIN . noecho ( & :getch ) )
2026-02-12 13:46:36 -06:00
print_mode " :3 " , grid_rows
2026-02-10 13:35:44 -06:00
exit if char == " \x04 " # ^D
2026-02-10 14:12:26 -06:00
if char == " \x0F " # (^O) Color change
if / [0-7] / . match? ( char2 = STDIN . noecho ( & :getch ) . upcase )
socket . puts " #{ VERSION [ :CLN_CO8 ] } | #{ char2 } "
next
else
char << char2
end
elsif char == " \x1b " # Detect arrows
2026-02-12 13:46:36 -06:00
keytype , keytext = parse_esckey ( grid_rows )
if keytype == :ARROW
socket . puts " #{ VERSION [ :CLN_ARO ] } | #{ keytext } "
2026-02-07 17:07:50 -06:00
next
2026-02-12 13:46:36 -06:00
elsif keytype != :KEY_ESCAPE
char = keytext
2026-02-07 17:07:50 -06:00
end
elsif ( char == " \x08 " || # backspace
char == " \x7F " ) # delete
2026-02-10 13:35:44 -06:00
socket . puts " #{ VERSION [ :CLN_ARO ] } |D "
2026-02-10 14:12:26 -06:00
socket . puts " #{ VERSION [ :CLN_TXT ] } | "
socket . puts " #{ VERSION [ :CLN_ARO ] } |D "
2026-02-10 13:35:44 -06:00
next
2026-02-07 10:18:34 -06:00
end
2026-02-07 17:07:50 -06:00
socket . puts " #{ VERSION [ :CLN_TXT ] } | #{ clean_chars ( char ) } "
2026-02-06 23:03:50 -06:00
end
ensure
2026-02-10 13:35:44 -06:00
print " \x1b [m " # Reset color
2026-02-06 23:03:50 -06:00
print " \x1b [?1049l " # Back to regular buffer
end
end
if options [ :server ]
2026-02-12 13:46:36 -06:00
do_server ( options [ :file ] )
2026-02-06 23:03:50 -06:00
else
do_client
end