Rename to src

This commit is contained in:
2022-02-18 16:26:51 -05:00
parent b4fbc7c7e2
commit 28355777e1
9 changed files with 0 additions and 0 deletions

463
src/command_prompt.py Normal file
View File

@ -0,0 +1,463 @@
from kivy.uix.label import Label
from kivy.core.window import Window
from kivy.graphics import Color,Rectangle
import glob
import os.path
import filecheck
class Command_prompt(Label):
def __init__(self,app,**kwargs):
# app is used to share information between widgets
self.app=app
# insert mode
self.insert=False
# width of a letter
self.char_width=9
# cursor position
self.cursor=0
# the text, with no color information
self.raw_text=""
# a one-time message that shows up over the bar
self.message=""
# array of command with arguments
self.argv=[]
# display characters from window_offset onwards (for text wrapping)
self.window_offset=0
# init Label
super(Command_prompt,self).__init__(**kwargs)
self.keyboard = Window.request_keyboard(None,self,"text")
self.keyboard.bind(on_textinput=self.on_textinput,on_key_down=self.on_key_down)
self.draw()
def draw(self):
self.canvas.before.clear()
# background
with self.canvas.before:
Color(0,0,0)
Rectangle(pos=self.pos,size=self.size)
# if message is not empty, draw message instead
if self.message!="":
# do not wrap
self.text=self.message[:min(len(self.message),int(self.width/self.char_width))]
self.message=""
return
# wrap text
window_size=int(self.width/self.char_width)
if self.cursor>=self.window_offset+window_size:
self.window_offset=self.cursor-window_size+1
elif self.cursor<self.window_offset:
self.window_offset=self.cursor
# do not stop right before ':'
if self.window_offset==1:
self.window_offset=0
# cursor
with self.canvas.before:
Color(1,1,1)
# wrap cursor position
Rectangle(pos=(self.pos[0]+(self.cursor-self.window_offset)*self.char_width,self.pos[1]),size=(self.char_width,self.height))
# make text under cursor black
end=min(len(self.raw_text),self.window_offset+window_size)
if self.cursor<end:
self.text=self.raw_text[self.window_offset:self.cursor]+"[color=000]"+self.raw_text[self.cursor]+"[/color]"+self.raw_text[self.cursor+1:end]
else:
self.text=self.raw_text[self.window_offset:end]
def on_key_down(self, keyboard, keycode, text, modifiers):
#print(keycode,text,modifiers)
if self.insert:
# process modifiers
mods=self.process_modifiers(modifiers)
# process command
if keycode[1]=="enter":
self.insert=False
self.run_command()
self.set_text("")
elif keycode[1]=="escape":
self.insert=False
self.set_text("")
# move
elif keycode[1]=="left":
self.move_cursor_relative(-1)
elif keycode[1]=="right":
self.move_cursor_relative(1)
elif mods==['c'] and text=="a":
self.move_cursor(1)
elif mods==['c'] and text=="e":
self.move_cursor(len(self.raw_text))
# delete
elif keycode[1]=="backspace":
self.backspace()
elif keycode[1]=="delete":
self.delete_forward()
elif mods==['c'] and text=="k":
self.delete_all_forward()
# tab completion
elif keycode[1]=="tab":
self.tab_complete()
def on_textinput(self,window,text):
# reset status_bar
self.app.status_bar.draw()
if self.insert:
self.append_text_at_cursor(text)
elif text==':':
self.append_text_at_cursor(text)
self.insert=True
# get modifiers from list
# returns an alphabetically ordered array of characters
# 'a' is alt
# 'c' is ctrl
# 'm' is meta
# 's' is shift
# ignore all others
def process_modifiers(self,modifiers):
out=[]
for mod in modifiers:
if mod=="alt":
out.append('a')
elif mod=="ctrl":
out.append('c')
elif mod=="meta":
out.append('m')
elif mod=="shift":
out.append('s')
out.sort()
return out
# append to text at position of cursor
def append_text_at_cursor(self,text):
if self.cursor==len(self.raw_text):
self.raw_text+=text
else:
self.raw_text=self.raw_text[:self.cursor]+text+self.raw_text[self.cursor:]
self.cursor+=len(text)
self.draw()
# delete before cursor
def backspace(self):
# do not delete leading ':'
if self.cursor==1:
return
if self.cursor==len(self.raw_text):
self.raw_text=self.raw_text[:self.cursor-1]
else:
self.raw_text=self.raw_text[:self.cursor-1]+self.raw_text[self.cursor:]
self.cursor-=1
self.draw()
# delete after cursor
def delete_forward(self):
if self.cursor==len(self.raw_text):
return
else:
self.raw_text=self.raw_text[:self.cursor]+self.raw_text[self.cursor+1:]
self.draw()
# delete until end of line(self):
def delete_all_forward(self):
if self.cursor==len(self.raw_text):
return
else:
self.raw_text=self.raw_text[:self.cursor]
self.draw()
# set text in prompt
def set_text(self,text):
self.raw_text=text
self.cursor=len(text)
self.draw()
# move cursor n steps to right (left if negative)
def move_cursor_relative(self,n):
if n==0 or (n>0 and self.cursor==len(self.raw_text)) or (n<0 and self.cursor==0):
return
self.cursor=max(1,min(len(self.raw_text),self.cursor+n))
self.draw()
# move cursor to absolute position
def move_cursor(self,n):
self.cursor=max(1,min(len(self.raw_text),n))
self.draw()
# parse text as argv
def parse_argv(self):
# init
self.argv=[""]
# return position of cursor
cursor=(0,0)
# whether inside quotes
single_quoted=False
double_quoted=False
# whether after '\'
backslashed=False
# start after ':'
# proceed one character at a time
for i in range(1,len(self.raw_text)):
char=self.raw_text[i]
# split argument
if single_quoted==False and double_quoted==False and backslashed==False and char==' ':
# new argv
self.argv.append("")
# quotes or '\'
elif double_quoted==False and backslashed==False and char=='\'':
single_quoted=not single_quoted
elif single_quoted==False and backslashed==False and char=='"':
double_quoted=not double_quoted
elif single_quoted==False and backslashed==False and char=='\\':
backslashed=True
# write character
else:
self.argv[len(self.argv)-1]+=char
# reset backslash
backslashed=False
# record position of cursor
if self.cursor==i+1:
cursor=(len(self.argv)-1,len(self.argv[len(self.argv)-1]))
return cursor
# tab completion
def tab_complete(self):
# parse argv
cursor=self.parse_argv()
# write and edit commands
if self.argv[0]=="w" or self.argv[0]=="e":
# check that cursor is in first argument
if cursor[0]==1:
# complete filesystem path
self.append_text_at_cursor(self.complete_path(self.argv[cursor[0]],cursor[1]))
# complete filesystem path
def complete_path(self,base,end):
paths=glob.glob(glob.escape(base[:end])+"*")
print(glob.escape(base[:end])+"*", paths)
if len(paths)==0:
return ""
elif len(paths)==1:
# append '/' to directories
if os.path.isdir(paths[0]):
return paths[0][end:]+"/"
else:
return paths[0][end:]
else:
# display in status bar
self.app.status_bar.message=""
for path in paths:
name=os.path.basename(path)
# add quotes if needed
if " " in name:
name="\""+name+"\""
self.app.status_bar.message+=name+" "
self.app.status_bar.draw()
return os.path.commonprefix(paths)[end:]
# run command
def run_command(self):
# parse command line
self.parse_argv()
# commands that cannot be compounded
if self.argv[0]=="set":
self.run_set(self.argv)
return
# single letter commands (which can be combined)
# write
if self.argv[0]=="w" or self.argv[0]=="w!" or self.argv[0]=="wq":
self.run_write(self.argv)
# edit file
if self.argv[0]=="e":
self.run_edit(self.argv)
# quit
if self.argv[0]=="q" or self.argv=="wq":
self.app.stop()
# set properties of particles
def run_set(self,argv):
if len(argv)<2:
self.message="error: 'set' command was run with too few arguments -- usage: 'set <property> <value>'"
return
if argv[1]=="color":
self.run_set_color(argv)
return
elif argv[1]=="grid":
self.run_set_grid(argv)
return
elif argv[1]=="zoom":
self.run_set_zoom(argv)
else:
self.message="error: unrecognized command '"+argv[1]+"'"
return
# set color
def run_set_color(self,argv):
if len(argv)<3:
self.message="error: 'set color' command was run with without anargument -- usage: 'set color <color_descriptor>'"
return
if argv[2]=="blue":
self.app.painter.set_color((0,0,1))
elif argv[2]=="green":
self.app.painter.set_color((0,1,0))
elif argv[2]=="red":
self.app.painter.set_color((1,0,0))
elif argv[2]=="cyan":
self.app.painter.set_color((0,1,1))
elif argv[2]=="magenta":
self.app.painter.set_color((1,0,1))
elif argv[2]=="yellow":
self.app.painter.set_color((1,1,0))
elif argv[2]=="white":
self.app.painter.set_color((1,1,1))
elif argv[2]=="black":
self.app.painter.set_color((0,0,0))
elif argv[2]=="gray":
self.app.painter.set_color((0.5,0.5,0.5))
else:
color_str=argv[2].split(",")
# error if improperly formatted
if len(color_str)!=3:
self.message="error: unrecognized color specification '"+argv[2]+"'; supported format is 'r,g,b' or one of red|green|blue|cyan|magenta|yellow|white|black|gray"
return
try:
color=(float(color_str[0]),float(color_str[1]),float(color_str[2]))
except:
self.message="error: unrecognized color specification '"+argv[2]+"'; supported format is 'r,g,b' or one of red|green|blue|cyan|magenta|yellow|white|black|gray"
return
self.app.painter.set_color(color)
# toggle grid
def run_set_grid(self,argv):
if len(argv)==2:
# no argument: set to toggle
self.app.painter.set_grid(-1)
elif argv[2]=="on":
self.app.painter.set_grid(1)
elif argv[2]=="off":
self.app.painter.set_grid(0)
else:
try:
mesh=float(argv[2])
except:
self.message="error: unrecognized argument '"+argv[2]+"' -- usage 'set grid [on|off|<float>]'"
return
if mesh<0:
self.message="error: grid size cannot be negative: '"+argv[2]+"'"
return
self.app.painter.set_grid(mesh)
# set zoom level (changes size of elements)
def run_set_zoom(self,argv):
if len(argv)==2:
self.message="error: missing argument in 'set zoom'"
return
else:
try:
zoom_level=float(argv[2])
except:
self.message="error: unrecognized argument '"+argv[2]+"' -- usage 'set zoom <zoom_level>'"
return
if zoom_level<0:
self.message="error: zoom level cannot be negative: '"+argv[2]+"'"
return
self.app.painter.set_zoom(zoom_level)
# write to file
def run_write(self,argv):
if len(argv)>2:
self.message="error: could not write to file: too many arguments -- usage: ':w [path to file]'"
return
elif len(argv)==1:
if self.app.openfile!="":
if self.app.readonly:
self.message="error: open file is readonly"
return
self.app.painter.write(self.app.openfile)
return
else:
self.message="error: no file is open for editing, specify a path"
return
(ret,self.message)=filecheck.check_write(argv[1],argv[0]=="w!")
# add comment if no overwrite
if ret==-2:
self.message+=" (use ':w!' to overwrite)"
if ret<0:
return
self.app.openfile=argv[1]
self.app.readonly=False
self.app.painter.write(argv[1])
# update status bar
self.app.status_bar.draw()
# edit file
def run_edit(self,argv):
if len(argv)>2:
self.message="error: could not open file: too many arguments -- usage: ':e <path to file>'"
return
elif len(argv)==1:
self.message="error: could not open file: no argument found -- usage: ':e <path to file>'"
return
# check that the file can be edited
(ret,self.message)=filecheck.check_edit(argv[1])
# error
if ret<0:
return
if os.path.isfile(argv[1]):
# read the file
self.app.painter.read(argv[1])
# set readonly mode
self.app.readonly=not os.access(argv[1],os.W_OK)
else:
# new file, reset painter
self.app.painter.reset()
self.app.readonly=False
# select openfile in app
self.app.openfile=argv[1]
# update status bar
self.app.status_bar.draw()

