// This file is part of Gopher2600. // // Gopher2600 is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Gopher2600 is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Gopher2600. If not, see . package sdlimgui import ( "bytes" "fmt" "image" "image/png" "os" "path/filepath" "strings" "golang.org/x/image/draw" "github.com/inkyblackness/imgui-go/v4" "github.com/jetsetilly/gopher2600/archivefs" "github.com/jetsetilly/gopher2600/cartridgeloader" "github.com/jetsetilly/gopher2600/gui/fonts" "github.com/jetsetilly/gopher2600/hardware/peripherals" "github.com/jetsetilly/gopher2600/hardware/television/specification" "github.com/jetsetilly/gopher2600/logger" "github.com/jetsetilly/gopher2600/properties" "github.com/jetsetilly/gopher2600/resources" "github.com/jetsetilly/gopher2600/thumbnailer" "github.com/sahilm/fuzzy" ) const winSelectROMID = "Select ROM" type winSelectROM struct { playmodeWin debuggerWin img *SdlImgui path archivefs.Path pathEntries []archivefs.Node // selectedName is the name of the ROM in a normalised form selectedName string // properties of selected selectedProperties properties.Entry showAllFiles bool showHidden bool scrollToTop bool centreOnFile bool informationOpen bool // height of options line at bottom of window. valid after first frame controlHeight float32 thmb *thumbnailer.Anim thmbTexture texture thmbImage *image.RGBA thmbDimensions image.Point // the return channel from the emulation goroutine for the property lookup // for the selected cartridge propertyResult chan properties.Entry // map of normalised ROM titles to box art images boxart []string boxartTexture texture boxartDimensions image.Point boxartUse bool } // boxart from libretro project // github.com/libretro-thumbnails/Atari_-_2600/tree/4ea759821724d6c7bcf2f46020d79fc4270ed2f6/Named_Boxarts const namedBoxarts = "Named_Boxarts" func newSelectROM(img *SdlImgui) (window, error) { win := &winSelectROM{ img: img, showAllFiles: false, showHidden: false, scrollToTop: true, centreOnFile: true, propertyResult: make(chan properties.Entry, 1), } win.debuggerGeom.noFocusTracking = true var err error // it is assumed in the polling routines that if the file rom selector is // open then the thumbnailer is open. if we ever decide that the thumbnailer // should be optional we should change this - we don't want the polling to // be high if there is no reason win.thmb, err = thumbnailer.NewAnim(win.img.dbg.VCS().Env.Prefs) if err != nil { return nil, err } win.thmbTexture = img.rnd.addTexture(textureColor, true, true) win.thmbImage = image.NewRGBA(image.Rect(0, 0, specification.ClksVisible, specification.AbsoluteMaxScanlines)) win.thmbDimensions = win.thmbImage.Bounds().Size() // load and normalise box art names boxartPath, err := resources.JoinPath(namedBoxarts) if err != nil { logger.Logf("sdlimgui", err.Error()) } else { boxartFiles, err := os.ReadDir(boxartPath) if err != nil { logger.Logf("sdlimgui", err.Error()) } else { for _, n := range boxartFiles { win.boxart = append(win.boxart, n.Name()) } } } // prepare boxart texture win.boxartTexture = img.rnd.addTexture(textureColor, false, false) return win, nil } func (win *winSelectROM) init() { } func (win winSelectROM) id() string { return winSelectROMID } func (win *winSelectROM) setOpen(open bool) { if !open { win.path.Close() return } // open at the most recently selected ROM recent := win.img.dbg.Prefs.RecentROM.String() err := win.setPath(recent) if err != nil { logger.Logf("sdlimgui", err.Error()) } } func (win *winSelectROM) playmodeSetOpen(open bool) { win.playmodeWin.playmodeSetOpen(open) win.centreOnFile = true win.setOpen(open) // set centreOnFile to true, ready for next time window is open if !open { win.centreOnFile = true } } func (win *winSelectROM) playmodeDraw() bool { if !win.playmodeOpen { win.thmb.EndCreation() return false } win.render() posFlgs := imgui.ConditionAppearing winFlgs := imgui.WindowFlagsNoSavedSettings | imgui.WindowFlagsAlwaysAutoResize imgui.SetNextWindowPosV(imgui.Vec2{75, 75}, posFlgs, imgui.Vec2{0, 0}) if imgui.BeginV(win.playmodeID(win.id()), &win.playmodeOpen, winFlgs) { win.draw() } win.playmodeWin.playmodeGeom.update() imgui.End() return true } func (win *winSelectROM) debuggerSetOpen(open bool) { win.debuggerWin.debuggerSetOpen(open) win.centreOnFile = true win.setOpen(open) // set centreOnFile to true, ready for next time window is open if !open { win.centreOnFile = true } } func (win *winSelectROM) debuggerDraw() bool { if !win.debuggerOpen { win.thmb.EndCreation() return false } win.render() posFlgs := imgui.ConditionFirstUseEver winFlgs := imgui.WindowFlagsAlwaysAutoResize imgui.SetNextWindowPosV(imgui.Vec2{75, 75}, posFlgs, imgui.Vec2{0, 0}) if imgui.BeginV(win.debuggerID(win.id()), &win.debuggerOpen, winFlgs) { win.draw() } win.debuggerWin.debuggerGeom.update() imgui.End() return true } func (win *winSelectROM) render() { // receive new thumbnail data and copy to texture select { case newImage := <-win.thmb.Render: if newImage != nil { // clear image for i := 0; i < len(win.thmbImage.Pix); i += 4 { s := win.thmbImage.Pix[i : i+4 : i+4] s[0] = 10 s[1] = 10 s[2] = 10 s[3] = 255 } // copy new image so that it is centred in the thumbnail image sz := newImage.Bounds().Size() y := ((win.thmbDimensions.Y - sz.Y) / 2) draw.Copy(win.thmbImage, image.Point{X: 0, Y: y}, newImage, newImage.Bounds(), draw.Over, nil) // render image win.thmbTexture.render(win.thmbImage) } default: } } func (win *winSelectROM) draw() { // check for new property information select { case win.selectedProperties = <-win.propertyResult: win.selectedName = win.selectedProperties.Name if win.selectedName == "" { win.selectedName = win.path.Base() win.selectedName = cartridgeloader.NameFromFilename(win.selectedName) } // normalise ROM name for presentation win.selectedName, _, _ = strings.Cut(win.selectedName, "(") win.selectedName = strings.TrimSpace(win.selectedName) // find box art as best we can err := win.findBoxart() if err != nil { logger.Logf("sdlimgui", err.Error()) } default: } // reset centreOnFile at end of draw defer func() { win.centreOnFile = false }() if imgui.Button("Parent") { d := filepath.Dir(win.path.Dir()) err := win.setPath(d) if err != nil { logger.Logf("sdlimgui", "error setting path (%s)", d) } win.scrollToTop = true } imgui.SameLine() imgui.Text(archivefs.RemoveArchiveExt(win.path.Dir())) if imgui.BeginTable("romSelector", 2) { imgui.TableSetupColumnV("filelist", imgui.TableColumnFlagsWidthStretch, -1, 0) imgui.TableSetupColumnV("emulation", imgui.TableColumnFlagsWidthStretch, -1, 1) imgui.TableNextRow() imgui.TableNextColumn() height := imgui.WindowHeight() - imgui.CursorPosY() - win.controlHeight - imgui.CurrentStyle().FramePadding().Y*2 - imgui.CurrentStyle().ItemInnerSpacing().Y imgui.BeginChildV("##selector", imgui.Vec2{X: 300, Y: height}, true, 0) if win.scrollToTop { imgui.SetScrollY(0) win.scrollToTop = false } // list directories imgui.PushStyleColor(imgui.StyleColorText, win.img.cols.ROMSelectDir) for _, e := range win.pathEntries { // ignore dot files if !win.showHidden && e.Name[0] == '.' { continue } if e.IsDir { s := strings.Builder{} if e.IsArchive { s.WriteString(string(fonts.Paperclip)) s.WriteString(" ") s.WriteString(archivefs.TrimArchiveExt(e.Name)) } else { s.WriteString(string(fonts.Directory)) s.WriteString(" ") s.WriteString(e.Name) } if imgui.Selectable(s.String()) { d := filepath.Join(win.path.Dir(), e.Name) err := win.setPath(d) if err != nil { logger.Logf("sdlimgui", "error setting path (%s)", d) } win.scrollToTop = true } } } imgui.PopStyleColor() // list files imgui.PushStyleColor(imgui.StyleColorText, win.img.cols.ROMSelectFile) for _, e := range win.pathEntries { // ignore dot files if !win.showHidden && e.Name[0] == '.' { continue } // ignore invalid file extensions unless showAllFiles flags is set ext := strings.ToUpper(filepath.Ext(e.Name)) if !win.showAllFiles { hasExt := false for _, e := range cartridgeloader.FileExtensions { if e == ext { hasExt = true break } } if !hasExt { for _, e := range archivefs.ArchiveExtensions { if e == ext { hasExt = true break } } } if !hasExt { continue // to next file } } if !e.IsDir { selected := e.Name == win.path.Base() if selected && win.centreOnFile { imgui.SetScrollHereY(0.0) } if imgui.SelectableV(e.Name, selected, 0, imgui.Vec2{0, 0}) { win.setPath(filepath.Join(win.path.Dir(), e.Name)) } if imgui.IsItemHovered() && imgui.IsMouseDoubleClicked(0) { win.insertCartridge() } } } imgui.PopStyleColor() imgui.EndChild() imgui.TableNextColumn() imgui.Image(imgui.TextureID(win.thmbTexture.getID()), imgui.Vec2{float32(win.thmbDimensions.X) * 2, float32(win.thmbDimensions.Y)}) imgui.EndTable() } // control buttons. start controlHeight measurement win.controlHeight = imguiMeasureHeight(func() { imgui.SetNextItemOpen(win.informationOpen, imgui.ConditionAlways) if !imgui.CollapsingHeaderV(win.selectedName, imgui.TreeNodeFlagsNone) { win.informationOpen = false } else { win.informationOpen = true if imgui.BeginTable("#properties", 3) { imgui.TableSetupColumnV("#information", imgui.TableColumnFlagsWidthStretch, -1, 0) imgui.TableSetupColumnV("#spacingA", imgui.TableColumnFlagsWidthFixed, -1, 1) imgui.TableSetupColumnV("#boxart", imgui.TableColumnFlagsWidthFixed, -1, 2) // property table. we measure the height of this table to // help centering the box art image in the next column imgui.TableNextRow() imgui.TableNextColumn() propertyTableTop := imgui.CursorPosY() if imgui.BeginTable("#properties", 2) { imgui.TableSetupColumnV("#category", imgui.TableColumnFlagsWidthFixed, -1, 0) imgui.TableSetupColumnV("#detail", imgui.TableColumnFlagsWidthFixed, -1, 1) // wrap text imgui.PushTextWrapPosV(imgui.CursorPosX() + imgui.ContentRegionAvail().X) defer imgui.PopTextWrapPos() imgui.TableNextRow() imgui.TableNextColumn() imgui.Text("Name") imgui.TableNextColumn() if win.selectedProperties.IsValid() { imgui.Text(win.selectedProperties.Name) } else { imgui.Text(win.selectedName) } // results of preview emulation from the thumbnailer selectedFilePreview := win.thmb.PreviewResults() imgui.TableNextRow() imgui.TableNextColumn() imgui.AlignTextToFramePadding() imgui.Text("Mapper") imgui.TableNextColumn() if selectedFilePreview != nil { imgui.Text(selectedFilePreview.VCS.Mem.Cart.ID()) } imgui.TableNextRow() imgui.TableNextColumn() imgui.PushItemFlag(imgui.ItemFlagsDisabled, true) imgui.PushStyleVarFloat(imgui.StyleVarAlpha, disabledAlpha) imgui.AlignTextToFramePadding() imgui.Text("Television") imgui.TableNextColumn() if selectedFilePreview != nil { imgui.SetNextItemWidth(80) if imgui.BeginCombo("##tvspec", selectedFilePreview.FrameInfo.Spec.ID) { for _, s := range specification.SpecList { if imgui.Selectable(s) { } } imgui.EndCombo() } } imgui.PopStyleVar() imgui.PopItemFlag() imgui.TableNextRow() imgui.TableNextColumn() imgui.PushItemFlag(imgui.ItemFlagsDisabled, true) imgui.PushStyleVarFloat(imgui.StyleVarAlpha, disabledAlpha) imgui.AlignTextToFramePadding() imgui.Text("Players") imgui.TableNextColumn() if selectedFilePreview != nil { imgui.SetNextItemWidth(100) if imgui.BeginCombo("##leftplayer", string(selectedFilePreview.VCS.RIOT.Ports.LeftPlayer.ID())) { for _, s := range peripherals.AvailableLeftPlayer { if imgui.Selectable(s) { } } imgui.EndCombo() } imgui.SameLineV(0, 15) imgui.Text("&") imgui.SameLineV(0, 15) imgui.SetNextItemWidth(100) if imgui.BeginCombo("##rightplayer", string(selectedFilePreview.VCS.RIOT.Ports.RightPlayer.ID())) { for _, s := range peripherals.AvailableRightPlayer { if imgui.Selectable(s) { } } imgui.EndCombo() } } imgui.PopStyleVar() imgui.PopItemFlag() if win.selectedProperties.Manufacturer != "" { imgui.TableNextRow() imgui.TableNextColumn() imgui.AlignTextToFramePadding() imgui.Text("Manufacturer") imgui.TableNextColumn() imgui.Text(win.selectedProperties.Manufacturer) } if win.selectedProperties.Rarity != "" { imgui.TableNextRow() imgui.TableNextColumn() imgui.AlignTextToFramePadding() imgui.Text("Rarity") imgui.TableNextColumn() imgui.Text(win.selectedProperties.Rarity) } if win.selectedProperties.Model != "" { imgui.TableNextRow() imgui.TableNextColumn() imgui.AlignTextToFramePadding() imgui.Text("Model") imgui.TableNextColumn() imgui.Text(win.selectedProperties.Model) } if win.selectedProperties.Note != "" { imgui.TableNextRow() imgui.TableNextColumn() imgui.AlignTextToFramePadding() imgui.Text("Note") imgui.TableNextColumn() imgui.Text(win.selectedProperties.Note) } imgui.EndTable() } propertyTableBottom := imgui.CursorPosY() propertyTableHeight := propertyTableBottom - propertyTableTop // spacing imgui.TableNextColumn() if win.boxartUse { imgui.TableNextColumn() sz := imgui.Vec2{float32(win.boxartDimensions.X), float32(win.boxartDimensions.Y)} // if thumbnail height is less than height of // property table then we position the image so that // it's centered in relation to the property table p := imgui.CursorPos() if sz.Y < propertyTableHeight { p.Y += (propertyTableHeight - sz.Y) / 2 imgui.SetCursorPos(p) } else { // if height of thumbnail is greater than or // equal to height of property table then we add // a imgui.Spacing(). this may expand the height // of the property table but that's okay imgui.Spacing() } imgui.Image(imgui.TextureID(win.boxartTexture.getID()), sz) } imgui.EndTable() } } imguiSeparator() if imgui.Button("Cancel") { // close rom selected in both the debugger and playmode win.debuggerSetOpen(false) win.playmodeSetOpen(false) } if win.selectedName != "" { var s string // load or reload button if win.path.String() == win.img.cache.VCS.Mem.Cart.Filename { s = fmt.Sprintf("Reload %s", win.selectedName) } else { s = fmt.Sprintf("Load %s", win.selectedName) } // only show load cartridge button if the file is being // emulated by the thumbnailer. if it's not then that's a good // sign that the file isn't supported if win.thmb.IsEmulating() { imgui.SameLine() if imgui.Button(s) { win.insertCartridge() } } } imgui.Spacing() imgui.Checkbox("Show All", &win.showAllFiles) imgui.SameLine() imgui.Checkbox("Show Hidden", &win.showHidden) }) } func (win *winSelectROM) insertCartridge() { // do not try to load cartridge if the file is not being emulated by the // thumbnailer. if it's not then that's a good sign that the file isn't // supported if !win.thmb.IsEmulating() { return } win.img.dbg.InsertCartridge(win.path.String()) // close rom selected in both the debugger and playmode win.debuggerSetOpen(false) win.playmodeSetOpen(false) } func (win *winSelectROM) setPath(path string) error { var err error win.path.Set(path) win.pathEntries, err = win.path.List() if err != nil { return err } if win.path.IsDir() { win.setSelectedFile("") } else { win.setSelectedFile(win.path.String()) } return nil } func (win *winSelectROM) setSelectedFile(filename string) { // return immediately if the filename is empty if filename == "" { return } // create cartridge loader and start thumbnail emulation cartload, err := cartridgeloader.NewLoaderFromFilename(filename, "AUTO") if err != nil { logger.Logf("ROM Select", err.Error()) return } // push function to emulation goroutine. result will be checked for in // draw() function win.img.dbg.PushPropertyLookup(cartload.HashMD5, win.propertyResult) // create thumbnail animation win.thmb.Create(cartload, thumbnailer.UndefinedNumFrames, true) // defer boxart lookup to when we receive the property } func (win *winSelectROM) findBoxart() error { // reset boxartUse flag until we are certain we've loaded a suitable image win.boxartUse = false // fuzzy find a candidate image n, _, _ := strings.Cut(win.selectedProperties.Name, "(") n = strings.TrimSpace(n) m := fuzzy.Find(n, win.boxart) if len(m) == 0 { return nil } // load image p, err := resources.JoinPath(namedBoxarts, m[0].Str) if err != nil { return fmt.Errorf("boxart: %w", err) } d, err := os.ReadFile(p) if err != nil { return fmt.Errorf("boxart: %w", err) } // conversion function render := func(src image.Image) { if _, ok := src.(*image.RGBA); ok { return } b := src.Bounds() dst := image.NewRGBA(image.Rect(0, 0, b.Dx()/4, b.Dy()/4)) draw.BiLinear.Scale(dst, dst.Bounds(), src, b, draw.Src, nil) win.boxartDimensions = dst.Bounds().Max win.boxartTexture.render(dst) win.boxartUse = true } // convert image and render into texture ext := filepath.Ext(p) switch ext { case ".png": img, err := png.Decode(bytes.NewReader(d)) if err != nil { return fmt.Errorf("boxart: %w", err) } render(img) default: return fmt.Errorf("boxart: unsupported file extension: *%s", ext) } return nil }