added snake and added projects section, still need a bit of work

This commit is contained in:
Ksan 2026-04-14 12:37:17 +02:00
parent a7f1bbdd19
commit e7af8511d1
3 changed files with 371 additions and 27 deletions

2
.gitignore vendored
View File

@ -1 +1 @@
snake.go connections.log

174
main.go
View File

@ -2,17 +2,40 @@ package main
import ( import (
"fmt" "fmt"
"strings"
"log"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh" "github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish" "github.com/charmbracelet/wish"
wishtea "github.com/charmbracelet/wish/bubbletea" wishtea "github.com/charmbracelet/wish/bubbletea"
"log"
"os"
"strings"
) )
type project struct {
name string
description string
tech string
url string
}
var projects = []project{
{
name: "SSH-portfolio",
description: "This very terminal — a Bubbletea + Wish SSH app.",
tech: "Go · Bubbletea · Lipgloss · Wish",
url: "https://git.ksan.dev/ksan/ssh-portfolio",
},
{
name: "E-Voting",
description: "A secure e-voting application for creating and participating in elections. It uses certificates, encryption, and digital signatures to protect identity, privacy, and data integrity.",
tech: "Java · BoucyCastle · JavaFx · Gradle",
url: "https://git.ksan.dev/ksan/e-voting",
},
}
const giteaLink = "https://git.ksan.dev/ksan"
var asciiArt = []string{} var asciiArt = []string{}
type model struct { type model struct {
@ -26,12 +49,14 @@ type model struct {
mouseY int mouseY int
mouseActive bool mouseActive bool
isClicked bool isClicked bool
snake SnakeModel
projectCursor int
} }
func initialModel() model { func initialModel() model {
return model{ return model{
title: "Đorđe Kšan", title: "Đorđe Kšan",
sections: []string{"About", "Projects", "Contact"}, sections: []string{"About", "Projects", "Contact", "Surprise"},
currentScreen: "menu", currentScreen: "menu",
mouseActive: true, mouseActive: true,
} }
@ -42,6 +67,24 @@ func (m model) Init() tea.Cmd {
} }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.currentScreen == "Surprise" {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "b", "backspace":
m.currentScreen = "menu"
return m, nil
}
}
var cmd tea.Cmd
m.snake, cmd = m.snake.Update(msg)
return m, cmd
}
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
@ -57,18 +100,35 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit return m, tea.Quit
case "up", "k": case "up", "k":
if m.selected > 0 { if m.currentScreen == "Projects" {
m.selected-- if m.projectCursor > 0 {
fmt.Println("up") m.projectCursor--
}
} else if m.currentScreen == "menu" {
if m.selected > 0 {
m.selected--
}
} }
case "down", "j": case "down", "j":
if m.selected < len(m.sections)-1 { if m.currentScreen == "Projects" {
m.selected++ if m.projectCursor < len(projects)-1 {
fmt.Println("down") m.projectCursor++
}
} else if m.currentScreen == "menu" {
if m.selected < len(m.sections)-1 {
m.selected++
}
} }
case "enter": case "enter":
m.currentScreen = m.sections[m.selected]
if m.currentScreen == "menu" {
chosen := m.sections[m.selected]
m.currentScreen = chosen
if chosen == "Surprise" {
m.snake = newSnakeModel()
return m, m.snake.Init()
}
}
return m, nil return m, nil
case "left", "h": case "left", "h":
@ -96,7 +156,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m model) center(content string) string {
if m.width == 0 || m.height == 0 {
return content
}
return lipgloss.Place(m.width, m.height,
lipgloss.Center, lipgloss.Center,
content,
)
}
func (m model) View() string { func (m model) View() string {
if m.currentScreen == "Surprise" {
return m.center(m.snake.View())
}
titleStyle := lipgloss.NewStyle(). titleStyle := lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(lipgloss.Color("#E2E8F0")). Foreground(lipgloss.Color("#E2E8F0")).
@ -129,14 +204,15 @@ func (m model) View() string {
} }
} }
output += "\n" + footerStyle.Render("Press q to quit.") + "\n" output += "\n" + footerStyle.Render(" ↑↓←→ / hjkl to move · b to go back · q to quit") + "\n"
} else { } else {
switch m.currentScreen { switch m.currentScreen {
case "About": case "About":
output = m.renderAbout() output = m.renderAbout()
case "Projects": case "Projects":
output = m.renderProjects() output = m.center(m.renderProjects())
case "Contact": case "Contact":
output = m.renderContact() output = m.renderContact()
@ -158,7 +234,7 @@ var aboutText = `
` `
var contactText = ` var contactText = `
Get in touch with me at djordje@ksan.dev. Get in touch with me at djordje@ksan.dev
` `
var projectsText = ` var projectsText = `
@ -193,23 +269,56 @@ func (m model) renderAbout() string {
str.WriteString("\n") str.WriteString("\n")
str.WriteString(contentStyle.Render(aboutText)) str.WriteString(contentStyle.Render(aboutText))
str.WriteString("\n") str.WriteString("\n")
str.WriteString(footerStyle.Render("esc: back to menu"))
str.WriteString(footerStyle.Render(" ↑↓←→ / hjkl to move · b to go back · q to quit") + "\n")
return str.String() return str.String()
} }
func link(url, text string) string {
return "\033]8;;" + url + "\033\\" + text + "\033]8;;\033\\"
}
func (m model) renderProjects() string { func (m model) renderProjects() string {
var str strings.Builder output := titleStyle.Render("━━━ Projects ━━━") + "\n\n"
output += sectionStyle.Render("Projects I worked on:") + "\n\n"
str.WriteString(titleStyle.Render("━━━ Projects ━━━")) cursorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8A1D7D")).Bold(true)
str.WriteString("\n") expandedNameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E2E8F0")).Bold(true)
str.WriteString(contentStyle.Render(projectsText)) collapsedNameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#94A3B8"))
str.WriteString("\n") descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CBD5E1")).PaddingLeft(4).Width(55).Align(lipgloss.Center)
str.WriteString(footerStyle.Render("esc: back to menu")) techStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8A1D7D")).PaddingLeft(4)
dividerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#1E293B"))
urlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#38BDF8")).Underline(true).PaddingLeft(4).Align(lipgloss.Center)
urlStyle2 := lipgloss.NewStyle().Foreground(lipgloss.Color("#38BDF8")).Underline(true).Align(lipgloss.Center).MaxWidth(55)
return str.String() for i, p := range projects {
if i == m.projectCursor {
output += cursorStyle.Render("▶ ") + expandedNameStyle.Render(link(p.url, p.name)) + "\n"
output += descStyle.Render(p.description) + "\n"
output += techStyle.Render("[ "+p.tech+" ]") + "\n"
output += link(p.url, urlStyle.Render("→ Check it out here ←")) + "\n"
output += urlStyle2.Render(p.url) + "\n"
} else {
output += collapsedNameStyle.Render(" ▸ "+p.name) + "\n"
}
if i < len(projects)-1 {
output += dividerStyle.Render(" "+"─────────────────────────────") + "\n"
}
}
gitStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#38BDF8")).
Underline(true).
MarginTop(1)
output += "\n" + sectionStyle.Render("More on: ") + gitStyle.Render(giteaLink) + "\n"
output += "\n" + footerStyle.Render(" ↑↓←→ / hjkl to move · b to go back · q to quit")
return output
} }
func (m model) renderContact() string { func (m model) renderContact() string {
@ -219,12 +328,13 @@ func (m model) renderContact() string {
str.WriteString("\n") str.WriteString("\n")
str.WriteString(contentStyle.Render(contactText)) str.WriteString(contentStyle.Render(contactText))
str.WriteString("\n") str.WriteString("\n")
str.WriteString(footerStyle.Render("esc: back to menu")) str.WriteString(footerStyle.Render(" ↑↓←→ / hjkl to move · b to go back · q to quit") + "\n")
return str.String() return str.String()
} }
func main() { func main() {
initLogger()
server, _ := wish.NewServer( server, _ := wish.NewServer(
wish.WithAddress("0.0.0.0:2222"), wish.WithAddress("0.0.0.0:2222"),
@ -235,13 +345,25 @@ func main() {
_ = server.ListenAndServe() _ = server.ListenAndServe()
} }
func initLogger() {
f, err := os.OpenFile("connections.log",
os.O_APPEND|os.O_CREATE|os.O_WRONLY,
0644,
)
if err != nil {
log.Fatal(err)
}
log.SetOutput(f)
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
}
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
ip := s.RemoteAddr().String() ip := s.RemoteAddr().String()
user := s.User() user := s.User()
println("New connection:") log.Printf("New connection | user=%s ip=%s", user, ip)
log.Println("User:", user, "IP:", ip)
return initialModel(), []tea.ProgramOption{ return initialModel(), []tea.ProgramOption{
tea.WithAltScreen(), tea.WithAltScreen(),

222
snake.go Normal file
View File

@ -0,0 +1,222 @@
package main
import (
"fmt"
"math/rand"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
gridWidth = 40
gridHeight = 20
)
type point struct {
x, y int
}
type snakeDirection int
const (
dirUp snakeDirection = iota
dirDown
dirLeft
dirRight
)
type snakeTickMsg time.Time
type SnakeModel struct {
snake []point
dir snakeDirection
nextDir snakeDirection
food point
score int
gameOver bool
rng *rand.Rand
}
func newSnakeModel() SnakeModel {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
snake := []point{
{gridWidth/2 + 1, gridHeight / 2},
{gridWidth / 2, gridHeight / 2},
{gridWidth/2 - 1, gridHeight / 2},
}
m := SnakeModel{
snake: snake,
dir: dirRight,
nextDir: dirRight,
rng: rng,
}
m.food = m.spawnFood()
return m
}
func (m SnakeModel) spawnFood() point {
occupied := make(map[point]bool, len(m.snake))
for _, s := range m.snake {
occupied[s] = true
}
for {
f := point{m.rng.Intn(gridWidth), m.rng.Intn(gridHeight)}
if !occupied[f] {
return f
}
}
}
func snakeTickCmd() tea.Cmd {
return tea.Tick(130*time.Millisecond, func(t time.Time) tea.Msg {
return snakeTickMsg(t)
})
}
func (m SnakeModel) Init() tea.Cmd {
return snakeTickCmd()
}
func (m SnakeModel) Update(msg tea.Msg) (SnakeModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
if m.dir != dirDown {
m.nextDir = dirUp
}
case "down", "j":
if m.dir != dirUp {
m.nextDir = dirDown
}
case "left", "h":
if m.dir != dirRight {
m.nextDir = dirLeft
}
case "right", "l":
if m.dir != dirLeft {
m.nextDir = dirRight
}
case "r":
if m.gameOver {
fresh := newSnakeModel()
return fresh, fresh.Init()
}
}
case snakeTickMsg:
if m.gameOver {
return m, nil
}
m.dir = m.nextDir
head := m.snake[0]
var next point
switch m.dir {
case dirUp:
next = point{head.x, head.y - 1}
case dirDown:
next = point{head.x, head.y + 1}
case dirLeft:
next = point{head.x - 1, head.y}
case dirRight:
next = point{head.x + 1, head.y}
}
if next.x < 0 || next.x >= gridWidth || next.y < 0 || next.y >= gridHeight {
m.gameOver = true
return m, nil
}
for _, s := range m.snake {
if s == next {
m.gameOver = true
return m, nil
}
}
if next == m.food {
m.snake = append([]point{next}, m.snake...)
m.score++
m.food = m.spawnFood()
} else {
m.snake = append([]point{next}, m.snake[:len(m.snake)-1]...)
}
return m, snakeTickCmd()
}
return m, nil
}
//TODO test if the characters are rendered on different terminals
func (m SnakeModel) View() string {
snakeIndex := make(map[point]int, len(m.snake))
for i, s := range m.snake {
snakeIndex[s] = i
}
//TODO move to top
borderStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8A1D7D"))
headStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#4ADE80")).Bold(true)
bodyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#16A34A"))
foodStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FB923C"))
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#E2E8F0"))
scoreStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#94A3B8"))
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#64748B"))
gameOverStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#EF4444"))
var sb strings.Builder
sb.WriteString(
titleStyle.Render(" SNAKE") +
" " +
scoreStyle.Render(fmt.Sprintf("Score: %d", m.score)) +
"\n",
)
sb.WriteString(borderStyle.Render("┌"+strings.Repeat("─", gridWidth)+"┐") + "\n")
for y := 0; y < gridHeight; y++ {
sb.WriteString(borderStyle.Render("│"))
for x := 0; x < gridWidth; x++ {
p := point{x, y}
if idx, isSnake := snakeIndex[p]; isSnake {
if idx == 0 {
sb.WriteString(headStyle.Render("■"))
} else {
sb.WriteString(bodyStyle.Render("□"))
}
} else if p == m.food {
sb.WriteString(foodStyle.Render("●"))
} else {
sb.WriteString(" ")
}
}
sb.WriteString(borderStyle.Render("│") + "\n")
}
sb.WriteString(borderStyle.Render("└"+strings.Repeat("─", gridWidth)+"┘") + "\n")
if m.gameOver {
sb.WriteString(
gameOverStyle.Render(" GAME OVER") +
" " +
scoreStyle.Render(fmt.Sprintf("Final score: %d", m.score)) +
"\n",
)
sb.WriteString(footerStyle.Render(" r to restart · b to go back · q to quit") + "\n")
} else {
sb.WriteString(footerStyle.Render(" ↑↓←→ / hjkl to move · b to go back · q to quit") + "\n")
}
return sb.String()
}