46
src/filecheck.py Normal file
View File

@ -0,0 +1,46 @@
import os.path
# check that a file can be edited
def check_edit(file):
# check that the file is not a directory
if os.path.isdir(file):
return(-1,"error: '"+file+"' is a directory")
if os.path.isfile(file):
# check permissions
if not os.access(file,os.R_OK):
return(-2,"error: permission denied: cannot read file '"+file+"'")
else:
# check write to directory
if len(os.path.dirname(file))>0:
if not os.access(os.path.dirname(file),os.W_OK):
return(-3,"error: permission denied: cannot write to parent directory '"+os.path.dirname(file)+"'")
else:
if not os.access(os.getcwd(),os.W_OK):
return(-4,"error: permission denied: cannot write to parent directory '"+os.getcwd()+"'")
return(0,"")
# check that a file can be written
def check_write(file,overwrite):
# check that the file is not a directory
if os.path.isdir(file):
return(-1,"error: '"+file+"' is a directory")
# check that file does not already exist
if not overwrite and os.path.isfile(file):
return(-2,"error: '"+file+"' already exists")
# check that the containing directory exists
if len(os.path.dirname(file))>0 and not os.path.isdir(os.path.dirname(file)):
return(-3,"error: could not find directory '"+os.path.dirname(file)+"'")
# check permissions
if os.path.isfile(file):
if not os.access(file,os.W_OK):
return(-4,"error: permission denied: cannot write to file '"+file+"'")
else:
# check write to directory
if len(os.path.dirname(file))>0:
if not os.access(os.path.dirname(file),os.W_OK):
return(-5,"error: permission denied: cannot write to parent directory '"+os.path.dirname(file)+"'")
else:
if not os.access(os.getcwd(),os.W_OK):
return(-6,"error: permission denied: cannot write to parent directory '"+os.getcwd()+"'")
return(0,"")

