From e7af8511d1af130c4c1dd35f0dfd45641b420c5e Mon Sep 17 00:00:00 2001 From: Ksan Date: Tue, 14 Apr 2026 12:37:17 +0200 Subject: [PATCH] added snake and added projects section, still need a bit of work --- .gitignore | 2 +- main.go | 174 ++++++++++++++++++++++++++++++++++------- snake.go | 222 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 371 insertions(+), 27 deletions(-) create mode 100644 snake.go diff --git a/.gitignore b/.gitignore index d263462..1203d91 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -snake.go +connections.log diff --git a/main.go b/main.go index 23a8327..cfbf8cf 100644 --- a/main.go +++ b/main.go @@ -2,17 +2,40 @@ package main import ( "fmt" - "strings" - - "log" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" 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{} type model struct { @@ -26,12 +49,14 @@ type model struct { mouseY int mouseActive bool isClicked bool + snake SnakeModel + projectCursor int } func initialModel() model { return model{ title: "Đorđe Kšan", - sections: []string{"About", "Projects", "Contact"}, + sections: []string{"About", "Projects", "Contact", "Surprise"}, currentScreen: "menu", mouseActive: true, } @@ -42,6 +67,24 @@ func (m model) Init() 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) { case tea.WindowSizeMsg: m.width = msg.Width @@ -57,18 +100,35 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case "up", "k": - if m.selected > 0 { - m.selected-- - fmt.Println("up") + if m.currentScreen == "Projects" { + if m.projectCursor > 0 { + m.projectCursor-- + } + } else if m.currentScreen == "menu" { + if m.selected > 0 { + m.selected-- + } } case "down", "j": - if m.selected < len(m.sections)-1 { - m.selected++ - fmt.Println("down") + if m.currentScreen == "Projects" { + if m.projectCursor < len(projects)-1 { + m.projectCursor++ + } + } else if m.currentScreen == "menu" { + if m.selected < len(m.sections)-1 { + m.selected++ + } } 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 case "left", "h": @@ -96,7 +156,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { + + if m.currentScreen == "Surprise" { + return m.center(m.snake.View()) + } + titleStyle := lipgloss.NewStyle(). Bold(true). 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 { switch m.currentScreen { case "About": output = m.renderAbout() case "Projects": - output = m.renderProjects() + output = m.center(m.renderProjects()) case "Contact": output = m.renderContact() @@ -158,7 +234,7 @@ var aboutText = ` ` var contactText = ` - Get in touch with me at djordje@ksan.dev. + Get in touch with me at djordje@ksan.dev ` var projectsText = ` @@ -193,23 +269,56 @@ func (m model) renderAbout() string { str.WriteString("\n") str.WriteString(contentStyle.Render(aboutText)) 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() } +func link(url, text string) string { + return "\033]8;;" + url + "\033\\" + text + "\033]8;;\033\\" +} + 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 ━━━")) - str.WriteString("\n") - str.WriteString(contentStyle.Render(projectsText)) - str.WriteString("\n") - str.WriteString(footerStyle.Render("esc: back to menu")) + cursorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#8A1D7D")).Bold(true) + expandedNameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E2E8F0")).Bold(true) + collapsedNameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#94A3B8")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CBD5E1")).PaddingLeft(4).Width(55).Align(lipgloss.Center) + 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 { @@ -219,12 +328,13 @@ func (m model) renderContact() string { str.WriteString("\n") str.WriteString(contentStyle.Render(contactText)) 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() } func main() { + initLogger() server, _ := wish.NewServer( wish.WithAddress("0.0.0.0:2222"), @@ -235,13 +345,25 @@ func main() { _ = 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) { ip := s.RemoteAddr().String() user := s.User() - println("New connection:") - log.Println("User:", user, "IP:", ip) + log.Printf("New connection | user=%s ip=%s", user, ip) return initialModel(), []tea.ProgramOption{ tea.WithAltScreen(), diff --git a/snake.go b/snake.go new file mode 100644 index 0000000..411de02 --- /dev/null +++ b/snake.go @@ -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() +}