local Y=require("yaru.core") local gfx=require("yaru.gfx") local ui=require("yaru.ui") local image=require("yaru.image") local fs=require("filesystem") local unicode=require("unicode") local M={} local PALETTE={0x000000,0x3C3C3C,0x787878,0xB4B4B4,0xFFFFFF,0xCC0000,0xFF4940,0xFF9240, 0xFFDB40,0x99FF40,0x33B640,0x006D40,0x33DBBF,0x0092FF,0x0049BF,0x330080, 0x9949FF,0xCC49BF,0xFF92BF,0x996D40,0x663300,0x2C001E,0xE95420,0xFFDBBF} local TOOLS={{"pen","▪ Pencil"},{"erase","▯ Eraser"},{"fill","◧ Fill"},{"pick","◉ Picker"}} function M.open(path) local st={tool="pen",c1=0x000000,c2=0xFFFFFF,undo={},dirty=false,size=1} local win=Y.newWindow{app="paint",title="Paint",w=100,h=34,minW=50,minH=16,precise=true} local function new(w,ph) st.w,st.ph,st.px=w,ph+ph%2,{} for i=1,st.w*st.ph do st.px[i]=0xFFFFFF end st.path,st.dirty=nil,false win.title="Untitled" end local function halves(cp,fg,bg) if cp==0x2580 then return fg,bg elseif cp==0x2584 then return bg,fg elseif cp==0x2588 then return fg,fg end return bg,bg end local function load(p) local img,err=image.load(p,true) if not img then return Y.notify("Paint",tostring(err))end local w,h,d=img[1],img[2],img[3] st.w,st.ph,st.px=w,h*2,{} for y=1,h do for x=1,w do local k=((y-1)*w+x)*3 local top,bot=halves(d[k-2],d[k-1],d[k]) if d[k]<0 then if d[k-2]==0x2580 then bot=-1 elseif d[k-2]==0x2584 then top=-1 else top,bot=-1,-1 end end st.px[(2*y-2)*w+x],st.px[(2*y-1)*w+x]=top,bot end end st.path,st.dirty=p,false win.title=fs.name(p) end local function snapshot() local c={} for i=1,#st.px do c[i]=st.px[i]end st.undo[#st.undo+1]=c if#st.undo>10 then table.remove(st.undo,1)end end local function toImage() local d,h={},st.ph//2 for y=1,h do for x=1,st.w do local k=((y-1)*st.w+x)*3 local top,bot=st.px[(2*y-2)*st.w+x],st.px[(2*y-1)*st.w+x] if top<0 and bot<0 then d[k-2],d[k-1],d[k]=32,0,-1 elseif top<0 then d[k-2],d[k-1],d[k]=0x2584,bot,-1 elseif bot<0 then d[k-2],d[k-1],d[k]=0x2580,top,-1 elseif top==bot then d[k-2],d[k-1],d[k]=32,top,top else d[k-2],d[k-1],d[k]=0x2580,top,bot end end end return{st.w,h,d} end local function save(p,after) p=p or st.path if not p then Y.dialog{title="Save Picture",text="File name (full path)",entry="/home/Pictures/picture.pic", buttons={{"Cancel"},{"Save",function(txt)if txt and txt~=""then if not txt:match("%.pic$")then txt=txt..".pic"end save(txt,after)end end,"suggested"}}} return end local ok,err=image.save(p,toImage()) if not ok then return Y.notify("Could not save",tostring(err))end st.path,st.dirty=p,false win.title=fs.name(p) for _,w in ipairs(Y.windowsOf("files"))do if w.reload then w.reload()end end if after then after()end end local function setPx(x,y,c) for dy=0,st.size-1 do for dx=0,st.size-1 do local xx,yy=x+dx,y+dy if xx>=1 and yy>=1 and xx<=st.w and yy<=st.ph then st.px[(yy-1)*st.w+xx]=c end end end st.dirty=true end local function fill(x,y,color) local target=st.px[(y-1)*st.w+x] if target==color then return end local stack={x,y} while#stack>0 do local yy,xx=table.remove(stack),table.remove(stack) local k=(yy-1)*st.w+xx if xx>=1 and yy>=1 and xx<=st.w and yy<=st.ph and st.px[k]==target then st.px[k]=color stack[#stack+1]=xx+1;stack[#stack+1]=yy stack[#stack+1]=xx-1;stack[#stack+1]=yy stack[#stack+1]=xx;stack[#stack+1]=yy+1 stack[#stack+1]=xx;stack[#stack+1]=yy-1 end end st.dirty=true end local SIDE=20 local cx0,cy0 local lastPx local function apply(px,py,btn,kind) local color=btn==1 and st.c2 or st.c1 if st.tool=="pen"or st.tool=="erase"then local c=st.tool=="erase"and-1 or color if kind=="drag"and lastPx then local x0,y0=lastPx[1],lastPx[2] local dx,dy=math.abs(px-x0),-math.abs(py-y0) local sx,sy=x0=dy then err=err+dy;x0=x0+sx end if e2<=dx then err=err+dx;y0=y0+sy end end else setPx(px,py,c)end lastPx={px,py} elseif st.tool=="fill"then fill(px,py,color) elseif st.tool=="pick"then local c=st.px[(py-1)*st.w+px] if c<0 then c=0xFFFFFF end if btn==1 then st.c2=c else st.c1=c end st.tool="pen" end end function win.header(w,hw) local x=2 x=x+ui.button(w,x,1,"New",function() Y.dialog{title="New Picture",text="Size in pixels (a cell holds 1x2 pixels), e.g. 32x32",entry=st.w.."x"..st.ph, buttons={{"Cancel"},{"Create",function(s) local a,b=(s or""):match("(%d+)%s*[xX×]%s*(%d+)") a,b=tonumber(a),tonumber(b) if a and b and a>=1 and b>=1 and a<=160 and b<=100 then snapshot();new(a,b)end end,"suggested"}}} end)+1 x=x+ui.button(w,x,1,"Open…",function() Y.dialog{title="Open Picture",text="Path to a .pic file",entry=st.path or"/home/Pictures/", buttons={{"Cancel"},{"Open",function(p)if p and p~=""then load(p)end end,"suggested"}}} end)+1 x=x+ui.button(w,x,1,"Undo",function()local u=table.remove(st.undo)if u then st.px=u end end,"flat")+1 ui.button(w,hw-7,1,"Save",function()save()end,"suggested",7) end function win.draw(w,cw,ch) local t=Y.theme gfx.fill(1,1,SIDE,ch,t.sidebar) gfx.text(2,1,"Tools",t.dim) for i,tl in ipairs(TOOLS)do local y=1+i if st.tool==tl[1]then ui.select(2,y,SIDE-2,t.dark and 0x454545 or 0xDEDDDA)end gfx.text(4,y,tl[2],t.fg) ui.hit(w,1,y,SIDE,1,function()st.tool=tl[1]end) end local py=#TOOLS+3 gfx.text(2,py,"Brush",t.dim) for i=1,3 do local bx=9+(i-1)*4 if st.size==i then ui.pill(bx-1,py,3,t.accent)end gfx.prect(bx,py*2-(i>1 and 1 or 0),i,i,st.size==i and 0xFFFFFF or t.fg) ui.hit(w,bx-1,py,3,1,function()st.size=i end) end py=py+2 gfx.text(2,py,"Colours",t.dim) for i,c in ipairs(PALETTE)do local x,y=2+((i-1)%4)*4,py+1+(i-1)//4 ui.pill(x,y,3,c) ui.hit(w,x,y,3,1,function(_,_,_,btn)if btn==1 then st.c2=c else st.c1=c end end) end py=py+2+math.ceil(#PALETTE/4) gfx.text(2,py,"Left",t.dim);ui.round(8,py,10,1,st.c1) gfx.text(2,py+1,"Right",t.dim);ui.round(8,py+1,10,1,st.c2) gfx.text(2,py+3,ui.trunc(st.w.."×"..st.ph.." px",SIDE-2),t.dim) ui.hit(w,8,py,10,2,function() Y.dialog{title="Custom Colour",text="Hex RGB, e.g. E95420",entry=string.format("%06X",st.c1), buttons={{"Cancel"},{"Set",function(s)local v=tonumber((s or""):gsub("#",""),16)if v then st.c1=v&0xFFFFFF end end,"suggested"}}} end) local ax,aw=SIDE+1,cw-SIDE gfx.fill(ax,1,aw,ch,t.dark and 0x1E1E1E or 0xDEDDDA) local hc=st.ph//2 cx0=ax+math.max(1,(aw-st.w)//2) cy0=1+math.max(1,(ch-hc)//2) gfx.pushClip(ax,1,aw,ch) ui.card(cx0,cy0,st.w,hc,0xFFFFFF) for y=1,st.ph do local base=(y-1)*st.w local py0=(cy0-1)*2+y for x=1,st.w do local c=st.px[base+x] if c<0 then c=((x+y)%2==0)and 0xF0F0F0 or 0xC3C3C3 end gfx.pset(cx0+x-1,py0,c) end end gfx.popClip() end function win.touch(w,lx,ly,btn,kind) if not cx0 then return end local px=lx-cx0+1 local cyl=ly-cy0+1 local lower=(Y.touchFrac and Y.touchFrac>=0.5)or(not Y.touchFrac and Y.mods.shift()) local py=cyl*2-(lower and 0 or 1) if px<1 or py<1 or px>st.w or py>st.ph then lastPx=nil return end if kind=="touch"then snapshot();lastPx=nil end if kind=="drop"then lastPx=nil return end if kind=="drag"and(st.tool~="pen"and st.tool~="erase")then return end apply(px,py,btn,kind) end function win.key(w,char,code) local K=Y.K if Y.mods.ctrl()then if code==K.s then save()elseif code==0x2C then local u=table.remove(st.undo)if u then st.px=u end end return end if char==91 then st.size=math.max(1,st.size-1)elseif char==93 then st.size=math.min(3,st.size+1)end end function win.onclose(w) if not st.dirty then return true end Y.dialog{title="Save the picture before closing?",text="Unsaved changes will be lost.", buttons={{"Cancel"},{"Close without Saving",function()Y.closeWin(w,true)end,"destructive"}, {"Save",function()save(nil,function()Y.closeWin(w,true)end)end,"suggested"}}} return false end if path and fs.exists(path)then load(path)else new(32,32);if path then st.path=path;win.title=fs.name(path)end end return win end return M