71
src/jam Executable file
View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
from kivy.app import App
from kivy.uix.widget import Widget
from kivy.uix.boxlayout import BoxLayout
from kivy.config import Config
import sys
import os.path
from painter import Painter
from status_bar import Status_bar
from command_prompt import Command_prompt
import filecheck
# App class
class Jam_app(App):
# name of .kv file for main interface
kv_file="jam.kv"
def __init__(self,**kwargs):
# the file open for editing
self.openfile=kwargs.get("openfile","")
# readonly mode
self.readonly=False
super(Jam_app,self).__init__()
def build(self):
layout=BoxLayout(orientation="vertical")
# painter
self.painter=Painter(self)
# status bar
self.status_bar=Status_bar(self)
# command prompt
self.command_prompt=Command_prompt(self)
layout.add_widget(self.painter)
layout.add_widget(self.status_bar)
layout.add_widget(self.command_prompt)
# read openfile
if os.path.isfile(self.openfile):
# read the file
self.painter.read(self.openfile)
# set readonly mode
self.readonly=not os.access(self.openfile,os.W_OK)
return layout
# disable red circles on right click
Config.set('input', 'mouse', 'mouse,disable_multitouch')
# do not exit on escape
Config.set('kivy', 'exit_on_escape', 0)
# read cli
openfile=""
if len(sys.argv)==2:
openfile=sys.argv[1]
# check file
(ret,message)=filecheck.check_edit(openfile)
if ret<0:
print(message,file=sys.stderr)
exit(-1)
# run
if __name__ == '__main__':
Jam_app(openfile=openfile).run()

