Commit 2303e0b

Ansari <ping@ansari.wtf>
2026-03-03 00:49:21
rules model with scroll offsets
1 parent b9f1702
Changed files (4)
internal/sections/rules/model.go
@@ -51,8 +51,12 @@ type Model struct {
 
 	// Two-table navigation
 	activeTable    Table // Which table (IPv4 or IPv6) is currently focused
-	ipv4CursorLine int   
-	ipv6CursorLine int  
+	ipv4CursorLine int
+	ipv6CursorLine int
+
+	// Scroll offsets for each table
+	ipv4ScrollOffset int
+	ipv6ScrollOffset int
 
 	// Detail view overlay
 	showDetails bool
@@ -64,9 +68,11 @@ type Model struct {
 
 	// Multi-step menu operations
 	menuContext *MenuContext
-	addWizard *AddWizard
+	addWizard   *AddWizard
 }
 
+const MaxVisibleRules = 7
+
 func New(styles ui.Styles) Model {
 	return Model{
 		styles:            styles,
@@ -76,6 +82,8 @@ func New(styles ui.Styles) Model {
 		activeTable:       IPv4Table,
 		ipv4CursorLine:    0,
 		ipv6CursorLine:    0,
+		ipv4ScrollOffset:  0,
+		ipv6ScrollOffset:  0,
 		showDetails:       false,
 		detailRule:        nil,
 		showDeleteConfirm: false,
@@ -141,6 +149,13 @@ func (m Model) CurrentCursorLine() int {
 	return m.ipv4CursorLine
 }
 
+func (m Model) CurrentScrollOffset() int {
+	if m.activeTable == IPv6Table {
+		return m.ipv6ScrollOffset
+	}
+	return m.ipv4ScrollOffset
+}
+
 func (m Model) ShowingAddWizard() bool {
 	return m.addWizard != nil
 }
internal/sections/rules/update.go
@@ -27,8 +27,8 @@ func (m Model) Update(msg tea.Msg, data RulesData) (Model, tea.Cmd) {
 	if m.showDeleteConfirm {
 		switch msg := msg.(type) {
 		case tea.KeyMsg:
-			switch msg.String() {
-			case "y", "Y":
+			switch {
+			case key.Matches(msg, keys.Bindings.Execute):
 				if m.deleteRule != nil {
 					log.Printf("Deleting rule #%d (confirmed)", m.deleteRule.Num)
 					ufw.DeleteRule(m.deleteRule.Num)
@@ -36,7 +36,7 @@ func (m Model) Update(msg tea.Msg, data RulesData) (Model, tea.Cmd) {
 				m.showDeleteConfirm = false
 				m.deleteRule = nil
 				return m, keys.Refresh()
-			case "n", "N", "esc":
+			case key.Matches(msg, keys.Bindings.Quit):
 				m.showDeleteConfirm = false
 				m.deleteRule = nil
 				return m, nil
@@ -379,10 +379,18 @@ func (m *Model) moveCursorUp() {
 	if m.activeTable == IPv6Table {
 		if m.ipv6CursorLine > 0 {
 			m.ipv6CursorLine--
+			// Scroll up if cursor goes above visible area
+			if m.ipv6CursorLine < m.ipv6ScrollOffset {
+				m.ipv6ScrollOffset = m.ipv6CursorLine
+			}
 		}
 	} else {
 		if m.ipv4CursorLine > 0 {
 			m.ipv4CursorLine--
+			// Scroll up if cursor goes above visible area
+			if m.ipv4CursorLine < m.ipv4ScrollOffset {
+				m.ipv4ScrollOffset = m.ipv4CursorLine
+			}
 		}
 	}
 }
@@ -391,10 +399,18 @@ func (m *Model) moveCursorDown(rules []ufw.Rule) {
 	if m.activeTable == IPv6Table {
 		if m.ipv6CursorLine < len(rules)-1 {
 			m.ipv6CursorLine++
+			// Scroll down if cursor goes below visible area
+			if m.ipv6CursorLine >= m.ipv6ScrollOffset+MaxVisibleRules {
+				m.ipv6ScrollOffset = m.ipv6CursorLine - MaxVisibleRules + 1
+			}
 		}
 	} else {
 		if m.ipv4CursorLine < len(rules)-1 {
 			m.ipv4CursorLine++
+			// Scroll down if cursor goes below visible area
+			if m.ipv4CursorLine >= m.ipv4ScrollOffset+MaxVisibleRules {
+				m.ipv4ScrollOffset = m.ipv4CursorLine - MaxVisibleRules + 1
+			}
 		}
 	}
 }
internal/sections/rules/view.go
@@ -18,29 +18,30 @@ type RulesData struct {
 func (m Model) View(data RulesData) string {
 	sectionActiveNoMenu := m.menu == nil && m.active && !m.showDetails && !m.showDeleteConfirm
 
-	ipTable := m.renderTable(data.IPv4, data.IPv6, m.ipv4CursorLine, sectionActiveNoMenu && m.activeTable == IPv4Table)
-	if m.activeTable == IPv6Table {
-		ipTable = m.renderTable(data.IPv4, data.IPv6, m.ipv6CursorLine, sectionActiveNoMenu && m.activeTable == IPv6Table)
-	}
+	ipTable := m.renderTable(data.IPv4, data.IPv6, sectionActiveNoMenu)
+
 	content := lipgloss.JoinVertical(
 		lipgloss.Left,
 		ipTable,
 	)
 
-	return ui.TitledBox("Active Rules", content, m.styles, -1, m.active)
+	return ui.TitledBox("Active Rules", content, m.styles, -1, m.active, 13)
 }
 
-func (m Model) renderTable(ipV4Rules []ufw.Rule, ipV6Rules []ufw.Rule, cursorLine int, isActive bool) string {
+func (m Model) renderTable(ipV4Rules []ufw.Rule, ipV6Rules []ufw.Rule, isActive bool) string {
 	var rows []string
 
 	titleStyle := m.styles.Label
 	if isActive {
 		titleStyle = titleStyle.Bold(true).Foreground(lipgloss.Color("12"))
 	}
+
 	activeTitle := "IPv4 Rules"
 	inActiveTitle := "IPv6 Rules"
 
 	rules := ipV4Rules
+	scrollOffset := m.ipv4ScrollOffset
+	cursorLine := m.ipv4CursorLine
 
 	maxIPv4 := len(fmt.Sprintf("%s (%d)", "IPv4 Rules", 9999))
 	maxIPv6 := len(fmt.Sprintf("%s (%d)", "IPv6 Rules", 9999))
@@ -53,11 +54,20 @@ func (m Model) renderTable(ipV4Rules []ufw.Rule, ipV6Rules []ufw.Rule, cursorLin
 		m.styles.Label.Foreground(lipgloss.Color("241")).Render(padTitle(inActiveTitle, len(ipV6Rules), maxIPv6))
 
 	if m.activeTable == IPv6Table {
+		scrollOffset = m.ipv6ScrollOffset
+		cursorLine = m.ipv6CursorLine
 		rules = ipV6Rules
 		tabTitle = m.styles.Label.Foreground(lipgloss.Color("241")).Render(padTitle(activeTitle, len(ipV4Rules), maxIPv4)) + "|  " +
 			titleStyle.Render(padTitle(inActiveTitle, len(ipV6Rules), maxIPv6))
 	}
 
+	if len(rules) > MaxVisibleRules {
+		scrollInfo := m.renderScrollIndicator(len(rules), scrollOffset)
+		lineWidth := lipgloss.Width(tabTitle)
+		scrollInfoRight := lipgloss.PlaceHorizontal(lineWidth, lipgloss.Right, scrollInfo)
+		tabTitle = lipgloss.JoinHorizontal(lipgloss.Left, tabTitle, scrollInfoRight)
+	}
+
 	rows = append(rows, tabTitle)
 	rows = append(rows, "")
 
@@ -75,7 +85,11 @@ func (m Model) renderTable(ipV4Rules []ufw.Rule, ipV6Rules []ufw.Rule, cursorLin
 	line := strings.Repeat("─", lipgloss.Width(headerContent))
 	rows = append(rows, "  "+headerContent, "  "+m.styles.Label.UnsetWidth().Render(line))
 
-	for i, r := range rules {
+	startIdx := scrollOffset
+	endIdx := min(scrollOffset+MaxVisibleRules, len(rules))
+
+	for i := startIdx; i < endIdx; i++ {
+		r := rules[i]
 		action := fmt.Sprintf("%-6s", r.Action)
 		row := fmt.Sprintf(
 			"%-3d │ %6s │ %-5s │ %-16s │ %-5s │ %-16s │ %-5s",
@@ -89,10 +103,16 @@ func (m Model) renderTable(ipV4Rules []ufw.Rule, ipV6Rules []ufw.Rule, cursorLin
 		)
 		rows = append(rows, ui.InsertCursorRulesSection(row, cursorLine == i && isActive, m.styles, r.Action))
 	}
-
 	return strings.Join(rows, "\n")
 }
 
+func (m Model) renderScrollIndicator(totalRules int, scrollOffset int) string {
+	current := scrollOffset + 1
+	end := min(scrollOffset+MaxVisibleRules, totalRules)
+	return m.styles.Label.Foreground(lipgloss.Color("241")).Render(
+		fmt.Sprintf("  Showing %d-%d of %d", current, end, totalRules))
+}
+
 func truncate(s string, maxLen int) string {
 	s = strings.TrimSuffix(s, " (v6)")
 	if len(s) > maxLen {
@@ -172,15 +192,15 @@ func (m Model) DeleteConfirmView() string {
 	lines = append(lines, "")
 	lines = append(lines, m.styles.Error.Render("  Are you sure you want to delete this rule?"))
 	lines = append(lines, "")
-	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Render("Rule #:"), m.styles.Value.Render(fmt.Sprintf("%d", r.Num))))
-	lines = append(lines, fmt.Sprintf("  %s  %s %s", m.styles.Label.Render("Action:"), ui.GetPolicyStyle(m.styles, r.Action).Render(r.Action), m.styles.Value.Render(r.ToPort)))
-	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Render("From:"), m.styles.Value.Render(r.FromSource)))
-	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Render("To:"), m.styles.Value.Render(r.ToDest)))
+	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Width(10).Render("Rule   :"), m.styles.Value.Render(fmt.Sprintf("%d", r.Num))))
+	lines = append(lines, fmt.Sprintf("  %s  %s %s", m.styles.Label.Width(10).Render("Action :"), ui.GetPolicyStyle(m.styles, r.Action).Render(r.Action), m.styles.Value.Render(r.ToPort)))
+	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Width(10).Render("From   :"), m.styles.Value.Render(r.FromSource)))
+	lines = append(lines, fmt.Sprintf("  %s  %s", m.styles.Label.Width(10).Render("To     :"), m.styles.Value.Render(r.ToDest)))
 	lines = append(lines, "")
 	text := m.styles.Label.
 		PaddingTop(1).
 		UnsetWidth().
-		Render("[y] Yes, delete  [n/Esc] Cancel")
+		Render("[Enter] Confirm  [Esc] Cancel")
 
 	centered := lipgloss.Place(
 		50,
internal/ui/titled_box.go
@@ -6,7 +6,7 @@ import (
 	"github.com/charmbracelet/lipgloss"
 )
 
-func TitledBox(title, content string, styles Styles, width int, activeSession bool) string {
+func TitledBox(title, content string, styles Styles, width int, activeSession bool, height ...int) string {
 
 	style := styles.SectionBorder
 	if activeSession {
@@ -26,6 +26,9 @@ func TitledBox(title, content string, styles Styles, width int, activeSession bo
 	if boxWidth > 0 {
 		style = style.Width(boxWidth)
 	}
+	if len(height) > 0 {
+		style = style.Height(height[0])
+	}
 
 	box := style.Render(content)