26
src/jam.kv Normal file
View File

@ -0,0 +1,26 @@
#: kivy 2.0.0
<Status_bar>:
canvas.before:
Color:
rgb: 1,1,1
Rectangle:
pos: self.pos
size: self.size
size_hint_y: None
height: 20
text_size: self.size
markup: True
color: (0,0,0,1)
font_name: "RobotoMono-Regular"
font_size: "15px"
<Command_prompt>:
size_hint_y: None
height: 20
markup: True
text_size: self.size
font_name: "RobotoMono-Regular"
font_size: "15px"

463
src/painter.py Normal file
View File

@ -0,0 +1,463 @@
import sys
import math
from kivy.uix.widget import Widget
from kivy.core.window import Window
from kivy.graphics import Color,Line
from point import Point
from polyomino import Cross
from polyomino import Square_element
from tools import remove_fromlist
# painter class
class Painter(Widget):
def __init__(self,app,**kwargs):
# list of particles
self.particles=[]
# particle under mouse
self.undermouse=None
# list of selected particles
self.selected=[]
# complement
self.unselected=[]
# relative position of mouse when moving
self.offset=Point(0,0)
# reference particle from which positions should be computed
self.reference=None
# app is used to share information between widgets
self.app=app
# modifiers
self.modifiers=[]
# init Widget
super(Painter,self).__init__(**kwargs)
# init keyboard
self.keyboard = Window.request_keyboard(None,self,"text")
self.keyboard.bind(on_key_down=self.on_key_down,on_key_up=self.on_key_up,on_textinput=self.on_textinput)
def reset(self):
self.particles=[]
self.undermouse=None
self.draw()
# draw all particles
def draw(self):
self.canvas.clear()
with self.canvas:
# draw order: particles, then grids, then transparent particles
for particle in self.particles:
particle.draw()
# draw grids
for particle in self.particles:
if particle.grid>0:
self.draw_grid(particle.squares[0].pos,particle.grid)
for particle in self.particles:
particle.draw(alpha=0.5)
def draw_grid(self,pos,mesh):
# height offset due to status bar and command prompt
height_offset=self.app.status_bar.height+self.app.command_prompt.height
# vertical lines
# offest wrt 0
offset=(pos.x-0.5)%mesh
for i in range(math.floor((self.width/Square_element.size-offset)/mesh)+1):
Color(1,1,1)
Line(points=((i*mesh+offset)*Square_element.size,height_offset,(i*mesh+offset)*Square_element.size,self.height+height_offset))
# horizontal lines
# offset wrt 0
offset=(pos.y-0.5)%1-height_offset/Square_element.size
for i in range(math.floor((self.height/Square_element.size-offset)/mesh)+1):
Color(1,1,1)
Line(points=(0,(i*mesh+offset)*Square_element.size+height_offset,self.width,(i*mesh+offset)*Square_element.size+height_offset))
# respond to keyboard
def on_key_down(self, keyboard, keycode, text, modifiers):
# check the command_prompt is not focused
if not self.app.command_prompt.insert:
if keycode[1]=="shift":
if not 's' in self.modifiers:
self.modifiers.append('s')
self.modifiers.sort()
# remove particles
elif keycode[1]=="backspace":
for particle in self.selected:
self.particles=remove_fromlist(self.particles,particle)
self.selected=[]
self.draw()
# respond to keyboard (text input is modifier-sensitive, i.e. one can use shift)
def on_textinput(self, window, text):
# check the command_prompt is not focused
if not self.app.command_prompt.insert:
# select all
if text=="a":
for particle in self.particles:
particle.selected=True
self.selected=self.particles.copy()
self.unselected=[]
self.draw()
# toggle grid
elif text=="g":
for particle in self.selected:
if particle.grid==0:
particle.grid=1
else:
particle.grid=-particle.grid
self.draw()
# zoom
elif text=="+":
# increment by 10%
self.set_zoom(Square_element.size/50*1.1)
elif text=="-":
# decrease by 10%
self.set_zoom(Square_element.size/50*0.9)
elif text=="=":
# reset
self.set_zoom(1)
# set reference
elif text=="r":
if len(self.selected)>0:
self.reference=self.selected[0]
else:
self.reference=None
self.app.status_bar.draw()
def on_key_up(self, keyboard, keycode):
if keycode[1]=="shift":
if 's' in self.modifiers:
# remove
self.modifiers[self.modifiers.index('s')]=self.modifiers[len(self.modifiers)-1]
self.modifiers=self.modifiers[:len(self.modifiers)-1]
self.modifiers.sort()
# respond to mouse down
def on_touch_down(self,touch):
# only respond to touch in drawing area
if self.collide_point(*touch.pos):
# create new cross
if touch.button=="right":
new=Cross(touch.x/Square_element.size,touch.y/Square_element.size)
if not self.check_interaction_any(new,Point(0,0)):
# add to list
self.particles.append(new)
# unselect all particles
for sel in self.selected:
sel.selected=False
self.selected=[]
self.unselected=self.particles
self.draw()
# select particle
if touch.button=="left":
# find particle under touch
self.undermouse=self.find_particle(Point(touch.x/Square_element.size,touch.y/Square_element.size))
# record relative position of click with respect to reference
if self.undermouse!=None:
self.offset=Point(touch.x/Square_element.size,touch.y/Square_element.size)-self.undermouse.squares[0].pos
# no modifiers
if self.modifiers==[]:
if self.undermouse==None:
# unselect all particles
for sel in self.selected:
sel.selected=False
self.selected=[]
self.unselected=self.particles
# select undermouse
elif not self.undermouse in self.selected:
for sel in self.selected:
sel.selected=False
self.selected=[self.undermouse]
self.unselected=self.particles.copy()
self.unselected=remove_fromlist(self.unselected,self.undermouse)
self.undermouse.selected=True
# shift-click
elif self.modifiers==['s']:
if self.undermouse!=None:
if self.undermouse not in self.selected:
self.selected.append(self.undermouse)
self.undermouse.selected=True
# remove from unselected
self.unselected=remove_fromlist(self.unselected,self.undermouse)
else:
# remove
self.selected=remove_fromlist(self.selected,self.undermouse)
self.undermouse.selected=False
# add to unselected
self.unselected.append(self.undermouse)
self.draw()
# draw status bar
self.app.status_bar.draw()
# respond to drag
def on_touch_move(self,touch):
# only respond to touch in drawing area
if self.collide_point(*touch.pos):
# only move on left click
if touch.button=="left" and self.modifiers==[] and self.undermouse!=None:
# attempted move determined by the relative position to the relative position of click within self.undermouse
delta=self.adjust_move(Point(touch.x/Square_element.size,touch.y/Square_element.size)-(self.offset+self.undermouse.squares[0].pos),0)
for particle in self.selected:
particle.move(delta)
# redraw
self.draw()
# draw status bar
self.app.status_bar.draw()
# find the particle at position pos
def find_particle(self,pos):
for particle in self.particles:
if particle.in_support(pos):
return particle
# none found
return None
# check whether a candidate particle intersects with any of the particles
def check_interaction_any(self,candidate,offset):
for particle in self.particles:
# do not check interaction if candidate=particle
if candidate!=particle and particle.check_interaction(candidate,offset):
return True
return False
# check whether shifting a list of particles by offset makes them interact with all particles
def check_interaction_list(self,array,offset):
for candidate in array:
if self.check_interaction_any(candidate,offset):
return True
return False
# check whether shifting a list of particles by offset makes them interact with the unselected particles
def check_interaction_unselected_list(self,array,offset):
for candidate in array:
for particle in self.unselected:
if particle.check_interaction(candidate,offset):
return True
return False
# check whether a candidate particle element with any of the unselected particles
def check_interaction_unselected_element(self,element,offset):
for particle in self.unselected:
for square in particle.squares:
if square.check_interaction(element.pos+offset):
return True
return False
# try to move all selected particles by delta, adjust if needed to avoid overlap with unselected particles
# we only track whether these elements collide with unselected particles, not with each other
def adjust_move(self,delta,recursion_depth):
# actual_delta is the smallest (componentwise) of all the computed delta's
actual_delta=Point(math.inf,math.inf)
for particle in self.selected:
for element in particle.squares:
# compute adjustment move due to unselected obstacles
adjusted_delta=self.adjust_move_element(delta,element,0)
# only keep the smallest delta's (in absolute value)
if abs(adjusted_delta.x)<abs(actual_delta.x):
actual_delta.x=adjusted_delta.x
if abs(adjusted_delta.y)<abs(actual_delta.y):
actual_delta.y=adjusted_delta.y
# try to move by actual_delta
if not self.check_interaction_unselected_list(self.selected,actual_delta):
return actual_delta
else:
# cannot move particles at all, try again
# give up if tried too many times
if recursion_depth>100:
print("warning: recursion depth exceeded when adjusting move by delta=(",delta.x,",",delta.y,")",file=sys.stderr)
return Point(0,0)
else:
return self.adjust_move(actual_delta,recursion_depth+1)
# trying to move a single element by delta, adjust if needed to avoid overlap with unselected particles
def adjust_move_element(self,delta,element,recursion_depth):
# whether newpos is acceptable
accept_newpos=True
for other in self.unselected:
for obstacle in other.squares:
# move would make element overlap with obstacle
if obstacle.check_interaction(element.pos+delta):
accept_newpos=False
# check if particle already touches obstacle
if obstacle.check_touch(element.pos):
# move along obstacle while remaining stuck
newdelta=obstacle.move_along(delta,element.pos)
else:
newdelta=obstacle.move_on_line_to_stick(element.pos,delta)
if not self.check_interaction_unselected_element(element,newdelta):
return newdelta
if accept_newpos:
return delta
else:
# cannot move particle at all, try again
# give up if tried too many times
if recursion_depth>100:
print("warning: recursion depth exceeded when adjusting move of element at (",element.pos.x,",",element.pos.y,") by delta=(",delta.x,",",delta.y,")",file=sys.stderr)
return Point(0,0)
else:
return self.adjust_move_element(newdelta,element,recursion_depth+1)
# set color of selected particles
def set_color(self,color):
for particle in self.selected:
particle.color=color
# redraw
self.draw()
# set grid for selected particles
# set mesh to -1 to toggle on/off
def set_grid(self,mesh):
for particle in self.selected:
if mesh==-1:
if particle.grid==0:
particle.grid=1
else:
particle.grid=-particle.grid
else:
particle.grid=mesh
# redraw
self.draw()
# write configuration to file
def write(self,file):
ff=open(file,"w")
for particle in self.particles:
if type(particle)==Cross:
ff.write("{:d};".format(CROSS_INDEX))
ff.write("{:05.2f},{:05.2f};{:3.1f},{:3.1f},{:3.1f}\n".format(particle.squares[0].pos.x,particle.squares[0].pos.y,particle.color[0],particle.color[1],particle.color[2]))
ff.close()
# read configuration from file
def read(self,file):
self.reset()
try:
ff=open(file,"r")
except:
self.app.command_prompt.message="error: could not read file '"+file+"' (this should not happen and is probably a bug)"
return
# counter
i=0
try:
lines=ff.readlines()
except:
self.app.command_prompt.message="error: could not read the contents of file '"+file+"'"
return
for line in lines:
i+=1
# remove newline
line=line[:len(line)-1]
# ignore comments
if '#' in line:
line=line[:line.find('#')]
# ignore empty lines
if len(line)==0:
continue
entries=line.split(";")
# skip line if improperly formatted
if len(entries)>3:
print("warning: ignoring line "+str(i)+" in file '"+file+"': more than three ';' spearated entries in '"+line+"'",file=sys.stderr)
if len(entries)<2:
print("warning: ignoring line "+str(i)+" in file '"+file+"': fewer than two ';' spearated entries in '"+line+"'",file=sys.stderr)
continue
# position
pos_str=entries[1].split(",")
# skip line if improperly formatted
if len(pos_str)!=2:
print("warning: ignoring line "+str(i)+" in file '"+file+"': position '"+entries[1]+"' does not have two components",file=sys.stderr)
continue
try:
pos=Point(float(pos_str[0]),float(pos_str[1]))
except:
print("warning: ignoring line "+str(i)+" in file '"+file+"': position '"+entries[1]+"' cannot be read",file=sys.stderr)
continue
# color
color=(0,0,1)
if len(entries)==3:
color_str=entries[2].split(",")
# skip line if improperly formatted
if len(color_str)!=3:
print("warning: ignoring line "+str(i)+" in file '"+file+"': color '"+entries[2]+"' does not have three components",file=sys.stderr)
continue
try:
color=(float(color_str[0]),float(color_str[1]),float(color_str[2]))
except:
print("warning: ignoring line "+str(i)+" in file '"+file+"': color '"+entries[2]+"' cannot be read",file=sys.stderr)
continue
# candidate particle
try:
particle_type=int(entries[0])
except:
print("warning: ignoring line "+str(i)+" in file '"+file+"': particle type '"+entries[0]+"' is not an integer",file=sys.stderr)
continue
if particle_type==CROSS_INDEX:
candidate=Cross(pos.x,pos.y,color=color)
else:
print("warning: ignoring line "+str(i)+" in file '"+file+"': unrecognized particle type: '"+entries[0]+"'",file=sys.stderr)
continue
if not self.check_interaction_any(candidate,Point(0,0)):
# add to list
self.particles.append(candidate)
self.unselected.append(candidate)
else:
print("warning: ignoring line "+str(i)+" in file '"+file+"': particle overlaps with existing particles",file=sys.stderr)
ff.close()
self.draw()
# set zoom level
def set_zoom(self,level):
Square_element.size=level*50
self.draw()
# global variables (used like precompiler variables)
CROSS_INDEX=1

49
src/point.py Normal file
View File

@ -0,0 +1,49 @@
import math
# point in two dimensions
class Point:
def __init__(self,x,y):
self.x=x
self.y=y
def __add__(self,point):
return Point(self.x+point.x,self.y+point.y)
def __sub__(self,point):
return Point(self.x-point.x,self.y-point.y)
def __neg__(self):
return Point(-self.x,-self.y)
def __mul__(self,a):
return Point(a*self.x,a*self.y)
def __truediv__(self,a):
return Point(self.x/a,self.y/a)
def __pow__(self,a):
if a==2:
return self.dot(self)
# dot product
def dot(self,x):
return self.x*x.x+self.y*x.y
# integer part
def int(self):
return Point(int(self.x),int(self.y))
# find the closest among a list of points
def closest(self,points):
dist=math.inf
closest=None
for point in points:
if (self-point)**2<dist:
closest=point
dist=(self-point)**2
return closest
# L infinity norm
def l_infinity(x):
return max(abs(x.x),abs(x.y))

231
src/polyomino.py Normal file
View File

@ -0,0 +1,231 @@
import math
import sys
from kivy.graphics import Color,Line,Rectangle
from point import Point,l_infinity
from tools import isint_nonzero,sgn,in_interval
# parent class of all polyominos
class Polyomino():
def __init__(self,**kwargs):
# square elements that maje up the polyomino
self.squares=kwargs.get("squares",[])
self.color=kwargs.get("color",(0,0,1))
self.selected=False
# mesh of background grid (no grid for mesh size 0)
self.grid=kwargs.get("grid",0)
# draw function
def draw(self,**kwargs):
alpha=kwargs.get("alpha",1)
# set color
if not self.selected:
Color(*self.color,alpha)
else:
(r,g,b)=self.color
# darken selected
Color(r/2,g/2,b/2,alpha)
for square in self.squares:
Rectangle(pos=((square.pos.x-0.5)*square.size,(square.pos.y-0.5)*square.size),size=(square.size,square.size))
# draw boundary
self.stroke()
# draw boundary (override for connected polyominos)
def stroke(self):
# white
Color(1,1,1)
for square in self.squares:
Line(points=(
*((square.pos.x-0.5)*square.size,(square.pos.y-0.5)*square.size),
*((square.pos.x-0.5)*square.size,(square.pos.y+0.5)*square.size),
*((square.pos.x+0.5)*square.size,(square.pos.y+0.5)*square.size),
*((square.pos.x+0.5)*square.size,(square.pos.y-0.5)*square.size),
*((square.pos.x-0.5)*square.size,(square.pos.y-0.5)*square.size)
))
# move by delta
def move(self,delta):
for square in self.squares:
square.pos+=delta
# whether x is in the support of the polyomino
def in_support(self,x):
for square in self.squares:
if l_infinity(square.pos-x)<=1/2:
return True
return False
# check whether self interacts with candidate if candidate were moved by offset
def check_interaction(self,candidate,offset):
for square1 in self.squares:
for square2 in candidate.squares:
if square1.check_interaction(square2.pos+offset):
return True
return False
# square
class Square(Polyomino):
def __init__(self,x,y,**kwargs):
super(Square,self).__init__(**kwargs,squares=[Square_element(x,y)])
# cross
class Cross(Polyomino):
def __init__(self,x,y,**kwargs):
super(Cross,self).__init__(**kwargs,squares=[\
Square_element(x,y),\
Square_element(x+1,y),\
Square_element(x-1,y),\
Square_element(x,y+1),\
Square_element(x,y-1)\
])
# redefine stroke to avoid lines between touching squares
def stroke(self):
Color(1,1,1)
Line(points=(
*((self.squares[0].pos.x-0.5)*Square_element.size,(self.squares[0].pos.y-0.5)*Square_element.size),
*((self.squares[0].pos.x-0.5)*Square_element.size,(self.squares[0].pos.y-1.5)*Square_element.size),
*((self.squares[0].pos.x+0.5)*Square_element.size,(self.squares[0].pos.y-1.5)*Square_element.size),
*((self.squares[0].pos.x+0.5)*Square_element.size,(self.squares[0].pos.y-0.5)*Square_element.size),
*((self.squares[0].pos.x+1.5)*Square_element.size,(self.squares[0].pos.y-0.5)*Square_element.size),
*((self.squares[0].pos.x+1.5)*Square_element.size,(self.squares[0].pos.y+0.5)*Square_element.size),
*((self.squares[0].pos.x+0.5)*Square_element.size,(self.squares[0].pos.y+0.5)*Square_element.size),
*((self.squares[0].pos.x+0.5)*Square_element.size,(self.squares[0].pos.y+1.5)*Square_element.size),
*((self.squares[0].pos.x-0.5)*Square_element.size,(self.squares[0].pos.y+1.5)*Square_element.size),
*((self.squares[0].pos.x-0.5)*Square_element.size,(self.squares[0].pos.y+0.5)*Square_element.size),
*((self.squares[0].pos.x-1.5)*Square_element.size,(self.squares[0].pos.y+0.5)*Square_element.size),
*((self.squares[0].pos.x-1.5)*Square_element.size,(self.squares[0].pos.y-0.5)*Square_element.size),
*((self.squares[0].pos.x-0.5)*Square_element.size,(self.squares[0].pos.y-0.5)*Square_element.size),
))
# square building block of polyominos
class Square_element():
# size
size=50
def __init__(self,x,y,**kwargs):
self.pos=Point(x,y)
# set position
def setpos(self,x,y):
self.pos.x=x
self.pos.y=y
# check whether a square at pos interacts with square
def check_interaction(self,pos):
return l_infinity(pos-self.pos)<1
# check whether a square at position pos is touching self
def check_touch(self,pos):
# allow for error
if in_interval(l_infinity(pos-self.pos),1-1e-11,1+1e-11):
return True
return False
# find position along a line that comes in contact with the line going through pos in direction v
def move_on_line_to_stick(self,pos,v):
# compute intersections with four lines making up square
if v.x!=0:
if v.y!=0:
intersections=[\
Point(self.pos.x+1,pos.y+v.y/v.x*(self.pos.x+1-pos.x)),\
Point(self.pos.x-1,pos.y+v.y/v.x*(self.pos.x-1-pos.x)),\
Point(pos.x+v.x/v.y*(self.pos.y+1-pos.y),self.pos.y+1),\
Point(pos.x+v.x/v.y*(self.pos.y-1-pos.y),self.pos.y-1)\
]
else:
intersections=[\
Point(self.pos.x+1,pos.y+v.y/v.x*(self.pos.x+1-pos.x)),\
Point(self.pos.x-1,pos.y+v.y/v.x*(self.pos.x-1-pos.x))
]
else:
if v.y!=0:
intersections=[\
Point(pos.x+v.x/v.y*(self.pos.y+1-pos.y),self.pos.y+1),\
Point(pos.x+v.x/v.y*(self.pos.y-1-pos.y),self.pos.y-1)\
]
else:
print("error: move_on_line_to_stick called with v=0, please file a bug report with the developer",file=sys.stderr)
exit(-1)
# compute closest one, on square
closest=None
dist=math.inf
for i in range(0,len(intersections)):
# check that it is on square
if abs(intersections[i].x-self.pos.x)<=1+1e-11 and abs(intersections[i].y-self.pos.y)<=1+1e-11:
if (intersections[i]-pos)**2<dist:
closest=intersections[i]
dist=(intersections[i]-pos)**2
if closest==None:
print("error: cannot move particle at (",pos.x,",",pos.y,") to the boundary of (",self.pos.x,",",self.pos.y,") in direction (",v.x,",",v.y,")",file=sys.stderr)
exit(-1)
# return difference to pos
return closest-pos
# move along edge of square
def move_along(self,delta,pos):
rel=pos-self.pos
# check if the particle is stuck in the x direction
if isint_nonzero(rel.x):
# check y direction
if isint_nonzero(rel.y):
# in corner
if sgn(delta.y)==-sgn(rel.y):
# stuck in x direction
return self.move_stuck_x(delta,pos)
elif sgn(delta.x)==-sgn(rel.x):
# stuck in y direction
return self.move_stuck_y(delta,pos)
# stuck in both directions
return pos
else:
# stuck in x direction
return self.move_stuck_x(delta,pos)
elif isint_nonzero(rel.y):
# stuck in y direction
return self.move_stuck_y(delta,pos)
# this should never happen
else:
print("error: stuck particle has non-integer relative position: (",rel.x,",",rel.y,")",file=sys.stderr)
exit(-1)
# move when stuck in the x direction
def move_stuck_x(self,delta,pos):
# only move in y direction
candidate=Point(0,delta.y)
# do not move past corners
rel=pos.y-self.pos.y
if delta.y>0:
if rel<math.ceil(rel)-1e-11 and delta.y+rel>math.ceil(rel)+1e-11 and math.ceil(rel)!=0:
# stick to corner
candidate.y=math.ceil(rel)+self.pos.y-pos.y
else:
if rel>math.floor(rel)+1e-11 and delta.y+rel<math.floor(rel)-1e-11 and math.floor(rel)!=0:
# stick to corner
candidate.y=math.floor(rel)+self.pos.y-pos.y
return candidate
# move when stuck in the y direction
def move_stuck_y(self,delta,pos):
# onlx move in x direction
candidate=Point(delta.x,0)
# do not move past corners
rel=pos.x-self.pos.x
if delta.x>0:
if rel<math.ceil(rel)-1e-11 and delta.x+rel>math.ceil(rel)+1e-11 and math.ceil(rel)!=0:
# stick to corner
candidate.x=math.ceil(rel)+self.pos.x-pos.x
else:
if rel>math.floor(rel)+1e-11 and delta.x+rel<math.floor(rel)-1e-11 and math.floor(rel)!=0:
# stick to corner
candidate.x=math.floor(rel)+self.pos.x-pos.x
return candidate

53
src/status_bar.py Normal file
View File

@ -0,0 +1,53 @@
from kivy.uix.label import Label
from kivy.graphics import Color,Rectangle
from kivy.utils import escape_markup
class Status_bar(Label):
def __init__(self,app,**kwargs):
# app is used to share information between widgets
self.app=app
# width of a character
self.char_width=9
# unformatted text
self.raw_text=""
# a one-time message that shows up over the bar
self.message=""
# init Label
super(Status_bar,self).__init__(**kwargs)
self.draw()
def draw(self):
# if message is not empty, draw message instead
if self.message!="":
self.text=self.message[:min(len(self.message),int(self.width/self.char_width))]
self.message=""
return
# list openfile
if self.app.openfile!="":
self.raw_text=self.app.openfile
else:
self.raw_text="[no file]"
# readonly
if self.app.readonly:
self.raw_text+=" [RO]"
# coordinates of selected cross
if len(self.app.painter.selected)>0:
# number of spaces to align right (use 13 characters to print position)
spaces=int(self.width/self.char_width)-len(self.raw_text)-13
if spaces>0:
if self.app.painter.reference==None:
self.raw_text+=" "*spaces+"({:05.2f},{:05.2f})\n".format(self.app.painter.selected[0].squares[0].pos.x,self.app.painter.selected[0].squares[0].pos.y)
else:
self.raw_text+=" "*spaces+"({:05.2f},{:05.2f})\n".format(self.app.painter.selected[0].squares[0].pos.x-self.app.painter.reference.squares[0].pos.x,self.app.painter.selected[0].squares[0].pos.y-self.app.painter.reference.squares[0].pos.y)
# do not wrap
self.text=self.raw_text[:min(len(self.raw_text),int(self.width/self.char_width))]

22
src/tools.py Normal file
View File

@ -0,0 +1,22 @@
# sign function
def sgn(x):
if x>=0:
return 1
return -1
# check whether a number is an integer (with tolerance)
def isint_nonzero(x):
if abs(x)<1e-11:
return False
return abs(round(x)-x)<1e-11
# check that a number is in an interval
def in_interval(x,a,b):
return x>=a and x<=b
# remove x from list a
def remove_fromlist(a,x):
if x in a:
a[a.index(x)]=a[len(a)-1]
a=a[:len(a)-1]
return a